EasterEgg/HexPlatforms.Rings.cs

Partial class for HexPlatforms implementing the Color Rings puzzle. It encodes ring positions, targets, and per-platform ammo mods in a packed bitfield, handles dealing random mods and targets, applies and mirrors host state, reacts to ammo-mod procs to move dots with cooldowns, draws platform glyph marks and mod icons locally, and provides console commands for testing and admin.

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

namespace NZombies;

/// <summary>
/// BASALT — STEP 3, COLOR RINGS (named by the user, 2026-09-26). It begins when Bonfire is done. The rings clue
/// (<see cref="RingsClue"/>) shows four dots, one per colour, each on its ring at one of 8 positions.
///
/// ⛔ EACH PLATFORM WANTS ITS OWN AMMO MOD, DEALT AT RANDOM, AND ITS ICON ON THE HEX CLUE SAYS WHICH. When Color Rings
/// begins the host deals the four Color Smash platforms one ammo mod each out of the six (<see cref="RingMods"/>), no two
/// alike, and every machine's hex clue (`HexClue`) draws that mod's icon (<see cref="ModIcon"/>) on the platform's tile, in
/// the platform's own colour. Trigger a platform's mod over it and its colour's dot moves one position on, clockwise. The
/// colour says which ring; the icon says which mod. Asked for as *"make an icon for each ammo mod, and we put a random icon
/// on each platform on the map — that is the ammo mod that we need to use for each of the platforms, meaning it's no
/// longer color related, the color is only meant to be a connection to the rings"*, and *"the icons must be able to be any
/// of the 4 colors to match the tile it's in"*. Until then the mods were fixed by colour: Dead Wire blue, Fire Works
/// yellow, Radioactive Decay green, Blast Furnace red.
///
/// ⚠️ THE ICONS ARE ON THE CLUE, NOT ON THE PLATFORMS. They were first laid on the platforms' tops, a misreading of "on the
/// map" that the user put right: *"the ammo mod icon only appears on the map … i mean as the ammo mod only appears in the
/// clue map we made"*, and *"the rings icon appears on the actual platform"* — the glyph. A platform shows only its glyph.
///
/// ⛔ THE HINT: EACH PLATFORM SHOWS A GLYPH, AND ITS COLOUR'S DOT MUST POINT AT IT. When Color Rings begins the host
/// deals each platform one of the eight glyphs (`RingsClue.GlyphStrokes`) — at random, repeats allowed — laid in the
/// middle of its top, in its colour. The glyph's place on the rings clue's clock (glyph k at position k-1) is where
/// that colour's dot must end. Asked for as *"each of the 4 hexagons that has a color from the color smash step, after
/// completing the bonfire step, will have one of these symbols appear on its face, the symbol is always random and can be
/// repeated among different tiles — that is where each color's circle will have to be pointing towards"*. No dot begins
/// where it must end. All four there: Color Rings is done (<see cref="RingsDone"/>) — the step-done clicking plays, and no
/// mod moves a dot after. What it opens is still to decide.
///
/// ⚠️ AN AMMO MOD ONLY EVER GOES OFF ON A ZOMBIE. Each mod procs on the shooter's machine when a bullet hits one — Blast
/// Furnace when one dies — so "triggered on the red platform" means on a zombie standing on it. Where that zombie stands
/// is the place: the tile its feet are on, with a body's slack on tile 1 (24u past the edge, 48u off the top). Another
/// platform's mod, or a mod no platform wants, moves nothing.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the SHOOTER'S MACHINE is the only one that knows a mod went off (`AmmoMods.Fire`, `BlastFurnace.OnZombieKilled`), and
///   it reports "mod X went off at P" to the host (`NZNet.HexAmmoMod`) — the slam's pattern;
/// - the POSITIONS, each platform's MOD and GLYPH, and whether it is DONE are HOST state, dealt together and MIRRORED whole,
///   in one long, on every move (`NZNet.HexRings`) and to a joiner;
/// - the PICTURES (the rings, and the hex clue with its icons) and the GLYPHS are LOCAL: every machine draws its own.
///
/// ⚠️ ONE MOVE A SECOND PER COLOUR (<see cref="RingCooldown"/>). Several reports can land together for what the player
/// sees as one trigger — a shotgun blast that kills three on a platform with Blast Furnace, Elemental Pop's surge going
/// off beside the fitted mod, two players at once — and without a pause they would move a dot several places.
/// </summary>
public sealed partial class HexPlatforms
{
	/// <summary>How many places a dot can stand on its ring: 8, an eighth of a turn apart.</summary>
	public const int RingPositions = 8;

