EasterEgg/HexSlotManager.cs

Manager component for the map's hexagon slots used in an Easter egg puzzle. It handles creating and placing slot objects, generating and packing a randomized roll of numbers (I-IV) and colours, enforcing the rule for successive-round rolls, mirroring and broadcasting shown state from host to clients, drawing slot geometry and numerals, and many editor/console commands for testing and editing slots.

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace NZombies;

/// <summary>
/// BASALT SEAL 1 — THE HEX SLOTS (Docs/BASALT_EASTER_EGG.md). Each is a hexagon of light on a wall showing a number, I to
/// IV, in one of the four colours, and no number and no colour comes up twice. Asked for as *"these slots will be
/// randomized every game to be a number from 1-4 and one of the 4 colors, no number and color must repeat"*.
///
/// ⛔ THEY ROLL AGAIN AT EVERY ROUND'S END, AND EACH ROLL FOLLOWS THE LAST (<see cref="Next"/>): every slot's number
/// changes, every slot's colour changes, and every colour's number changes. Asked for as *"every round the numbers and
/// colors change for each slot, meaning if red IV was on slot 2, on the next round red cannot be IV and cannot be on slot
/// 2"*, and *"each number should be able to be any color"*. The first version rolled once a game, so for a whole game
/// each number kept one colour.
///
/// ⛔ THEY ARE THE STEP'S ORDER. The colour on the slot showing I is slammed first, then II's, III's and IV's
/// (`HexPlatforms`, <see cref="ColoursInOrder"/>). Once all four are slammed in order the step is done, and the slots
/// go — asked for as *"the numbers also disappear after that"* — until a new game.
///
/// ⛔ THEY APPEAR ONLY ONCE THE POWER IS ON, go again when a new game turns it off, and go for good once the step is done. Asked for as *"make it so they only
/// appear after the power has been turned on"*. Before that there is nothing of them on the walls, not even a bare hexagon.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the SPOTS are config (`MapConfig.HexSlots`), which every machine is sent whole;
/// - the ROLL is HOST state until the power is on. From then the walls show it to everyone, so the host MIRRORS it as what
///   the slots show (<see cref="Shown"/>, `NZNet.HexSlotsShown`): nothing before the power, the roll after. The clue's
///   picks work the same way. A client that held the roll early could read the order off its console before anyone could
///   see it;
/// - the LIGHTS are LOCAL: every machine builds its own from the spots and what it was sent.
///
/// ⚠️ THE HOST DECIDES WHEN, NOT EACH MACHINE'S OWN `Power.IsOn`. The power reaches every machine when it is switched, but
/// nothing replays it to a player who joins later, so a joiner's own flag can read off while the host's reads on. What the
/// slots show IS replayed to a joiner (`NZNet.PushState`), so they see what everyone else sees.
///
/// ⚠️ THE SAME SHAPE AS EVERY PLACEABLE MANAGER: `Ensure` creates on demand, NotSaved keeps it out of the scene, and
/// `Rebuild` is the one way anything is built. It is called from the same three places as the rest: `NZGame.ShowConfig`,
/// `RoundManager.StartGame` (as <see cref="NewGame"/>, which rolls first) and the map editor's tool. `RoundManager`'s round
/// clear rolls them again (<see cref="OnRoundEnd"/>).
///
/// ⚠️ A SLOT IS LIGHT, NOT A PANEL: flat strips of the map's own light material on the wall, in the colour's tinted copy
/// (<see cref="HexPlatforms.MaterialFor"/>). That is the material a lit tile of that colour is drawn with, so a slot and
/// its tiles are exactly one colour, and `nz_hex_colour` retunes both.
/// </summary>
public sealed class HexSlotManager : Component
{
	public static HexSlotManager Instance { get; private set; }

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

	const string SlotTag = "nz_hexslot";

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

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

		// ⚠️ ORPHANED SLOTS ARE SWEPT FIRST, for `HexPlatforms.Ensure`'s reason: a hotload destroys a component made at
		// runtime, and a new manager would otherwise find the old one's slots still on the walls, tracked by nothing — and
		// build a second set over them.
		foreach ( var old in scene.GetAllObjects( false ).Where( g => g.Tags.Has( SlotTag ) ).ToList() )
			old.Destroy();

