EasterEgg/HexPlatforms.cs

Component managing the Basalt maps hex-platform Easter egg. Handles picking four random tiles, tracking lit tiles and colours, detecting player ground slams on tiles, enforcing the Color Smash step rules (order, failures, completion), mirroring lit/clue/done state across network, building visual panels and the reward tile, drawing icons and sounds, and integrating with round/game lifecycle and other egg steps.

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

namespace NZombies;

/// <summary>
/// BASALT SEAL 1 — THE SLAM PLATFORMS (Docs/BASALT_EASTER_EGG.md). Its first step is called COLOR SMASH (named by the
/// user, 2026-09-26): slam the four coloured tiles in the slots' order. The second, BONFIRE, lives in
/// `HexPlatforms.Bonfire.cs`: a napalm zombie killed on tile 1 sets it burning, and pests killed on it turn the offering
/// green. The third, COLOR RINGS, in `HexPlatforms.Rings.cs`: each platform's own ammo mod — dealt at random, its icon
/// on its top — over it moves its colour's dot round the rings clue (`RingsClue`).
///
/// The HOST picks <see cref="Count"/> of the 54 available hex tiles at random, and EACH PICK HAS ITS OWN COLOUR — blue,
/// yellow, green and red, one each (<see cref="ColourName"/>). Once the power is on, the clue shows the four in their
/// colours (<see cref="HexClue"/>), and the hex slots put a number on each colour (<see cref="HexSlotManager"/>): the
/// ORDER to slam them in, I first. A PhD Flopper Ground Slam (augment m1) — triggered in the air, landing on the tile,
/// its explosion going off there — on the pick whose turn it is lights it in its colour on all six sides, for everybody.
///
/// ⛔ THE RULES, as asked (2026-09-26): *"If I stomp a wrong panel or one of the correct ones but in the wrong order, I
/// fail the step and must try again next round. When failing a step it goes back to the start, meaning all panels turn
/// off. When all 4 panels have a color, the first step is complete, and it no longer resets. The numbers also disappear
/// after that. The image does not change."*
/// - a slam on any other of the 54, or on a pick out of its turn, FAILS: every tile goes out, and nothing lights again
///   until the round ends;
/// - at every round end an unfinished step fully resets: four new picks, new numbers on the slots, a new attempt. Round
///   1 picks at its start;
/// - a slam on a pick already lit in its turn changes nothing;
/// - all four in order: the step is DONE. It no longer resets, the four stay lit, the slots go, and the clue stays as it
///   is. Tile 1 — where the soul boxes' wonder-weapon part lands — lights up white, with the napalm icon in red on its top
///   (<see cref="RewardTile"/>). A new game starts it over;
/// - ⚠️ NOTHING COUNTS BEFORE THE POWER IS ON, neither a pick nor a failure. That one is a choice, not asked for: the
///   order only shows from then, so without it a slam before anyone could know the order would fail the round.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the PICKS, and so which tile is which colour, are HOST state — UNTIL THE POWER IS ON. From then the clue shows them
///   to everyone (<see cref="HexClue"/>), so the host MIRRORS them as the clue set (`NZNet.HexPlatformsClue`) when the
///   power comes on and at every reset after it. Before that, a client that held them would know where to slam;
/// - the LIT set is decided by the host and MIRRORED: which tiles are lit and each one's colour, four longs
///   (<see cref="Lit"/>) sent whole on every change. A tile's colour leaves the host only once it is lit, when every
///   player can see it anyway;
/// - the STEP — which of the order are slammed, whether this round failed, whether it is done — is HOST state. What it
///   shows travels as the lit set, the clue and the slots, each mirrored already, and one flag for tile 1: whether it is
///   done (`NZNet.HexPlatformsDone`), which is MIRRORED;
/// - the PANELS are LOCAL: every machine builds its own from the set it was sent.
/// The slam is detected on the slammer's machine, the only one that knows it was a slam rather than a fall,
/// which reports "I landed on tile N" and nothing more. The host decides whether that lights anything, or fails the
/// step.
///
/// ⚠️ STATE LIVES ON THIS COMPONENT, NOT IN STATICS. `Instance` is only how it is found, the same shape as
/// `BuildPartManager`. The tile table is map data and the numbers are tuning, which is what statics are for.
///
/// ⚠️ THE TILES ARE MAP GEOMETRY, NOT OBJECTS. A tile's top is one polygon inside one "Mesh N" chunk of the
/// imported BSP and its sides are spread over others, so there is nothing to recolour or attach to. A lit
/// tile is our own panels (`HexSidePanels.Build`) laid just proud of its six sides.
/// </summary>
public sealed partial class HexPlatforms : Component
{
	public static HexPlatforms Instance { get; private set; }

	protected override void OnAwake() => Instance = this;
	protected override void OnDestroy() { if ( Instance == this ) Instance = null; }

	const string PanelTag = "nz_hexpanel";

	/// <summary>The scene's manager, made on first use.</summary>
	public static HexPlatforms Ensure( Scene scene = null )
	{
		if ( Instance.IsValid() ) return Instance;

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

		// ⚠️ ORPHANED PANELS ARE SWEPT FIRST. A hotload destroys a component created at runtime (`Slide` and
		// `PhdAugments.Tick` both carry that warning), so a new manager can arrive to find the old one's panels
		// still standing with nothing tracking them — lit tiles that nothing could ever put out again.
		foreach ( var old in scene.GetAllObjects( false ).Where( g => g.Tags.Has( PanelTag ) ).ToList() )
			old.Destroy();

		var go = scene.CreateObject();
		go.Name = "Hex Platforms";
		go.Flags |= GameObjectFlags.NotSaved;

		// tile 1 plain from the start, on every machine — its sweep above took the old one's
		var m = go.Components.Create<HexPlatforms>();
		if ( OnBasalt ) m.BuildRewardTile();
		return m;
	}

	// ══ tuning ══════════════════════════════════════════════════════════════════════════════
	//
	// ⚠️ GETTERS OVER NULLABLE FIELDS, the project's rule for numbers that get retuned: an initialiser's value
	// is carried across a hotload, so changing a default here would do nothing until a restart.

	static int? _count;
	/// <summary>How many tiles the host picks each round. 4 — one per colour.</summary>
	public static int Count { get => _count ?? Colours; set => _count = value; }

	// ⛔ BOTH WERE TOO TIGHT, AND THE PLAYTEST COUNTED IT (2026-09-26): of 20 Ground Slams on basalt, 6 reached a tile at
	// all — green 91 took five tries, red 4 took four. Reported as "the panels have a hard time detecting the explosion
	// from the slam, make them more sensitive". A miss logged nothing, so which limit caught each one is not known; both
	// had a reason to:
	// - EDGE 8: a player standing on the very rim has their centre up to ~16u past it — the hull's radius — and a slam
	//   carried forward by a jump lands further on still;
	// - HEIGHT 16: the controller can call a 1400u/s slam landed a frame early, up to ~23u above the top at 60 fps.
	// At 48 each, the floor or lava beside a tile still does not count: it lies 88u or more below the tile's top.

	static float? _edgeSlack;
	/// <summary>
	/// How far past a tile's edge a slam's landing still counts, in units. 48 — see the note above. Where two tiles'
	/// margins overlap, the nearer tile wins (<see cref="TileAt(Vector3)"/>).
	/// </summary>
	public static float EdgeSlack { get => _edgeSlack ?? 48f; set => _edgeSlack = value; }

	static float? _heightSlack;
	/// <summary>How far the feet may be above or below a tile's top and still be on it. 48 — see the note above.</summary>
	public static float HeightSlack { get => _heightSlack ?? 48f; set => _heightSlack = value; }

	// ══ the colours ═════════════════════════════════════════════════════════════════════════
	//
	// ⛔ ONE COLOUR PER PICK, IN THE ORDER THE PICKS ARE DRAWN: the first blue, then yellow, green and red (asked for
	// as "make it so each of them is one color — blue yellow green red"). The draw is random, so which TILE is which
	// colour is as random as the picks themselves, while the ORDER of the colours stays fixed — which is what the
	// napalm part will walk them by.

	/// <summary>How many colours there are. A pick's colour is its place in the draw.</summary>
	public const int Colours = 4;

	/// <summary>The colour number of a tile lit by hand that is not a pick (`nz_hex_light`): the map's own white.</summary>
	public const int White = 4;

	/// <summary>A colour's name: blue, yellow, green, red — and white.</summary>
	public static string ColourName( int colour ) => colour switch
	{
		0 => "blue", 1 => "yellow", 2 => "green", 3 => "red", _ => "white",
	};

	/// <summary>A colour's number from its name, or -1.</summary>
	public static int ColourNumber( string name ) => name?.Trim().ToLowerInvariant() switch
	{
		"blue" => 0, "yellow" => 1, "green" => 2, "red" => 3, "white" => White, _ => -1,
	};

	/// <summary>
	/// A colour's hue and how hard the unlit light material drives it, as the code has them.
	///
	/// ⚠️ SATURATED HUES, AND LESS DRIVE FOR RED — both measured on the zombie eyes (ZombieEyes.cs). The tint is driven
	/// far above 1 and the tonemapper compresses it, so the channels converge as they brighten: a pale hue arrives as
	/// plain white, and a bright red drifts toward orange. So each keeps its own channel at the top and the others
	/// low, red at the hounds' 2.2. These are first looks — retune by eye with `nz_hex_colour`, then paste here.
	///
	/// ⚠️ A METHOD, NOT A STATIC TABLE: an initialiser does not re-run on hotload, and these are the numbers most
	/// likely to be changed (INSTRUCTIONS.md, "A STATIC'S INITIALISER DOES NOT RE-RUN ON HOTLOAD").
	/// </summary>
	static (string Hex, float Drive) Authored( int colour ) => colour switch
	{
		0 => ("#0D2BFF", 3.0f),         // blue
		1 => ("#FFC400", 2.0f),         // yellow
		2 => ("#00FF22", 2.2f),         // green
		3 => ("#FF0000", 2.2f),         // red
		_ => ("#FFFFFF", 2.0f),         // white — white001's own [2 2 2]
	};

	/// <summary>`nz_hex_colour`'s retunes, by colour number — only what was changed, so nothing here can go stale.</summary>
	static Dictionary<int, (Color Hue, float Drive)> _tuned;

	static (Color Hue, float Drive) Tuning( int colour )
	{
		if ( _tuned is not null && _tuned.TryGetValue( colour, out var t ) ) return t;

		var (hex, drive) = Authored( colour );
		return (Color.Parse( hex ) ?? Color.White, drive);
	}

	/// <summary>A colour's hue as it stands — the code's, or `nz_hex_colour`'s — for the clue, which draws the picks in it.</summary>
	public static Color HueOf( int colour ) => colour < 0 || colour >= Colours ? Color.White : Tuning( colour ).Hue;

	/// <summary>Counts `nz_hex_colour`'s changes, so a clue drawn in the old colours knows to draw again.</summary>
	public static int TuneVersion { get; private set; }

	/// <summary>What `g_vColorTint` is set to for a colour: its hue times its drive.</summary>
	public static Vector3 TintOf( int colour )
	{
		var (h, d) = Tuning( colour );
		return new Vector3( h.r * d, h.g * d, h.b * d );
	}

	/// <summary>The map's own unlit light material: every white strip in basalt is drawn with it.</summary>
	public const string LightMaterial = "materials/lights/white001.vmat";

	/// <summary>
	/// The material a tile lit in this colour is drawn with: the map's own white001 for white, and for a colour A COPY
	/// of it, tinted. Null if white001 is missing.
	///
	/// ⛔ A COPY, NEVER white001 ITSELF. `Material.Load` hands back the one material every light strip in the map is
	/// drawn with, so tinting it would turn the whole map blue — and the design keeps that very tint for the progress
	/// narrator (Docs/BASALT_EASTER_EGG.md §7). `CreateCopy` gives each colour one of its own, shared by every tile
	/// lit in it.
	///
	/// ⚠️ THE TINT IS WRITTEN ON EVERY CALL, not only when the copy is made: the copies outlive a hotload in the cache
	/// while the colours are code, so writing it again is what lets a retuned colour take — and every panel already
	/// lit in that colour changes with it, because they share the copy.
	///
	/// ⚠️ THE CACHE IS STATIC AND LOCAL: render resources, the same on every machine, not state.
	/// </summary>
	public static Material MaterialFor( int colour )
	{
		var white = Material.Load( LightMaterial );
		if ( white is null ) { Log.Warning( $"[nz-hex] {LightMaterial} not found" ); return null; }
		if ( colour < 0 || colour >= Colours ) return white;

		_copies ??= new();
		if ( !_copies.TryGetValue( colour, out var m ) || m is null )
			_copies[colour] = m = white.CreateCopy( $"nz_hex_{ColourName( colour )}" );

		m.Set( "g_vColorTint", TintOf( colour ) );
		return m;
	}

	static Dictionary<int, Material> _copies;

	// ══ the tiles ═══════════════════════════════════════════════════════════════════════════

	/// <summary>One available tile: its ID on the picker map, its centre, its top, and the floor its sides reach.</summary>
	public readonly record struct Tile( string Id, float X, float Y, float Top, float Bottom );

	/// <summary>
	/// The 54 tiles marked available in the Basalt Hex Picker (2026-09-25), with the IDs it shows. Centres are
	/// exact, read from each tile's own top outline. Every one is the same hexagon — points 144u out on ±Y,
	/// flats 124u out — so only the heights differ:
	/// - 2-13, the lava room's stepping stones (top 1184, sides down to 1088);
	/// - 22-54, the hex floor north-east of it (top 1184, sides down to 1096);
	/// - 61-92, the column field to the south-east (tops 1328-1356, all standing on 1216).
	/// </summary>
	/// <remarks>⛔ AT MOST 64 — the lit set crosses the network as one bit per tile in each of four longs.</remarks>
	public static readonly Tile[] Tiles =
	{
		// lava room
		new( "2", -3872, -288, 1184, 1088 ), new( "3", -3620, -288, 1184, 1088 ), new( "4", -3368, -288, 1184, 1088 ),
		new( "5", -3116, -288, 1184, 1088 ), new( "6", -2864, -288, 1184, 1088 ), new( "7", -2612, -288, 1184, 1088 ),
		new( "8", -2360, -288, 1184, 1088 ), new( "9", -2108, -288, 1184, 1088 ), new( "10", -2990, -508, 1184, 1088 ),
		new( "11", -2864, -728, 1184, 1088 ), new( "12", -2990, -948, 1184, 1088 ), new( "13", -3116, -1168, 1184, 1090 ),
		// hex floor
		new( "22", -1064, 608, 1184, 1096 ), new( "23", -808, 608, 1184, 1096 ), new( "24", -532, 608, 1184, 1096 ),
		new( "25", -276, 608, 1184, 1096 ), new( "26", -20, 608, 1184, 1096 ), new( "27", 236, 608, 1184, 1096 ),
		new( "34", -1704, 384, 1184, 1096 ), new( "35", -936, 384, 1184, 1096 ), new( "37", -1576, 160, 1184, 1096 ),
		new( "38", -1320, 160, 1184, 1096 ), new( "39", -1064, 160, 1184, 1096 ), new( "41", -1448, -64, 1184, 1096 ),
		new( "42", -936, -64, 1184, 1096 ), new( "52", -1832, -288, 1184, 1096 ), new( "53", -1576, -288, 1184, 1096 ),
		new( "54", -808, -288, 1184, 1096 ),
		// column field
		new( "61", -540, -968, 1356, 1216 ), new( "62", -668, -1192, 1352, 1216 ), new( "63", -796, -1416, 1348, 1216 ),
		new( "64", -540, -1416, 1348, 1216 ), new( "65", -284, -1416, 1344, 1216 ), new( "66", -1180, -1640, 1340, 1216 ),
		new( "67", -924, -1640, 1344, 1216 ), new( "68", -412, -1640, 1340, 1216 ), new( "69", -1564, -1864, 1348, 1216 ),
		new( "70", -1308, -1864, 1344, 1216 ), new( "71", -1052, -1864, 1344, 1216 ), new( "72", -540, -1864, 1344, 1216 ),
		new( "74", -796, -1864, 1348, 1216 ), new( "76", -1692, -2088, 1344, 1216 ), new( "77", -1436, -2088, 1352, 1216 ),
		new( "78", -1180, -2088, 1348, 1216 ), new( "79", -668, -2088, 1348, 1216 ), new( "80", -412, -2088, 1352, 1216 ),
		new( "81", -156, -2088, 1348, 1216 ), new( "82", 100, -2088, 1344, 1216 ), new( "87", -1564, -2312, 1340, 1216 ),
		new( "88", -1308, -2312, 1344, 1216 ), new( "89", -28, -2312, 1340, 1216 ), new( "90", 228, -2312, 1336, 1216 ),
		new( "91", -156, -2536, 1328, 1216 ), new( "92", 100, -2536, 1332, 1216 ),
	};

	/// <summary>
	/// A tile's inradius, flat to centre.
	///
	/// ⚠️ 124.5, NOT 124: the two flats sit exactly 124 out, but the four slanted sides run 124.5 from the centre,
	/// because the corners are at (±124, ±72) rather than a perfect hexagon's (±124.7, ±72). The hit test uses
	/// the larger so no part of the top falls outside it.
	/// </summary>
	const float Apothem = 124.5f;

	/// <summary>Is basalt loaded? The coordinates mean nothing on any other map.</summary>
	public static bool OnBasalt => NZMap.Current.Contains( "basalt", StringComparison.OrdinalIgnoreCase );

	/// <summary>
	/// The tile a landing here is on, or -1: of the tiles whose top is within <see cref="HeightSlack"/> and whose edge is
	/// within <see cref="EdgeSlack"/>, the one these feet are furthest INTO — so where two tiles' margins overlap, the
	/// one you are actually over wins, not whichever comes first in the table.
	/// </summary>
	public static int TileAt( Vector3 feet ) => TileAt( feet, EdgeSlack, HeightSlack );

	/// <summary>The same, with other slack: a zombie's, for the rings (<see cref="ModWentOff"/>).</summary>
	static int TileAt( Vector3 feet, float edgeSlack, float heightSlack )
	{
		var best = -1;
		var bestOut = float.MaxValue;

		for ( var i = 0; i < Tiles.Length; i++ )
		{
			var t = Tiles[i];
			if ( MathF.Abs( feet.z - t.Top ) > heightSlack ) continue;

			var o = OutOf( feet, t );
			if ( o > edgeSlack || o >= bestOut ) continue;

			best = i;
			bestOut = o;
		}

		return best;
	}

	/// <summary>
	/// How far these feet are OUTSIDE a tile's hexagon, flat: negative inside, as deep as they are in.
	/// Points on ±Y: the flats are |x| = apothem, the other four sides ½|x| + (√3/2)|y| = apothem.
	/// </summary>
	static float OutOf( Vector3 feet, Tile t )
	{
		var ax = MathF.Abs( feet.x - t.X );
		var ay = MathF.Abs( feet.y - t.Y );
		return MathF.Max( ax - Apothem, 0.5f * ax + 0.8660254f * ay - Apothem );
	}

	/// <summary>The nearest tile to a landing that counted on none — for the near-miss line — or -1 if none is close.</summary>
	static int NearMiss( Vector3 feet, out float past, out float above )
	{
		var best = -1;
		past = above = 0f;
		var score = float.MaxValue;

		for ( var i = 0; i < Tiles.Length; i++ )
		{
			var t = Tiles[i];
			var o = MathF.Max( 0f, OutOf( feet, t ) );
			var dz = feet.z - t.Top;
			var s = o + MathF.Abs( dz );
			if ( s >= score ) continue;

			best = i;
			score = s;
			past = o;
			above = dz;
		}

		// ⚠️ ONLY A NEAR MISS IS WORTH A LINE: a slam across the map at a zombie is no attempt at a tile.
		return score <= 200f ? best : -1;
	}

	// ══ what is lit ═════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// Which tiles are lit, and in which colour — all a machine needs to draw them, in four longs so it crosses the
	/// network as one message. Bit i of <see cref="Mask"/> is Tiles[i] lit; bit i of C0, C1 and C2 are the three bits
	/// of its colour's number (0-3 the four colours, <see cref="White"/> a tile lit by hand that is not a pick).
	/// `default` is nothing lit.
	/// </summary>
	public readonly record struct Lit( long Mask, long C0, long C1, long C2 )
	{
		public bool Has( int tile ) => tile >= 0 && tile < 64 && (Mask & (1L << tile)) != 0;

		/// <summary>The colour a tile is lit in — meaningful only where <see cref="Has"/>.</summary>
		public int ColourOf( int tile ) => tile < 0 || tile >= 64 ? White
			: (int)(((C0 >> tile) & 1L) | (((C1 >> tile) & 1L) << 1) | (((C2 >> tile) & 1L) << 2));

		/// <summary>The same set with one more tile lit, or a lit one in another colour.</summary>
		public Lit With( int tile, int colour )
		{
			var b = 1L << tile;
			return new Lit( Mask | b, Bit( C0, b, colour & 1 ), Bit( C1, b, colour & 2 ), Bit( C2, b, colour & 4 ) );
		}

		static long Bit( long plane, long b, int on ) => on != 0 ? plane | b : plane & ~b;
	}