	/// <summary>
	/// The ammo mods a platform can want: all six the Arsenal sells, each with its icon (<see cref="ModIcon"/>). A
	/// platform's mod crosses the network as its place in this list, in 3 bits, so ⚠️ ADD AT THE END, NEVER BETWEEN — and
	/// eight at most. A property, not a static array, so a change reaches a running game (INSTRUCTIONS.md §1).
	/// </summary>
	public static string[] RingMods => new[] { "deadwire", "fireworks", "radiation", "blastfurnace", "thunderwall", "cryofreeze" };

	/// <summary>A mod's own name — "Dead Wire" for `deadwire` — or its id, if the catalogue has no such mod.</summary>
	public static string ModName( string mod ) => AmmoMods.Find( mod )?.Name ?? mod;

	/// <summary>
	/// The whole of Color Rings, in one long so it crosses the network as one message:
	/// - colour c's dot (0-7: 0 straight up, then clockwise) in bits 3c to 3c+2;
	/// - bit 12 set once the puzzle has begun;
	/// - the mod colour c's platform wants, as its place in <see cref="RingMods"/>, in bits 13+3c to 15+3c;
	/// - where colour c's dot must end — its platform's glyph's place on the clock — in bits 25+3c to 27+3c;
	/// - bit 37 set once every dot is there: Color Rings done.
	/// `default` is not begun.
	///
	/// ⚠️ A LONG SINCE THE GLYPHS: 38 bits, where an int held the first 25.
	/// </summary>
	public readonly record struct RingState( long Packed )
	{
		const long Begun = 1L << 12, Finished = 1L << 37;
		const int ModBits = 13, TargetBits = 25;

		/// <summary>Has the puzzle begun — the dots on the rings, the icons on the hex clue, the glyphs on the platforms?</summary>
		public bool Active => (Packed & Begun) != 0;

		/// <summary>Is Color Rings done — every dot where its platform's glyph says?</summary>
		public bool Done => (Packed & Finished) != 0;

		/// <summary>Where a colour's dot stands, 0-7.</summary>
		public int PositionOf( int colour ) => colour < 0 || colour >= Colours ? 0 : (int)((Packed >> (colour * 3)) & 7);

		/// <summary>Where a colour's dot must end, 0-7: the place on the clock of the glyph its platform shows.</summary>
		public int TargetOf( int colour ) => colour < 0 || colour >= Colours ? 0 : (int)((Packed >> (TargetBits + colour * 3)) & 7);

		/// <summary>The glyph a colour's platform shows, 1-8 — glyph k stands at position k-1 — or 0 before the puzzle begins.</summary>
		public int GlyphOf( int colour ) => Active && colour >= 0 && colour < Colours ? TargetOf( colour ) + 1 : 0;

		/// <summary>Is every dot where it must end?</summary>
		public bool Solved
		{
			get
			{
				for ( var c = 0; c < Colours; c++ )
					if ( PositionOf( c ) != TargetOf( c ) ) return false;

				return true;
			}
		}

		/// <summary>The ammo mod a colour's platform wants, by id — or "", before the puzzle begins.</summary>
		public string ModOf( int colour )
		{
			if ( !Active || colour < 0 || colour >= Colours ) return "";

			var mods = RingMods;
			var k = (int)((Packed >> (ModBits + colour * 3)) & 7);
			return k < mods.Length ? mods[k] : "";
		}

		/// <summary>
		/// Only what the hex clue shows of it — begun, and each platform's mod — so `HexClue` draws again when a mod is
		/// dealt, not at every move of a dot.
		/// </summary>
		public long ModsShown => Active ? Packed & ((0xFFFL << ModBits) | Begun) : 0L;

		/// <summary>The same, with one colour's dot one position on, clockwise. Everything else stays.</summary>
		public RingState Moved( int colour )
		{
			if ( colour < 0 || colour >= Colours ) return this;

			var p = (long)((PositionOf( colour ) + 1) % RingPositions);
			return new RingState( (Packed & ~(7L << (colour * 3))) | (p << (colour * 3)) | Begun );
		}

		/// <summary>The same, with the dots at these positions, by colour number (blue, yellow, green, red).</summary>
		public RingState WithPositions( IReadOnlyList<int> positions )
		{
			var packed = Packed & ~0xFFFL;
			for ( var c = 0; c < Colours && c < positions.Count; c++ )
				packed |= (long)(positions[c] & 7) << (c * 3);

			return new RingState( packed );
		}

		/// <summary>The same, with the dots due at these positions, by colour number — the glyphs the platforms show, less one.</summary>
		public RingState WithTargets( IReadOnlyList<int> targets )
		{
			var packed = Packed & ~(0xFFFL << TargetBits);
			for ( var c = 0; c < Colours && c < targets.Count; c++ )
				packed |= (long)(targets[c] & 7) << (TargetBits + c * 3);

			return new RingState( packed );
		}

		/// <summary>
		/// The same, with the platforms wanting these mods, by colour number (blue, yellow, green, red) — ids out of
		/// <see cref="RingMods"/>. One it does not know becomes the first.
		/// </summary>
		public RingState WithMods( IReadOnlyList<string> ids )
		{
			var mods = RingMods;
			var packed = Packed & ~(0xFFFL << ModBits);
			for ( var c = 0; c < Colours && c < ids.Count; c++ )
			{
				var k = 0;
				while ( k < mods.Length && mods[k] != ids[c] ) k++;
				packed |= (long)(k < mods.Length ? k : 0) << (ModBits + c * 3);
			}

			return new RingState( packed );
		}

		/// <summary>The same, done.</summary>
		public RingState AsDone() => new( Packed | Finished );

		/// <summary>
		/// Begun, with the dots at these positions, by colour number (blue, yellow, green, red). Every platform wants the
		/// first mod, and every dot is due at 0, until told otherwise.
		/// </summary>
		public static RingState Of( int blue, int yellow, int green, int red )
			=> new RingState( Begun ).WithPositions( new[] { blue, yellow, green, red } );

		/// <summary>Four different mods out of <see cref="RingMods"/>, at random, one per colour's platform — only mods the game has built.</summary>
		public static string[] DealMods()
		{
			var pool = RingMods.Where( id => AmmoMods.Find( id )?.Built == true ).ToList();
			var dealt = new string[Colours];
			for ( var c = 0; c < Colours; c++ )
			{
				// ⚠️ NEVER SHORT WITH SIX, but a pool cut below four repeats a mod rather than leave a platform bare
				if ( pool.Count == 0 ) pool = RingMods.ToList();

				var k = Game.Random.Next( pool.Count );
				dealt[c] = pool[k];
				pool.RemoveAt( k );
			}

			return dealt;
		}

		/// <summary>Where each dot must end, one per colour, each any of the eight — repeats allowed, as asked.</summary>
		public static int[] DealTargets()
			=> Enumerable.Range( 0, Colours ).Select( _ => Game.Random.Next( RingPositions ) ).ToArray();

		/// <summary>
		/// Begun: each platform dealt a different mod and a glyph of its own, and every dot somewhere at random — ⚠️ BUT NOT
		/// WHERE IT MUST END, so the puzzle never begins with a dot done, nor, by chance, all four.
		/// </summary>
		public static RingState Random()
		{
			var targets = DealTargets();
			var starts = targets.Select( t => (t + 1 + Game.Random.Next( RingPositions - 1 )) % RingPositions ).ToArray();
			return new RingState( Begun ).WithPositions( starts ).WithTargets( targets ).WithMods( DealMods() );
		}

		/// <summary>The four, in words: each colour's dot, where it must end (its platform's glyph), and its platform's mod.</summary>
		public string Describe()
		{
			// ⚠️ A COPY, because a lambda in a struct cannot reach `this` (CS1673)
			var s = this;
			return string.Join( ", ", Enumerable.Range( 0, Colours )
					.Select( c => $"{ColourName( c )} {s.PositionOf( c )}→{s.TargetOf( c )} (glyph {s.TargetOf( c ) + 1}, {ModName( s.ModOf( c ) )})" ) )
				+ ( s.Done ? " · DONE" : "" );
		}
	}