		var go = scene.CreateObject();
		go.Name = "Hex Slot Manager";
		go.Flags |= GameObjectFlags.NotSaved;
		return go.Components.Create<HexSlotManager>();
	}

	// ══ the roll ════════════════════════════════════════════════════════════════════════════

	/// <summary>How many slots can have a number and a colour: four — the numbers 1-4, one slot per colour.</summary>
	public const int Max = HexPlatforms.Colours;

	/// <summary>
	/// A game's roll: each slot's number, 1-4, and colour (<see cref="HexPlatforms.ColourName"/>), in one int so it crosses
	/// the network as one message. Slot i is bits 4i to 4i+3 — its number less one, then its colour — and bits 16-18 are how
	/// many slots were rolled. `default` is none.
	/// </summary>
	public readonly record struct Roll( int Packed )
	{
		/// <summary>How many slots this roll covers: the placed ones, up to <see cref="Max"/>.</summary>
		public int Count => (Packed >> 16) & 7;

		/// <summary>A slot's number, 1-4, or 0 for a slot the roll does not cover.</summary>
		public int NumberOf( int slot ) => slot < 0 || slot >= Count ? 0 : ((Packed >> (slot * 4)) & 3) + 1;

		/// <summary>A slot's colour, 0-3, or -1 for a slot the roll does not cover.</summary>
		public int ColourOf( int slot ) => slot < 0 || slot >= Count ? -1 : (Packed >> (slot * 4 + 2)) & 3;

		/// <summary>The roll that gives slot i the number numbers[i] (1-4) and the colour colours[i].</summary>
		public static Roll Of( IReadOnlyList<int> numbers, IReadOnlyList<int> colours )
		{
			var n = Math.Min( Math.Min( numbers.Count, colours.Count ), Max );
			var p = n << 16;
			for ( var i = 0; i < n; i++ )
				p |= (((numbers[i] - 1) & 3) | ((colours[i] & 3) << 2)) << (i * 4);
			return new Roll( p );
		}
	}

	/// <summary>
	/// A fresh roll for this many slots: the numbers 1-4 shuffled, the colours shuffled on their own, and slot i given the
	/// i-th of each. So no number and no colour comes up twice, and which number goes with which colour is as random as
	/// either.
	/// </summary>
	public static Roll Make( int slots )
	{
		var n = Math.Clamp( slots, 0, Max );
		var numbers = Shuffled( Max ).Select( k => k + 1 ).Take( n ).ToList();
		var colours = Shuffled( HexPlatforms.Colours ).Take( n ).ToList();
		return Roll.Of( numbers, colours );
	}

	/// <summary>0 … count−1 in a random order — Fisher-Yates, off `Game.Random`.</summary>
	static List<int> Shuffled( int count )
	{
		var l = Enumerable.Range( 0, count ).ToList();
		for ( var i = l.Count - 1; i > 0; i-- )
		{
			var j = Game.Random.Next( i + 1 );
			(l[i], l[j]) = (l[j], l[i]);
		}
		return l;
	}

	/// <summary>
	/// The roll after <paramref name="prev"/>: every slot's number changes, every slot's colour changes, and every colour's
	/// number changes. So if slot 2 showed red IV, next round slot 2 shows another number in another colour, and red is on
	/// another slot with another number. One of all the rolls that keep that, at random; for four slots there are always 24.
	/// With nothing to follow — the first roll, or slots placed or removed since — any roll will do (<see cref="Make"/>).
	///
	/// ⚠️ EVERY ROLL IS TRIED, NOT A SHUFFLE REDRAWN UNTIL ONE FITS: 576 rolls of four is nothing, and picking from all the
	/// ones that fit is fair to each of them and cannot run long.
	/// </summary>
	public static Roll Next( Roll prev, int slots )
	{
		var n = Math.Clamp( slots, 0, Max );
		if ( n == 0 || prev.Count != n ) return Make( n );

		var orders = Orders( Max );
		var fits = new List<Roll>();
		foreach ( var ns in orders )
			foreach ( var cs in orders )
			{
				var r = Roll.Of( ns.Take( n ).Select( k => k + 1 ).ToList(), cs.Take( n ).ToList() );
				if ( Follows( prev, r ) ) fits.Add( r );
			}

		return fits.Count > 0 ? fits[Game.Random.Next( fits.Count )] : Make( n );
	}

	/// <summary>
	/// Does <paramref name="next"/> follow <paramref name="prev"/>: every slot's number changed, every slot's colour changed,
	/// and every colour's number changed?
	/// </summary>
	public static bool Follows( Roll prev, Roll next )
	{
		if ( next.Count != prev.Count ) return false;

		for ( var i = 0; i < prev.Count; i++ )
		{
			if ( next.NumberOf( i ) == prev.NumberOf( i ) || next.ColourOf( i ) == prev.ColourOf( i ) ) return false;

			// the colour slot i showed, wherever it is now, must not carry the number it had
			for ( var j = 0; j < next.Count; j++ )
				if ( next.ColourOf( j ) == prev.ColourOf( i ) && next.NumberOf( j ) == prev.NumberOf( i ) ) return false;
		}

		return true;
	}

	/// <summary>A number, 1-4, as Roman numerals — what a slot shows.</summary>
	public static string Numeral( int number ) => number switch
	{
		1 => "I", 2 => "II", 3 => "III", 4 => "IV", _ => "",
	};

	/// <summary>A slot in words, for the log: "II red", or "not rolled".</summary>
	static string Label( int number, int colour )
		=> number > 0 ? $"{Numeral( number )} {HexPlatforms.ColourName( colour )}" : "not rolled";

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

	/// <summary>HOST — this game's roll. It leaves the host only as <see cref="_shown"/>, once the power is on.</summary>
	Roll _roll;

	/// <summary>
	/// MIRROR — what the slots show: this game's roll once the power is on, nothing before it. The host sends it
	/// (<see cref="SendShown"/>); every machine builds its slots from it.
	/// </summary>
	Roll _shown;

	/// <summary>What the slots show, for `NZNet.PushState` to replay to a joiner.</summary>
	public Roll Shown => _shown;

	/// <summary>This game's roll, shown or not. HOST — for `nz_hex_selftest` to check the order against.</summary>
	public Roll HostRoll => _roll;

	/// <summary>Are they on the walls by hand (`nz_hex_slots_show 1`), whatever the power and the step say?</summary>
	public bool ShownByHand => _forced;

	/// <summary>Why the slots are off the walls, in words.</summary>
	static string Hidden() => HexPlatforms.StepDone ? "gone — Color Smash is done" : "hidden until the power is on";

	/// <summary>
	/// The order the slots give: for each number, I to IV, the colour on the slot showing it. HOST — `HexPlatforms` slams
	/// by it. Null unless this game's roll puts all four numbers on the walls.
	/// </summary>
	public static int[] ColoursInOrder()
	{
		var m = Instance;
		if ( !m.IsValid() || m._roll.Count < Max ) return null;

		var order = new int[Max];
		for ( var i = 0; i < Max; i++ ) order[m._roll.NumberOf( i ) - 1] = m._roll.ColourOf( i );
		return order;
	}

	/// <summary>HOST — `nz_hex_slots_show 1`: the slots on the walls with the power off, to look at or place. Testing only.</summary>
	bool _forced;

	/// <summary>
	/// HOST — the power as last seen, so <see cref="OnUpdate"/> notices it come on, or go off at a new game.
	///
	/// ⚠️ NULLABLE, SO THE FIRST FRAME ALWAYS SENDS, whatever the power is. A new manager, or one a hotload has just handed
	/// this field, may find slots on the walls from before, and has to settle what they show.
	/// </summary>
	bool? _poweredWas;

	/// <summary>LOCAL — the slots this machine built, one per placed spot, in the config's order.</summary>
	readonly List<GameObject> _built = new();

	/// <summary>How many are standing right now.</summary>
	public int Built => _built.Count( g => g.IsValid() );

	/// <summary>How many the config places.</summary>
	static int Placed => ActiveConfig.Current?.HexSlots?.Count ?? 0;

	/// <summary>What the slots show now, for the tool panel: "I blue · IV red …", or why nothing.</summary>
	public static string Showing()
	{
		var n = Placed;
		if ( n == 0 ) return "none placed";

		var m = Instance;
		var r = m.IsValid() ? m._shown : default;
		if ( r.Count == 0 ) return $"{Hidden()} · nz_hex_slots_show 1 shows them";

		return string.Join( " · ", Enumerable.Range( 0, Math.Min( n, Max ) ).Select( i => Label( r.NumberOf( i ), r.ColourOf( i ) ) ) )
			+ ( m._forced && !Power.IsOn ? " · shown by hand, the power is off" : "" );
	}

	// ══ rolling and building ════════════════════════════════════════════════════════════════

	/// <summary>
	/// A new game: a new roll. HOST — `RoundManager.StartGame`, which has turned the power off by then. It is this manager's
	/// rebuild there too: what the slots show (nothing, until the power comes on) goes to every machine, and each builds
	/// from it.
	/// </summary>
	public void NewGame()
	{
		if ( NZGame.IsClient ) return;

		// no slots on this map: nothing to roll, and nothing to tell anyone
		if ( Placed == 0 && _roll.Count == 0 ) { Build(); return; }

		RollNow( "a new game" );
	}

	/// <summary>
	/// A round was cleared: the next round's roll, following this one's (<see cref="Next"/>). HOST — `RoundManager`'s round
	/// clear, beside the step's own reset (`HexPlatforms.OnRoundEnd`), so the slots and the tiles change together.
	/// </summary>
	public static void OnRoundEnd( int round, string why = null )
	{
		// ⚠️ NOT ONCE THE STEP IS DONE: the slots are gone for good, and the order they gave has been used
		if ( NZGame.IsClient || Placed == 0 || HexPlatforms.StepDone ) return;

		var m = Ensure();
		if ( m.IsValid() ) m.RollNow( why ?? $"round {round} over" );
	}

	/// <summary>Roll, and tell everybody what the slots show now. HOST.</summary>
	void RollNow( string why )
	{
		_roll = Next( _roll, Placed );
		SendShown();

		if ( _roll.Count == 0 ) return;

		// ⚠️ A LIST ONLY ONCE THEY ARE ON THE WALLS. Before that the roll is the puzzle, and the console is readable by anyone
		// at the machine; `nz_hex_slots` prints it on purpose, when testing. `HexPlatforms.Pick` logs a count for the same
		// reason.
		Log.Info( _shown.Count > 0
			? $"[nz-hex] slots rolled ({why}) — {Describe( _roll, _roll.Count )}"
			: $"[nz-hex] slots rolled ({why}) — {Hidden()}" );
	}

	/// <summary>What the slots show: nothing, or — once <paramref name="on"/> — this game's roll. HOST.</summary>
	Roll ShownFor( bool on ) => on ? _roll : default;

	/// <summary>
	/// Tell everybody what the slots show now: the roll while the power is on and the step not done, or by hand. HOST — at
	/// every roll, when the power comes on or goes off, and when the step is done or starts over.
	/// </summary>
	void SendShown() => NZNet.HexSlotsShown( ShownFor( _forced || (Power.IsOn && !HexPlatforms.StepDone) ).Packed );

	/// <summary>The step was done, or started over: show what the slots should. HOST — `HexPlatforms.SetDone`.</summary>
	public void RefreshShown()
	{
		if ( NZGame.IsClient ) return;
		SendShown();
	}

	/// <summary>What the slots show. EVERY machine — `NZNet.HexSlotsShown`. Builds them from it.</summary>
	public void ApplyShown( Roll r )
	{
		_shown = r;
		Build();
	}

	/// <summary>
	/// HOST: notice the power change, and put the slots on the walls — or take them off.
	///
	/// ⚠️ POLLED, NOT `Power.OnPowered`, for `HexPlatforms.OnUpdate`'s reasons: 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.
	/// </summary>
	protected override void OnUpdate()
	{
		if ( NZGame.IsClient ) return;

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

		_poweredWas = on;

		// a map with no slots has nothing to show, and nothing to tell anyone
		if ( Placed == 0 && _shown.Count == 0 ) return;

		SendShown();
	}

	/// <summary>
	/// Destroy what is standing and build the config's slots again, with this game's roll. On the host, a new roll first if
	/// the slots placed no longer match it: none yet, or one placed or removed in the editor.
	///
	/// ⚠️ A ROLL THAT STILL FITS IS KEPT. This runs on every mode change and config load, and rolling there would change the
	/// slots mid-game for nothing. Only a round's end (<see cref="OnRoundEnd"/>) and a new game (<see cref="NewGame"/>) roll
	/// regardless.
	/// </summary>
	public void Rebuild()
	{
		if ( !NZGame.IsClient && _roll.Count != Math.Min( Placed, Max ) )
		{
			// what the slots show comes straight back to this machine, through ApplyShown, and that builds them
			RollNow( _roll.Count == 0 ? "the first build" : "the slots placed changed" );
			return;
		}

		Build();
	}

	void Build()
	{
		foreach ( var g in _built )
			if ( g.IsValid() ) g.Destroy();
		_built.Clear();

		var list = ActiveConfig.Current?.HexSlots;
		if ( list is null || list.Count == 0 ) return;

		// ⛔ NOTHING ON THE WALLS UNTIL THE POWER IS ON, not even the hexagons without their numbers
		if ( _shown.Count == 0 )
		{
			Log.Info( $"[nz] {list.Count} hex slot(s) placed — {Hidden()}" );
			return;
		}

		for ( var i = 0; i < list.Count; i++ )
		{
			var go = BuildSlot( list[i], i );
			if ( go.IsValid() ) _built.Add( go );
		}

		Log.Info( $"[nz] {Built} of {list.Count} hex slot(s) built — {Describe( _shown, list.Count )}" );

		if ( list.Count > Max )
			Log.Warning( $"[nz-hex] {list.Count} hex slots placed, but there are only {Max} numbers and {Max} colours — the"
				+ $" {list.Count - Max} past the fourth stay white, with no number (the Hex slot tool's RMB removes one)" );
	}

	/// <summary>What the slots show, slot by slot.</summary>
	static string Describe( Roll r, int slots )
		=> string.Join( ", ", Enumerable.Range( 0, slots ).Select( i => $"{i + 1}: {Label( r.NumberOf( i ), r.ColourOf( i ) )}" ) );

	GameObject BuildSlot( HexSlotSpot spot, int i )
	{
		var number = _shown.NumberOf( i );
		var colour = _shown.ColourOf( i );

		var model = Shape( number, colour );
		if ( model is null ) return null;

		var go = Scene.CreateObject();
		go.Name = $"Hex slot {i + 1} ({Label( number, 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( SlotTag );
		go.WorldPosition = spot.Position;
		go.WorldRotation = spot.Angles.ToRotation();

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

	// ══ the look ════════════════════════════════════════════════════════════════════════════
	//
	// ⚠️ 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 float? _radius;
	/// <summary>
	/// A slot's hexagon, centre to point, in units: 9, so it stands 18u tall.
	///
	/// ⛔ SIZED TO THE SPOTS, NOT TO LOOK BIG. The first four stand where wall buys were, and a gun's chalk needs little
	/// wall. At 16 (32u tall) three of them ran past the edge of their wall: slot 3 has 10u of wall above its centre, the
	/// top of a block (`nz_hex_slot_room` measures it). 9 clears every one but slot 4, which stands 3u from the edge of its
	/// wall. A slot moved to open wall can be bigger: `nz_hex_slot_size`.
	/// </summary>
	public static float Radius { get => _radius ?? 9f; set => _radius = value; }

	static float? _line;
	/// <summary>How wide its strips of light are, in units: 1, a ninth of the radius — bold enough to read at 18u tall.</summary>
	public static float Line { get => _line ?? 1f; set => _line = value; }

	/// <summary>
	/// A slot's light: the tiles' own hexagon, points up and down, and inside it the number as Roman numerals — all in the
	/// colour's material. A slot the roll does not cover gets the map's white and no number. Null if the material is
	/// missing.
	///
	/// ⚠️ ROMAN, BECAUSE A STRIP OF LIGHT DRAWS A STRAIGHT LINE. I, II, III and IV are nothing but bars and one V, so they
	/// come out as clean as the hexagon around them, and read as marks cut in the stone rather than a sign on the wall.
	/// </summary>
	static Model Shape( int number, int colour )
	{
		var material = HexPlatforms.MaterialFor( number > 0 ? colour : -1 );
		if ( material is null ) return null;

		var s = new Strips();
		s.HexRing( Radius, Line );
		if ( number > 0 ) s.Numeral( number, Radius * 0.95f, Line );
		return s.Build( material );
	}

	/// <summary>
	/// A slot's light, in its own plane (<see cref="LightStrokes"/>, which also draws the napalm icon): the hexagon and the
	/// Roman numerals. u runs to the right as you face the wall, v up.
	///
	/// ⚠️ BUILT IN THE SLOT'S LOCAL SPACE, where the wall's normal is +X (the spot's rotation faces out of the wall). So u is
	/// local +Y: the LEFT of something facing out of the wall, which is the right of whoever faces it.
	/// </summary>
	sealed class Strips : LightStrokes
	{
		/// <summary>The hexagon's outline, <paramref name="w"/> wide, centred on the circumradius <paramref name="r"/>.</summary>
		public void HexRing( float r, float w )
		{
			// Each side is the band between the hexagon half a strip out and half a strip in. Their corners lie along the
			// corner's own direction, w/2 ÷ cos 30° off, which is where two offset sides meet — so the strips mitre.
			var k = w * 0.5f / MathF.Cos( MathF.PI / 6f );
			for ( var i = 0; i < 6; i++ )
				Poly( Corner( i, r + k ), Corner( i + 1, r + k ), Corner( i + 1, r - k ), Corner( i, r - k ) );
		}

		/// <summary>Corner i of the hexagon at circumradius r: the first straight up, then counter-clockwise.</summary>
		static Vector2 Corner( int i, float r )
		{
			var a = MathF.PI / 2f + i * MathF.PI / 3f;
			return new Vector2( r * MathF.Cos( a ), r * MathF.Sin( a ) );
		}

		/// <summary>A number, 1-4, as Roman numerals <paramref name="h"/> tall and centred: bars, and for IV a V.</summary>
		public void Numeral( int number, float h, float w )
		{
			var gap = w * 1.8f;                                         // the clear space between two pieces
			var half = h * 0.3f;                                        // a V's half-width at its top, centre line to centre line
			var across = w * MathF.Sqrt( half * half + h * h ) / h;     // one of a V's arms, measured flat across
			var vWide = 2f * half + across;

			// the pieces left to right: true a bar, false a V
			var pieces = number switch
			{
				1 => new[] { true },
				2 => new[] { true, true },
				3 => new[] { true, true, true },
				4 => new[] { true, false },
				_ => Array.Empty<bool>(),
			};
			if ( pieces.Length == 0 ) return;

			var total = pieces.Sum( bar => bar ? w : vWide ) + gap * (pieces.Length - 1);
			float x = -total / 2f, lo = -h / 2f, hi = h / 2f;

			foreach ( var bar in pieces )
			{
				if ( bar )
				{
					Poly( new Vector2( x, lo ), new Vector2( x + w, lo ), new Vector2( x + w, hi ), new Vector2( x, hi ) );
					x += w + gap;
				}
				else
				{
					V( x + vWide / 2f, lo, hi, half, across );
					x += vWide + gap;
				}
			}
		}

		/// <summary>
		/// A V centred on <paramref name="xc"/>: two arms from the top, <paramref name="half"/> either side of the centre,
		/// meeting at the bottom. Three pieces that cover it exactly, so nothing is drawn twice.
		/// </summary>
		void V( float xc, float lo, float hi, float half, float across )
		{
			var t = across / 2f;
			var a = new Vector2( xc - half - t, hi );                   // the left arm's top, outside and in
			var b = new Vector2( xc - half + t, hi );
			var d = new Vector2( xc + half - t, hi );                   // the right arm's top, in and outside
			var e = new Vector2( xc + half + t, hi );
			var g = new Vector2( xc - t, lo );                          // the foot
			var f = new Vector2( xc + t, lo );
			var c = new Vector2( xc, lo + across * (hi - lo) / (2f * half) ); // where the arms' inner edges meet

			Poly( a, b, c, g );
			Poly( c, d, e, f );
			Poly( g, f, c );
		}
	}

	// ══ placing ═════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// Place a slot on the surface at <paramref name="point"/>, facing out along <paramref name="normal"/>, and record it in
	/// the config. The map editor's tool (LMB), and `nz_hex_slot`.
	///
	/// ⚠️ A WALL BUY'S PLACEMENT, EXACTLY: 1.5u off the surface, turned to face out of it. The first four WERE wall buys
	/// (`nz_hex_slots_from_wallbuys`), so a slot placed by hand sits the way they do.
	/// </summary>
	public static bool PlaceOn( Vector3 point, Vector3? normal )
	{
		var cfg = ActiveConfig.Current;
		if ( cfg is null ) return false;

		// no surface to read (a console placement): face whoever is placing it
		var n = normal is Vector3 v && !v.IsNearlyZero() ? v.Normal : FacingPlayer( point );

		cfg.HexSlots.Add( new HexSlotSpot { Position = point + n * 1.5f, Angles = Rotation.LookAt( n ).Angles() } );
		Ensure()?.Rebuild();

		Log.Info( $"[nz-hex] hex slot {cfg.HexSlots.Count} placed at {point}"
			+ ( cfg.HexSlots.Count > Max ? $" — that is more than {Max}: the extra stay white, with no number" : "" )
			+ ( Instance.IsValid() && Instance._shown.Count == 0 ? $" · {Hidden()} (nz_hex_slots_show 1 shows them)" : "" )
			+ " · in memory: Save (nz_save) keeps it" );
		return true;
	}

	static Vector3 FacingPlayer( Vector3 point )
	{
		var me = NZPlayer.Local;
		var d = me.IsValid() ? (me.WorldPosition - point).WithZ( 0f ) : Vector3.Zero;
		return d.IsNearlyZero() ? Vector3.Forward : d.Normal;
	}

	/// <summary>Remove the slot nearest <paramref name="at"/>, within <paramref name="radius"/>. The tool's RMB.</summary>
	public static bool RemoveNear( Vector3 at, float radius )
	{
		var list = ActiveConfig.Current?.HexSlots;
		if ( list is null || list.Count == 0 ) return false;

		var best = -1;
		var bestD = radius;
		for ( var i = 0; i < list.Count; i++ )
		{
			var d = list[i].Position.Distance( at );
			if ( d > bestD ) continue;

			best = i;
			bestD = d;
		}

		if ( best < 0 ) { Log.Info( "[nz-hex] no hex slot near there" ); return false; }

		list.RemoveAt( best );
		Ensure()?.Rebuild();

		Log.Info( $"[nz-hex] hex slot {best + 1} removed ({list.Count} left) · in memory: Save (nz_save) keeps it" );
		return true;
	}

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

	/// <summary>`nz_hex_slot` — place a slot on the wall you are aiming at, as the Hex slot tool's LMB does.</summary>
	[ConCmd( "nz_hex_slot" )]
	public static void PlaceCmd()
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return; }

		var ed = Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();
		var hit = ed.IsValid() ? ed.AimTrace() : null;
		if ( hit is null ) { Log.Warning( "[nz-hex] aim at a wall first — in Creative, where the map editor runs" ); return; }

		PlaceOn( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>
	/// `nz_hex_slots` — every slot placed: where it is, and what it shows this game. On the host, while they are hidden, it
	/// prints the roll they will show. Testing only: that gives the order away, as `nz_hex_show` gives the picks.
	/// </summary>
	[ConCmd( "nz_hex_slots" )]
	public static void ListCmd()
	{
		var list = ActiveConfig.Current?.HexSlots;
		if ( list is null || list.Count == 0 )
		{
			Log.Info( "[nz-hex] no hex slots placed — Q > Easter egg > Interactables > Hex slot, or nz_hex_slots_from_wallbuys" );
			return;
		}

		var m = Instance;
		var shown = m.IsValid() ? m._shown : default;
		var hidden = shown.Count == 0;

		for ( var i = 0; i < list.Count; i++ )
		{
			var s = list[i];
			var what = !hidden ? Label( shown.NumberOf( i ), shown.ColourOf( i ) )
				: !NZGame.IsClient && m.IsValid() && m._roll.Count > 0 ? $"{Label( m._roll.NumberOf( i ), m._roll.ColourOf( i ) )} (hidden)"
				: "hidden";
			Log.Info( $"[nz-hex] slot {i + 1}: {what} · at {s.Position} facing yaw {s.Angles.yaw:0}" );
		}

		Log.Info( $"[nz-hex] {list.Count} placed, {( m.IsValid() ? m.Built : 0 )} standing"
			+ ( hidden ? $" · {Hidden()} (nz_hex_slots_show 1 shows them)" : " · rolled again at every round's end (nz_hex_slots_roll rolls now)" ) );
	}

	/// <summary>
	/// `nz_hex_slots_show [1|0]` — HOST: `1` puts the slots on the walls with the power off, to look at them or place them.
	/// Testing only: it gives the order away. `0` goes back to following the power. Bare, it says what they show and why.
	/// </summary>
	[ConCmd( "nz_hex_slots_show" )]
	public static void ShowCmd( int show = -1 )
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }

		var m = Ensure();
		if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no scene" ); return; }

		if ( show >= 0 )
		{
			m._forced = show > 0;
			m.SendShown();
		}

		Log.Info( $"[nz-hex] the slots are {( m._shown.Count > 0 ? "on the walls" : "hidden" )} · power {( Power.IsOn ? "on" : "off" )}"
			+ ( m._forced ? " · SHOWN BY HAND (nz_hex_slots_show 0 undoes)" : " · they follow the power" ) );
	}

	/// <summary>`nz_hex_slots_roll` — HOST: roll the slots again now, as a round's end does: following the roll before.</summary>
	[ConCmd( "nz_hex_slots_roll" )]
	public static void RollCmd()
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }
		if ( Placed == 0 ) { Log.Info( "[nz-hex] no hex slots placed — nothing to roll" ); return; }

		var m = Ensure();
		if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no scene" ); return; }

		m.RollNow( "by hand" );
	}

	/// <summary>`nz_hex_slots_clear` — remove every slot from the config.</summary>
	[ConCmd( "nz_hex_slots_clear" )]
	public static void ClearCmd()
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return; }

		var list = ActiveConfig.Current?.HexSlots;
		var n = list?.Count ?? 0;
		list?.Clear();
		Ensure()?.Rebuild();

		Log.Info( $"[nz-hex] removed {n} hex slot(s) · in memory: Save (nz_save) keeps it" );
	}

	/// <summary>
	/// `nz_hex_slots_from_wallbuys [weapon]` — HOST: turn every wall buy selling this weapon into a slot, in the same place
	/// and facing the same way, and remove the wall buy. The weapon is matched anywhere in its prefab's path. The default is
	/// the .410 Jury (`taurus_judge`), which is what the first four were placed as. In memory, like every edit: Save keeps it.
	/// </summary>
	[ConCmd( "nz_hex_slots_from_wallbuys" )]
	public static void FromWallBuys( string weapon = "taurus_judge" )
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return; }

		var cfg = ActiveConfig.Current;
		var match = weapon?.Trim() ?? "";
		if ( cfg is null || match == "" ) return;

		var hits = cfg.WallBuys
			.Where( w => w.WeaponPrefab is not null && w.WeaponPrefab.Contains( match, StringComparison.OrdinalIgnoreCase ) )
			.ToList();

		if ( hits.Count == 0 )
		{
			Log.Warning( $"[nz-hex] no wall buy sells '{match}' — nz_wallbuy_list shows what they sell" );
			return;
		}

		foreach ( var w in hits )
		{
			cfg.HexSlots.Add( new HexSlotSpot { Position = w.Position, Angles = w.Angles } );
			cfg.WallBuys.Remove( w );
		}

		WallBuyManager.Ensure()?.Rebuild();
		Ensure()?.Rebuild();

		Log.Info( $"[nz-hex] {hits.Count} wall buy(s) selling '{match}' became hex slots, where they stood — {cfg.HexSlots.Count}"
			+ $" slot(s) placed now, {cfg.WallBuys.Count} wall buy(s) left · in memory: Save (nz_save) keeps it" );

		if ( cfg.HexSlots.Count > Max )
			Log.Warning( $"[nz-hex] that is {cfg.HexSlots.Count} slots, and only {Max} can have a number and a colour" );
	}

	/// <summary>
	/// `nz_hex_slot_size [radius] [line]` — how big a slot's hexagon is, centre to point, and how wide its strips; bare, it
	/// prints them. On this machine and until a restart: a size is chosen by looking at it, so settle on one here, then set
	/// it as the default in `Radius` and `Line`.
	/// </summary>
	[ConCmd( "nz_hex_slot_size" )]
	public static void SizeCmd( float radius = -1f, float line = -1f )
	{
		if ( radius > 0f ) Radius = radius;
		if ( line > 0f ) Line = line;
		if ( (radius > 0f || line > 0f) && Instance.IsValid() ) Instance.Build();

		Log.Info( $"[nz-hex] a slot is {Radius:0.#}u centre to point ({2f * Radius:0.#}u tall), its strips {Line:0.##}u wide" );
	}

	/// <summary>
	/// `nz_hex_slot_nudge &lt;slot&gt; &lt;right&gt; [up]` — HOST: slide a slot along its wall, in units: to the right as you face
	/// it (negative to the left), and up. For the last few units, without placing it again. In memory: Save keeps it.
	/// </summary>
	[ConCmd( "nz_hex_slot_nudge" )]
	public static void NudgeCmd( int slot, float right, float up = 0f )
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return; }

		var list = ActiveConfig.Current?.HexSlots;
		if ( list is null || slot < 1 || slot > list.Count ) { Log.Warning( $"[nz-hex] no slot {slot} — there are {list?.Count ?? 0}" ); return; }

		var s = list[slot - 1];
		var rot = s.Angles.ToRotation();
		s.Position += rot.Left * right + rot.Up * up;          // its left is the right of whoever faces it
		Ensure()?.Rebuild();

		Log.Info( $"[nz-hex] slot {slot} moved {right:0.#}u right and {up:0.#}u up, to {s.Position} · in memory: Save (nz_save) keeps it" );
	}

	/// <summary>
	/// `nz_hex_slot_room [slot]` — how big a hexagon each slot's wall has room for, which way it runs out and why, and so the
	/// largest `nz_hex_slot_size` that fits every slot as it is placed. With a slot number, every direction's reading too.
	/// </summary>
	[ConCmd( "nz_hex_slot_room" )]
	public static void RoomCmd( int slot = 0 )
	{
		var list = ActiveConfig.Current?.HexSlots;
		var scene = Game.ActiveScene;
		if ( list is null || list.Count == 0 || !scene.IsValid() ) { Log.Info( "[nz-hex] no hex slots placed" ); return; }

		var me = NZPlayer.Local;
		var ignore = me.IsValid() ? me.GameObject : null;
		var all = float.MaxValue;

		for ( var i = 0; i < list.Count; i++ )
		{
			if ( slot > 0 && i != slot - 1 ) continue;

			var room = RoomFor( list[i], scene, ignore, slot > 0 );
			all = MathF.Min( all, room.Fit );
			Log.Info( $"[nz-hex] slot {i + 1}: room for a radius of {room.Fit:0.#}u ({2f * room.Fit:0.#}u tall) — tightest"
				+ $" {Heading( room.Deg )}, where {room.Why} {room.Free:0.#}u out"
				+ ( room.Fit < Radius ? $" · at {Radius:0.#}u now it runs past it" : "" ) );
		}

		if ( slot <= 0 )
			Log.Info( $"[nz-hex] {Radius:0.#}u now · up to {all:0.#}u fits every slot where it is"
				+ $" — nz_hex_slot_size {MathF.Max( 0.5f, MathF.Floor( all * 2f ) / 2f ):0.#}" );
	}

	/// <summary>A direction in a slot's face, as whoever faces the wall sees it.</summary>
	static string Heading( int deg ) => deg switch
	{
		0 => "to the right", 90 => "up", 180 => "to the left", 270 => "down",
		< 90 => $"up-right ({deg}°)", < 180 => $"up-left ({deg}°)", < 270 => $"down-left ({deg}°)", _ => $"down-right ({deg}°)",
	};

	/// <summary>
	/// How much flat wall a slot has: from its centre, every 10°, how far the wall it is on runs before it ends or something
	/// stands in front of it. Fit is the largest <see cref="Radius"/> whose hexagon fits, its strips' outer edge included;
	/// Deg, Free and Why say which direction decided it, how far the wall ran there, and what stopped it.
	///
	/// ⚠️ THE WALL IS FOUND BY TRACING THROUGH IT, NOT ALONG IT: at each step a short ray from 4.5u in front of the wall's
	/// plane to 12u behind it. The slot sits 1.5u out, so the wall is there while the ray stops about 4.5u in; an edge lets
	/// it through, and anything standing in front stops it short.
	///
	/// ⛔ A SMALL STEP IS NOT AN EDGE. The first version stopped at any step of more than ¾u, and put slot 4 at 0.6u of room:
	/// it stands at the edge of a strip of wall raised 1.5u. A hexagon 1.5u out still clears that, and only looks wrong
	/// where the wall falls away under it (more than 4u back) or where something comes out through it (in front of the
	/// slot's own 1.5u).
	/// </summary>
	static (float Fit, int Deg, float Free, string Why) RoomFor( HexSlotSpot spot, Scene scene, GameObject ignore, bool verbose )
	{
		var rot = spot.Angles.ToRotation();
		var n = rot.Forward;
		var best = (Fit: float.MaxValue, Deg: 0, Free: 0f, Why: "");

		for ( var deg = 0; deg < 360; deg += 10 )
		{
			var a = deg * MathF.PI / 180f;
			var dir = rot.Left * MathF.Cos( a ) + rot.Up * MathF.Sin( a );     // in the slot's face: u right, v up

			var free = 0f;
			var why = "the wall runs on past";
			for ( var d = 0.5f; d <= 48f; d += 0.5f )
			{
				var p = spot.Position + dir * d;
				var t = scene.Trace.Ray( p + n * 3f, p - n * 13.5f );
				if ( ignore.IsValid() ) t = t.IgnoreGameObjectHierarchy( ignore );

				var tr = t.Run();
				if ( !tr.Hit || tr.Distance > 8.5f ) { why = "the wall ends (or falls back more than 4u)"; break; }
				if ( tr.Distance < 2.75f )
				{
					why = $"something comes {4.5f - tr.Distance:0.#}u out of the wall, through the slot ({tr.GameObject?.Name ?? "?"})";
					break;
				}
				free = d;
			}

			// a hexagon with its points up reaches apothem ÷ cos φ along a direction φ off its nearest flat's normal
			var phi = ((deg + 30) % 60 - 30) * MathF.PI / 180f;
			var fit = free * MathF.Cos( phi ) / MathF.Cos( MathF.PI / 6f ) - Line * 0.5f / MathF.Cos( MathF.PI / 6f );

			if ( verbose ) Log.Info( $"[nz-hex]   {deg,3}° {Heading( deg )}: {free:0.#}u, then {why}" );
			if ( fit < best.Fit ) best = (fit, deg, free, why);
		}

		return best;
	}

	/// <summary>
	/// `nz_hex_slot_photo [slot] [distance] [angle]` — a picture of a slot from in front of it, straight on or turned
	/// <paramref name="angle"/> degrees round it (positive to its right, as you face it), saved as
	/// `nz_hex_slot_&lt;n&gt;.png` in the game's data folder, to check one without walking there. Needs no game: it only
	/// renders.
	///
	/// ⚠️ A THROWAWAY CAMERA, NOT THE PLAYER'S: made, rendered once into a bitmap, destroyed. It is not the main camera and
	/// sits far below it in priority, so the screen never shows it.
	/// </summary>
	[ConCmd( "nz_hex_slot_photo" )]
	public static void PhotoCmd( int slot = 1, float distance = 72f, float angle = 0f )
	{
		var list = ActiveConfig.Current?.HexSlots;
		var scene = Game.ActiveScene;
		if ( list is null || slot < 1 || slot > list.Count || !scene.IsValid() )
		{
			Log.Warning( $"[nz-hex] no slot {slot} — there are {list?.Count ?? 0}" );
			return;
		}

		var face = list[slot - 1].Angles.ToRotation();
		var go = scene.CreateObject();
		go.Name = "nz_hex_slot_photo camera";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		// turned round the slot's own up: its left is the right of whoever faces it
		var toward = Rotation.FromAxis( face.Up, angle ) * face.Forward;
		go.WorldPosition = list[slot - 1].Position + toward * distance;
		go.WorldRotation = Rotation.LookAt( -toward, face.Up );

		var cam = go.Components.Create<CameraComponent>();
		cam.IsMainCamera = false;
		cam.Priority = -1000;
		cam.FieldOfView = 60f;

		try
		{
			using var pic = new Bitmap( 960, 720 );
			cam.RenderToBitmap( pic );

			var file = angle == 0f ? $"nz_hex_slot_{slot}.png" : $"nz_hex_slot_{slot}_{angle:0}.png";
			FileSystem.Data.WriteAllBytes( file, pic.ToPng() );
			Log.Info( $"[nz-hex] slot {slot} from {distance:0}u in front{( angle == 0f ? "" : $", {angle:0}° round" )}"
				+ $" -> {FileSystem.Data.GetFullPath( file )}"
				+ ( Instance.IsValid() && Instance._shown.Count > 0 ? "" : $" — the slots are {Hidden()}, so it is bare wall: nz_hex_slots_show 1" ) );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[nz-hex] could not photograph slot {slot}: {e.Message}" );
		}
		finally
		{
			go.Destroy();
		}
	}

	/// <summary>
	/// `nz_hex_slots_selftest` — HOST: check the roll's rules in code — no number and no colour twice, every order possible,
	/// the packing exact — that each round's roll changes every slot's number, every slot's colour and every colour's
	/// number, that nothing shows until the power is on, and that the slots shown are the ones rolled, in their colours.
	/// Puts this game's roll back when it is done, and the power's say over it.
	///
	/// ⚠️ IT GOES THROUGH THE REAL ROLL AND BROADCAST, as `nz_hex_selftest` does, because testing a copy of the logic would
	/// prove the copy. On a networked game the other players see the slots change once, and back.
	/// </summary>
	[ConCmd( "nz_hex_slots_selftest" )]
	public static void SelfTest()
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }

		// ⚠️ NO GAME, STILL A TEST: the roll's own rules need nothing, so they run in the editor too. The round's end, the
		// power and the walls need a game, and are skipped — and said to be — without one.
		var m = Ensure();
		var saved = m.IsValid() ? m._roll : default;
		var savedForced = m.IsValid() && m._forced;
		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}" );
		}

		int[] Numbers( Roll r ) => Enumerable.Range( 0, Max ).Select( i => r.NumberOf( i ) ).ToArray();
		int[] Colours( Roll r ) => Enumerable.Range( 0, Max ).Select( i => r.ColourOf( i ) ).ToArray();

		// 1. a roll of four: the numbers 1-4 once each, the four colours once each
		var rolls = Enumerable.Range( 0, 600 ).Select( _ => Make( Max ) ).ToList();
		Check( rolls.All( r => r.Count == Max
				&& Numbers( r ).OrderBy( x => x ).SequenceEqual( new[] { 1, 2, 3, 4 } )
				&& Colours( r ).OrderBy( x => x ).SequenceEqual( new[] { 0, 1, 2, 3 } ) ),
			"600 rolls of four: each has the numbers 1-4 once, and the four colours once" );

		// 2. every order comes up — the numbers', the colours', and which number goes with which colour
		Check( rolls.Select( r => string.Concat( Numbers( r ) ) ).Distinct().Count() == 24, "…all 24 orders of the numbers come up" );
		Check( rolls.Select( r => string.Concat( Colours( r ) ) ).Distinct().Count() == 24, "…all 24 orders of the colours come up" );
		Check( rolls.Select( r => r.NumberOf( 0 ) * 10 + r.ColourOf( 0 ) ).Distinct().Count() == 16,
			"…and slot 1 gets all 16 pairs of a number and a colour: neither decides the other" );

		// 3. the packing: every roll of four comes back out exactly as it went in
		var orders = Orders( Max );
		var exact = true;
		foreach ( var ns in orders )
			foreach ( var cs in orders )
			{
				var r = Roll.Of( ns.Select( k => k + 1 ).ToList(), cs );
				for ( var i = 0; i < Max; i++ )
					exact &= r.NumberOf( i ) == ns[i] + 1 && r.ColourOf( i ) == cs[i];
				exact &= r.Count == Max && r.NumberOf( Max ) == 0 && r.ColourOf( Max ) == -1;
			}
		Check( exact, $"all {orders.Count * orders.Count} rolls of four pack into one int and come back out exactly" );

		// 4. fewer than four slots, more than four, none
		var three = Make( 3 );
		var n3 = Enumerable.Range( 0, 3 ).Select( i => three.NumberOf( i ) ).ToList();
		var c3 = Enumerable.Range( 0, 3 ).Select( i => three.ColourOf( i ) ).ToList();
		Check( three.Count == 3 && n3.Distinct().Count() == 3 && n3.All( x => x is >= 1 and <= 4 )
				&& c3.Distinct().Count() == 3 && c3.All( x => x is >= 0 and <= 3 ) && three.NumberOf( 3 ) == 0,
			"three slots: three different numbers from 1-4 and three different colours, and no fourth" );
		Check( Make( 9 ).Count == Max, "nine slots: only four are rolled" );
		Check( default( Roll ).Count == 0 && default( Roll ).NumberOf( 0 ) == 0 && default( Roll ).ColourOf( 0 ) == -1,
			"no roll shows nothing" );

		// 5. the next round: every slot's number and colour change, and every colour's number. The rule is written out here
		// on its own, the way it was asked ("if red IV was on slot 2, next round red cannot be IV and cannot be on slot 2"),
		// so this does not just run `Follows` again.
		bool AsAsked( Roll a, Roll b )
		{
			for ( var c = 0; c < HexPlatforms.Colours; c++ )
			{
				var was = Enumerable.Range( 0, a.Count ).Where( i => a.ColourOf( i ) == c ).DefaultIfEmpty( -1 ).First();
				var now = Enumerable.Range( 0, b.Count ).Where( i => b.ColourOf( i ) == c ).DefaultIfEmpty( -1 ).First();
				if ( was >= 0 && now >= 0 && (now == was || b.NumberOf( now ) == a.NumberOf( was )) ) return false;
			}
			return a.Count == b.Count && Enumerable.Range( 0, a.Count ).All( i => b.NumberOf( i ) != a.NumberOf( i ) );
		}

		bool Fair( Roll r, int n ) => r.Count == n
			&& Enumerable.Range( 0, n ).Select( i => r.NumberOf( i ) ).Distinct().Count() == n
			&& Enumerable.Range( 0, n ).All( i => r.NumberOf( i ) is >= 1 and <= 4 )
			&& Enumerable.Range( 0, n ).Select( i => r.ColourOf( i ) ).Distinct().Count() == n
			&& Enumerable.Range( 0, n ).All( i => r.ColourOf( i ) is >= 0 and <= 3 );

		var rounds = true;
		for ( var k = 0; k < 300 && rounds; k++ )
		{
			var prev = Make( Max );
			var next = Next( prev, Max );
			rounds = Fair( next, Max ) && AsAsked( prev, next );
		}
		Check( rounds, "300 rounds: each next roll changes every slot's number, every slot's colour and every colour's number" );

		var start = Make( Max );
		var allowed = new HashSet<int>();
		foreach ( var ns in orders )
			foreach ( var cs in orders )
			{
				var r = Roll.Of( ns.Select( k => k + 1 ).ToList(), cs );
				if ( AsAsked( start, r ) ) allowed.Add( r.Packed );
			}
		var drawn = Enumerable.Range( 0, 400 ).Select( _ => Next( start, Max ).Packed ).ToHashSet();
		Check( allowed.Count > 0 && drawn.SetEquals( allowed ),
			$"…from one roll, all {allowed.Count} rolls the rule allows come up ({drawn.Count} seen), and no other" );

		var three3 = true;
		for ( var k = 0; k < 100 && three3; k++ )
		{
			var prev = Make( 3 );
			var next = Next( prev, 3 );
			three3 = Fair( next, 3 ) && AsAsked( prev, next );
		}
		Check( three3, "…and with three slots too" );

		if ( !m.IsValid() )
		{
			Log.Info( $"[nz-hex-test] {pass} passed, {fail} failed — the roll's own rules only. No game is running, so the"
				+ " round's end, the power and the walls were not checked: run it again in one" );
			return;
		}

		if ( Placed > 0 )
		{
			if ( m._roll.Count != Math.Min( Placed, Max ) ) m.RollNow( "the selftest" );
			var before = m._roll;
			OnRoundEnd( 0, "the selftest's round end" );
			Check( m._roll != before && Fair( m._roll, before.Count ) && AsAsked( before, m._roll ),
				"a round's end rolls them again, by the rule" );
		}
		else
			Check( true, "a round's end: nothing placed, so nothing to roll" );

		// 6. the power: nothing shows while it is off, the roll once it is on — and what went out is what the power says now
		var placed = Placed;
		m._forced = false;
		m.RollNow( "the selftest" );
		var sent = m._roll;
		Check( sent.Count == Math.Min( placed, Max ), $"a roll covers {sent.Count} of the {placed} placed" );
		Check( m.ShownFor( false ).Count == 0, "…the slots show nothing while the power is off" );
		Check( m.ShownFor( true ) == sent, "…and the roll once it is on" );
		var showsNow = Power.IsOn && !HexPlatforms.StepDone;
		Check( m._shown == m.ShownFor( showsNow ) && m.Built == (showsNow ? placed : 0),
			$"…and with the power {( Power.IsOn ? "on" : "off" )}{( HexPlatforms.StepDone ? " and the step done" : "" )} now,"
			+ $" {m.Built} of {placed} stand on the walls" );

		// 7. shown — by hand, whatever the power — each stands where its spot is, in its colour's material
		m._forced = true;
		m.SendShown();
		Check( m._shown == sent && m.Built == placed, $"shown by hand, every placed slot stands: {m.Built} of {placed}" );

		var list = ActiveConfig.Current?.HexSlots ?? new List<HexSlotSpot>();
		var built = m._built.Where( g => g.IsValid() ).ToList();
		var wrong = new List<string>();
		for ( var i = 0; i < built.Count && i < list.Count; i++ )
		{
			var go = built[i];
			var want = HexPlatforms.MaterialFor( sent.NumberOf( i ) > 0 ? sent.ColourOf( i ) : -1 );
			var mats = go.Components.Get<ModelRenderer>()?.Model?.Materials.Select( x => x?.Name ).ToList() ?? new List<string>();
			if ( want is null || !mats.Contains( want.Name ) )
				wrong.Add( $"slot {i + 1} draws in [{string.Join( ", ", mats )}], not {want?.Name}" );
			if ( go.WorldPosition.Distance( list[i].Position ) > 0.01f
				|| go.WorldRotation.Forward.Distance( list[i].Angles.ToRotation().Forward ) > 0.001f )
				wrong.Add( $"slot {i + 1} is not where its spot is" );
		}
		Check( wrong.Count == 0, placed == 0
			? "…nothing placed, so nothing to look at on the walls"
			: wrong.Count == 0 ? "…each where its spot is, in its colour's material" : string.Join( "; ", wrong ) );

		// put this game's roll back, and the power's say over it
		m._roll = saved;
		m._forced = savedForced;
		m.SendShown();

		Log.Info( $"[nz-hex-test] {pass} passed, {fail} failed — this game's roll restored" );
	}

	/// <summary>Every order of 0 … n−1.</summary>
	static List<int[]> Orders( int n )
	{
		var all = new List<int[]>();
		void Walk( int[] a, int k )
		{
			if ( k == n ) { all.Add( a.ToArray() ); return; }         // ⚠️ NOT a.Clone(): the whitelist refuses Array.Clone
			for ( var i = k; i < n; i++ )
			{
				(a[k], a[i]) = (a[i], a[k]);
				Walk( a, k + 1 );
				(a[k], a[i]) = (a[i], a[k]);
			}
		}
		Walk( Enumerable.Range( 0, n ).ToArray(), 0 );
		return all;
	}
}