	/// <summary>Send the whole lit set to everybody, the host included. HOST.</summary>
	static void Send( Lit s ) => NZNet.HexPlatformsLit( s.Mask, s.C0, s.C1, s.C2 );

	// ══ state ═══════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// HOST — this round's picks in the order they were drawn: pick k lights in colour k (<see cref="PickColour"/>).
	/// Never sent anywhere.
	/// </summary>
	readonly List<int> _picked = new();

	/// <summary>MIRROR — what is lit, and in which colour. The host's copy is the truth.</summary>
	Lit _lit;

	/// <summary>LOCAL — the panels this machine built for its lit tiles, and the colour each was built in.</summary>
	readonly Dictionary<int, (GameObject Go, int Colour)> _panels = new();

	/// <summary>What is lit, for `NZNet.PushState` to replay to a joiner.</summary>
	public Lit LitState => _lit;

	/// <summary>
	/// MIRROR — what the clue shows: this round's picks in their colours once the power is on, nothing before it. The
	/// host sends it (<see cref="SendClue"/>); every machine draws its own picture from it (<see cref="HexClue"/>).
	/// </summary>
	Lit _clue;

	/// <summary>What the clue shows, for `HexClue` to draw and `NZNet.PushState` to replay to a joiner.</summary>
	public Lit ClueState => _clue;

	/// <summary>HOST — `nz_hex_clue 1`: the picks on the clue with the power off, to look at it. Testing only.</summary>
	bool _clueForced;

	/// <summary>HOST — the power as last seen, so <see cref="OnUpdate"/> notices it come on, or go off at a new game.</summary>
	bool _poweredWas;

	/// <summary>The colour a pick lights in — its place in the draw — or -1 for a tile that is not a pick. HOST.</summary>
	int PickColour( int tile )
	{
		var k = _picked.IndexOf( tile );
		return k < 0 ? -1 : k % Colours;
	}

	// ── the step ──

	/// <summary>HOST — the picks slammed in their turn so far this round, in order. The one due next comes after them.</summary>
	readonly List<int> _sequence = new();

	/// <summary>HOST — this round's attempt failed: every tile went out, and nothing lights until the round ends.</summary>
	bool _failed;

	/// <summary>HOST — the step is done: all four slammed in order. It no longer resets; a new game starts it over.</summary>
	bool _done;

	/// <summary>Is the step done? HOST — the hex slots ask, and go once it is.</summary>
	public static bool StepDone => Instance.IsValid() && Instance._done;

	/// <summary>HOST — `nz_hex_selftest`'s stand-ins for the power and for the slots' order, so it can walk every rule
	/// whatever the real ones are. Null outside the test.</summary>
	bool? _armedForTest;
	int[] _orderForTest;

	/// <summary>Does a slam count yet? Only once the power is on: the order shows from then.</summary>
	bool Armed => _armedForTest ?? Power.IsOn;

	/// <summary>
	/// The order: for each number, I to IV, the colour whose pick is slammed then — the colour on the hex slot showing that
	/// number (`HexSlotManager.ColoursInOrder`). Null when the slots do not give all four; then any order counts.
	/// </summary>
	int[] Order()
	{
		var order = _orderForTest ?? HexSlotManager.ColoursInOrder();
		return order is { Length: >= Colours } ? order : null;
	}

	/// <summary>The pick due next: the next number's colour. -1 when there is no order to follow.</summary>
	int Due()
	{
		var order = Order();
		if ( order is null || _sequence.Count >= order.Length ) return -1;

		var colour = order[_sequence.Count];
		return colour >= 0 && colour < _picked.Count ? _picked[colour] : -1;
	}

	/// <summary>HOST: the step done, or not. The slots are told, since they go once it is.</summary>
	void SetDone( bool done )
	{
		if ( _done == done ) return;

		_done = done;
		if ( HexSlotManager.Instance.IsValid() ) HexSlotManager.Instance.RefreshShown();

		// Bonfire follows Color Smash: undone with it, at a new game
		if ( !done ) ResetBonfire();

		// …and tile 1, on every machine: plain until the step is done, white after
		NZNet.HexPlatformsDone( done );
	}

	/// <summary>Where the step stands, in words. HOST.</summary>
	string StepState() => _done ? "Color Smash DONE — all four slammed in order; it no longer resets"
		: !Armed ? "waiting for the power — nothing counts before it"
		: _failed ? "Color Smash FAILED this round — every tile out; the next round tries again"
		: $"Color Smash: {_sequence.Count} of {_picked.Count} slammed in order";

	// ══ the clue ════════════════════════════════════════════════════════════════════════════
	//
	// ⛔ THE PICKS GO ON THE CLUE ONLY ONCE THE POWER IS ON, and follow every reset after it (asked for as "when the power
	// is turned on, 4 of those hexagons become colored accordingly … that image must be dynamic and change every round
	// according to the step reset"). The clue itself is placed by hand, with the Clue tool and `HexClue.ImagePath`.

	/// <summary>What the clue shows: nothing, or — once <paramref name="powered"/> — the picks, each in its colour. HOST.</summary>
	Lit ClueFor( bool powered )
	{
		var s = default( Lit );
		if ( !powered ) return s;

		foreach ( var p in _picked ) s = s.With( p, PickColour( p ) );
		return s;
	}

	/// <summary>Tell everybody what the clue shows now. HOST — at every reset, and when the power comes on or goes off.</summary>
	void SendClue()
	{
		var s = ClueFor( Power.IsOn || _clueForced );
		NZNet.HexPlatformsClue( s.Mask, s.C0, s.C1, s.C2 );
	}

	/// <summary>What the clue shows. EVERY machine — `NZNet.HexPlatformsClue`; `HexClue` draws from it.</summary>
	public void ApplyClue( Lit s ) => _clue = s;

	/// <summary>
	/// HOST: notice the power change, and put the picks on the clue — or take them off. (And first, on every machine: the
	/// platforms' marks, the lock, the code's symbols, the altar, the Shrieker platform's green sides, the Mastermind's
	/// lights and the risen lava laid again after a hotload that changed how — <see cref="MarksLayout"/>,
	/// <see cref="LockLayout"/>, <see cref="SymbolsLayout"/>, <see cref="AltarLayout"/>, <see cref="ShriekerSidesLayout"/>,
	/// <see cref="MastermindLayout"/>, <see cref="LavaLayout"/>, <see cref="JunctionsLayout"/>, <see cref="ButtonsLayout"/>, <see cref="BlueAltarLayout"/>, <see cref="ArenaLayout"/> — any cursed flame a hotload left behind swept up,
	/// <see cref="SweepCursedFlames"/>, a Mastermind try's flash run, and the lava's surface moved and this machine's player
	/// tested against it, <see cref="TickLava"/>; then, on the host, Torch Carry's carrier watched, the symbols near the
	/// flame shown, the altar's defense run, a Mastermind try settled and the lava's clock kept, <see cref="WatchFlameCarrier"/>,
	/// <see cref="WatchSymbols"/>, <see cref="WatchDefense"/>, <see cref="WatchMastermind"/>, <see cref="WatchLava"/> and <see cref="WatchJunctions"/> —
	/// before the early return below, which would gate them.)
	///
	/// ⚠️ POLLED, NOT `Power.OnPowered`. The power also goes OFF, at every new game (`Power.Reset`), and nothing fires for
	/// that; and a static event would hold on to this component past a hotload. A bool read once a frame misses neither.
	/// </summary>
	protected override void OnUpdate()
	{
		if ( _marksLaid != MarksLayout ) BuildPlatformMarks();
		if ( !_cursedSwept ) { SweepCursedFlames(); BuildRewardTile(); }
		if ( _lockLaid != LockLayout ) BuildLock();
		if ( _symbolsLaid != SymbolsLayout ) BuildSymbols();
		if ( _altarLaid != AltarLayout ) BuildAltar();
		if ( _shriekerSidesLaid != ShriekerSidesLayout ) BuildShriekerSides();
		if ( _mmLaid != MastermindLayout ) { ClearMastermind(); DressMastermind(); }
		TickMastermindFlash();
		if ( _lavaLaid != LavaLayout ) BuildLava();
		TickLava();
		TickQuake();
		if ( _jnLaid != JunctionsLayout ) BuildJunctions();
		TickJunctions();
		if ( _btnLaid != ButtonsLayout ) BuildButtons();
		TickButtons();
		if ( _blueAltarLaid != BlueAltarLayout ) BuildBlueAltar();
		TickBlueAltar();
		if ( _arenaLaid != ArenaLayout || ArenaWantsTry ) BuildArena();
		if ( _hazardsLaid != HazardsLayout ) BuildHazards();
		TickHazards();

		if ( NZGame.IsClient ) return;

		WatchFlameCarrier();
		WatchSymbols();
		WatchDefense();
		WatchMastermind();
		WatchLava();
		WatchJunctions();
		WatchArenaSend();
		WatchBossFight();

		var on = Power.IsOn;
		if ( on == _poweredWas ) return;

		_poweredWas = on;
		if ( _picked.Count > 0 ) SendClue();
	}

	/// <summary>The cursed flame moved to where it should be this frame (`HexPlatforms.Torch.cs`). LOCAL.</summary>
	protected override void OnPreRender() => MoveCursedFlame();

	// ══ the round ═══════════════════════════════════════════════════════════════════════════
	//
	// ⛔ AN UNFINISHED STEP RESETS AT EVERY ROUND END; A DONE ONE NEVER DOES. The user's rule is "every round where the
	// step is not finished, the step fully resets", and what finishes it is all four slammed in the slots' order (asked
	// for 2026-09-26). A first version counted four smashed tiles in ANY order as finished, back when the napalm part was
	// to finish it, and froze the reset; the playtest found round 2 keeping round 1's four. The order is what makes four
	// lit tiles a finish now.

	/// <summary>
	/// A round was cleared: an unfinished step starts over — every tile out, four new picks — and a done one stays. HOST —
	/// `RoundManager`'s round clear.
	/// </summary>
	public static void OnRoundEnd()
	{
		// ⚠️ HOST ONLY, as `HexSlotManager.OnRoundEnd` is: on a client it would pick four tiles of its own and broadcast a
		// reset to everyone. `RoundManager` only runs its round clear on the host; this does not rely on that.
		if ( NZGame.IsClient || !OnBasalt ) return;

		var m = Ensure();
		if ( m.IsValid() ) m.RoundEnded();
	}

	/// <summary>
	/// A round began. HOST — `RoundManager.BeginRound`.
	///
	/// ⚠️ ONLY ROUND 1 PICKS. Every later round was already reset when the one before it ENDED; resetting again here,
	/// ten seconds on, would throw away picks the squad may have slammed during the break. Round 1 has no round before
	/// it, and it is also a NEW GAME, so anything an earlier game in this scene left lit goes out here.
	/// </summary>
	public static void OnRoundStart( int round )
	{
		if ( NZGame.IsClient || !OnBasalt ) return;

		var m = Ensure();
		if ( m.IsValid() ) m.RoundBegan( round );
	}

	/// <summary>
	/// The round-end rule, apart from the map check so `nz_hex_selftest` can walk it anywhere. An unfinished step starts
	/// over — a failed one included, which is what "try again next round" means — and a done one stays done.
	/// </summary>
	void RoundEnded()
	{
		// ⚠️ THE RISING LAVA COUNTS ITS ROUNDS HERE, ABOVE THE EARLY RETURN: Color Smash is long done by then
		LavaRoundEnded( RoundManager.Instance?.Round ?? 0 );

		if ( _done ) return;
		StartOver();
	}

	/// <summary>
	/// The round-start rule, apart from the map check. Round 1 is a new game, which starts the step over, done or not, and
	/// deals the shield lock a new code. A manager made mid-game has no picks either — a hotload destroys runtime
	/// components — so an empty pick list also starts over. And every round frees a lock a wrong code jammed, and brings
	/// back a cursed flame a zombie's hit snuffed out.
	/// </summary>
	void RoundBegan( int round )
	{
		if ( round <= 1 ) { ResetEasterEgg(); return; }
		UnjamLock();
		ReturnFlame();
		MastermindRound();
		if ( _picked.Count == 0 ) StartOver();
	}

	/// <summary>
	/// The whole Easter egg back to its start: every step undone and every light out, from Color Smash to the boss fight — the
	/// twin shield's reset chains on through the Shriekers, the Mastermind, the lava, the junctions, the teleporter's buttons,
	/// the send and the fight (`ResetTwin` … `ResetBossFight`). HOST — a new game's first round, and <see cref="OnGameReset"/>.
	/// </summary>
	void ResetEasterEgg()
	{
		// ⚠️ BONFIRE — AND WITH IT THE RINGS, THE FLAME AND THE ALTAR'S DEFENSE — RESET HERE TOO, not only through
		// `SetDone( false )`, which resets them only when Color Smash WAS done: a fire put out by hand (`nz_hex_bonfire out`)
		// on an undone step would otherwise carry its flame, and a defense, into a new game. The selftest found it.
		SetDone( false ); ResetBonfire(); ResetLock(); ResetTwin();
		UnjamLock();
		ReturnFlame();
		MastermindRound();
		StartOver();
	}

	/// <summary>
	/// A game over, the lobby, or a new game: Oberon gone at once and the whole Easter egg back to its start — *"oberon must
	/// despawn on gameover, and all ester egg steps must reset back to the start"* (2026-09-27). HOST — `RoundManager.EndGame`,
	/// `ReturnToLobby` and `StartGame`; the reset is <see cref="ResetEasterEgg"/>.
	///
	/// ⛔ ALL THREE, NOT ROUND 1 ALONE AS BEFORE. The boss fight freezes the round (<see cref="FreezesRound"/>), so a game that
	/// ended in it left the fight on — behind the score screen, his phase spawning him again the moment anything took him —
	/// and the next game's first round never began, so the reset that lives there never ran.
	///
	/// ⚠️ <paramref name="keepZombies"/> at a game over: his fight's zombies stay, as every zombie does behind the score screen
	/// (`RoundManager.EndGame`); only he goes. The lobby clears the rest.
	/// </summary>
	public static void OnGameReset( string why, bool keepZombies = false )
	{
		if ( NZGame.IsClient || !OnBasalt ) return;

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

		m.EndBossFight( why, keepZombies );
		m.ResetEasterEgg();
		Log.Info( $"[nz-hex] the Easter egg is back at its start — {why}" );
	}

	/// <summary>
	/// The reset: every light out, four new picks — and with them four new colours, since a pick's colour is its place
	/// in the draw — and the clue redrawn with them once the power is on.
	///
	/// ⚠️ THE LIGHTS GO OUT WITH THE PICKS. A tile still lit from last round could be one the new picks do not
	/// include, and it would read as found when it is not even in play.
	/// </summary>
	void StartOver()
	{
		var had = _lit.Mask != 0L || _failed;
		_sequence.Clear();
		_failed = false;
		Pick();
		Send( default );
		SendClue();

		if ( had ) Log.Info( "[nz-hex] step reset — every tile out, four new picks, a new attempt" );
	}

	void Pick()
	{
		_picked.Clear();

		var pool = Enumerable.Range( 0, Math.Min( Tiles.Length, 64 ) ).ToList();
		var n = Math.Clamp( Count, 0, pool.Count );

		for ( var i = 0; i < n; i++ )
		{
			var k = Game.Random.Next( pool.Count );
			_picked.Add( pool[k] );
			pool.RemoveAt( k );
		}

		// ⚠️ A COUNT, NOT A LIST. The picks are the puzzle, and the console is readable by anyone at the machine;
		// `nz_hex_show` reveals them on purpose when testing.
		Log.Info( $"[nz-hex] this round's picks made — {_picked.Count} of {Tiles.Length} tiles, one of each colour" );
	}

	// ══ the slam ════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// A Ground Slam landed. Runs on the SLAMMER'S machine, from `PhdAugments.Tick`'s landing branch — the only
	/// place that knows it was a slam and not a fall. Reports the tile to the host and decides nothing.
	/// </summary>
	public static void OnSlamLanded( NZPlayer player, Vector3 feet )
	{
		if ( !player.IsValid() || player.IsProxy ) return;
		if ( !OnBasalt ) return;

		var i = TileAt( feet );
		if ( i < 0 )
		{
			// ⚠️ A NEAR MISS SAYS HOW NEAR. A miss used to log nothing, which is why the too-tight limits could only be
			// counted afterwards (20 slams, 6 on a tile) and never measured.
			var n = NearMiss( feet, out var past, out var above );
			if ( n >= 0 )
				Log.Info( $"[nz-hex] slam on no tile — nearest {Tiles[n].Id}: {past:0}u past its edge (counts within {EdgeSlack:0}),"
					+ $" {above:+0;-0;0}u from its top (counts within ±{HeightSlack:0})" );
			return;
		}

		NZNet.HexPlatformSlam( i, NZPlayers.NameOf( player ) );
	}

	/// <summary>
	/// `nz_hex_slack [edge] [height]` — how forgiving a slam's landing is: how far past a tile's edge, and how far off its
	/// top, still counts. Bare, it prints them. Until a restart, on this machine — the slammer's machine is the one that
	/// measures; the defaults are `EdgeSlack` and `HeightSlack`.
	/// </summary>
	[ConCmd( "nz_hex_slack" )]
	public static void SlackCmd( float edge = -1f, float height = -1f )
	{
		if ( edge >= 0f ) EdgeSlack = edge;
		if ( height >= 0f ) HeightSlack = height;

		Log.Info( $"[nz-hex] a slam counts up to {EdgeSlack:0}u past a tile's edge and ±{HeightSlack:0}u from its top"
			+ " — where two tiles' margins overlap, the one you are over wins" );
	}

	/// <summary>
	/// A slam report arrived. HOST — `NZNet.HexPlatformSlam`. Lights the tile in its colour if it is the pick whose turn it
	/// is, and fails the step if it is any other of the 54 (<see cref="Slammed"/>).
	/// </summary>
	public static void HostSlam( int tile, string who = "" )
	{
		if ( NZGame.IsClient ) return;
		if ( tile < 0 || tile >= Tiles.Length || tile >= 64 ) return;

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

		m.Slammed( tile, who );
	}

	/// <summary>" (by Name)" for the host's log, or nothing when the report named nobody.</summary>
	static string By( string who ) => string.IsNullOrWhiteSpace( who ) ? "" : $" (by {who})";

	/// <summary>A slam on this tile, by the rules at the top of this class. HOST. `who` only names the slammer in the log.</summary>
	void Slammed( int tile, string who = "" )
	{
		var id = Tiles[tile].Id;
		var by = By( who );

		if ( _done ) { Log.Info( $"[nz-hex] slam on tile {id}{by} — the step is done" ); return; }
		if ( !Armed ) { Log.Info( $"[nz-hex] slam on tile {id}{by} — nothing counts before the power is on" ); return; }
		if ( _failed ) { Log.Info( $"[nz-hex] slam on tile {id}{by} — this round's attempt failed; the next round tries again" ); return; }

		// ⚠️ A PICK ALREADY SLAMMED IN ITS TURN CHANGES NOTHING. A second landing on it is no attempt at the next one.
		if ( _sequence.Contains( tile ) ) return;

		var colour = PickColour( tile );
		var due = Due();

		// ⚠️ WITH NO ORDER TO FOLLOW — the slots not giving all four numbers — any pick not slammed yet is in its turn
		if ( colour < 0 || (due >= 0 && tile != due) )
		{
			Fail( (colour < 0
				? $"tile {id} is not one of the four"
				: $"tile {id} is {ColourName( colour )}, and {HexSlotManager.Numeral( _sequence.Count + 1 )} is {ColourName( Order()[_sequence.Count] )}") + by,
				TileTop( tile ) );
			return;
		}

		// ⚠️ ITS OWN COLOUR, EVEN OVER ONE LIT BY HAND IN ANOTHER (`nz_hex_light`), so testing by hand cannot leave a pick
		// showing the wrong one.
		_sequence.Add( tile );
		Send( _lit.With( tile, colour ) );
		Cue( SmashCue, TileTop( tile ) );
		Log.Info( $"[nz-hex] tile {id} LIT {ColourName( colour )}{by} — {_sequence.Count} of {_picked.Count} in order" );

		if ( _sequence.Count >= _picked.Count ) Complete();
	}