	/// <summary>HOST — where the dots stand, and which mod each platform wants.</summary>
	RingState _rings;

	/// <summary>MIRROR — the same, as this machine was told (`NZNet.HexRings`). `RingsClue` and `HexClue` draw from it; the glyphs are laid from it.</summary>
	RingState _ringsShown;

	/// <summary>Where the dots stand and what each platform wants, as this machine shows them.</summary>
	public RingState RingsShown => _ringsShown;

	/// <summary>HOST — the same, for `NZNet.PushState` to replay to a joiner.</summary>
	public static RingState RingsState => Instance.IsValid() ? Instance._rings : default;

	void SendRings() => NZNet.HexRings( _rings.Packed );

	/// <summary>
	/// Where the dots stand and what each platform wants. EVERY machine — `NZNet.HexRings`: the rings clue and the hex
	/// clue's icons draw again from it, and the platforms' glyphs are laid to match.
	/// </summary>
	public void ApplyRings( RingState r )
	{
		_ringsShown = r;
		BuildPlatformMarks();
	}

	/// <summary>The puzzle begins: each platform dealt its mod and its glyph, every dot somewhere else. HOST — Bonfire done.</summary>
	void StartRings()
	{
		if ( _rings.Active ) return;

		_rings = RingState.Random();
		SendRings();
		Log.Info( "[nz-hex] ◎ COLOR RINGS BEGUN — each platform's own ammo mod, its icon on the hex clue, moves its colour's"
			+ " dot, to the glyph on the platform: " + _rings.Describe() );
	}

	/// <summary>
	/// Every dot where its platform's glyph says: Color Rings is done. HOST. The dots stay where they are and no mod moves one
	/// after; the step-done clicking plays, as it does for Color Smash. What it opens is still to decide.
	/// </summary>
	void RingsDone( string who = "" )
	{
		_rings = _rings.AsDone();
		SendRings();
		Cue( DoneCue );
		Fanfare( 3 );
		Log.Info( $"[nz-hex] ✦ COLOR RINGS DONE{By( who )} — every dot where its platform's glyph says: {_rings.Describe()}" );
	}

	/// <summary>The puzzle back to not begun, the icons off the clue and the glyphs off the platforms. HOST — a new game, with Bonfire.</summary>
	void ResetRings()
	{
		if ( _rings.Packed == 0 ) return;

		_rings = default;
		SendRings();
	}

	static float? _ringCooldown;
	/// <summary>How long after a dot moves before its colour can move it again, in seconds — see the class note.</summary>
	public static float RingCooldown { get => _ringCooldown ?? 1f; set => _ringCooldown = value; }

	TimeSince[] _ringMoved;

	/// <summary>
	/// HOST — when each colour's dot last moved. ⚠️ MADE ON FIRST USE, NOT BY AN INITIALISER, so a manager that a hotload
	/// carried over from before the field existed still gets one.
	/// </summary>
	TimeSince[] RingMoved => _ringMoved ??= new TimeSince[Colours];

	// ══ the marks: the ammo icons on the hex clue, the glyphs on the platforms ══════════════════