	/// <summary>
	/// A wrong slam: back to the start — every tile out — and nothing more this round. HOST. Its power-down sounds at the
	/// tile slammed, or everywhere alike when there is none (`nz_hex_step fail`).
	/// </summary>
	void Fail( string why, Vector3? at = null )
	{
		_failed = true;
		_sequence.Clear();
		Send( default );
		Cue( FailCue, at );

		Log.Info( $"[nz-hex] Color Smash FAILED — {why}. Every tile out; the next round tries again" );
	}

	/// <summary>
	/// All four in order: Color Smash is done. The four stay lit, the slots go (<see cref="SetDone"/>), tile 1 lights up
	/// with the napalm icon on it, and the clue is left exactly as it is. HOST.
	/// </summary>
	void Complete()
	{
		SetDone( true );
		if ( !Testing ) DoneCueLater();
		Fanfare( 1 );
		Log.Info( "[nz-hex] ✦ COLOR SMASH DONE — all four slammed in order. It no longer resets; the slots are gone, the clue"
			+ " stays, and tile 1 lights up white with the napalm icon on it" );
	}

	// ══ the sounds ══════════════════════════════════════════════════════════════════════════
	//
	// Asked for as *"we need sounds and cues to tell the player when they succeeded and failed"*:
	// - a right tile: *"a sound as if a big rock is being scraped against the floor"* — at the tile;
	// - a wrong one, which fails the attempt: *"a sound that is subtle, like a power down sound"* — at the tile;
	// - the step done: *"a loud clicking sound that's a bit intimidating"* — heard by everyone alike, and a moment after
	//   the last tile's scrape, so the two do not bury each other.
	//
	// ⛔ THE HOST DECIDES EVERY SLAM, SO THE HOST SENDS EVERY SOUND (`NZSound.PlayShared`). A client learns that a slam was
	// right or wrong only from the host's word; a sound it played for itself as well would be heard twice.
	//
	// ⚠️ THE SELFTEST IS SILENT. It walks every rule with real slams, and would set off a dozen scrapes, failures and a
	// finish on every machine in the game.

	/// <summary>The cues, each an `Assets/sounds/nz/&lt;cue&gt;.sound`: a right slam, a wrong one, and the step done.</summary>
	public const string SmashCue = "nz.hex.smash", FailCue = "nz.hex.fail", DoneCue = "nz.hex.done";

	static float? _doneCueDelay;
	/// <summary>How long after the last tile's scrape the step's clicking starts, in seconds.</summary>
	public static float DoneCueDelay { get => _doneCueDelay ?? 1.2f; set => _doneCueDelay = value; }

	/// <summary>
	/// Is the selftest walking the rules, or `nz_hex_skipto` jumping through them? Neither plays their sounds — a skip to
	/// Torch Carry would otherwise set off the step-done clicking three times over.
	/// </summary>
	bool Testing => _armedForTest.HasValue || _quiet;

	/// <summary>HOST — `nz_hex_skipto` is jumping through the steps (<see cref="Testing"/>).</summary>
	bool _quiet;

	/// <summary>Where a tile's sound comes from: the middle of its top.</summary>
	static Vector3 TileTop( int tile ) => new( Tiles[tile].X, Tiles[tile].Y, Tiles[tile].Top );

	/// <summary>A Color Smash cue for everyone: at a place, or everywhere alike. HOST.</summary>
	void Cue( string cue, Vector3? at = null )
	{
		if ( Testing ) return;

		if ( at is Vector3 p ) NZSound.PlayShared( cue, p );
		else NZSound.PlayShared( cue );
	}

	/// <summary>The step's clicking, <see cref="DoneCueDelay"/> after the last tile — unless a new game came first. HOST.</summary>
	async void DoneCueLater()
	{
		await GameTask.Delay( (int)(MathF.Max( 0f, DoneCueDelay ) * 1000f) );

		// ⚠️ RE-VALIDATED AFTER THE WAIT, the project's rule for a continuation (`PhdAugments.ChainRest`)
		if ( !this.IsValid() || !_done ) return;

		NZSound.PlayShared( DoneCue );
	}

	/// <summary>
	/// A step of the Easter egg done: its fanfare on every machine — the map's motif again, and at 7, 10 and 13 the beast's roar and
	/// the ground shaking (`EggFanfare`, 2026-09-28). HOST, where the step is decided; each machine plays it from this word, its
	/// clock starting with the step's clicking. ⚠️ Not while testing, as no step's sounds are.
	///
	/// ⚠️ STEPS 6 AND 14 SEND IT TOO NOW (2026-09-29), for their banner (`EggProgress`) — but their fanfare plays nothing
	/// (`EggFanfare.For`): the defense's music and the fight's quiet begin there.
	/// </summary>
	void Fanfare( int step )
	{
		if ( Testing ) return;

		NZNet.EggStepDone( step );
	}

	/// <summary>
	/// `nz_hex_sound [smash|fail|done] [volume]` — hear one of Color Smash's sounds, on this machine only: a right slam's
	/// scrape and a wrong one's power-down where you stand, the step's clicking everywhere alike. A volume (1 is the asset
	/// as authored) sets that cue's live multiplier first, as `nz_vol` does, until a restart; `nz_vol_bake` prints what to
	/// write into the asset. Bare, it says whether each cue's asset is there.
	/// </summary>
	[ConCmd( "nz_hex_sound" )]
	public static void SoundCmd( string which = "", float volume = -1f )
	{
		var cue = which.Trim().ToLowerInvariant() switch
		{
			"smash" or "right" or "scrape" => SmashCue,
			"fail" or "wrong" or "powerdown" => FailCue,
			"done" or "complete" or "click" => DoneCue,
			_ => "",
		};

		if ( cue == "" )
		{
			foreach ( var c in new[] { SmashCue, FailCue, DoneCue } )
				Log.Info( $"[nz-hex] {c}: {( NZSound.Exists( c ) ? "there" : "MISSING" )} (Assets/sounds/nz/{c}.sound)"
					+ $" · volume x{NZSound.VolumeOf( c ):0.00}" );
			Log.Info( "[nz-hex] nz_hex_sound smash, fail or done [volume] to hear one" );
			return;
		}

		if ( volume >= 0f ) NZSound.SetVolume( cue, volume );

		var me = NZPlayer.Local;
		if ( cue == DoneCue || !me.IsValid() ) NZSound.Play( cue );
		else NZSound.Play( cue, me.WorldPosition );

		Log.Info( $"[nz-hex] played {cue} (volume x{NZSound.VolumeOf( cue ):0.00})" );
	}

	// ══ the panels ══════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// Show exactly what <paramref name="s"/> lights, each tile in its colour. EVERY machine — `NZNet.HexPlatformsLit`.
	///
	/// ⚠️ IDEMPOTENT: a set already on show changes nothing, which is what makes sending the whole set again — to a
	/// joiner, or twice — free. A tile lit in another colour than its panels are is rebuilt.
	/// </summary>
	public void ApplyLit( Lit s )
	{
		for ( var i = 0; i < Tiles.Length && i < 64; i++ )
		{
			var on = s.Has( i );
			var colour = s.ColourOf( i );
			var had = _panels.TryGetValue( i, out var p );

			if ( had && (!on || p.Colour != colour) )
			{
				_panels.Remove( i );
				if ( p.Go.IsValid() ) p.Go.Destroy();
				had = false;
			}

			if ( on && !had )
			{
				var go = BuildPanel( i, colour );
				if ( go.IsValid() ) _panels[i] = (go, colour);
			}
		}

		_lit = s;

		// the glyphs lie on the tiles lit in their colours, so a new lit set may move them
		BuildPlatformMarks();
	}

	GameObject BuildPanel( int i, int colour )
	{
		var model = HexSidePanels.Build( new[] { Tiles[i] }, MaterialFor( colour ) );
		if ( model is null ) return null;

		var go = Scene.CreateObject();
		go.Name = $"Hex panel {Tiles[i].Id} ({ColourName( colour )})";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		go.Tags.Add( PanelTag );

		var r = go.Components.Create<ModelRenderer>();
		r.Model = model;
		r.RenderType = ModelRenderer.ShadowRenderType.Off;
		return go;
	}

	// ══ tile 1 — where the soul boxes' part lands ═══════════════════════════════════════════
	//
	// ⛔ STEP 1 DONE LIGHTS IT UP. Asked for as *"that platform must have the sides light up in white after completing the
	// step"*. It is the hexagon at the lava room's west end, past stepping stone 2, that soul-box part 3 drops onto
	// (`SoulBoxSpot.PartDrop` -4343.7 -289.0 1185.5, 4u off its centre).
	//
	// ⚠️ THE MAP ALREADY DRAWS ITS SIX SIDES IN `lights/white001`, from its top at 1176 down to 1092, so it is lit all game
	// and "lighting it" would have changed nothing. By the user's choice it wears the stones' plain concrete over them
	// until the step is done, and white panels after. The concrete is laid the way the map lays it on the stones beside
	// it: `concretewall010a`, one repeat per 128 units, measured off their own texture coordinates.
	//
	// ⚠️ NOT ONE OF THE 54. It is never picked, and a slam on it is no slam on a panel: `TileAt` does not know it.

	/// <summary>Tile 1: the hexagon soul-box part 3 lands on. Its top is at 1176 and its sides reach down to 1092.</summary>
	public static readonly Tile RewardTile = new( "1", -4348, -288, 1176, 1092 );

	/// <summary>What the stepping stones' own sides are, and tile 1's until step 1 is done.</summary>
	public const string StoneMaterial = "materials/concrete/concretewall010a.vmat";

	/// <summary>World units per repeat of <see cref="StoneMaterial"/>, as the map lays it on the stones.</summary>
	const float StoneUnits = 128f;

	/// <summary>
	/// The napalm icon, laid in red light on tile 1's top once Color Smash is done: THE OFFERING — a figure with its arms
	/// raised, as the oldest carvings draw a person, burning inside a fire of teeth on a stepped altar. Asked for as *"an
	/// icon that is cryptic and red, but that hints at the napalm zombie"*, then *"more ancient looking, something to do
	/// with fire and sacrifices"*: the napalm zombie is the burning one, and its death is what the fire pit asks for. In
	/// the ammo symbols' stroke family; the points span -1..1, y up.
	///
	/// ⚠️ `Tools/basalt_hex_symbols.py` READS THESE LINES to draw the icon as a picture, so the shape has this one home. Keep
	/// each shape on one line, as ("kind", new[] { numbers }).
	/// </summary>
	static readonly (string Kind, float[] P)[] NapalmIcon =
	{
		("line", new[] { -0.78f, -0.86f, 0.78f, -0.86f, 0.78f, -0.70f, 0.56f, -0.70f, 0.56f, -0.54f, -0.56f, -0.54f, -0.56f, -0.70f, -0.78f, -0.70f, -0.78f, -0.86f }),
		("line", new[] { -0.44f, -0.54f, -0.52f, -0.22f, -0.38f, -0.04f, -0.50f, 0.24f, -0.30f, 0.44f, -0.26f, 0.68f, -0.10f, 0.54f, 0.00f, 0.88f, 0.10f, 0.54f, 0.26f, 0.68f, 0.30f, 0.44f, 0.50f, 0.24f, 0.38f, -0.04f, 0.52f, -0.22f, 0.44f, -0.54f }),
		("line", new[] { -0.14f, -0.54f, 0.00f, -0.24f, 0.14f, -0.54f }),
		("line", new[] { 0.00f, -0.24f, 0.00f, 0.08f }),
		("line", new[] { -0.28f, 0.30f, -0.24f, 0.06f, 0.00f, 0.02f, 0.24f, 0.06f, 0.28f, 0.30f }),
		("ring", new[] { 0.00f, 0.22f, 0.08f }),
	};

	/// <summary>The icon's half-size on tile 1's top, in units: its -1..1 spans 170u, well inside the tile's 249u across.</summary>
	const float IconScale = 85f;

	/// <summary>How wide its strokes are, in the icon's own units: the ammo symbols' 0.085, a little finer.</summary>
	const float IconStroke = 0.068f;

	/// <summary>
	/// Which way the icon's top — the fire's peak — points: west. It stands upright to whoever comes along the stepping
	/// stones from the east, the altar nearest them.
	/// </summary>
	static readonly Vector3 IconTop = new( -1f, 0f, 0f );

	/// <summary>How far above tile 1's top the icon lies: enough not to flicker against it, too little to see.</summary>
	const float IconLift = 0.5f;

	/// <summary>
	/// The napalm icon as a model, in a colour's own tinted light — the red tile's until Bonfire is done, the green tile's
	/// after. Null if it cannot be made.
	/// </summary>
	static Model NapalmModel( int colour ) => IconModel( NapalmIcon, IconStroke, colour );

	/// <summary>
	/// An icon in the ammo symbols' stroke family — ("line", x0 y0 x1 y1 …), ("ring", x y r), ("arc", x y r a0 a1),
	/// ("dot", x y r), spanning -1..1 — as a model: <paramref name="scale"/> units a unit (<see cref="IconScale"/> unless
	/// told), <paramref name="stroke"/> wide in the icon's own units, in a colour's own tinted light. The offering on tile 1,
	/// and the platforms' glyphs (`HexPlatforms.Rings.cs`). Null if it cannot be made.
	/// </summary>
	static Model IconModel( (string Kind, float[] P)[] shapes, float stroke, int colour, float scale = IconScale )
	{
		var material = MaterialFor( colour );
		if ( material is null || shapes is null ) return null;

		var s = new LightStrokes();
		var w = stroke * scale;
		foreach ( var (kind, p) in shapes )
		{
			Vector2 At( int i ) => new Vector2( p[i] * scale, p[i + 1] * scale );
			switch ( kind )
			{
				case "line":
				{
					var pts = new List<Vector2>();
					for ( var i = 0; i + 1 < p.Length; i += 2 ) pts.Add( At( i ) );
					s.Line( pts, w );
					break;
				}
				case "ring": s.Ring( At( 0 ), p[2] * scale, w ); break;
				case "arc": s.Arc( At( 0 ), p[2] * scale, p[3], p[4], w ); break;
				case "dot": s.Dot( At( 0 ), p[2] * scale ); break;
			}
		}

		return s.Build( material );
	}

	/// <summary>MIRROR — is step 1 done, as this machine was told (`NZNet.HexPlatformsDone`)? Tile 1 is dressed from it.</summary>
	bool _doneShown;

	/// <summary>LOCAL — tile 1's panels, and whether they were built white (done) or plain; the icon, and its colour.</summary>
	GameObject _rewardGo;
	bool _rewardWhite;
	GameObject _iconGo;
	int _iconColour = -1;

	/// <summary>Step 1 done, or not. EVERY machine — `NZNet.HexPlatformsDone`: tile 1 dressed to match.</summary>
	public void ApplyDone( bool done )
	{
		_doneShown = done;
		BuildRewardTile();
	}

	/// <summary>
	/// Tile 1 as the steps stand on this machine. LOCAL, and only on basalt — the coordinates are basalt's:
	/// - its sides: the stones' plain concrete over the map's white until Color Smash is done, white after;
	/// - the offering on its top, once Color Smash is done: red, and green once Bonfire is — gone once step 4 puts the fire out;
	/// - the bonfire over it, from a napalm zombie's death on it (`HexPlatforms.Bonfire.cs`), until step 4 puts it out;
	/// - the cursed flame after that, floating over it or in its carrier's hands (`BuildCursedFlame`, Torch Carry).
	/// Each part is rebuilt only when what it should show has changed.
	/// </summary>
	void BuildRewardTile()
	{
		if ( !OnBasalt ) { ClearRewardTile(); return; }

		BuildTileSides();
		BuildOffering();
		BuildBonfire();
		BuildCursedFlame();
	}

	/// <summary>
	/// The colour the offering is drawn in now, or -1 for none: red once Color Smash is done, green once Bonfire is, and none
	/// once step 4 has put the fire out — *"remove the icon in the floor"*.
	/// </summary>
	int OfferingColour() => !_doneShown || _bonfireShown >= BonfireOut ? -1
		: _bonfireShown >= BonfireDone ? ColourNumber( "green" ) : ColourNumber( "red" );

	void BuildTileSides()
	{
		if ( _rewardGo.IsValid() && _rewardWhite == _doneShown ) return;

		if ( _rewardGo.IsValid() ) _rewardGo.Destroy();
		_rewardGo = null;

		var white = _doneShown;
		var material = white ? MaterialFor( White ) : Material.Load( StoneMaterial );
		if ( material is null )
		{
			Log.Warning( $"[nz-hex] {( white ? LightMaterial : StoneMaterial )} not found — tile 1 left as the map draws it" );
			return;
		}

		var model = HexSidePanels.Build( new[] { RewardTile }, material, white ? 0f : StoneUnits );
		if ( model is null ) return;

		var go = Scene.CreateObject();
		go.Name = $"Hex tile {RewardTile.Id} ({( white ? "white — Color Smash done" : "plain until Color Smash is done" )})";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		go.Tags.Add( PanelTag );

		var r = go.Components.Create<ModelRenderer>();
		r.Model = model;
		r.RenderType = ModelRenderer.ShadowRenderType.Off;

		_rewardGo = go;
		_rewardWhite = white;
	}

	/// <summary>The offering on tile 1's top, in the colour Bonfire gives it — or gone, before Color Smash is done.</summary>
	void BuildOffering()
	{
		var colour = OfferingColour();
		if ( colour == _iconColour && _iconGo.IsValid() == (colour >= 0) ) return;

		if ( _iconGo.IsValid() ) _iconGo.Destroy();
		_iconGo = null;
		_iconColour = -1;
		if ( colour < 0 ) return;

		var icon = NapalmModel( colour );
		if ( icon is null ) return;

		var ig = Scene.CreateObject();
		ig.Name = $"Hex tile {RewardTile.Id} — the offering ({ColourName( colour )})";
		ig.Flags |= GameObjectFlags.NotSaved;
		ig.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		ig.Tags.Add( PanelTag );
		ig.WorldPosition = new Vector3( RewardTile.X, RewardTile.Y, RewardTile.Top + IconLift );
		ig.WorldRotation = Rotation.LookAt( Vector3.Up, IconTop );          // facing up, its top to the west

		var ir = ig.Components.Create<ModelRenderer>();
		ir.Model = icon;
		ir.RenderType = ModelRenderer.ShadowRenderType.Off;
		_iconGo = ig;
		_iconColour = colour;
	}

	void ClearRewardTile()
	{
		if ( _rewardGo.IsValid() ) _rewardGo.Destroy();
		if ( _iconGo.IsValid() ) _iconGo.Destroy();
		_rewardGo = null;
		_iconGo = null;
		_iconColour = -1;
		ClearBonfire();
		ClearCursedFlame();
	}

	/// <summary>
	/// A config was put in the world — `NZGame.ShowConfig`, on every machine. On basalt, tile 1 is dressed from the start,
	/// and the host deals the shield lock a new code — *"loading the config or getting a game over rerolls the code"*; on
	/// any other map its panels go, since their coordinates are basalt's.
	/// </summary>
	public static void OnConfigShown( Scene scene )
	{
		if ( OnBasalt )
		{
			var m = Ensure( scene );
			if ( !m.IsValid() ) return;

			if ( !NZGame.IsClient ) m.DealCode();
			m.BuildRewardTile();
			m.BuildPlatformMarks();
			m.BuildLock();
			m.BuildSymbols();
			m.BuildAltar();
			m.BuildTwin();
			m.BuildShriekerSides();
			m.DressMastermind();
			m.BuildLava();
			m.BuildJunctions();
			m.BuildButtons();
			m.BuildBlueAltar();
			m._arenaTries = 0;
			m._arenaLowered = false;
			m.BuildArena();
			return;
		}

		if ( Instance.IsValid() )
		{
			Instance.ClearRewardTile();
			Instance.BuildPlatformMarks();
			Instance.ClearLock();
			Instance.ClearSymbols();
			Instance.ClearAltar();
			Instance.ClearTwin();
			Instance.ClearShriekerSides();
			Instance.ClearMastermind();
			Instance.ClearLava();
			Instance.ClearJunctions();
			Instance.ClearButtons();
			Instance.ClearBlueAltar();
			Instance.ClearArena();
		}

		// ⛔ AND THE LAVA'S FOG, WHETHER OR NOT BASALT'S MANAGER IS HERE (the co-op audit, 2026-09-27). It is a static, only the
		// lava sets it, and only `ClearLava` cleared it — so a machine that left basalt mid-flood, by a disconnect or a quit,
		// carried basalt's fog into the next map, everywhere.
		FogAreaManager.Forced = null;

		// the walkers' eyes back to their own blue, if an altar defense left them purple
		ZombieEyes.SetDefense( false );
	}

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

	/// <summary>`nz_hex` — the tile you are standing on, what is lit and in which colour, and how many are picked (host).</summary>
	[ConCmd( "nz_hex" )]
	public static void Report()
	{
		Log.Info( $"[nz-hex] map '{NZMap.Current}'{( OnBasalt ? "" : " — not basalt, nothing runs here" )}"
			+ $" · {Tiles.Length} tiles available" );

		var me = NZPlayer.Local;
		if ( me.IsValid() )
		{
			var i = TileAt( me.WorldPosition );
			Log.Info( i >= 0 ? $"[nz-hex] you are on tile {Tiles[i].Id}" : "[nz-hex] you are not on an available tile" );
		}

		var m = Instance;
		if ( !m.IsValid() ) { Log.Info( "[nz-hex] nothing picked yet — picks are made when a round starts" ); return; }

		var lit = Enumerable.Range( 0, Math.Min( Tiles.Length, 64 ) )
			.Where( m._lit.Has ).Select( i => $"{Tiles[i].Id} {ColourName( m._lit.ColourOf( i ) )}" ).ToList();

		Log.Info( $"[nz-hex] lit: {( lit.Count > 0 ? string.Join( ", ", lit ) : "none" )}"
			+ ( NZGame.IsHost ? $" · {m._picked.Count} picked this round (nz_hex_show reveals them)" : "" ) );

		if ( NZGame.IsHost ) Log.Info( $"[nz-hex] step: {m.StepState()}" );

		Log.Info( "[nz-hex] tile 1, where the soul boxes' part lands: "
			+ ( !m._rewardGo.IsValid() ? "as the map draws it (not dressed here)"
				: m._rewardWhite ? "white, with the offering — Color Smash done" : "plain stone until Color Smash is done" ) );

		Log.Info( NZGame.IsHost ? $"[nz-hex] {m.BonfireStateText()}"
			: $"[nz-hex] Bonfire: stage {m._bonfireShown}, {m._pestsShown} of {BonfireTarget} pests (as the host last said)" );

		var rings = NZGame.IsHost ? m._rings : m._ringsShown;
		Log.Info( $"[nz-hex] Color Rings: {( rings.Active ? rings.Describe() : "not begun — it begins when Bonfire is done" )}" );
	}