	/// <summary>
	/// Each ammo mod's icon, drawn on a platform's tile on the hex clue (`HexClue.Draw`) when Color Rings deals it that mod —
	/// null for "", or a mod with none. The ammo symbols' stroke family —
	/// one stroke, round ends, small rings and dots, most directions the hexagon's own — each hinting at its mod without
	/// naming it:
	/// - Dead Wire: two terminals, and a zig-zag broken in the middle — the current jumps the gap;
	/// - Fire Works: a burst of rays and sparks, and a broken trail rising into it;
	/// - Radioactive Decay: a core, three rays, and a ring broken into pieces;
	/// - Blast Furnace: the alchemists' fire (an upward triangle) over a hearth, a flame inside;
	/// - Thunderwall: a gust of wind against a wall;
	/// - Cryofreeze: a frost crystal, six branched arms — the same whichever way it is turned.
	/// The points span -1..1, y up.
	///
	/// ⚠️ `Tools/basalt_hex_symbols.py` READS THESE LINES to draw the icons as pictures, so each shape has this one home. Keep
	/// each mod between its `"id" => new[]` line and its `},`, one shape a line, as ("kind", new[] { numbers }).
	///
	/// ⚠️ A METHOD, NOT A STATIC ARRAY, so a change to a shape reaches a running game with the hotload (INSTRUCTIONS.md §1).
	/// </summary>
	public static (string Kind, float[] P)[] ModIcon( string mod ) => mod switch
	{
		"deadwire" => new[]
		{
			("ring", new[] { 0.00f, 0.74f, 0.12f }),
			("ring", new[] { 0.00f, -0.74f, 0.12f }),
			("line", new[] { 0.00f, 0.62f, 0.26f, 0.40f, -0.22f, 0.20f, 0.10f, 0.14f }),
			("line", new[] { -0.10f, -0.14f, 0.22f, -0.20f, -0.26f, -0.40f, 0.00f, -0.62f }),
		},
		"fireworks" => new[]
		{
			("line", new[] { 0.2425f, 0.14f, 0.485f, 0.28f }),
			("line", new[] { 0.00f, 0.28f, 0.00f, 0.56f }),
			("line", new[] { -0.2425f, 0.14f, -0.485f, 0.28f }),
			("line", new[] { -0.2425f, -0.14f, -0.485f, -0.28f }),
			("line", new[] { 0.2425f, -0.14f, 0.485f, -0.28f }),
			("dot", new[] { 0.6582f, 0.38f, 0.055f }),
			("dot", new[] { 0.00f, 0.76f, 0.055f }),
			("dot", new[] { -0.6582f, 0.38f, 0.055f }),
			("dot", new[] { -0.6582f, -0.38f, 0.055f }),
			("dot", new[] { 0.6582f, -0.38f, 0.055f }),
			("line", new[] { 0.00f, -0.92f, 0.00f, -0.76f }),
			("line", new[] { 0.00f, -0.64f, 0.00f, -0.36f }),
		},
		"radiation" => new[]
		{
			("dot", new[] { 0.00f, 0.00f, 0.10f }),
			("line", new[] { 0.00f, 0.26f, 0.00f, 0.60f }),
			("line", new[] { -0.2252f, -0.13f, -0.5196f, -0.30f }),
			("line", new[] { 0.2252f, -0.13f, 0.5196f, -0.30f }),
			("arc", new[] { 0.00f, 0.00f, 0.80f, -5.00f, 65.00f }),
			("arc", new[] { 0.00f, 0.00f, 0.80f, 115.00f, 185.00f }),
			("arc", new[] { 0.00f, 0.00f, 0.80f, 235.00f, 305.00f }),
		},
		"blastfurnace" => new[]
		{
			("line", new[] { 0.00f, 0.72f, -0.66f, -0.42f, 0.66f, -0.42f, 0.00f, 0.72f }),
			("line", new[] { -0.82f, -0.66f, 0.82f, -0.66f }),
			("line", new[] { -0.20f, -0.22f, 0.00f, 0.14f, 0.20f, -0.22f }),
		},
		"thunderwall" => new[]
		{
			("line", new[] { -0.80f, 0.36f, 0.12f, 0.36f }),
			("arc", new[] { 0.12f, 0.54f, 0.18f, -90.00f, 160.00f }),
			("line", new[] { -0.90f, 0.00f, 0.42f, 0.00f }),
			("line", new[] { -0.80f, -0.36f, 0.12f, -0.36f }),
			("arc", new[] { 0.12f, -0.54f, 0.18f, -160.00f, 90.00f }),
			("line", new[] { 0.72f, 0.70f, 0.72f, -0.70f }),
		},
		"cryofreeze" => new[]
		{
			("ring", new[] { 0.00f, 0.00f, 0.10f }),
			("line", new[] { 0.00f, 0.20f, 0.00f, 0.80f }),
			("line", new[] { 0.00f, 0.54f, 0.1732f, 0.64f }),
			("line", new[] { 0.00f, 0.54f, -0.1732f, 0.64f }),
			("line", new[] { -0.1732f, 0.10f, -0.6928f, 0.40f }),
			("line", new[] { -0.4677f, 0.27f, -0.4677f, 0.47f }),
			("line", new[] { -0.4677f, 0.27f, -0.6409f, 0.17f }),
			("line", new[] { -0.1732f, -0.10f, -0.6928f, -0.40f }),
			("line", new[] { -0.4677f, -0.27f, -0.6409f, -0.17f }),
			("line", new[] { -0.4677f, -0.27f, -0.4677f, -0.47f }),
			("line", new[] { 0.00f, -0.20f, 0.00f, -0.80f }),
			("line", new[] { 0.00f, -0.54f, -0.1732f, -0.64f }),
			("line", new[] { 0.00f, -0.54f, 0.1732f, -0.64f }),
			("line", new[] { 0.1732f, -0.10f, 0.6928f, -0.40f }),
			("line", new[] { 0.4677f, -0.27f, 0.4677f, -0.47f }),
			("line", new[] { 0.4677f, -0.27f, 0.6409f, -0.17f }),
			("line", new[] { 0.1732f, 0.10f, 0.6928f, 0.40f }),
			("line", new[] { 0.4677f, 0.27f, 0.6409f, 0.17f }),
			("line", new[] { 0.4677f, 0.27f, 0.4677f, 0.47f }),
		},
		_ => null,
	};

	/// <summary>Which way a platform's glyph's top points: north, as the clues are drawn.</summary>
	static Vector3 MarkTop => new( 0f, 1f, 0f );

	/// <summary>
	/// A platform's glyph — where its dot must end — lies in the middle of its top, in the platform's colour, its top to the
	/// north. 85 world units to a glyph unit: about 155u across and 110u tall, the size of the offering on tile 1
	/// (<see cref="IconScale"/>), well inside the tile's 249u. It was 55u a unit, 75u south of the middle, while the ammo
	/// icon still shared the top.
	/// </summary>
	const float MarkGlyphScale = 85f;

	/// <summary>
	/// What <see cref="BuildPlatformMarks"/> lays, and how: ⚠️ BUMP IT WHEN THAT CHANGES. 2: the glyph alone, in the middle,
	/// the ammo icons gone to the hex clue (2026-09-26).
	///
	/// ⚠️ A HOTLOAD CAN KEEP THE MANAGER BUT NOT WHAT IT HELD. The array that held the icons went with them, so icons already
	/// lying in a running game would have had nothing left to take them up. A manager that has not laid this layout yet
	/// sweeps every mark in the scene first, once (<see cref="SweepMarks"/>) — and `OnUpdate` asks every frame, so that is
	/// the frame after the hotload.
	/// </summary>
	const int MarksLayout = 2;

	/// <summary>LOCAL — the layout this manager last laid its marks in; 0 before it has.</summary>
	int _marksLaid;

	/// <summary>Every platform mark carries it, so a sweep knows them.</summary>
	const string MarkTag = "nz_hexmark";

	/// <summary>LOCAL — each colour's platform's glyph: its object, which glyph (1-8), and the tile.</summary>
	(GameObject Go, int Glyph, int Tile)[] _platformGlyphs;

	/// <summary>⚠️ Made on first use, like <see cref="RingMoved"/>.</summary>
	(GameObject Go, int Glyph, int Tile)[] PlatformGlyphs => _platformGlyphs ??= new (GameObject, int, int)[Colours];

	/// <summary>
	/// Each platform's glyph as things stand on this machine. LOCAL, and only on basalt: once Color Rings has begun, each
	/// colour's platform — the tile lit in that colour — has its glyph in the middle of its top, in that colour. None before
	/// the puzzle begins, or after a new game. A glyph is laid again only when it, or its tile, changes. The platform's
	/// ammo icon is not here: it is on the hex clue (`HexClue.Draw`).
	/// </summary>
	void BuildPlatformMarks()
	{
		if ( _marksLaid != MarksLayout ) SweepMarks();

		var glyphs = PlatformGlyphs;
		for ( var c = 0; c < Colours; c++ )
		{
			var glyph = OnBasalt ? _ringsShown.GlyphOf( c ) : 0;
			var tile = glyph == 0 ? -1 : LitTileOf( c );
			if ( tile < 0 ) glyph = 0;

			var had = glyphs[c];
			if ( glyph == 0 ? !had.Go.IsValid() : had.Go.IsValid() && had.Glyph == glyph && had.Tile == tile ) continue;

			if ( had.Go.IsValid() ) had.Go.Destroy();
			glyphs[c] = default;
			if ( glyph == 0 ) continue;

			var t = Tiles[tile];
			var go = Mark( $"Hex tile {t.Id} — glyph {glyph} ({ColourName( c )})",
				IconModel( RingsClue.GlyphShapes( glyph ), RingsClue.GlyphStroke, c, MarkGlyphScale ),
				new Vector3( t.X, t.Y, t.Top + IconLift ) );
			glyphs[c] = (go, glyph, tile);
		}
	}

	/// <summary>
	/// Every platform mark in the scene gone, and none tracked: ours, and any a hotload left behind — known by their tag or,
	/// from before it, by their names ("Hex tile 25 — the Dead Wire icon (blue)", "Hex tile 25 — glyph 3 (blue)"). Never
	/// the offering, the bonfire or a panel. LOCAL.
	/// </summary>
	void SweepMarks()
	{
		_marksLaid = MarksLayout;
		_platformGlyphs = null;

		if ( !Scene.IsValid() ) return;

		foreach ( var old in Scene.GetAllObjects( false )
			.Where( x => x.Tags.Has( MarkTag )
				|| (x.Tags.Has( PanelTag ) && (x.Name.Contains( " icon (" ) || x.Name.Contains( " — glyph " ))) )
			.ToList() )
			old.Destroy();
	}

	/// <summary>One mark laid flat on a platform's top, facing up, its top to the north — or null, with no model to show.</summary>
	GameObject Mark( string name, Model model, Vector3 at )
	{
		if ( model is null ) return null;

		var go = Scene.CreateObject();
		go.Name = name;
		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 );
		go.Tags.Add( MarkTag );
		go.WorldPosition = at;
		go.WorldRotation = Rotation.LookAt( Vector3.Up, MarkTop );

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

	/// <summary>The tile lit in this colour on this machine, or -1 — once Color Smash is done, that colour's platform.</summary>
	int LitTileOf( int colour )
	{
		for ( var i = 0; i < Tiles.Length && i < 64; i++ )
			if ( _lit.Has( i ) && _lit.ColourOf( i ) == colour ) return i;

		return -1;
	}

	/// <summary>The glyph lying on this colour's platform on this machine, 1-8, or 0. For the selftest.</summary>
	int GlyphShown( int colour ) => PlatformGlyphs[colour].Go.IsValid() ? PlatformGlyphs[colour].Glyph : 0;