	/// <summary>`nz_hex_show` — HOST: print this round's picks and their colours. Testing only — it prints the answer.</summary>
	[ConCmd( "nz_hex_show" )]
	public static void Show()
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] only the host knows the picks" ); return; }

		var m = Instance;
		if ( !m.IsValid() || m._picked.Count == 0 ) { Log.Info( "[nz-hex] nothing picked — nz_hex_roll picks now" ); return; }

		Log.Info( "[nz-hex] this round's picks: "
			+ string.Join( ", ", m._picked.Select( p => $"{Tiles[p].Id} {ColourName( m.PickColour( p ) )}"
				+ ( m._lit.Has( p ) ? " (lit)" : "" ) ) )
			+ ( m._clue.Mask != 0L ? " · on the clue" : " · not on the clue (the power is off)" ) );

		var order = m.Order();
		Log.Info( order is null
			? $"[nz-hex] the order: any — the slots do not give all four numbers · {m.StepState()}"
			: "[nz-hex] the order: " + string.Join( ", ", Enumerable.Range( 0, order.Length ).Select( k =>
				$"{HexSlotManager.Numeral( k + 1 )} {ColourName( order[k] )} (tile {Tiles[m._picked[order[k]]].Id})" ) )
				+ $" · {m.StepState()}" );
	}

	/// <summary>
	/// `nz_hex_step [done|fail|reset]` — HOST: where Color Smash stands. To test what follows it: `done` finishes it, as
	/// all four in order would; `fail` fails this round's attempt, as a wrong slam would; `reset` starts it over with four
	/// new picks, as a new game does.
	/// </summary>
	[ConCmd( "nz_hex_step" )]
	public static void StepCmd( 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 "done":
			{
				var lit = m._lit;
				foreach ( var p in m._picked ) lit = lit.With( p, m.PickColour( p ) );
				m._sequence.Clear();
				m._sequence.AddRange( m._picked );
				Send( lit );
				m.Complete();
				break;
			}

			case "fail":
				m.Fail( "by hand (nz_hex_step fail)" );
				break;

			case "reset":
				m.SetDone( false );
				m.StartOver();
				break;

			default:
				Log.Warning( "[nz-hex] nz_hex_step done, fail or reset — or nothing, to see where it stands" );
				return;
		}

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

	/// <summary>
	/// `nz_hex_skipto &lt;smash|bonfire|rings|torch|altar|defense|twin|shriekers|mastermind|lava|junctions|buttons&gt;` — HOST: straight to the start of a step, every step before it done and
	/// nothing after it begun, to test that step without playing the ones before. Asked for as *"do we have commands that
	/// auto complete the steps? i want to be on the start of the torch step"*. It runs the steps' own commands in order —
	/// `nz_hex_step reset` and `done`, `nz_hex_bonfire done`, `nz_hex_rings solve`, `nz_hex_bonfire out` — so it lands
	/// exactly where they would; but silently, none of the steps' sounds playing on the way.
	/// - `smash`: Color Smash from its start, four new picks, as a new game has it (slams count once the power is on);
	/// - `bonfire`: Color Smash done — its four lit, tile 1 white, the red offering — and Bonfire waiting for its napalm zombie;
	/// - `rings`: Bonfire done too — tile 1 burning, the offering green — and Color Rings freshly dealt;
	/// - `torch`: Color Rings solved too, and the fire out: the cursed flame floats over tile 1, nobody holding it, and the
	///   shield lock shut — its code is what the carry finds;
	/// - `altar`: the shield lock open too, and the cursed flame in your hands, to set on the altar (`nz_hex_lock open`,
	///   `nz_hex_torch take`);
	/// - `defense`: the flame set on the altar too, and its defense begun — the wave on its way;
	/// - `twin`: the altar held too — the flame light blue — and the flame in your hands, to take to the twin shield;
	/// - `shriekers`: the twin shield down too, the flame spent in it, and none of the Shrieker platform's three killed yet;
	/// - `mastermind`: the Shrieker platform done too, its sides green, and the Mastermind begun — each ring a colour, three
	///   tries;
	/// - `lava`: the Mastermind solved too, every light green, and the rising lava's thirty seconds' warning begun — its
	///   banner and rumble heard, as the solve would have them;
	/// - `junctions`: the lava survived too, back in its bed, and the junctions awake — the rings blinking their route;
	/// - `buttons`: the junctions through too — the teleporter's sides light blue — and its six buttons awake, all red.
	/// </summary>
	[ConCmd( "nz_hex_skipto" )]
	public static void SkipToCmd( string step = "" )
	{
		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; }

		var (to, name) = step.Trim().ToLowerInvariant() switch
		{
			"smash" or "colorsmash" or "1" => (1, "Color Smash"),
			"bonfire" or "2" => (2, "Bonfire"),
			"rings" or "colorrings" or "3" => (3, "Color Rings"),
			"torch" or "torchcarry" or "4" => (4, "Torch Carry"),
			"altar" or "5" => (5, "the Altar"),
			"defense" or "altardefense" or "6" => (6, "the Altar Defense"),
			"twin" or "twinshield" or "7" => (7, "the twin shield"),
			"shriekers" or "shriekerplatform" or "8" => (8, "the Shrieker platform"),
			"mastermind" or "9" => (9, "the Mastermind"),
			"lava" or "risinglava" or "10" => (10, "the rising lava"),
			"junctions" or "junction" or "11" => (11, "the junctions"),
			"buttons" or "button" or "destination" or "12" => (12, "the teleporter's buttons"),
			_ => (0, ""),
		};
		if ( to == 0 )
		{
			Log.Warning( "[nz-hex] nz_hex_skipto smash, bonfire, rings, torch, altar, defense, twin, shriekers, mastermind, lava, junctions or buttons — the step to be at the start of" );
			return;
		}

		m._quiet = true;
		try
		{
			// from the top: Color Smash anew, and everything after it undone with it — the shield lock shut and free too
			StepCmd( "reset" );
			m.ResetBonfire();
			m._lockOpen = false;
			m._lockJammed = false;
			m.SendLock();
			m.ResetTwin();

			if ( to >= 2 ) StepCmd( "done" );
			if ( to >= 3 ) BonfireCmd( "done" );
			if ( to >= 4 )
			{
				RingsCmd( "solve" );
				BonfireCmd( "out" );
			}
			if ( to >= 5 )
			{
				LockCmd( "open" );
				TorchCmd( "take" );
			}
			if ( to >= 6 )
			{
				var refused = m.PlaceFlame( NZPlayer.Local, anywhere: true );
				if ( refused != "" ) Log.Warning( $"[nz-hex] the cursed flame is not set on the altar: {refused}" );
			}
			if ( to >= 7 )
			{
				if ( m._defense == Defense.Running ) m.WinDefense();
				TorchCmd( "take" );
			}
			if ( to >= 8 )
			{
				var refused = m.BreakTwin( NZPlayer.Local, anywhere: true );
				if ( refused != "" ) Log.Warning( $"[nz-hex] the twin shield is not down: {refused}" );
			}
			if ( to >= 9 )
			{
				m._shriekerKills = ShriekersTarget;
				m.SendShriekers();
				m.StartMastermind();
			}
			if ( to >= 10 )
			{
				m._mmPending = -1;
				m._mmColours = m._mmAnswer;
				m._mmFlags = MmActive | MmDone;
				m.SendMastermind();
			}
			if ( to >= 11 )
			{
				m._lavaRoseRound = 0;
				m.SetLava( LavaDone );
				m.StartJunctions();
			}
			if ( to >= 12 )
			{
				m._jnSettings = m._jnAnswers;
				m._jnFlags = JnActive | JnDone;
				m.SendJunctions();
				m.StartButtons();
			}
		}
		finally
		{
			m._quiet = false;
		}

		// the lava's warning out of the quiet, so its banner and its rumble come as the solve would bring them
		if ( to == 10 ) m.StartLava();

		Log.Info( $"[nz-hex] ⏭ at the start of {name}" + ( to >= 4 ? $" — {m.TorchStateText()}" : "" )
			+ ( to >= 8 ? $" · {m.ShriekersStateText()}" : "" ) + ( to >= 9 ? $" · {m.MastermindStateText()}" : "" )
			+ ( to >= 10 ? $" · {m.LavaStateText()}" : "" ) + ( to >= 11 ? $" · {m.JunctionsStateText()}" : "" )
			+ ( to >= 12 ? $" · {m.ButtonsStateText()}" : "" ) );
	}

	/// <summary>
	/// `nz_hex_clue [1|0]` — HOST: what the clue shows now, and why. `1` puts the picks on it with the power off, to look
	/// at it — testing only, it gives the answer away; `0` goes back to following the power.
	/// </summary>
	[ConCmd( "nz_hex_clue" )]
	public static void ClueCmd( int show = -1 )
	{
		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; }

		if ( show >= 0 )
		{
			m._clueForced = show > 0;
			m.SendClue();
		}

		var shown = Enumerable.Range( 0, Math.Min( Tiles.Length, 64 ) ).Where( m._clue.Has )
			.Select( i => $"{Tiles[i].Id} {ColourName( m._clue.ColourOf( i ) )}" ).ToList();

		Log.Info( $"[nz-hex] the clue shows {( shown.Count > 0 ? string.Join( ", ", shown ) : "the tiles, no picks" )}"
			+ $" · power {( Power.IsOn ? "on" : "off" )}{( m._clueForced ? " · FORCED on (nz_hex_clue 0 undoes)" : "" )}"
			+ $" · it is any clue placed with the image {HexClue.ImagePath}" );
	}

	/// <summary>`nz_hex_roll` — HOST: reset the step now — every light out, four new picks — as a round end does.</summary>
	[ConCmd( "nz_hex_roll" )]
	public static void Roll()
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }
		if ( !OnBasalt ) { Log.Warning( $"[nz-hex] map is '{NZMap.Current}', not basalt" ); return; }

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

		m.StartOver();
	}

	/// <summary>
	/// `nz_hex_selftest` — HOST: walk the step's rules in code, without the map or a real slam, and print each
	/// check. Puts the picks, the lights and the step back exactly as they were when it is done. The power and the slots'
	/// order are stood in for (<see cref="_armedForTest"/>, <see cref="_orderForTest"/>), so every rule is walked whatever
	/// the real ones are.
	///
	/// ⚠️ IT GOES THROUGH THE REAL `HostSlam` AND ROUND RULES, broadcasts included, because testing a copy of the
	/// logic would prove the copy. On a networked game the other players see the test's lights flicker once.
	/// </summary>
	[ConCmd( "nz_hex_selftest" )]
	public static void SelfTest()
	{
		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; }

		var savedPicks = m._picked.ToList();
		var savedLit = m._lit;
		var savedSequence = m._sequence.ToList();
		var savedFailed = m._failed;
		var savedDone = m._done;
		var savedBonfire = m._bonfire;
		var savedPests = m._pests;
		var savedRings = m._rings;
		var savedCarrier = m._flameCarrier;
		var savedLost = m._flameLost;
		var savedPlaced = m._flamePlaced;
		var savedDefense = m._defense;
		var savedHits = m._altarHits;
		var savedTwin = m._twinOpen;
		var savedShriekers = m._shriekerKills;
		var (savedMmColours, savedMmAnswer, savedMmTries, savedMmFlags) = (m._mmColours, m._mmAnswer, m._mmTries, m._mmFlags);
		var (savedLava, savedLavaSince, savedLavaRose) = (m._lava, (float)m._lavaSince, m._lavaRoseRound);
		var (savedJnRoute, savedJnSettings, savedJnAnswers, savedJnFlags) = (m._jnRoute, m._jnSettings, m._jnAnswers, m._jnFlags);
		var (savedBtnLinks, savedBtnColours, savedBtnFlags) = (m._btnLinks, m._btnColours, m._btnFlags);
		var (savedArenaSend, savedArenaSince, savedArenaSent, savedArenaShown) = (m._arenaSend, m._arenaSendSince, m._arenaSent, m._arenaSendShown);
		var savedLockCode = m._lockGlyphs;
		var savedSpots = m._codeSpots?.ToArray();
		var savedLockOpen = m._lockOpen;
		var savedJammed = m._lockJammed;
		var savedAll = m._symbolsAll;

		int pass = 0, fail = 0;
		void Check( bool ok, string what )
		{
			if ( ok ) pass++; else fail++;
			Log.Info( $"[nz-hex-test] {( ok ? "ok  " : "FAIL" )} {what}" );
		}
		bool InItsColour( int i ) => m._lit.Has( i ) && m._lit.ColourOf( i ) == m.PickColour( i );
		string Picks() => string.Join( ",", m._picked );

		// ⚠️ AN ORDER THAT IS NOT THE COLOURS' OWN — I green, II blue, III red, IV yellow — so a rule that quietly followed
		// the draw instead of the order would fail here
		m._armedForTest = true;
		m._orderForTest = new[] { 2, 0, 3, 1 };
		int InOrder( int k ) => m._picked[m._orderForTest[k]];
		int NotAPick() => Enumerable.Range( 0, Tiles.Length ).First( i => !m._picked.Contains( i ) );

		// 1. a new game: round 1 picks four, one of each colour, every tile out
		m.RoundBegan( 1 );
		Check( m._picked.Count == Count && m._picked.Distinct().Count() == Count, $"round 1 picks {Count} different tiles" );
		Check( Enumerable.Range( 0, m._picked.Count ).All( k => m.PickColour( m._picked[k] ) == k % Colours ),
			"…in the colours blue, yellow, green, red: "
			+ string.Join( ", ", m._picked.Select( p => $"{Tiles[p].Id} {ColourName( m.PickColour( p ) )}" ) ) );
		Check( m._lit.Mask == 0L && m._sequence.Count == 0 && !m._failed && !m._done,
			"…every tile is out, and the step is at its start" );

		// 2. nothing counts before the power: neither the pick due nor a wrong tile
		m._armedForTest = false;
		HostSlam( InOrder( 0 ) );
		HostSlam( NotAPick() );
		Check( m._lit.Mask == 0L && !m._failed && m._sequence.Count == 0,
			"before the power, the pick due lights nothing and a wrong tile fails nothing" );
		m._armedForTest = true;

		// 3. in order: the pick whose turn it is lights in its own colour — once
		HostSlam( InOrder( 0 ) );
		Check( InItsColour( InOrder( 0 ) ) && m._lit.ColourOf( InOrder( 0 ) ) == 2,
			$"I is green here: a slam on the green pick, tile {Tiles[InOrder( 0 )].Id}, lights it green" );

		if ( m._panels.TryGetValue( InOrder( 0 ), out var panel ) )
			Check( panel.Colour == 2 && panel.Go.IsValid(), "…and its panels are built, in green" );
		else
			Check( false, "…and its panels are built, in green" );

		HostSlam( InOrder( 0 ) );
		Check( m._sequence.Count == 1 && !m._failed && InItsColour( InOrder( 0 ) ), "…and slamming it again changes nothing" );

		// 4. out of its turn: back to the start, and nothing more this round
		HostSlam( InOrder( 2 ) );
		Check( m._failed && m._lit.Mask == 0L && m._sequence.Count == 0, "III before II fails the step: every tile out" );

		HostSlam( InOrder( 0 ) );
		HostSlam( InOrder( 1 ) );
		Check( m._lit.Mask == 0L && m._failed, "…and for the rest of the round nothing lights, not even the pick due" );

		// 5. a later round START changes nothing — the reset is at the round end
		var picksBefore = Picks();
		m.RoundBegan( 2 );
		Check( Picks() == picksBefore && m._failed, "a round 2 start leaves the picks, and the failure, alone" );

		// 6. a round END starts it over: four new picks, and the attempt open again
		m.RoundEnded();
		Check( m._lit.Mask == 0L && m._picked.Count == Count && !m._failed && m._sequence.Count == 0,
			"a round end: four new picks, every tile out, a new attempt" );

		// 7. a wrong tile fails too
		HostSlam( InOrder( 0 ) );
		HostSlam( NotAPick() );
		Check( m._failed && m._lit.Mask == 0L, "a slam on a tile that is not one of the four fails the step, and puts out the one lit" );
		m.RoundEnded();

		// 8. all four in order: done — it no longer resets, the slots go, and the clue stays as it is
		for ( var k = 0; k < Colours; k++ ) HostSlam( InOrder( k ) );
		Check( m._picked.All( InItsColour ),
			"all four in order light all four, each in its own colour: "
			+ string.Join( ", ", Enumerable.Range( 0, Colours ).Select( k => $"{Tiles[InOrder( k )].Id} {ColourName( m._lit.ColourOf( InOrder( k ) ) )}" ) ) );
		Check( m._done, "…and the step is done" );

		var picksDone = Picks();
		var litDone = m._lit;
		var clueDone = m._clue;
		m.RoundEnded();
		Check( Picks() == picksDone && m._lit == litDone && m._done, "a round end no longer resets it: the same four, still lit" );

		HostSlam( NotAPick() );
		Check( m._lit == litDone && !m._failed, "…and a slam on any tile changes nothing" );
		Check( m._clue == clueDone, "…and the clue has not changed" );

		var slots = HexSlotManager.Instance;
		Check( !slots.IsValid() || slots.Shown.Count == 0 || slots.ShownByHand,
			slots.IsValid() ? "…and the slots are gone" : "…and there are no slots here to hide" );

		bool TileOneIn( string material ) => m._rewardGo.IsValid()
			&& (m._rewardGo.Components.Get<ModelRenderer>()?.Model?.Materials.Any( x => x?.Name?.Contains( material ) ?? false ) ?? false);

		bool IconIn( string material ) => m._iconGo.IsValid()
			&& (m._iconGo.Components.Get<ModelRenderer>()?.Model?.Materials.Any( x => x?.Name == material ) ?? false);

		if ( OnBasalt )
		{
			Check( m._rewardWhite && TileOneIn( "white001" ), "…and tile 1, where the soul boxes' part lands, lights up white" );
			var red = MaterialFor( ColourNumber( "red" ) );
			Check( red is not null && IconIn( red.Name )
					&& m._iconGo.WorldPosition.Distance( new Vector3( RewardTile.X, RewardTile.Y, RewardTile.Top + IconLift ) ) < 0.01f,
				"…with the napalm icon on its top, in red" );
		}

		// 8b. Bonfire: a napalm zombie dying on tile 1 sets it burning; pests dying on it count; the target turns the
		// offering green — through the real rule, `BodyFell`, without a zombie
		var one = new Vector3( RewardTile.X, RewardTile.Y, RewardTile.Top );
		var off = one + new Vector3( 0f, 400f, 0f );
		m.BodyFell( "", one );
		m.BodyFell( SpecialEnemies.Pest, one );
		m.BodyFell( SpecialEnemies.Napalm, off );
		Check( m._bonfire == BonfireWaiting && m._pests == 0,
			"Bonfire: a walker or a pest dying on tile 1, or a napalm zombie off it, lights nothing" );

		m.BodyFell( SpecialEnemies.Napalm, one );
		Check( m._bonfire == BonfireLit && (!OnBasalt || m._fireGo.IsValid()), "…a napalm zombie dying on tile 1 sets it burning" );

		m.BodyFell( SpecialEnemies.Pest, off );
		m.BodyFell( SpecialEnemies.Napalm, one );
		m.BodyFell( "", one );
		Check( m._pests == 0, "…and pests off it, or walkers and more napalm zombies on it, do not count" );

		var target = BonfireTarget;
		for ( var k = 0; k < target - 1; k++ ) m.BodyFell( SpecialEnemies.Pest, one );
		Check( m._bonfire == BonfireLit && m._pests == target - 1 && (!OnBasalt || m._iconColour == ColourNumber( "red" )),
			$"…{target - 1} pests on it: still burning, the offering still red" );

		m.BodyFell( SpecialEnemies.Pest, one + new Vector3( 100f, 0f, 20f ) );
		Check( m._bonfire == BonfireDone && (!OnBasalt || (m._iconColour == ColourNumber( "green" ) && m._fireGo.IsValid())),
			$"…pest {target}, near the edge, turns the offering green, and it burns on: Bonfire done" );

		// 8c. Color Rings: Bonfire done begins it, each platform dealt its own ammo mod — no two alike, its icon on its tile
		// on the hex clue, in its colour — and a platform's own mod over it moves its colour's dot, once a second; nothing
		// else moves any
		Check( m._rings.Active, "Color Rings begins when Bonfire is done" );
		var ringsBegun = m._rings;

		var dealt = Enumerable.Range( 0, Colours ).Select( c => m._rings.ModOf( c ) ).ToArray();
		Check( dealt.Distinct().Count() == Colours && dealt.All( id => AmmoMods.Find( id )?.Built == true && ModIcon( id ) is { Length: > 0 } ),
			$"…each platform dealt a different ammo mod, each with its icon: {m._rings.Describe()}" );

		try
		{
			using var dealtPic = HexClue.Draw( m.ClueFor( true ), m._rings );
			Check( Enumerable.Range( 0, Colours ).All( c => HexClue.IconOn( dealtPic, m._picked[c], c )
					&& !HexClue.IconOn( dealtPic, m._picked[c], (c + 1) % Colours ) ),
				"…and the hex clue shows each one's icon on its platform's tile, in that tile's colour" );
		}
		catch ( Exception e )
		{
			Check( false, $"…and the hex clue shows each one's icon — it could not be drawn: {e.Message}" );
		}

		Vector3 OnPick( int colour ) => new( Tiles[m._picked[colour]].X, Tiles[m._picked[colour]].Y, Tiles[m._picked[colour]].Top );
		void NoPause() { for ( var c = 0; c < Colours; c++ ) m.RingMoved[c] = 100f; }

		int redC = ColourNumber( "red" ), blueC = ColourNumber( "blue" ), yellowC = ColourNumber( "yellow" );
		string redMod = dealt[redC], blueMod = dealt[blueC], yellowMod = dealt[yellowC];
		var unwanted = RingMods.FirstOrDefault( id => !dealt.Contains( id ) ) ?? "";
		var ringsBefore = m._rings;
		NoPause();
		m.ModWentOff( blueMod, OnPick( redC ) );
		m.ModWentOff( unwanted, OnPick( redC ) );
		m.ModWentOff( redMod, OnPick( blueC ) );
		m.ModWentOff( redMod, OnPick( redC ) + new Vector3( 0f, 0f, 100f ) );
		Check( m._rings == ringsBefore,
			$"on the red platform the blue one's mod ({ModName( blueMod )}) or one no platform wants ({ModName( unwanted )}),"
			+ $" its own ({ModName( redMod )}) on the blue platform, or 100u above the red, moves nothing" );
		Check( m.CouldMove( redMod, OnPick( redC ) ) && !m.CouldMove( redMod, OnPick( blueC ) )
				&& !m.CouldMove( blueMod, OnPick( redC ) ) && !m.CouldMove( unwanted, OnPick( redC ) ),
			"a shooter's machine tells the host only of what could move a dot: the red platform's own mod on it, nothing else" );

		m.ModWentOff( redMod, OnPick( redC ) );
		var redMoved = m._rings.PositionOf( redC ) == (ringsBefore.PositionOf( redC ) + 1) % RingPositions
			&& Enumerable.Range( 0, Colours ).Where( c => c != redC ).All( c => m._rings.PositionOf( c ) == ringsBefore.PositionOf( c ) );
		Check( redMoved, $"{ModName( redMod )} on the red platform moves the red dot one on, and no other" );

		m.ModWentOff( redMod, OnPick( redC ) );
		Check( m._rings.PositionOf( redC ) == (ringsBefore.PositionOf( redC ) + 1) % RingPositions,
			"…and again within a second, no further" );

		var ringsRound = m._rings;
		for ( var k = 0; k < RingPositions; k++ ) { NoPause(); m.ModWentOff( yellowMod, OnPick( yellowC ) ); }
		Check( m._rings == ringsRound, $"eight {ModName( yellowMod )} on the yellow platform take its dot round, back where it began" );

		try
		{
			using var pic = RingsClue.Draw( m._rings );
			Check( Enumerable.Range( 0, Colours ).All( c => RingsClue.DotAt( pic, c, m._rings.PositionOf( c ) )
					&& !RingsClue.DotAt( pic, c, (m._rings.PositionOf( c ) + 4) % RingPositions ) ),
				$"…and the rings are drawn so, each dot where it stands: {m._rings.Describe()}" );
			Check( Enumerable.Range( 0, RingPositions ).All( k => RingsClue.GlyphAt( pic, k ) ),
				"…with the eight glyphs round them, one at each position, as a clock has its numbers" );
		}
		catch ( Exception e )
		{
			Check( false, $"…drawn so — the rings could not be drawn: {e.Message}" );
		}

		// 8d. the hint: each platform shows a glyph — at random, repeats allowed — and its colour's dot must end at that
		// glyph's place on the clock; no dot begins there; all four there is Color Rings done, and nothing moves after
		Check( Enumerable.Range( 0, Colours ).All( c => ringsBegun.PositionOf( c ) != ringsBegun.TargetOf( c )
				&& ringsBegun.GlyphOf( c ) >= 1 && ringsBegun.GlyphOf( c ) <= RingPositions ),
			$"each platform shows a glyph, and no dot begins at its own: {ringsBegun.Describe()}" );

		bool GlyphIn( int c )
		{
			var (glyphGo, _, glyphTile) = m.PlatformGlyphs[c];
			return glyphGo.IsValid() && glyphTile == m._picked[c]
				&& MathF.Abs( glyphGo.WorldPosition.x - Tiles[glyphTile].X ) < 1f && MathF.Abs( glyphGo.WorldPosition.y - Tiles[glyphTile].Y ) < 1f
				&& (glyphGo.Components.Get<ModelRenderer>()?.Model?.Materials.Any( x => x?.Name == MaterialFor( c )?.Name ) ?? false);
		}
		if ( OnBasalt )
			Check( Enumerable.Range( 0, Colours ).All( c => m.GlyphShown( c ) == m._rings.GlyphOf( c ) && GlyphIn( c ) )
					&& !m.Scene.GetAllObjects( false ).Any( x => x.Tags.Has( PanelTag ) && x.Name.Contains( " icon (" ) ),
				"…laid in the middle of its top, in its own colour: the platform's only mark, its ammo icon on the clue alone" );

		var goals = Enumerable.Range( 0, Colours ).Select( c => m._rings.TargetOf( c ) ).ToArray();
		var almost = goals.ToArray();
		almost[redC] = (goals[redC] + RingPositions - 1) % RingPositions;       // red one short of its glyph
		m._rings = m._rings.WithPositions( almost );
		var notYet = !m._rings.Solved && !m._rings.Done;
		NoPause();
		m.ModWentOff( redMod, OnPick( redC ) );
		Check( notYet && m._rings.Solved && m._rings.Done,
			"three dots at their glyphs is not done; the fourth, moved onto its own, finishes Color Rings" );
		Check( HexClue.Gone && LiveClues.Gone( HexClue.ImagePath ) && !LiveClues.Gone( RingsClue.ImagePath ),
			"…and the hex map goes from every wall it is on, the rings clue staying" );

		var finished = m._rings;
		NoPause();
		m.ModWentOff( redMod, OnPick( redC ) );
		m.ModWentOff( blueMod, OnPick( blueC ) );
		Check( m._rings == finished && !m.CouldMove( redMod, OnPick( redC ) ), "…and after it, no mod moves a dot" );

		// 8e. Torch Carry begins: with Color Rings done, a Shrieker dying in the fire puts it out — the flames and the
		// offering go, and the cursed flame floats over tile 1. Before Color Rings is done, off the tile, or any other death:
		// nothing
		var ringsDone = m._rings;
		m._rings = ringsBegun;
		m.BodyFell( SpecialEnemies.Shrieker, one );
		var stillBurning = m._bonfire == BonfireDone;
		m._rings = ringsDone;
		m.BodyFell( SpecialEnemies.Shrieker, off );
		m.BodyFell( SpecialEnemies.Pest, one );
		m.BodyFell( SpecialEnemies.Napalm, one );
		m.BodyFell( "", one );
		Check( stillBurning && m._bonfire == BonfireDone,
			"Torch Carry: a Shrieker dying in the fire before Color Rings is done, or off tile 1, or anything else dying in it, puts nothing out" );

		m.BodyFell( SpecialEnemies.Shrieker, one + new Vector3( 60f, -40f, 10f ) );
		Check( m._bonfire == BonfireOut, "…a Shrieker dying in it, with Color Rings done, puts the fire out" );
		if ( OnBasalt )
		{
			var flameAt = m._cursedGo.IsValid() ? m._cursedGo.WorldPosition : default;
			Check( !m._fireGo.IsValid() && !m._iconGo.IsValid() && m._cursedGo.IsValid() && !m._cursedBuilt.Held
					&& MathF.Abs( flameAt.x - RewardTile.X ) < 1f && MathF.Abs( flameAt.y - RewardTile.Y ) < 1f
					&& MathF.Abs( flameAt.z - (RewardTile.Top + CursedFlameHeight) ) <= CursedFlameBob + 0.5f
					&& m._cursedGo.Components.GetAll<ParticleSpriteRenderer>( FindMode.EverythingInSelfAndDescendants )
						.Any( x => x.Sprite?.ResourcePath == CursedFlameSprite ),
				"…the flames and the offering gone, and the cursed flame floating over tile 1, burning purple" );
		}

		m.BodyFell( SpecialEnemies.Napalm, one );
		m.BodyFell( SpecialEnemies.Pest, one );
		m.BodyFell( SpecialEnemies.Shrieker, one );
		Check( m._bonfire == BonfireOut, "…and nothing dying in it after lights it again, nor does another Shrieker change it" );

		// 8f. Torch Carry: the cursed flame floats loose; a look through it is at it; a player takes it — once, and only
		// with the fire out — and it is theirs until it is put back over tile 1
		Check( string.IsNullOrEmpty( m._flameCarrier ), "Torch Carry: with the fire out, the cursed flame floats loose, nobody holding it" );
		Check( RayMeetsSphere( Vector3.Zero, Vector3.Forward, new Vector3( 100f, 0f, 20f ), 22f )
				&& !RayMeetsSphere( Vector3.Zero, Vector3.Forward, new Vector3( 100f, 0f, 50f ), 22f )
				&& !RayMeetsSphere( Vector3.Zero, Vector3.Forward, new Vector3( -100f, 0f, 0f ), 22f ),
			"…a look passing 20u from its middle is at it; one 50u off, or behind the eye, is not" );

		var taker = NZPlayer.Local;
		if ( taker.IsValid() && !CannotCarry( taker ) )
		{
			m._bonfire = BonfireDone;
			var early = m.TakeFlame( taker, anywhere: true );
			m._bonfire = BonfireOut;
			var took = m.TakeFlame( taker, anywhere: true );
			var again = m.TakeFlame( taker, anywhere: true );
			Check( early != "" && took == "" && m._flameCarrier == CarrierIdOf( taker ) && again != ""
					&& (!OnBasalt || (m._cursedGo.IsValid() && m._cursedBuilt.Held)),
				$"…{NameFor( taker )} takes it — not before the fire is out, and not twice — and holds it as a torch" );

			m.ResetFlame();
			Check( string.IsNullOrEmpty( m._flameCarrier ) && (!OnBasalt || (m._cursedGo.IsValid() && !m._cursedBuilt.Held)),
				"…and put back, it floats loose over tile 1 again" );

			// …a zombie's hit: on someone not carrying it, nothing; on its carrier, it is snuffed out until the next round
			var missed = m.CarrierHit( taker );
			m.TakeFlame( taker, anywhere: true );
			var snuffed = m.CarrierHit( taker );
			var retake = m.TakeFlame( taker, anywhere: true );
			Check( !missed && snuffed && m._flameLost && m._flameLostShown && string.IsNullOrEmpty( m._flameCarrier ) && retake != ""
					&& !m.FlameLoose && (!OnBasalt || !m._cursedGo.IsValid()),
				"…a zombie's hit on someone not carrying it takes nothing; on its carrier it snuffs it out — gone from their hands and from tile 1, and nobody can take it" );

			m.RoundBegan( 2 );
			Check( !m._flameLost && !m._flameLostShown && string.IsNullOrEmpty( m._flameCarrier )
					&& (!OnBasalt || (m.FlameLoose && m._cursedGo.IsValid() && !m._cursedBuilt.Held)),
				"…and the next round brings it back, floating over tile 1" );
		}
		else
		{
			Check( true, "…there is no player up here to take it, so the take is not walked" );
			Check( true, "…nor the putting back" );
			Check( true, "…nor a zombie's hit snuffing it out" );
			Check( true, "…nor the next round bringing it back" );
		}

		// 9. a new game starts it over
		m.RoundBegan( 1 );
		Check( !m._done && m._lit.Mask == 0L && m._sequence.Count == 0 && !m._failed && m._bonfire == BonfireWaiting
			&& m._pests == 0 && !m._rings.Active && string.IsNullOrEmpty( m._flameCarrier ) && !m._flameLost && !HexClue.Gone,
			"a new game starts the step over, and Bonfire, Color Rings and Torch Carry with it — the hex map back" );
		if ( OnBasalt )
			Check( !m._rewardWhite && TileOneIn( "concretewall010a" ) && !m._iconGo.IsValid() && !m._fireGo.IsValid()
					&& !m._cursedGo.IsValid() && Enumerable.Range( 0, Colours ).All( c => m.GlyphShown( c ) == 0 ),
				"…and tile 1 is plain stone again, the offering, the fire and the cursed flame gone, and the platforms' glyphs" );

		// 10. with no order to follow — the slots not giving all four numbers — any order counts
		m._orderForTest = Array.Empty<int>();
		foreach ( var p in m._picked.AsEnumerable().Reverse().ToList() ) HostSlam( p );
		Check( m._done && m._picked.All( InItsColour ), "with no order to follow, all four in any order finish it" );
		m.RoundBegan( 1 );

		// 11. the order off the real slots: each number's colour is the colour on the slot showing it
		m._orderForTest = null;
		var real = HexSlotManager.ColoursInOrder();
		var roll = slots.IsValid() ? slots.HostRoll : default;
		if ( real is null )
			Check( roll.Count < HexSlotManager.Max, $"the slots give no order here ({roll.Count} rolled), so any order counts" );
		else
			Check( real.OrderBy( x => x ).SequenceEqual( new[] { 0, 1, 2, 3 } )
					&& Enumerable.Range( 0, roll.Count ).All( i => real[roll.NumberOf( i ) - 1] == roll.ColourOf( i ) ),
				"the order is the slots': "
				+ string.Join( ", ", Enumerable.Range( 0, real.Length ).Select( k => $"{HexSlotManager.Numeral( k + 1 )} {ColourName( real[k] )}" ) ) );

		// 11b. tile 1: where the soul boxes' part lands, and no panel of the step
		if ( OnBasalt )
		{
			var drops = ActiveConfig.Current?.SoulBoxes?.Where( b => b.HasPartDrop ).Select( b => b.PartDrop ).ToList()
				?? new List<Vector3>();
			Check( drops.All( d => OutOf( d, RewardTile ) <= 0f && MathF.Abs( d.z - RewardTile.Top ) < 24f ),
				drops.Count == 0 ? "tile 1: no soul box drops a part anywhere, so nothing to check it against"
					: $"tile 1 is where the soul boxes' part lands: {string.Join( "; ", drops )}" );
			Check( TileAt( new Vector3( RewardTile.X, RewardTile.Y, RewardTile.Top ) ) < 0, "…and a slam on it is no slam on a panel" );
		}

		// 12. where a landing counts: forgiving at the rim and a frame early — never in the lava or on the floor below
		var t = Array.FindIndex( Tiles, x => x.Id == "11" );
		var c = new Vector3( Tiles[t].X, Tiles[t].Y, Tiles[t].Top );
		Check( TileAt( c ) == t, "landing on tile 11's centre is on tile 11" );
		Check( TileAt( c + new Vector3( 124f, 0f, 0f ) ) == t, "…and on its rim" );
		Check( TileAt( c + new Vector3( Apothem + 40f, 0f, 0f ) ) == t, "…and 40u past its edge, a player on the rim still on it" );
		Check( TileAt( c + new Vector3( 0f, 0f, 40f ) ) == t, "…and 40u above its top, a landing seen a frame early" );
		Check( TileAt( c + new Vector3( Apothem + 80f, 0f, 0f ) ) < 0, "…but not 80u past its edge" );
		Check( TileAt( c + new Vector3( 150f, 0f, Tiles[t].Bottom - Tiles[t].Top ) ) < 0, "…nor in the lava beside it" );
		Check( TileAt( c - new Vector3( 0f, 0f, 96f ) ) < 0, "…nor on the floor under it" );

		// …and where two tiles' margins overlap, the one you are over wins, not the first in the table
		var t22 = Array.FindIndex( Tiles, x => x.Id == "22" );
		Check( TileAt( new Vector3( Tiles[t22].X + 114f, Tiles[t22].Y, Tiles[t22].Top ) ) == t22,
			"between neighbours 22 and 23, just inside 22, it is 22" );

		// 13. the lit set carries every colour on every tile, and one tile's colour leaves its neighbours alone
		var carries = true;
		for ( var i = 0; i < 64 && carries; i++ )
			for ( var col = 0; col <= White && carries; col++ )
			{
				var j = (i + 1) % 64;
				var before = new Lit( -1L, -1L, 0L, -1L );              // every tile lit, colour bits 1-0-1
				var s = before.With( i, col );
				carries = s.Has( i ) && s.ColourOf( i ) == col && s.Has( j ) && s.ColourOf( j ) == before.ColourOf( j );
			}
		Check( carries, "the lit set carries all five colours on each of 64 tiles, one tile at a time" );

		// 14. each colour is its own material, never the map's own white
		var white = Material.Load( LightMaterial );
		var mats = Enumerable.Range( 0, Colours ).Select( MaterialFor ).ToList();
		Check( white is not null && mats.All( x => x is not null && !ReferenceEquals( x, white ) )
			&& mats.Distinct().Count() == Colours, "each colour draws with a copy of its own, never white001 itself" );

		// 15. the clue: no picks before the power, the four in their colours after it — and drawn so
		Check( m.ClueFor( false ).Mask == 0L, "the clue shows no picks while the power is off" );

		var clue = m.ClueFor( true );
		Check( m._picked.All( p => clue.Has( p ) && clue.ColourOf( p ) == m.PickColour( p ) )
			&& Enumerable.Range( 0, 64 ).Count( clue.Has ) == m._picked.Count,
			"…and with the power on, exactly the four picks, each in its colour" );

		var notPick = NotAPick();
		try
		{
			using var pic = HexClue.Draw( clue, m._rings );
			Check( m._picked.All( p => HexClue.Reads( pic, p, m.PickColour( p ) ) ) && HexClue.Reads( pic, notPick, White ),
				$"…drawn so: each pick's face its colour, tile {Tiles[notPick].Id} (no pick) white" );
			Check( !m._rings.Active && !m._picked.Any( p => HexClue.IconOn( pic, p, m.PickColour( p ) ) ),
				"…with no ammo icon on any, Color Rings not begun" );
		}
		catch ( Exception e )
		{
			Check( false, $"…drawn so — the clue could not be drawn: {e.Message}" );
		}

		// 16. the sounds: a right slam's scrape, a wrong one's power-down and the step's clicking are there to play
		var cues = new[] { SmashCue, FailCue, DoneCue };
		Check( cues.All( NZSound.Exists ), "Color Smash's sounds are there: " + string.Join( ", ", cues.Select( c =>
			$"{c} {( NZSound.Exists( c ) ? "ok" : "MISSING" )}" ) ) );

		// …and the cursed flame's purple: its frames, recoloured by Tools/basalt_purple_flame.py
		Check( ResourceLibrary.Get<Sprite>( CursedFlameSprite ) is not null, $"the cursed flame's purple frames are there to burn: {CursedFlameSprite}" );

		// 17. nz_hex_skipto torch: straight to Torch Carry's start — every step before it done, the fire out, the flame loose
		SkipToCmd( "torch" );
		Check( m._done && m._picked.All( InItsColour ) && m._bonfire == BonfireOut && m._rings.Done
				&& string.IsNullOrEmpty( m._flameCarrier ),
			"nz_hex_skipto torch lands at Torch Carry's start: Color Smash done and its four lit, Color Rings solved, the fire out, the cursed flame loose" );

		// 18. the shield lock: a wrong code jams it — shut, and taking no other code, not even the right one — until the next
		// round starts; then the right one opens it — on basalt the lockpad and the cyan shield's faces gone; a new game
		// shuts it, the shield back
		var typer = NZPlayer.Local;
		if ( typer.IsValid() && !CannotCarry( typer ) )
		{
			m._lockOpen = false;
			m._lockJammed = false;
			m.SendLock();
			var code = m.LockCode;
			var wrongCode = $"{(code[0] - '0') % 8 + 1}{code[1..]}";
			var wrong = m.TryCode( typer, wrongCode, anywhere: true );
			var jammed = m._lockJammed && m._lockJammedShown;
			var blocked = m.TryCode( typer, code, anywhere: true );
			Check( wrong == LockTry.Wrong && jammed && blocked == LockTry.Ignored && !m._lockOpen,
				$"the shield lock: a wrong code ({Spell( wrongCode )}) jams it — it stays shut, and takes no other code, not even the right one ({Spell( code )})" );

			m.RoundBegan( 2 );
			var right = m.TryCode( typer, code, anywhere: true );
			Check( !m._lockJammed && !m._lockJammedShown && right == LockTry.Opened && m._lockOpen,
				"…until the next round starts: then the right one opens it" );
			Check( !OnBasalt || (!m._lockGo.IsValid() && !m.ShieldStands() && m.LockColumn.Faces is { Count: > 0 }),
				OnBasalt ? $"…and the lockpad and the cyan shield are gone: {m.LockColumn.Faces?.Count ?? 0} faces hidden in {(m.LockColumn.Mesh.IsValid() ? m.LockColumn.Mesh.GameObject.Name : "no mesh")}"
					: "…the lockpad and the shield are basalt's, so not built here" );

			m.ResetLock();
			Check( !m._lockOpen && !m._lockJammed && (!OnBasalt || (m._lockGo.IsValid() && m.ShieldStands())),
				"…and a new game shuts it again, with the shield back and a new code" );
		}
		else
		{
			Check( true, "the shield lock: there is no player up here to type a code, so it is not walked" );
			Check( true, "…nor the next round freeing it" );
			Check( true, "…nor the lockpad and the shield going" );
			Check( true, "…nor a new game shutting it" );
		}

		// 19. the code's symbols: four different spots of the 28, one per colour; a flame near one, on its side of its
		// surface, shows it, with its spot and glyph; far off, or behind its surface, not; a symbol not on show carries
		// nothing of itself; nobody carrying the flame, or the lock open, none shows; and each is drawn on its spot in its
		// slot's colour
		m.DealCode();
		var spotsNow = m.SymbolSpots;
		var codeNow = m.LockCode;
		Check( spotsNow.Length == Colours && spotsNow.Distinct().Count() == Colours && spotsNow.All( i => i >= 0 && i < CodeSpots.Length )
				&& IsGlyphCode( codeNow ),
			$"the code's symbols: four glyphs on four different spots of the {CodeSpots.Length}, one per colour — {m.SymbolsText()}" );

		var (blueAt, blueFacing) = CodeSpots[spotsNow[0]];
		var outward = blueFacing.ToRotation().Forward;
		var near = m.SymbolsFrom( blueAt + outward * 100f );
		var far = m.SymbolsFrom( blueAt + outward * (RevealRadius + 40f) );
		var behind = m.SymbolsFrom( blueAt - outward * 60f );
		Check( near.Shows( 0 ) && near.SpotOf( 0 ) == spotsNow[0] && near.GlyphOf( 0 ) == codeNow[0] - '0' && !far.Shows( 0 ) && !behind.Shows( 0 ),
			$"…a flame 100u before the blue one shows it, with its spot and glyph; {RevealRadius + 40f:0}u off, or behind its surface, it does not" );

		var others = default( SymbolSet ).With( 0, 27, 8 ).With( 1, 13, 5 ).With( 2, 0, 1 ).With( 3, 21, 4 );
		var packs = true;
		for ( var k = 0; k < Colours && packs; k++ )
			for ( var spot = 0; spot < CodeSpots.Length && packs; spot++ )
				for ( var g = 1; g <= RingPositions && packs; g++ )
				{
					var probe = others.With( k, spot, g );
					packs = probe.Shows( k ) && probe.SpotOf( k ) == spot && probe.GlyphOf( k ) == g
						&& Enumerable.Range( 0, Colours ).Where( j => j != k ).All( j => probe.Shows( j ) && probe.SpotOf( j ) == others.SpotOf( j ) && probe.GlyphOf( j ) == others.GlyphOf( j ) );
				}
		bool Bare( SymbolSet s ) => Enumerable.Range( 0, Colours ).All( k => s.Shows( k ) || ((s.Packed >> (k * 10)) & 1023L) == 0L );
		Check( packs && Bare( near ) && Bare( far ) && Bare( behind ),
			"…the symbols' set carries every spot and glyph in each colour, and a symbol not on show carries nothing of itself" );

		m.ResetFlame();
		var noFlame = m.SymbolsNow();
		m._symbolsAll = true;
		var forced = m.SymbolsNow();
		m._lockOpen = true;
		var spent = m.SymbolsNow();
		m._lockOpen = false;
		m._symbolsAll = false;
		Check( noFlame.Count == 0 && (!OnBasalt || forced.Count == Colours) && spent.Count == 0,
			"…with nobody carrying the cursed flame none shows, nz_hex_symbols all shows all four, and with the lock open none — the code is spent" );

		if ( OnBasalt )
		{
			m.ApplySymbols( m.AllSymbols() );
			Check( Enumerable.Range( 0, Colours ).All( k => m._symbolGos[k].IsValid()
					&& m._symbolGos[k].WorldPosition.Distance( CodeSpots[spotsNow[k]].At ) < 0.01f
					&& Vector3.Dot( m._symbolGos[k].WorldRotation.Forward, CodeSpots[spotsNow[k]].Facing.ToRotation().Forward ) > 0.999f
					&& (m._symbolGos[k].Components.Get<ModelRenderer>()?.Model?.Materials.Any( x => x?.Name == MaterialFor( k )?.Name ) ?? false) ),
				"…each drawn on its spot, facing out of its surface, in its slot's colour" );

			m.ApplySymbols( default );
			Check( m._symbolGos.All( x => !x.IsValid() ), "…and gone again once none shows" );
		}

		// 20. the Altar: on basalt it stands in the middle of the shield's column, in the stones' concrete, solid, its
		// engravings in the map's light; the cursed flame goes on it only from its carrier and only with the shield lock
		// open, and there it burns — no zombie's hit, no new round and no reach for it takes it off; a new game does
		if ( OnBasalt )
		{
			var parts = m._altarGo.IsValid() ? m._altarGo.Children.Where( x => x.IsValid() ).ToList() : new List<GameObject>();
			var pillar = m._altarGo.IsValid() ? m._altarGo.Components.Get<ModelRenderer>() : null;
			Check( m._altarGo.IsValid() && m._altarGo.WorldPosition.Distance( AltarAt ) < 0.01f
					&& (pillar?.Model?.Materials.Any( x => x?.Name?.Contains( "concretewall010a" ) ?? false ) ?? false)
					&& m._altarGo.Components.Get<BoxCollider>().IsValid()
					&& parts.Count == 7
					&& parts.All( x => x.Components.Get<ModelRenderer>()?.Model?.Materials.Any( y => y?.Name?.Contains( "white001" ) ?? false ) ?? false ),
				$"the altar stands in the middle of the cyan shield's column, {AltarHeight:0}u tall, in the stones' concrete, solid,"
				+ $" its {parts.Count} engravings in the map's white light" );
		}

		var placer = NZPlayer.Local;
		if ( placer.IsValid() && !CannotCarry( placer ) )
		{
			m._bonfire = BonfireOut;
			m.SendBonfire();
			m.ResetFlame();
			m._lockOpen = false;
			m.SendLock();

			var notCarried = m.PlaceFlame( placer, anywhere: true );
			m.TakeFlame( placer, anywhere: true );
			var shieldUp = m.PlaceFlame( placer, anywhere: true );
			m._lockOpen = true;
			m.SendLock();
			var setDown = m.PlaceFlame( placer, anywhere: true );
			var burnsAt = m._cursedGo.IsValid() ? m._cursedGo.WorldPosition : default;
			Check( notCarried != "" && shieldUp != "" && setDown == "" && m._flamePlaced && m._flamePlacedShown && string.IsNullOrEmpty( m._flameCarrier )
					&& (!OnBasalt || (m._cursedGo.IsValid() && m._cursedBuilt.OnAltar
						&& MathF.Abs( burnsAt.x - AltarAt.x ) < 1f && MathF.Abs( burnsAt.y - AltarAt.y ) < 1f
						&& MathF.Abs( burnsAt.z - AltarFlameHome.z ) <= CursedFlameBob + 0.5f)),
				"the Altar: the cursed flame goes on it only from its carrier, and only with the shield lock open — and there it burns" );

			var hit = m.CarrierHit( placer );
			m.RoundBegan( 2 );
			var reach = m.TakeFlame( placer, anywhere: true );
			var twice = m.PlaceFlame( placer, anywhere: true );
			Check( !hit && m._flamePlaced && reach != "" && twice != "" && !m.FlameLoose,
				"…and nothing takes it off: not a zombie's hit, nor a new round, nor a reach for it" );

			m.RoundBegan( 1 );
			Check( !m._flamePlaced && !m._flamePlacedShown && (!OnBasalt || !m._cursedGo.IsValid()), "…until a new game" );
		}
		else
		{
			Check( true, "the Altar: there is no player up here to carry the flame, so setting it there is not walked" );
			Check( true, "…nor its staying there" );
			Check( true, "…nor a new game taking it off" );
		}

		// 21. the Altar Defense: the purple flame set on the altar begins it and holds the round open, the walkers' eyes
		// purple; hits count, and the tenth fails it — the flame gone, and the next round back over tile 1; held to the end,
		// the flame burns light blue and can be taken up again, and lost to a hit it comes back to the altar, not tile 1; a
		// new game ends it all
		var defender = NZPlayer.Local;
		if ( defender.IsValid() && !CannotCarry( defender ) )
		{
			// from the carry: Color Smash done, the fire out, the shield lock open, the flame in hand
			// ⚠️ COLOR SMASH DONE AGAIN: the Altar's last check was a new game, which undid it — and on basalt the flame is
			// only loose over tile 1, and only drawn, once it is done (`FlameLoose`). Off basalt nothing asked, and the first
			// run on basalt (2026-09-27) failed two checks for it.
			m.SetDone( true );
			m._bonfire = BonfireOut;
			m.SendBonfire();
			m.ResetFlame();
			m._lockOpen = true;
			m.SendLock();
			m.TakeFlame( defender, anywhere: true );
			m.PlaceFlame( defender, anywhere: true );
			var began = m._defense == Defense.Running && m._defenseShown == Defense.Running && HoldsRound && m._flamePlaced
				&& !m.FlameBlue && DefensePoints && (!OnBasalt || ZombieEyes.InDefense);

			for ( var k = 0; k < AltarHitsToFail - 1; k++ ) m.TakeAltarHit();
			var standing = m._defense == Defense.Running && m._altarHits == AltarHitsToFail - 1 && m._altarHitsShown == AltarHitsToFail - 1;
			m.TakeAltarHit();
			Check( began && standing && m._defense == Defense.None && !HoldsRound && !m._flamePlaced && m._flameLost && m._flameLostShown
					&& !ZombieEyes.InDefense && !DefensePoints,
				$"the Altar Defense: the purple flame set on the altar begins it, holds the round open, pays a kill {DefenseKillPoints} and a hit"
				+ $" {DefenseHitPoints}, and turns the walkers' eyes purple; {AltarHitsToFail - 1} hits do not end it, the {AltarHitsToFail}th fails"
				+ " it — the flame gone, the pay and the eyes as before" );

			m.RoundBegan( 2 );
			Check( !m._flameLost && !m._flamePlaced && string.IsNullOrEmpty( m._flameCarrier ) && m._defense == Defense.None
					&& (!OnBasalt || m.FlameLoose),
				"…and the next round brings the flame back over tile 1, to carry to the altar again" );

			m.TakeFlame( defender, anywhere: true );
			m.PlaceFlame( defender, anywhere: true );
			if ( OnBasalt ) { m._defenseLeft = 0f; m.WatchDefense(); }
			else m.WinDefense();                                    // off basalt its watch ends it at once: the clock is basalt's
			var held = m._defense == Defense.Won && m.FlameBlue && m.FlameBlueShown && m._flamePlaced && !HoldsRound
				&& (!OnBasalt || (m._cursedGo.IsValid() && m._cursedBuilt.Blue && m._cursedBuilt.OnAltar && m.FlameTakeable));
			var retaken = m.TakeFlame( defender, anywhere: true );
			Check( held && retaken == "" && m._flameCarrier == CarrierIdOf( defender ) && !m._flamePlaced && m.FlameBlue,
				"…held to the end, the flame burns light blue on the altar, and can be taken up again" );

			m.CarrierHit( defender );
			m.RoundBegan( 3 );
			Check( m._flamePlaced && !m._flameLost && m.FlameBlue && m._defense == Defense.Won,
				"…a zombie's hit on its carrier takes the light blue flame too, and the next round brings it back to the altar, where it turned" );

			m.RoundBegan( 1 );
			Check( m._defense == Defense.None && !m.FlameBlue && !m._flamePlaced && !m._flameLost && !ZombieEyes.InDefense,
				"…and a new game ends it all: the defense over, the flame purple again" );
		}
		else
		{
			Check( true, "the Altar Defense: there is no player up here to carry the flame, so its defense is not walked" );
			Check( true, "…nor the flame's return after a fall" );
			Check( true, "…nor the altar held" );
			Check( true, "…nor the light blue flame's return" );
			Check( true, "…nor a new game ending it" );
		}

		// 22. the twin shield: the light blue flame's carrier brings it down, and the flame is spent — not with the purple
		// flame, not with nobody carrying it; a new game raises it again
		var breaker = NZPlayer.Local;
		if ( breaker.IsValid() && !CannotCarry( breaker ) )
		{
			m._bonfire = BonfireOut;
			m.SendBonfire();
			m.ResetFlame();
			m._twinOpen = false;
			m.SendTwin();

			m.TakeFlame( breaker, anywhere: true );
			var purple = m.BreakTwin( breaker, anywhere: true );

			m._flameCarrier = "";
			m._flamePlaced = true;
			m._defense = Defense.Won;
			m.SendDefense();
			m.SendFlame();
			var empty = m.BreakTwin( breaker, anywhere: true );

			m.TakeFlame( breaker, anywhere: true );
			var broke = m.BreakTwin( breaker, anywhere: true );
			var retake = m.TakeFlame( breaker, anywhere: true );
			Check( purple != "" && empty != "" && broke == "" && retake != "" && m._twinOpen && m._twinOpenShown
					&& string.IsNullOrEmpty( m._flameCarrier ) && !m.FlameTakeable
					&& (!OnBasalt || (!m._cursedGo.IsValid() && !m.ColumnStands( m.TwinColumn ) && m.TwinColumn.Faces is { Count: > 0 })),
				OnBasalt ? $"the twin shield: the light blue flame's carrier brings it down — {m.TwinColumn.Faces?.Count ?? 0} faces of"
					+ $" {(m.TwinColumn.Mesh.IsValid() ? m.TwinColumn.Mesh.GameObject.Name : "no mesh")} — and the flame is spent; not with the"
					+ " purple flame, nor with nobody carrying it"
					: "the twin shield: the light blue flame's carrier brings it down, and the flame is spent; not with the purple flame, nor with nobody carrying it" );

			m.RoundBegan( 1 );
			Check( !m._twinOpen && !m._twinOpenShown && (!OnBasalt || m.ColumnStands( m.TwinColumn )),
				"…and a new game raises it again" );
		}
		else
		{
			Check( true, "the twin shield: there is no player up here to carry the flame, so bringing it down is not walked" );
			Check( true, "…nor a new game raising it" );
		}

		// 23. the Shrieker platform: once the twin shield is down, Shriekers dying on the 1911 platform count — its outer step
		// too — three finishing it and turning its sides green; not one before the shield fell, not a walker or a napalm
		// zombie, not a Shrieker beside its edge, and nothing past three. A new game starts it over, with the shield
		m._twinOpen = false;
		m.SendTwin();
		m._shriekerKills = 0;
		m.SendShriekers();
		var shrTop = ShriekerPlatformTop;
		var shrWest = shrTop + new Vector3( -81f, 1f, 2f );                   // where the 1911 lies, on its west side
		m.ShriekerFell( SpecialEnemies.Shrieker, shrWest );
		var shrEarly = m._shriekerKills;

		m._twinOpen = true;
		m.SendTwin();
		m.ShriekerFell( "", shrWest );
		m.ShriekerFell( SpecialEnemies.Napalm, shrWest );
		m.ShriekerFell( SpecialEnemies.Shrieker, shrTop + new Vector3( Apothem + 24f, 0f, -10f ) );                   // the floor by its east flat
		var shrStrays = m._shriekerKills;

		m.ShriekerFell( SpecialEnemies.Shrieker, shrWest );
		m.ShriekerFell( SpecialEnemies.Shrieker, shrTop + new Vector3( 0f, -136f, -4f ) );                          // the outer step, by its south point
		var shrTwo = m._shriekerKills == 2 && !m.ShriekersDoneShown;
		m.ShriekerFell( SpecialEnemies.Shrieker, shrTop );                                                         // where the column stood
		m.ShriekerFell( SpecialEnemies.Shrieker, shrTop );
		Check( shrEarly == 0 && shrStrays == 0 && shrTwo && m._shriekerKills == ShriekersTarget && m.ShriekersDoneShown,
			"the Shrieker platform: once the twin shield is down, three Shriekers dying on the 1911 platform — its outer step too —"
			+ " finish it; not one before the shield fell, not a walker or a napalm zombie, not a Shrieker beside its edge, and"
			+ " nothing past three" );
		Check( OnBasalt ? m._shriekerSidesGo.IsValid() && m._shriekerSidesGo.Children.Count == 2 : !m._shriekerSidesGo.IsValid(),
			OnBasalt ? "…and its sides turn green, both steps'" : "…and off basalt, no sides are lit green" );

		m.RoundBegan( 1 );
		Check( m._shriekerKills == 0 && !m.ShriekersDoneShown && !m._twinOpen && !m._shriekerSidesGo.IsValid(),
			"…and a new game starts it over, with the shield — its sides no longer green" );

		// 24. the Mastermind: the Shrieker platform done begins it, every ring a colour and three tries; a round on a ring
		// moves it one colour on round the wheel of eight, and one a moment after another moves nothing; a try with a ring
		// wrong flashes, takes no round while it does and spends a try, the round's third locking it — the rings white, no
		// round taken — until the next round gives colours again; the right three solve it, every light green; a round is
		// found where it lands; and a new game ends it
		void MmSettle()
		{
			m.ResolveMastermind();
			m._mmFlashing = false;
			m.DressMastermind();
		}

		m._twinOpen = true;
		m.SendTwin();
		m._shriekerKills = 0;
		m.SendShriekers();
		m.ResetMastermind();
		for ( var mmI = 0; mmI < ShriekersTarget; mmI++ ) m.ShriekerFell( SpecialEnemies.Shrieker, ShriekerPlatformTop );
		var mmBegun = (m._mmFlags & MmActive) != 0 && (m._mmFlagsShown & MmActive) != 0 && m._mmTries == 0;

		var mmFrom = MmColourIn( m._mmColours, 0 );
		m.MastermindShot( 0, anytime: true );
		var mmNext = MmColourIn( m._mmColours, 0 ) == (mmFrom + 1) % WheelColours && m._mmColoursShown == m._mmColours;
		for ( var mmI = 1; mmI < WheelColours; mmI++ ) m.MastermindShot( 0, anytime: true );
		var mmRound = MmColourIn( m._mmColours, 0 ) == mmFrom && m.MastermindShot( 0 ) != "";
		Check( mmBegun && mmNext && mmRound,
			"the Mastermind: the Shrieker platform done begins it, three tries; a round on a ring moves it one colour on, eight"
			+ " bring it round, and one a moment after another moves nothing" );

		m._mmColours = MmWithColour( m._mmAnswer, 0, (MmColourIn( m._mmAnswer, 0 ) + 1) % WheelColours );
		m.MastermindShot( MmLamp );
		var mmFlash = m._mmPending == 0b110 && m._mmFlashing && m._mmFlashRight == 0b110;
		var mmBusy = m.MastermindShot( 1, anytime: true ) != "" && m.MastermindShot( MmLamp ) != "";
		MmSettle();
		Check( mmFlash && mmBusy && m._mmTries == 1 && (m._mmFlags & (MmLocked | MmDone)) == 0,
			"…a try with the west ring wrong flashes it red and the other two green, takes no round while it flashes, and spends"
			+ " a try" );

		m.MastermindShot( MmLamp );
		MmSettle();
		m.MastermindShot( MmLamp );
		MmSettle();
		var mmLocked = (m._mmFlags & MmLocked) != 0 && (m._mmFlagsShown & MmLocked) != 0 && m._mmTries == MastermindTries
			&& m.MastermindShot( 1, anytime: true ) != "" && m.MastermindShot( MmLamp ) != "";
		Check( mmLocked && (!OnBasalt || m._mmRingGo.All( g => !g.IsValid() || !g.Enabled )),
			"…the round's third wrong try locks it: the rings white, and neither a ring nor the ceiling light takes a round" );

		m.MastermindRound();
		Check( (m._mmFlags & MmLocked) == 0 && m._mmTries == 0 && m.MastermindShot( 1, anytime: true ) == ""
				&& (!OnBasalt || m._mmRingGo.All( g => g.IsValid() && g.Enabled )),
			"…and the next round gives the rings colours again, and three tries" );

		m._mmColours = m._mmAnswer;
		m.MastermindShot( MmLamp );
		var mmAllRight = m._mmPending == 0b111;
		MmSettle();
		Check( mmAllRight && m.MastermindDoneShown && m.MastermindShot( 0, anytime: true ) != ""
				&& (!OnBasalt || (m._mmRingGo.All( g => g.IsValid() && g.Enabled ) && m._mmLampGo.IsValid())),
			OnBasalt ? "…the right three solve it: every ring green, and the ceiling light, for good" : "…the right three solve it, for good" );

		Check( MastermindTargetAt( new Vector3( -373.83f, -507.95f, 1221.65f ) ) == 0
				&& MastermindTargetAt( new Vector3( -82.78f, -869.2f, 1224.47f ) ) == 1
				&& MastermindTargetAt( new Vector3( 239.75f, -492.3f, 1223.09f ) ) == 2
				&& MastermindTargetAt( new Vector3( -120.01f, -615.16f, 1918f ) ) == MmLamp
				&& MastermindTargetAt( new Vector3( -216f, -400f, 1300f ) ) == -1
				&& MastermindTargetAt( new Vector3( -37.55f, -607.35f, 1918f ) ) == -1,
			"…and a round is found where it lands: where the user's 1911s marked each ring and the ceiling light — not on a"
			+ " column over its ring, nor on the ceiling beside the light" );

		m.RoundBegan( 1 );
		Check( (m._mmFlags & MmActive) == 0 && (m._mmFlagsShown & MmActive) == 0
				&& (!OnBasalt || (m._mmRingGo.All( g => !g.IsValid() || !g.Enabled ) && !m._mmLampGo.IsValid())),
			"…and a new game ends it, every light white again" );

		// 25. the rising lava: the Mastermind solved warns of it, the lava still in its bed; thirty seconds on it rises, and
		// is up; the ends of the round it rose in and the two after leave it up, the third's sinks it, and sunk it is
		// survived; up, it takes a body below its surface anywhere in the map, not one above it or beyond the map; a new game
		// ends it
		m.StartMastermind();
		m._mmColours = m._mmAnswer;
		m.MastermindShot( MmLamp );
		MmSettle();
		var lvLow = new Vector3( -20f, -700f, 1184f );
		var lvWarned = m._lava == LavaWarning && m._lavaShown == LavaWarning && MathF.Abs( m.LavaHeightShown - m._lavaFrom ) < 0.01f
			&& !m.LavaTakes( lvLow );
		m._lavaSince = LavaWarnSeconds + 0.1f;
		m.WatchLava();
		var lvRising = m._lava == LavaRising && m._lavaShown == LavaRising;
		m._lavaSince = LavaRiseSeconds + 0.1f;
		m.WatchLava();
		Check( lvWarned && lvRising && m._lava == LavaUp && m._lavaShown == LavaUp && MathF.Abs( m.LavaHeightShown - LavaTop ) < 0.01f
				&& (!OnBasalt || m._lavaGo.IsValid()) && (FogAreaManager.Forced is not null) == OnBasalt,
			$"the rising lava: the Mastermind solved warns of it, the lava still in its bed; thirty seconds on it rises, and is up"
			+ $" at {LavaTop:0.#}" + ( OnBasalt ? ", basalt's fog over the whole map" : "" ) );

		var lvRose = m._lavaRoseRound;
		m.LavaRoundEnded( lvRose );
		m.LavaRoundEnded( lvRose + 1 );
		m.LavaRoundEnded( lvRose + 2 );
		var lvHeld = m._lava == LavaUp;
		Check( lvHeld && m.LavaTakes( lvLow ) && m.LavaTakes( new Vector3( RewardTile.X, RewardTile.Y, RewardTile.Top ) )
				&& !m.LavaTakes( lvLow + Vector3.Up * (LavaTop + 1f - lvLow.z) ) && !m.LavaTakes( new Vector3( -4400f, -300f, 1696f ) )
				&& !m.LavaTakes( new Vector3( 9000f, 0f, 1184f ) ),
			"…up, it takes a body below its surface anywhere in the map — the hex floor, tile 1 — not one above it, in the spawn"
			+ " room or beyond the map; and the round it rose in and the two after end with it still up" );

		m.LavaRoundEnded( lvRose + 3 );
		var lvSinks = m._lava == LavaSinking;
		m._lavaSince = LavaSinkSeconds + 0.1f;
		m.WatchLava();
		Check( lvSinks && m._lava == LavaDone && m.LavaDoneShown && !m.LavaTakes( lvLow ) && !m._lavaGo.IsValid()
				&& FogAreaManager.Forced is null,
			"…the third round's end after it sinks it, and sunk it is survived, the lava back in its bed and the fog gone" );

		m.RoundBegan( 1 );
		Check( m._lava == LavaNone && m._lavaShown == LavaNone && !m._lavaGo.IsValid(), "…and a new game ends it" );

		// 26. the junctions: the lava survived wakes them — a route of all six, a setting for each; a round turns a junction,
		// and one a moment after another does not; the energy sent runs the route, dying at the first junction set wrong
		// whatever the rest are set to, with nothing lost and no junction turning while it runs; the rings blink the route's
		// colours in order, white between them and through the gap; all six right, it is through to the teleporter; a round
		// is found on a junction's boxes and not beside them, and a reload's burst at the route's first junction sends the
		// energy while one across the room does not; a new game ends them
		m.StartLava();
		m.SetLava( LavaSinking );
		m._lavaSince = LavaSinkSeconds + 0.1f;
		m.WatchLava();
		var jnRoute = Enumerable.Range( 0, Junctions ).Select( k => JnRouteAt( m._jnRoute, k ) ).ToList();
		var jnBegun = m._lava == LavaDone && (m._jnFlags & JnActive) != 0 && (m._jnFlagsShown & JnActive) != 0
			&& jnRoute.Distinct().Count() == Junctions && jnRoute.All( j => j < Junctions )
			&& Enumerable.Range( 0, Junctions ).All( j => JnSetting( m._jnSettings, j ) < JunctionSettings
				&& JnSetting( m._jnAnswers, j ) < JunctionSettings );
		var jnFrom = JnSetting( m._jnSettings, 0 );
		m.JunctionShot( 0, anytime: true );
		var jnTurned = JnSetting( m._jnSettings, 0 ) == (jnFrom + 1) % JunctionSettings && m._jnSettingsShown == m._jnSettings
			&& m.JunctionShot( 0 ) != "";
		Check( jnBegun && jnTurned, $"the junctions: the lava survived wakes them — the route {JnRouteText( m._jnRoute )}, a"
			+ " setting for each; a round turns a junction, and one a moment after another does not" );

		var jnThird = jnRoute[2];
		m._jnSettings = JnWithSetting( m._jnAnswers, jnThird, (JnSetting( m._jnAnswers, jnThird ) + 1) % JunctionSettings );
		var jnBefore = m._jnSettings;
		m.StartJunctionTest();
		var jnBusy = m.JunctionShot( jnRoute[4], anytime: true ) != "" && m.StartJunctionTest() != "";
		for ( var jnI = 0; jnI < Junctions && (m._jnFlags & JnTesting) != 0; jnI++ )
		{
			m._jnStepSince = JnStepSeconds + 0.01f;
			m.WatchJunctions();
		}
		Check( jnBusy && (m._jnFlags & (JnTesting | JnDone)) == 0 && (m._jnFlags & JnActive) != 0 && m._jnStep == 2
				&& m._jnPulse == jnThird && !m._jnPulseRight && m._jnSettings == jnBefore,
			"…the energy sent runs the route: set right but for the third, it dies at the third, and nothing is lost for it — nor"
			+ " does a junction turn while it runs" );

		var jnColours = jnRoute.Select( JunctionColour ).ToList();
		int JnBlinkAt( float t ) { m._jnBlinkSince = t; return m.JunctionBlinkShown(); }
		Check( OnBasalt
				? JnBlinkAt( 0.1f ) == jnColours[0] && JnBlinkAt( 0.6f ) == -1 && JnBlinkAt( 1.1f ) == jnColours[1]
					&& JnBlinkAt( 5.1f ) == jnColours[5] && JnBlinkAt( 6.1f ) == -1 && JnBlinkAt( 10.4f ) == -1
					&& JnBlinkAt( 10.6f ) == jnColours[0]
				: JnBlinkAt( 0.1f ) == -2,
			OnBasalt ? "…the rings blink the route's colours in order, white between them and through the gap, and begin again"
				: "…and off basalt the rings blink nothing" );

		m._jnSettings = m._jnAnswers;
		m.StartJunctionTest();
		for ( var jnI = 0; jnI <= Junctions && (m._jnFlags & JnTesting) != 0; jnI++ )
		{
			m._jnStepSince = JnStepSeconds + 0.01f;
			m.WatchJunctions();
		}
		Check( (m._jnFlags & JnDone) != 0 && m.JunctionsDoneShown && m._jnPulse == jnRoute[5] && m._jnPulseRight
				&& m.JunctionShot( 0, anytime: true ) != "" && m.JunctionBlinkShown() == -2,
			"…all six right, the energy is through to the teleporter: no junction turns after, and the rings stop blinking" );

		var (jnAt0, jnFace0) = JunctionSpot( 0 );
		var jnOut0 = new Vector3( jnFace0.x, jnFace0.y, 0f );
		var jnAlong0 = new Vector3( jnFace0.y, -jnFace0.x, 0f );
		var (jnFirstAt, jnFirstFace) = JunctionSpot( jnRoute[0] );
		var jnFirstOut = new Vector3( jnFirstFace.x, jnFirstFace.y, 0f );
		var jnFeet = jnFirstAt + jnFirstOut * 60f + Vector3.Down * 54f;
		Check( JunctionAt( jnAt0 + jnOut0 * 4f ) == 0 && JunctionAt( jnAt0 + jnOut0 * 4f + jnAlong0 * 80f ) == -1
				&& JunctionAt( jnAt0 + jnOut0 * 40f ) == -1
				&& m.PopReachesFirstJunction( jnFeet, PerkEffects.PopRadius )
				&& !m.PopReachesFirstJunction( jnFeet + jnFirstOut * 300f, PerkEffects.PopRadius ),
			"…a round is found on a junction's boxes, not beside them nor in the room before them; a reload's burst at the route's"
			+ " first junction sends the energy, and one across the room does not" );

		m.RoundBegan( 1 );
		Check( (m._jnFlags & JnActive) == 0 && (m._jnFlagsShown & JnActive) == 0 && m.JunctionBlinkShown() == -2,
			"…and a new game ends them" );

		// 27. the teleporter's buttons: the energy through to the teleporter wakes them — all red, a fair deal, each button
		// turning itself; a press turns it and those it is linked to on a colour, one a moment after another does not, and
		// three bring them back; the answer pressed sets the destination, all six light blue, and no press after; a look at a
		// button within reach finds it, not one beside it nor one from out of reach; a new game ends them
		m.StartJunctions();
		m._jnSettings = m._jnAnswers;
		m.StartJunctionTest();
		for ( var jnI = 0; jnI <= Junctions && (m._jnFlags & JnTesting) != 0; jnI++ )
		{
			m._jnStepSince = JnStepSeconds + 0.01f;
			m.WatchJunctions();
		}
		var (btCount, btAnswer) = BtnAnswers( m._btnLinks, 0 );
		Check( (m._jnFlags & JnDone) != 0 && (m._btnFlags & BtnActive) != 0 && (m._btnFlagsShown & BtnActive) != 0
				&& m._btnColours == 0 && m._btnColoursShown == 0 && btCount == 1 && BtnFair( m._btnLinks ) && BtnFair( BtnFallback )
				&& Enumerable.Range( 0, Buttons ).All( b => (BtnLinksOf( m._btnLinks, b ) >> b & 1) != 0 ),
			$"the teleporter's buttons: the energy through wakes them, all red — a fair deal, its one answer {BtnPressesText( btAnswer )},"
			+ $" {BtnPressCount( btAnswer )} presses; each button turns itself" );

		var btTurns = BtnLinksOf( m._btnLinks, 0 );
		m.PressButton( 0, anywhere: true, anytime: true );
		var btOnce = Enumerable.Range( 0, Buttons ).All( j => BtnColour( m._btnColours, j ) == (btTurns >> j & 1) )
			&& m._btnColoursShown == m._btnColours;
		var btSoon = m.PressButton( 0, anywhere: true ) == "pressed a moment ago";
		m.PressButton( 0, anywhere: true, anytime: true );
		m.PressButton( 0, anywhere: true, anytime: true );
		Check( btOnce && btSoon && m._btnColours == 0,
			"…a press turns it and those it is linked to on a colour, one a moment after another does not, and three bring them"
			+ " back to red" );

		for ( var b = 0; b < Buttons; b++ )
			for ( var btPress = 0; btPress < BtnTimes( btAnswer, b ); btPress++ ) m.PressButton( b, anywhere: true, anytime: true );
		Check( m._btnColours == BtnAllBlue() && (m._btnFlags & BtnDone) != 0 && m.ButtonsDoneShown
				&& m.PressButton( 1, anywhere: true, anytime: true ) != "" && m._btnColours == BtnAllBlue(),
			"…the answer pressed sets the destination: all six light blue, and no press after it" );
		Check( OnBasalt ? m._blueAltarGo.IsValid() && m._blueAltarRising && m._blueAltarBox.IsValid() && !m._blueAltarBox.Enabled
				: !m._blueAltarGo.IsValid(),
			OnBasalt ? "…and the blue altar rises in the teleporter's middle, not solid until it stands"
				: "…and off basalt no blue altar rises" );

		var btFeet = ButtonSpot( 3 ) + new Vector3( 0f, -60f, 0f );
		var btEye = btFeet + Vector3.Up * 64f;
		var btFar = new Vector3( 0f, -200f, 0f );
		Check( ButtonAimedFrom( btEye, (ButtonTop( 3 ) - btEye).Normal, btFeet ) == 3
				&& ButtonAimedFrom( btEye, (ButtonTop( 3 ) + new Vector3( 40f, 0f, 0f ) - btEye).Normal, btFeet ) == -1
				&& ButtonAimedFrom( btEye + btFar, (ButtonTop( 3 ) - btEye - btFar).Normal, btFeet + btFar ) == -1,
			"…a look at a button within reach finds it — not one beside it, nor one from out of reach" );

		m.RoundBegan( 1 );
		Check( (m._btnFlags & BtnActive) == 0 && (m._btnFlagsShown & BtnActive) == 0 && m._btnColours == 0
				&& !m._blueAltarGo.IsValid(),
			"…and a new game ends them, and the blue altar with them" );

		// 28. the boss arena: the upper floor's outline is one shape, not crossing itself, counter-clockwise, inside the slab's
		// box, the arena's middle on it; and on basalt the floor stands raised three zombies' height, the upper floor on it — a
		// look straight down meets the upper floor in the middle, and the raised floor in the gap by the walls; off basalt,
		// neither is built
		var arenaOutline = ArenaStageOutline;
		Check( arenaOutline.Length >= 3 && DebrisMesh.IsSimple( arenaOutline ) && ArenaOutlineArea() > 0f
				&& arenaOutline.All( arenaP => InArenaSlab( new Vector3( arenaP.x, arenaP.y, ArenaSlabMax.z ) ) ) && InOutline( arenaOutline, ArenaMiddle )
				&& !InOutline( arenaOutline, ArenaGapPoint() ),
			$"the boss arena: the upper floor's outline is one shape of {arenaOutline.Length} points, not crossing itself, inside the"
			+ " slab, the arena's middle on it and the gap by the walls off it" );

		var arenaTop = ArenaFloorTop;
		var arenaGap = ArenaGapPoint();
		var arenaMid = OnBasalt ? m.FloorUnder( new Vector3( ArenaMiddle.x, ArenaMiddle.y, arenaTop + 150f ) ) : null;
		var arenaLow = OnBasalt ? m.FloorUnder( new Vector3( arenaGap.x, arenaGap.y, arenaTop + 150f ) ) : null;
		Check( OnBasalt
				? (m._arenaSplit?.Count ?? 0) > 0 && m._arenaStageGo.IsValid()
					&& arenaMid is float arenaUp && MathF.Abs( arenaUp - (arenaTop + ArenaStageHeight) ) < 2f
					&& arenaLow is float arenaDown && MathF.Abs( arenaDown - arenaTop ) < 2f
				: (m._arenaSplit?.Count ?? 0) == 0 && !m._arenaStageGo.IsValid(),
			OnBasalt
				? $"…its floor raised {ArenaRaise:0}u and the upper floor {ArenaStageHeight:0}u over it: a look down meets"
					+ $" {( arenaMid is float arenaA ? arenaA.ToString( "0.#" ) : "nothing" )} in the middle and"
					+ $" {( arenaLow is float arenaB ? arenaB.ToString( "0.#" ) : "nothing" )} in the gap by the walls"
				: "…and off basalt neither floor is built" );

		var arenaUnder = ArenaCeilingUnder;
		var arenaOver = OnBasalt ? m.CeilingOver( new Vector3( ArenaMiddle.x, ArenaMiddle.y, arenaTop + ArenaStageHeight + 64f ) ) : null;
		Check( OnBasalt
				? m._arenaCeilingGo.IsValid() && arenaUnder is float arenaC && arenaOver is float arenaO && MathF.Abs( arenaO - arenaC ) < 2f
				: !m._arenaCeilingGo.IsValid(),
			OnBasalt
				? $"…and a ceiling under the lava bed: a look straight up from the upper floor meets"
					+ $" {( arenaOver is float arenaO2 ? arenaO2.ToString( "0.#" ) : "nothing" )}, its underside"
					+ $" {( arenaUnder is float arenaC2 ? arenaC2.ToString( "0.#" ) : "— no lava bed" )}"
				: "…and off basalt no ceiling is built" );

		// 29. the blue altar's send: refused before the destination is set, and while the lava is out of its bed; on basalt it
		// charges, the passage follows and every rider is sent — no body moved while testing — a second send while one runs
		// refused; off basalt, with no upper floor to land on, it sends no one. The riders land apart on the upper floor, which
		// counts as in the arena where the teleporter's room does not; a look at the altar within reach is at it, one beside
		// it or from out of reach is not; and a new game ends it
		var sendEarly = m.StartArenaSend( NZPlayer.Local, anywhere: true );
		m._btnColours = BtnAllBlue();
		m._btnFlags = BtnActive | BtnDone;
		var sendLavaWas = m._lava;
		m._lava = LavaUp;
		var sendLava = m.StartArenaSend( NZPlayer.Local, anywhere: true );
		m._lava = sendLavaWas;
		var sendOk = m.StartArenaSend( NZPlayer.Local, anywhere: true );
		if ( OnBasalt )
		{
			var sendTwice = m.StartArenaSend( NZPlayer.Local, anywhere: true );
			var sendCharging = m._arenaSend == ArenaCharging && m._arenaSendShown == ArenaCharging;
			m._arenaSendSince = ArenaWarmupSeconds + 0.1f;
			m.WatchArenaSend();
			var sendPassage = m._arenaSend == ArenaPassage && m._arenaSendShown == ArenaPassage;
			m._arenaSendSince = ArenaTransitSeconds + 0.1f;
			m.WatchArenaSend();
			Check( sendEarly != "" && sendLava != "" && sendOk == "" && sendTwice != "" && sendCharging && sendPassage
					&& m._arenaSend == ArenaIdle && m._arenaSendShown == ArenaIdle && m._arenaSent,
				"the blue altar's send: refused before the destination is set and while the lava is out; then it charges, the"
				+ " passage follows and every rider is sent — no body moved while testing — and a second send while one runs is"
				+ " refused" );
		}
		else
			Check( sendEarly != "" && sendLava != "" && sendOk != "" && m._arenaSend == ArenaIdle,
				$"the blue altar's send: refused before the destination is set, while the lava is out, and off basalt ({sendOk})" );

		var landings = Enumerable.Range( 0, 4 ).Select( k => ArenaLanding( k, 4 ) ).ToList();
		var landingsApart = landings.SelectMany( ( a, k ) => landings.Skip( k + 1 ).Select( b => a.Distance( b ) ) ).Min();
		Check( landings.All( l => InOutline( ArenaStageOutline, new Vector2( l.x, l.y ) ) && InBossArena( l ) )
				&& landingsApart > 64f && !InBossArena( BlueAltarFoot ) && !InBossArena( TwinCentre ),
			$"…four riders land on the arena's upper floor, {landingsApart:0}u apart at the least, and there counts as in the"
			+ " arena — the teleporter's room does not" );

		var sendFeet = BlueAltarFoot + new Vector3( 60f, 0f, 0f );
		var sendEye = sendFeet + Vector3.Up * 64f;
		var sendMid = BlueAltarFoot + Vector3.Up * (AltarHeight * 0.5f);
		var sendFar = new Vector3( 200f, 0f, 0f );
		Check( BlueAltarAimedFrom( sendEye, (sendMid - sendEye).Normal, sendFeet )
				&& !BlueAltarAimedFrom( sendEye, (sendMid + new Vector3( 0f, 40f, 0f ) - sendEye).Normal, sendFeet )
				&& !BlueAltarAimedFrom( sendEye + sendFar, (sendMid - sendEye - sendFar).Normal, sendFeet + sendFar ),
			"…a look at the blue altar within reach is at it — not one beside it, nor one from out of reach" );

		m.RoundBegan( 1 );
		Check( m._arenaSend == ArenaIdle && !m._arenaSent && (m._btnFlags & BtnDone) == 0, "…and a new game ends it" );

		// 30. the boss fight's rules: its lines, and the cap — a phase ends exactly at its line, nothing lands between phases, the
		// last has none; the changes' clocks — he comes back once the arena has changed; and the arena — the shrunk floor inside
		// the upper floor, the seven platforms inside the shrunk floor and a jump apart, the flood over the floor and under the
		// platforms, the core over the middle one. Pure: nothing of a fight under way is touched
		Check( PhaseLine( 1 ) == 0.75f && PhaseLine( 2 ) == 0.5f && PhaseLine( 3 ) == 0.25f && PhaseLine( 4 ) == 0f
				&& BossCapAmount( FightPhase, 1, 800f, 1000f, 200f ) == 50f && BossCapAmount( FightPhase, 1, 750f, 1000f, 10f ) == 0f
				&& BossCapAmount( FightChange, 2, 750f, 1000f, 100f ) == 0f && BossCapAmount( FightIntro, 1, 1000f, 1000f, 100f ) == 0f
				&& BossCapAmount( FightPhase, 4, 100f, 1000f, 500f ) == 100f,
			"the boss fight: its phases end at 75, 50 and 25%; a hit stops at the line, none lands between phases, and in the last"
			+ " one takes him to nothing" );

		Check( BossDamageShare( true ) == 1f && BossDamageShare( false ) == BossArmour && BossArmour < 1f,
			$"…he takes {BossArmour * 100f:0.#}% of a hit, and all of it while the Prisma's fuse burns on him" );

		Check( Enumerable.Range( 2, 3 ).All( n => ReturnAt( n ) > ChangeDone( n ) && ReturnAt( n ) >= 10f )
				&& SinkT( FightChange, 2, ShrinkSinkAt - 0.1f ) == 0f && SinkT( FightChange, 2, ShrinkSinkAt + ShrinkSinkSeconds ) == 1f
				&& SinkT( FightPhase, 1, 0f ) == 0f && SinkT( FightPhase, 4, 0f ) == 1f
				&& HexT( FightChange, 3, HexRiseAt ) == 0f && HexT( FightPhase, 3, 0f ) == 1f && HexT( FightPhase, 2, 0f ) == 0f,
			$"…he comes back once the arena has changed — at {ReturnAt( 2 ):0}, {ReturnAt( 3 ):0} and {ReturnAt( 4 ):0}s into each"
			+ " change — and each change runs on its clock" );

		float fightA = Apothem, fightR = Apothem * 2f / MathF.Sqrt( 3f );
		var fightCorners = Enumerable.Range( 0, FightHexes ).SelectMany( k => new[]
		{
			new Vector2( 0f, fightR ), new Vector2( -fightA, fightR * 0.5f ), new Vector2( -fightA, -fightR * 0.5f ),
			new Vector2( 0f, -fightR ), new Vector2( fightA, -fightR * 0.5f ), new Vector2( fightA, fightR * 0.5f ),
		}.Select( d => FightHexCentre( k ) + d ) ).ToList();
		var fightApart = Enumerable.Range( 0, FightHexes ).SelectMany( i => Enumerable.Range( i + 1, FightHexes - i - 1 ),
			( i, j ) => (FightHexCentre( i ) - FightHexCentre( j )).Length ).Min();
		var shrunkOut = ArenaShrunkOutline.Count( p => !InOutline( ArenaStageOutline, p ) );
		Check( ArenaShrunkOutline.Length >= 3 && DebrisMesh.IsSimple( ArenaShrunkOutline ) && shrunkOut == 0,
			$"…the shrunk floor is one shape of {ArenaShrunkOutline.Length} points, not crossing itself, inside the upper floor"
			+ $"{( shrunkOut > 0 ? $" — {shrunkOut} of its points are NOT" : "" )}" );

		var cornersOut = fightCorners.Count( p => !InOutline( ArenaShrunkOutline, p ) );

		// a ring: each the spacing from the next round it, and from exactly two in all — none nearer, none overlapping
		var ringLinked = Enumerable.Range( 0, FightHexes ).All( k =>
			MathF.Abs( (FightHexCentre( k ) - FightHexCentre( (k + 1) % FightHexes )).Length - FightHexSpacing ) < 0.5f
			&& Enumerable.Range( 0, FightHexes ).Count( j => j != k && (FightHexCentre( j ) - FightHexCentre( k )).Length < FightHexSpacing + 0.5f ) == 2 );
		Check( cornersOut == 0 && ringLinked && fightApart >= FightHexSpacing - 0.5f && FightHexSeam > 0f && FightHexSeam <= 16f,
			$"…the {FightHexes} platforms lie inside it, a ring — each beside the next, {fightApart - Apothem * 2f:0}u between"
			+ $"{( cornersOut > 0 ? $" — {cornersOut} of their corners are NOT" : "" )}{( ringLinked ? "" : " — NOT one ring" )}" );

		Check( FloodTop > ShrunkFloorTop && FloodTop < ShrunkFloorTop + FightHexRise && FloodBed < ShrunkFloorTop
				&& InOutline( ArenaShrunkOutline, new Vector2( CoreRest.x, CoreRest.y ) )
				&& InOutline( ArenaShrunkOutline, new Vector2( OrbAt.x, OrbAt.y ) ),
			$"…the flood rises over the floor and stops under the platforms — {FloodBed:0.#} to {FloodTop:0.#}, their tops at"
			+ $" {ShrunkFloorTop + FightHexRise:0.#} — and the green orb floats over the floor, at {OrbAt:0}" );

		// …and home: the players come back in a ring round the blue altar, clear of its pillar and of the six buttons round it,
		// for as many as a game holds
		var homes = Enumerable.Range( 1, 8 ).SelectMany( n => Enumerable.Range( 0, n ).Select( i => HomeLanding( i, n ) ) ).ToList();
		var homeNearButton = homes.Min( h => Enumerable.Range( 0, Buttons ).Min( b => FlatDistance( h, ButtonSpot( b ) ) ) );
		Check( homes.All( h => MathF.Abs( FlatDistance( h, BlueAltarFoot ) - 90f ) < 0.5f && MathF.Abs( h.z - BlueAltarFoot.z - 2f ) < 0.1f )
				&& homeNearButton > 40f,
			$"…the players come home 90u round the blue altar, {homeNearButton:0}u from its nearest button at the least" );

		// 31. the Easter egg's rewards: the gifts as asked; and the rules it opens — Pack-a-Punch to MK6 on the ladders carried one
		// step on, from a save that holds five too; rarity to Godly, red, one more x1.5, the box's gate and the Arsenal's price one
		// step on; and the augment limit lifted. Pure: the egg's flag passed, not set
		var savedFive = new PapSettings
		{
			Costs = new[] { 5000, 15000, 30000, 55000, 90000 },
			Multipliers = new[] { 2.7f, 6.48f, 13.608f, 24.4944f, 36.7416f },
			UnlockRounds = new[] { 1, 15, 30, 45, 60 },
		};
		Check( EggPoints == 50000 && EggSalvage == 5000
				&& NZPlayer.PapLevelCap( true ) == 6 && NZPlayer.PapLevelCap( false ) <= PapSettings.MaxMapTiers
				&& savedFive.CostToReach( 5, 6 ) == 135000 && savedFive.CostToReach( 5, 5 ) == 0
				&& MathF.Abs( savedFive.MultiplierAt( 6 ) - 36.7416f * 1.2f ) < 0.001f
				&& savedFive.TierOpenAt( 6, 1 ) && savedFive.UnlockRoundFor( 5 ) == 0
				&& !savedFive.TierOpenAt( 5, 59 ) && savedFive.TierOpenAt( 5, 60 )
				&& savedFive.MaxTierForRound( 59, 6 ) == 4 && savedFive.MaxTierForRound( 60, 6 ) == 6 && savedFive.MaxTierForRound( 60, 5 ) == 5
				&& NZPlayer.PapLevelHeld( 6, false ) == 5 && NZPlayer.PapLevelHeld( 6, true ) == 6 && NZPlayer.PapLevelHeld( 5, false ) == 5,
			$"the Easter egg's rewards: {EggPoints:N0} points and {EggSalvage:N0} salvage; Pack-a-Punch to MK6 —"
			+ $" {savedFive.CostToReach( 5, 6 ):N0} points, x{savedFive.MultiplierAt( 6 ):0.##}, at any round: the egg its only gate, and an"
			+ " MK6 from elsewhere an MK5 without it — from a save that holds five too" );

		var savedSix = new AmmoBoxSettings { BasePrices = new[] { 500, 1000, 2000, 3000, 4000, 5000 } };
		Check( savedSix.BaseFor( 6 ) == 6000 && savedSix.BaseFor( 5 ) == 5000,
			$"…an MK6 gun's ammo costs {savedSix.BaseFor( 6 ):N0}, from that save too" );

		var godly = Rarity.ColorFor( Rarity.GodlyTier );
		float[] boxOne = Rarity.BoxWeights( 1, true ), boxLate = Rarity.BoxWeights( 40, true );
		var godlyOne = boxOne[Rarity.GodlyTier] / boxOne.Sum() * 100f;
		var godlyLate = boxLate[Rarity.GodlyTier] / boxLate.Sum() * 100f;
		Check( Rarity.TopTierFor( false ) == Rarity.LegendaryTier && Rarity.TopTierFor( true ) == Rarity.GodlyTier
				&& Rarity.NameFor( Rarity.GodlyTier ) == "Godly" && godly.r > 0.9f && godly.g < 0.3f && godly.b < 0.3f
				&& MathF.Abs( Rarity.Mult( Rarity.GodlyTier ) - 7.59375f ) < 0.001f
				&& Rarity.BoxWeights( 1, false )[Rarity.GodlyTier] == 0f && Rarity.BoxWeights( 40, false )[Rarity.GodlyTier] == 0f
				&& boxOne[Rarity.GodlyTier] > 0f && boxOne[1] == 0f && boxOne[Rarity.LegendaryTier] == 0f
				&& boxLate[Rarity.GodlyTier] * 2f == boxLate[Rarity.LegendaryTier]
				&& Rarity.TierHeld( Rarity.GodlyTier, false ) == Rarity.LegendaryTier && Rarity.TierHeld( Rarity.GodlyTier, true ) == Rarity.GodlyTier
				&& new ArsenalSpot().RarityTier5Price == 10000,
			$"…rarity to {Rarity.NameFor( Rarity.GodlyTier )}, {Rarity.HexFor( Rarity.GodlyTier )}, x{Rarity.Mult( Rarity.GodlyTier ):0.##}: in the box"
			+ $" at any round once the Easter egg is complete — {godlyOne:0.#}% of rolls on round 1, {godlyLate:0.#}% on round 40 — and"
			+ $" never before it, the other tiers' rounds as they were; the Arsenal for {new ArsenalSpot().RarityTier5Price:N0} salvage" );

		Check( PerkAugments.LimitsLifted( false, true ) && PerkAugments.LimitsLifted( true, true ) && !PerkAugments.LimitsLifted( false, false ),
			"…and no limit on a perk's augments, for everyone, out of Creative too" );

		// put everything back — the step where it stood, the clue with the picks, the slots as the step says
		m._armedForTest = null;
		m._orderForTest = null;
		m._picked.Clear(); m._picked.AddRange( savedPicks );
		m._sequence.Clear(); m._sequence.AddRange( savedSequence );
		m._failed = savedFailed;
		m.SetDone( savedDone );
		m._bonfire = savedBonfire;
		m._pests = savedPests;
		m.SendBonfire();
		m._rings = savedRings;
		m.SendRings();
		m._flameCarrier = savedCarrier;
		m._flameLost = savedLost;
		m._flamePlaced = savedPlaced;
		m._defense = savedDefense;
		m._altarHits = savedHits;
		m.SendDefense();
		m._twinOpen = savedTwin;
		m.SendTwin();
		m._shriekerKills = savedShriekers;
		m.SendShriekers();
		(m._mmColours, m._mmAnswer, m._mmTries, m._mmFlags) = (savedMmColours, savedMmAnswer, savedMmTries, savedMmFlags);
		m._mmPending = -1;
		m._mmFlashing = false;
		m.SendMastermind();
		(m._lava, m._lavaSince, m._lavaRoseRound) = (savedLava, savedLavaSince, savedLavaRose);
		m.SendLava();
		(m._jnRoute, m._jnSettings, m._jnAnswers, m._jnFlags) = (savedJnRoute, savedJnSettings, savedJnAnswers, savedJnFlags);
		m._jnStep = 0;
		m._jnPulse = -1;
		m.SendJunctions();
		(m._btnLinks, m._btnColours, m._btnFlags) = (savedBtnLinks, savedBtnColours, savedBtnFlags);
		m._btnPressed = null;
		m.SendButtons();
		(m._arenaSend, m._arenaSendSince, m._arenaSent, m._arenaSendShown) = (savedArenaSend, savedArenaSince, savedArenaSent, savedArenaShown);
		m.SendFlame();
		m._lockGlyphs = savedLockCode;
		m._codeSpots = savedSpots;
		m._lockOpen = savedLockOpen;
		m._lockJammed = savedJammed;
		m.SendLock();
		m._symbolsAll = savedAll;
		m.ApplySymbols( m._symbolsSent );          // this machine's symbols as everyone else's still are: the test drew its own
		Send( savedLit );
		m.SendClue();

		Log.Info( $"[nz-hex-test] {pass} passed, {fail} failed — state restored" );
	}

	/// <summary>
	/// `nz_hex_light &lt;id&gt; [colour]` — HOST: light a tile without slamming, to check how it looks. No colour: a
	/// pick's own, anything else white. 0 puts all out.
	/// </summary>
	[ConCmd( "nz_hex_light" )]
	public static void Light( string id = "", string colour = "" )
	{
		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; }

		if ( id == "0" ) { Send( default ); Log.Info( "[nz-hex] all out" ); return; }

		var i = Array.FindIndex( Tiles, t => t.Id == id );
		if ( i < 0 || i >= 64 ) { Log.Warning( $"[nz-hex] no available tile '{id}' — use the IDs on the picker map" ); return; }

		var c = colour == "" ? m.PickColour( i ) : ColourNumber( colour );
		if ( colour == "" && c < 0 ) c = White;
		if ( c < 0 ) { Log.Warning( $"[nz-hex] '{colour}' is not a colour — blue, yellow, green, red or white" ); return; }

		Send( m._lit.With( i, c ) );
		Log.Info( $"[nz-hex] tile {id} lit {ColourName( c )} by hand" );
	}

	/// <summary>
	/// `nz_hex_colour [colour] [#hex] [drive]` — retune a colour by eye; bare, it prints all four. `nz_hex_colour reset`
	/// puts the code's back. A tile already lit in that colour changes at once, since every tile of a colour shares
	/// its material.
	///
	/// ⚠️ ON THIS MACHINE ONLY, AND UNTIL A RESTART — the deal `nz_zombie_eyes` makes, for the same reason: a colour
	/// is chosen by looking at it. Settle on one, then paste the line this prints into `Authored`.
	/// </summary>
	[ConCmd( "nz_hex_colour" )]
	public static void ColourCmd( string colour = "", string hex = "", float drive = -1f )
	{
		if ( colour.Trim().ToLowerInvariant() == "reset" )
		{
			_tuned = null;
			TuneVersion++;                                          // the clue draws its picks in these too
			for ( var k = 0; k < Colours; k++ ) MaterialFor( k );
			Log.Info( "[nz-hex] colours back to the code's" );
		}
		else if ( colour != "" )
		{
			var k = ColourNumber( colour );
			if ( k < 0 || k >= Colours ) { Log.Warning( "[nz-hex] the colours are blue, yellow, green and red" ); return; }

			var (h, d) = Tuning( k );
			if ( hex != "" )
			{
				if ( Color.Parse( hex ) is not Color parsed ) { Log.Warning( $"[nz-hex] '{hex}' is not a colour — #RRGGBB" ); return; }
				h = parsed;
			}
			if ( drive > 0f ) d = drive;

			(_tuned ??= new())[k] = (h, d);
			TuneVersion++;
			MaterialFor( k );
		}

		for ( var k = 0; k < Colours; k++ )
		{
			var (h, d) = Tuning( k );
			var t = TintOf( k );
			var retuned = _tuned is not null && _tuned.ContainsKey( k );

			Log.Info( $"[nz-hex] {ColourName( k )}: {h.Hex} × {d:0.##} → g_vColorTint [{t.x:0.###} {t.y:0.###} {t.z:0.###}]"
				+ ( retuned ? $"  ← retuned; in Authored: {k} => (\"{h.Hex}\", {d:0.##}f)," : "" ) );
		}
	}
}