	// ══ the ammo mods ═══════════════════════════════════════════════════════════════════════

	/// <summary>
	/// An ammo mod went off on a zombie standing here. THE SHOOTER'S MACHINE — `AmmoMods.Fire` for the rolled mods,
	/// `BlastFurnace.OnZombieKilled` for its kill. Passes it to the host only when it could move a dot
	/// (<see cref="CouldMove"/>), so the host is not told of every proc on the map.
	/// </summary>
	public static void AmmoModWentOff( string mod, Vector3 at )
	{
		if ( !OnBasalt || !Instance.IsValid() || !Instance.CouldMove( mod, at ) ) return;

		// the shooter is this machine's own player — a mod only ever procs where it was fitted
		NZNet.HexAmmoMod( mod, at, NZPlayers.NameOf( NZPlayer.Local ) );
	}

	/// <summary>
	/// Could this move a dot, as THIS machine sees it: the rings begun, and `at` on a tile lit in a colour whose platform
	/// wants this mod. EVERY machine — the lit tiles are MIRRORED, and once Color Smash is done they are the four platforms,
	/// each in its colour. The host asks again of its own picks (<see cref="ModWentOff"/>).
	/// </summary>
	bool CouldMove( string mod, Vector3 at )
	{
		if ( !_ringsShown.Active || _ringsShown.Done || string.IsNullOrEmpty( mod ) ) return false;

		var tile = TileAt( at, BodyEdgeSlack, BodyHeightSlack );
		if ( tile < 0 || !_lit.Has( tile ) ) return false;

		var colour = _lit.ColourOf( tile );
		return colour >= 0 && colour < Colours && _ringsShown.ModOf( colour ) == mod;
	}

	/// <summary>A mod went off here. HOST — `NZNet.HexAmmoMod`. `who` only names the shooter in the log.</summary>
	public static void HostAmmoMod( string mod, Vector3 at, string who = "" )
	{
		if ( NZGame.IsClient ) return;

		var m = Instance;
		if ( m.IsValid() ) m.ModWentOff( mod, at, who );
	}

	/// <summary>
	/// A mod went off on a zombie standing here, by the rings' rules: on a platform that wants this mod, it moves the
	/// platform's colour's dot one on. HOST — apart from the RPC, so the selftest can walk it.
	///
	/// ⚠️ THE TILE IT STANDS ON, NOT THE FIRST PICK NEAR IT. Two picks can touch, and their margins overlap: `TileAt` gives
	/// the one of all 54 its feet are furthest into, the slam's rule.
	/// </summary>
	void ModWentOff( string mod, Vector3 at, string who = "" )
	{
		if ( !_rings.Active || _rings.Done || string.IsNullOrEmpty( mod ) ) return;

		var tile = TileAt( at, BodyEdgeSlack, BodyHeightSlack );
		var colour = tile < 0 ? -1 : PickColour( tile );
		if ( colour < 0 || _rings.ModOf( colour ) != mod ) return;
		if ( RingMoved[colour] < RingCooldown ) return;

		RingMoved[colour] = 0f;
		_rings = _rings.Moved( colour );
		SendRings();

		Log.Info( $"[nz-hex] {ModName( mod )} on the {ColourName( colour )} platform ({Tiles[tile].Id}){By( who )} — the"
			+ $" {ColourName( colour )} dot moves to {_rings.PositionOf( colour )}"
			+ ( _rings.PositionOf( colour ) == _rings.TargetOf( colour ) ? ", its platform's glyph" : "" ) );

		if ( _rings.Solved ) RingsDone( who );
	}

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

	/// <summary>
	/// `nz_hex_rings [start|move &lt;colour&gt;|mods [&lt;blue&gt; &lt;yellow&gt; &lt;green&gt; &lt;red&gt;]|reset]` — HOST: where the dots
	/// stand, and the mod each platform wants. To test:
	/// - `start` begins the puzzle as Bonfire's end does;
	/// - `move red` moves the red dot, as the red platform's own mod on it would;
	/// - `mods` deals the platforms new mods at random, or these four, one per colour (ids: deadwire, fireworks,
	///   radiation, blastfurnace, thunderwall, cryofreeze);
	/// - `glyphs` deals the platforms new glyphs at random, or these four, 1-8, one per colour;
	/// - `solve` puts every dot on its platform's glyph, and so finishes it, as the last right mod would;
	/// - `reset` takes the dots, the icons and the glyphs off.
	/// It prints the answer — no secret here, the clues and the platforms show it.
	/// </summary>
	[ConCmd( "nz_hex_rings" )]
	public static void RingsCmd( string what = "", string arg1 = "", string arg2 = "", string arg3 = "", string arg4 = "" )
	{
		if ( NZGame.IsClient )
		{
			var shown = Instance.IsValid() ? Instance._ringsShown : default;
			Log.Info( $"[nz-hex] Color Rings: {( shown.Active ? shown.Describe() : "not begun" )} (as the host last said)" );
			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 "start":
				m.StartRings();
				if ( !m._done )
					Log.Warning( "[nz-hex] Color Smash is not done, so no platform is lit in its colour and no mod can move a"
						+ " dot — nz_hex_step done first" );
				break;

			case "move":
			{
				var k = ColourNumber( arg1 );
				if ( k < 0 || k >= Colours ) { Log.Warning( "[nz-hex] nz_hex_rings move blue, yellow, green or red" ); return; }
				if ( !m._rings.Active ) { Log.Warning( "[nz-hex] Color Rings has not begun — nz_hex_rings start" ); return; }

				if ( m._rings.Done ) { Log.Warning( "[nz-hex] Color Rings is done — nothing moves now (nz_hex_rings reset)" ); return; }

				m._rings = m._rings.Moved( k );
				m.SendRings();
				if ( m._rings.Solved ) m.RingsDone( "hand" );
				break;
			}

			case "glyphs":
			{
				if ( !m._rings.Active ) { Log.Warning( "[nz-hex] Color Rings has not begun — nz_hex_rings start" ); return; }
				if ( m._rings.Done ) { Log.Warning( "[nz-hex] Color Rings is done — nz_hex_rings reset first" ); return; }

				var given = new[] { arg1, arg2, arg3, arg4 };
				int[] targets;
				if ( given.All( x => string.IsNullOrWhiteSpace( x ) ) )
					targets = RingState.DealTargets();
				else if ( given.All( x => int.TryParse( x, out var n ) && n >= 1 && n <= RingPositions ) )
					targets = given.Select( x => int.Parse( x ) - 1 ).ToArray();
				else
				{
					Log.Warning( "[nz-hex] nz_hex_rings glyphs <blue> <yellow> <green> <red> — four glyphs, 1-8, repeats allowed —"
						+ " or nothing, to deal them at random" );
					return;
				}

				m._rings = m._rings.WithTargets( targets );
				m.SendRings();
				if ( m._rings.Solved ) m.RingsDone( "hand" );
				break;
			}

			case "solve":
			{
				if ( !m._rings.Active ) { Log.Warning( "[nz-hex] Color Rings has not begun — nz_hex_rings start" ); return; }
				if ( m._rings.Done ) { Log.Warning( "[nz-hex] Color Rings is done already" ); break; }

				m._rings = m._rings.WithPositions( Enumerable.Range( 0, Colours ).Select( c => m._rings.TargetOf( c ) ).ToArray() );
				m.SendRings();
				m.RingsDone( "hand" );
				break;
			}

			case "mods":
			{
				if ( !m._rings.Active ) { Log.Warning( "[nz-hex] Color Rings has not begun — nz_hex_rings start" ); return; }

				var given = new[] { arg1, arg2, arg3, arg4 }.Select( x => x.Trim().ToLowerInvariant() ).ToArray();
				var pool = RingMods;
				string[] ids;
				if ( given.All( x => x == "" ) || (given[0] == "random" && given.Skip( 1 ).All( x => x == "" )) )
					ids = RingState.DealMods();
				else if ( given.All( x => pool.Contains( x ) ) && given.Distinct().Count() == Colours )
					ids = given;
				else
				{
					Log.Warning( "[nz-hex] nz_hex_rings mods <blue> <yellow> <green> <red> — four different of "
						+ string.Join( ", ", pool ) + " — or nothing, to deal them at random" );
					return;
				}

				m._rings = m._rings.WithMods( ids );
				m.SendRings();
				break;
			}

			case "reset":
				m.ResetRings();
				break;

			default:
				Log.Warning( "[nz-hex] nz_hex_rings start, move <colour>, mods [four mods], glyphs [four glyphs], solve or reset"
					+ " — or nothing, to see where they stand" );
				return;
		}

		Log.Info( $"[nz-hex] Color Rings: {( m._rings.Active ? m._rings.Describe() : "not begun" )}"
			+ ( m._rings.Active && m._picked.Count >= Colours
				? " · the platforms: " + string.Join( ", ", Enumerable.Range( 0, Colours )
					.Select( k => $"{ColourName( k )} tile {Tiles[m._picked[k]].Id}" ) )
				: "" ) );
	}
}