EasterEgg/HexPlatforms.Buttons.cs

Component of the HexPlatforms manager implementing the teleporter rooms six hexagonal buttons. It defines button positions, visuals, host-side link generation and rules, pressing logic, UI aiming/interaction helpers, runtime mesh building, console commands and mirroring state to clients.

NetworkingFile Access
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace NZombies;

/// <summary>
/// BASALT — THE TELEPORTER'S BUTTONS (2026-09-27): its destination set, the last puzzle before the teleporter is used. The
/// energy through to it, six small hex stones wake on the floor round it, where the user laid six ASP wall buys
/// (Docs/BASALT_MARKED_SPOTS.md). Each is lit round its sides red, green or light blue, and all start red. E on one turns
/// it to its next colour — red, green, light blue, then red again — and with it the others it is linked to: each its own
/// one to three, one way, so the north one may turn the south one without the south one turning it back. The links are
/// dealt every game. All six the teleporter's light blue, and its destination is set: they turn no more, the step-done
/// clicking plays, and the blue altar rises out of the floor in the teleporter's middle (`HexPlatforms.BlueAltar.cs`).
///
/// Asked for as *"6 buttons on the teleporter room, and i need to press them a bunch of times — each button once again
/// gets a color, i need all of them to have the blue color like the teleporter, the light blue — but pressing a button
/// changes the other buttons too, not all of them, each button changes a set ammount of buttons — each button has 3
/// positions, red green and blue — it cycles between them, they all start red and i need to turn them all blue"*; then
/// *"pressing E, but not hud message"*, *"random"*, and *"a lit hex panel, but a lot smaller, like the size of a pistol,
/// the front is grey like hte stone texture on the hexagons, only the sides are colored"*.
///
/// ⛔ THE ORDER OF THE PRESSES NEVER MATTERS — ONLY HOW MANY EACH BUTTON GETS: 0, 1 or 2, since three turn it back to where
/// it was. An answer is a count for each button, and there are 729 ways to press them (3^6).
///
/// ⛔ ONLY A FAIR DEAL IS KEPT (<see cref="DealButtons"/>): exactly one answer from all red, of 7 to 10 presses in all, some
/// button pressed twice and some never. Measured on paper before it was built (2026-09-27, thousands of random deals):
/// - every press turning exactly three — itself and two others — can NEVER have exactly one answer: every deal had
///   several, or none;
/// - one pattern for every button, each turning its two neighbours round the ring, is worse: pressing all six once
///   changes nothing, and it has nine answers, the shortest four presses;
/// - itself and one to three others, one way, has exactly one answer about half the time, and is fair about one deal in
///   seven — a handful of draws.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - a PRESS is noticed on the presser's machine — the use key and its own camera (`NZPlayer.TickUse`) — which asks the
///   host (`NZNet.HexButtonPress`); the host decides: the buttons awake, the presser up and within reach;
/// - the LINKS are the HOST's alone, never sent — and with them the answer;
/// - the COLOURS and whether they are awake or the destination set are HOST state, MIRRORED (`NZNet.HexButtonState`) on
///   every change and to a joiner, with the button just pressed, for its push;
/// - the STONES, their lit sides and the push are LOCAL.
///
/// ⚠️ NO PROMPT, BY THE USER'S WORD. `UsePrompt.Text` asks the key's own question and shows nothing — nor anything below
/// it, since E presses the button.
/// </summary>
public sealed partial class HexPlatforms
{
	// ══ where they are ══════════════════════════════════════════════════════════════════════

	/// <summary>How many buttons, and how many colours each turns through.</summary>
	const int Buttons = 6, ButtonColours = 3;

	/// <summary>
	/// The six spots, where the user's ASP wall buys lay on the teleporter room's floor on 2026-09-27, round the teleporter
	/// about 151u out, off the corners of its outer step: each floor point, traced in the game — the floor at 1696, facing
	/// up. Clockwise from the north.
	/// </summary>
	static Vector3 ButtonSpot( int b ) => b switch
	{
		0 => new( -4993.06f, -138.37f, 1696f ),
		1 => new( -4861.24f, -210.45f, 1696f ),
		2 => new( -4861.01f, -364.67f, 1696f ),
		3 => new( -4991.78f, -440.07f, 1696f ),
		4 => new( -5124.02f, -365.94f, 1696f ),
		_ => new( -5122.51f, -213.56f, 1696f ),
	};

	static string ButtonName( int b ) => b switch
	{
		0 => "north", 1 => "north-east", 2 => "south-east", 3 => "south", 4 => "south-west", _ => "north-west",
	};

	/// <summary>A button by its number clockwise from the north (1-6) or its name — "north", "ne", "south-west"…; -1 if none.</summary>
	static int ButtonOf( string which )
	{
		if ( int.TryParse( which, out var n ) ) return n >= 1 && n <= Buttons ? n - 1 : -1;

		return (which ?? "").Trim().ToLowerInvariant().Replace( "-", "" ).Replace( " ", "" ).Replace( "_", "" ) switch
		{
			"north" or "n" => 0,
			"northeast" or "ne" => 1,
			"southeast" or "se" => 2,
			"south" or "s" => 3,
			"southwest" or "sw" => 4,
			"northwest" or "nw" => 5,
			_ => -1,
		};
	}

	/// <summary>A button's colours, 0-2 — red, green, light blue — as wheel colours. Light blue is the teleporter's: the goal.</summary>
	static int ButtonWheel( int colour ) => colour switch { 0 => WheelRed, 1 => WheelGreen, _ => WheelLightBlue };
	const int ButtonBlue = 2;

	static float? _buttonAcross, _buttonHeight;

	/// <summary>
	/// How wide a button is, corner to corner, in units: 10 — a pistol's length, as the user asked. `nz_hex_button_size` sets
	/// it on this machine and until a restart; set the default here once settled.
	/// </summary>
	public static float ButtonAcross { get => Math.Clamp( _buttonAcross ?? 10f, 2f, 60f ); set => _buttonAcross = value; }

	/// <summary>How far a button stands up off the floor, in units: 4 — its lit sides' height. `nz_hex_button_size` sets it too.</summary>
	public static float ButtonHeight { get => Math.Clamp( _buttonHeight ?? 4f, 0.5f, 30f ); set => _buttonHeight = value; }

	/// <summary>The middle of a button's top: where it is looked at, reached and heard.</summary>
	static Vector3 ButtonTop( int b ) => ButtonSpot( b ) + Vector3.Up * ButtonHeight;

	// ══ the state ════════════════════════════════════════════════════════════════════════════

	/// <summary>The flags: awake; the destination set.</summary>
	const int BtnActive = 1, BtnDone = 2;

	/// <summary>
	/// HOST — the links, six bits a button: bit j of button b's set when pressing b turns j, its own always. Never sent.
	/// </summary>
	long _btnLinks;

	/// <summary>HOST — every button's colour, two bits each, 0 red; and the flags.</summary>
	int _btnColours, _btnFlags;

	/// <summary>MIRROR — the colours and the flags as this machine was told (`NZNet.HexButtonState`). Never the links.</summary>
	int _btnColoursShown, _btnFlagsShown;

	/// <summary>HOST — when each button was last pressed. ⚠️ MADE ON FIRST USE, NOT BY AN INITIALISER, for the hotload.</summary>
	TimeSince[] _btnPressed;

	/// <summary>
	/// How soon after a press the same button takes another, in seconds: 0.05 — only an E seen twice for one press. ⚠️ IT WAS
	/// 0.25, AND IT ATE THE USER'S QUICK DOUBLE TAPS: six second taps refused in their first run (2026-09-27, 02:59-03:01),
	/// each "pressed a moment ago" — a button two colours on is two taps, and they come quick.
	/// </summary>
	const float BtnPressCooldown = 0.05f;

	/// <summary>HOST — the buttons as they stand, for `NZNet.PushState` to replay to a joiner. Never the links.</summary>
	public static (int Colours, int Flags) ButtonsNow
		=> Instance.IsValid() ? (Instance._btnColours, Instance._btnFlags) : (0, 0);

	/// <summary>Is the teleporter's destination set, as this machine was told?</summary>
	public bool ButtonsDoneShown => (_btnFlagsShown & (BtnActive | BtnDone)) == (BtnActive | BtnDone);

	static int BtnColour( int colours, int b ) => (colours >> (2 * b)) & 3;
	static int BtnWithColour( int colours, int b, int c ) => (colours & ~(3 << (2 * b))) | ((c & 3) << (2 * b));
	static int BtnLinksOf( long links, int b ) => (int)((links >> (6 * b)) & 63);

	/// <summary>Six buttons' links from six masks, bit j of the b-th set when b turns j.</summary>
	static long BtnPack( params int[] masks )
	{
		long links = 0;
		for ( var b = 0; b < Buttons && b < masks.Length; b++ ) links |= (long)(masks[b] & 63) << (6 * b);
		return links;
	}

	/// <summary>Every button light blue.</summary>
	static int BtnAllBlue()
	{
		var packed = 0;
		for ( var b = 0; b < Buttons; b++ ) packed = BtnWithColour( packed, b, ButtonBlue );
		return packed;
	}

	/// <summary>The colours after one press of button b: it and every button it is linked to turned on a colour.</summary>
	static int BtnAfterPress( long links, int colours, int b )
	{
		var turns = BtnLinksOf( links, b );
		for ( var j = 0; j < Buttons; j++ )
			if ( (turns >> j & 1) != 0 ) colours = BtnWithColour( colours, j, (BtnColour( colours, j ) + 1) % ButtonColours );
		return colours;
	}

	/// <summary>How many times an answer presses button b, 0-2 — two bits a button.</summary>
	static int BtnTimes( int presses, int b ) => (presses >> (2 * b)) & 3;
	static int BtnPressCount( int presses ) => Enumerable.Range( 0, Buttons ).Sum( b => BtnTimes( presses, b ) );

	/// <summary>
	/// The ways to press them — how many times each, 0-2 — that turn these colours all light blue: how many there are,
	/// counted as far as two, which is all a deal asks, and the first.
	/// </summary>
	static (int Count, int Presses) BtnAnswers( long links, int from )
	{
		var goal = BtnAllBlue();
		int count = 0, first = -1;
		for ( var code = 0; code < 729; code++ )
		{
			int colours = from, presses = 0, rest = code;
			for ( var b = 0; b < Buttons; b++ )
			{
				var times = rest % 3;
				rest /= 3;
				presses |= times << (2 * b);
				for ( var t = 0; t < times; t++ ) colours = BtnAfterPress( links, colours, b );
			}

			if ( colours != goal ) continue;
			if ( count == 0 ) first = presses;
			if ( ++count > 1 ) break;
		}
		return (count, first);
	}

	/// <summary>Is this deal fair: exactly one answer from all red, of 7 to 10 presses in all, some button twice and some never?</summary>
	static bool BtnFair( long links )
	{
		var (count, presses) = BtnAnswers( links, 0 );
		if ( count != 1 ) return false;

		var total = BtnPressCount( presses );
		var times = Enumerable.Range( 0, Buttons ).Select( b => BtnTimes( presses, b ) ).ToList();
		return total >= 7 && total <= 10 && times.Contains( 2 ) && times.Contains( 0 );
	}

	/// <summary>
	/// A fair deal made on paper, should a thousand draws find none — about one draw in seven is fair (measured 2026-09-27),
	/// so it is never needed. Its answer: north never, north-east twice, south-east twice, south once, south-west once,
	/// north-west twice — 8 presses.
	/// </summary>
	static long BtnFallback => BtnPack( 0b100101, 0b110010, 0b010110, 0b101011, 0b011011, 0b100010 );

	/// <summary>A fair deal: each button turns itself and one to three others, drawn apart, one way (<see cref="BtnFair"/>).</summary>
	static long DealButtons()
	{
		var others = new int[Buttons - 1];
		for ( var draw = 0; draw < 1000; draw++ )
		{
			long links = 0;
			for ( var b = 0; b < Buttons; b++ )
			{
				for ( int j = 0, k = 0; j < Buttons; j++ ) if ( j != b ) others[k++] = j;
				for ( var i = others.Length - 1; i > 0; i-- )
				{
					var k = Game.Random.Next( i + 1 );
					(others[i], others[k]) = (others[k], others[i]);
				}

				var mask = 1 << b;
				var reach = 1 + Game.Random.Next( 3 );
				for ( var i = 0; i < reach; i++ ) mask |= 1 << others[i];
				links |= (long)mask << (6 * b);
			}

			if ( BtnFair( links ) ) return links;
		}

		Log.Warning( "[nz-hex] no fair deal for the teleporter's buttons in a thousand draws — the one made on paper" );
		return BtnFallback;
	}

	void SendButtons( int pressed = -1 ) => NZNet.HexButtonState( _btnColours, _btnFlags, pressed );

	/// <summary>
	/// The buttons as they stand. EVERY machine — `NZNet.HexButtonState`: their lit sides dressed to match, and the one just
	/// pressed, if any, pushed down and let up.
	/// </summary>
	public void ApplyButtons( int colours, int flags, int pressed )
	{
		var wasSet = ButtonsDoneShown;
		_btnColoursShown = colours;
		_btnFlagsShown = flags;

		if ( pressed >= 0 && pressed < Buttons )
		{
			EndButtonPush();
			_btnPush = pressed;
			_btnPushSince = 0;
		}

		DressButtons();

		// the blue altar, as the destination goes set or unset here: rising if a press has just set it; standing if it was set
		// before this machine heard — joining, `nz_hex_buttons done`; gone once it is not (`HexPlatforms.BlueAltar.cs`)
		if ( ButtonsDoneShown != wasSet ) BuildBlueAltar( rise: ButtonsDoneShown && pressed >= 0 );
	}

	/// <summary>The buttons wake: all red, their links dealt. HOST — the energy through to the teleporter.</summary>
	void StartButtons()
	{
		if ( (_btnFlags & BtnActive) != 0 ) return;

		_btnLinks = DealButtons();
		_btnColours = 0;
		_btnFlags = BtnActive;
		SendButtons();
		Log.Info( "[nz-hex] ⬡ THE TELEPORTER'S BUTTONS WAKE — six stones round it, all red. E on one turns it and those it is linked"
			+ " to on a colour; all six light blue sets its destination" );
	}

	/// <summary>Back to not begun. HOST — with the junctions, which they follow; and the blue altar's send with them.</summary>
	void ResetButtons()
	{
		ResetArenaSend();
		if ( _btnFlags == 0 && _btnColours == 0 && _btnLinks == 0 ) return;

		_btnLinks = 0;
		_btnColours = _btnFlags = 0;
		SendButtons();
	}

	// ══ pressing one ════════════════════════════════════════════════════════════════════════

	/// <summary>How near a player's feet must be to a button's top to press it, in units: a step or two off it.</summary>
	const float ButtonReach = 100f;

	/// <summary>How far off a button a look may pass and still be at it, in units, beyond its own half width.</summary>
	const float ButtonAimSlack = 3f;

	/// <summary>
	/// Which button this player is looking at, near enough to press, while the buttons take presses; -1 for none. LOCAL — the
	/// prompt's test and the use key's, one question for both (`UsePrompt.Text`, `NZPlayer.TickUse`), from this machine's
	/// camera.
	/// </summary>
	public static int HexButtonAimed( NZPlayer player )
	{
		var m = Instance;
		if ( !m.IsValid() || !OnBasalt || (m._btnFlagsShown & (BtnActive | BtnDone)) != BtnActive || CannotCarry( player ) ) return -1;

		var cam = m.Scene.Camera;
		return cam.IsValid() ? ButtonAimedFrom( cam.WorldPosition, cam.WorldRotation.Forward, player.WorldPosition ) : -1;
	}

	/// <summary>
	/// Which button a look from this eye, this way (a unit direction), is at, the looker's feet here; -1 for none. The
	/// stones have no collider for a trace to stop on, so the look's ray must pass through a sphere round a button's top, as
	/// for the cursed flame; the nearest along it wins.
	/// </summary>
	static int ButtonAimedFrom( Vector3 eye, Vector3 dir, Vector3 feet )
	{
		var best = -1;
		var bestAlong = float.MaxValue;
		for ( var b = 0; b < Buttons; b++ )
		{
			var top = ButtonTop( b );
			if ( top.Distance( feet ) > ButtonReach ) continue;
			if ( !RayMeetsSphere( eye, dir, top, ButtonAcross * 0.5f + ButtonAimSlack ) ) continue;

			var along = Vector3.Dot( top - eye, dir );
			if ( along >= bestAlong ) continue;

			best = b;
			bestAlong = along;
		}
		return best;
	}

	/// <summary>E on a button. LOCAL — the presser's machine asks the host, which decides (<see cref="HostPressButton"/>).</summary>
	public static void PressHexButton( NZPlayer player, int button )
	{
		if ( player.IsValid() && button >= 0 && button < Buttons ) NZNet.HexButtonPress( button );
	}

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

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

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

		var refused = m.PressButton( button, body );
		if ( refused != "" )
			Log.Info( $"[nz-hex] the {( button >= 0 && button < Buttons ? ButtonName( button ) : $"#{button}" )} button is not"
				+ $" pressed: {refused}" );
	}

	/// <summary>
	/// The rules for a press. HOST — apart from the RPC, so the selftest can walk it. The buttons must be awake and the
	/// destination not set; the presser up and within reach (`anywhere` skips both, for the test and the command); and the
	/// button not pressed a moment ago (`anytime` skips that). It and every button it is linked to turn on a colour, heard
	/// at it; all six light blue, the destination is set. Returns why not, or "".
	/// </summary>
	string PressButton( int button, NZPlayer by = null, bool anywhere = false, bool anytime = false )
	{
		if ( (_btnFlags & BtnActive) == 0 ) return "the buttons are not awake";
		if ( (_btnFlags & BtnDone) != 0 ) return "the destination is set already";
		if ( button < 0 || button >= Buttons ) return "no such button";

		if ( !anywhere )
		{
			if ( CannotCarry( by ) ) return "the presser is down or gone";
			if ( ButtonTop( button ).Distance( by.WorldPosition ) > ButtonReach + ReachSlack ) return "out of reach";
		}

		_btnPressed ??= new TimeSince[Buttons];
		if ( !anytime && _btnPressed[button] < BtnPressCooldown ) return "pressed a moment ago";
		_btnPressed[button] = 0;

		_btnColours = BtnAfterPress( _btnLinks, _btnColours, button );
		var set = _btnColours == BtnAllBlue();
		if ( set ) _btnFlags |= BtnDone;
		SendButtons( button );
		Cue( ButtonCue, ButtonTop( button ) );
		Log.Info( $"[nz-hex] ⬡ {( by.IsValid() ? NameFor( by ) : "someone" )} pressed the {ButtonName( button )} button — "
			+ BtnColoursText( _btnColours ) );
		if ( !set ) return "";

		if ( !Testing ) DoneCueLater();
		Cue( BlueAltarCue, BlueAltarFoot );
		Fanfare( 13 );
		Log.Info( $"[nz-hex] ✦ THE TELEPORTER'S DESTINATION IS SET — {( by.IsValid() ? NameFor( by ) : "someone" )} turned the last"
			+ " of the six light blue, and the blue altar rises in its middle" );
		return "";
	}

	// ══ the sound ════════════════════════════════════════════════════════════════════════════

	/// <summary>A button pressed: a stone's thunk and the lights' blip, heard at it, 3D (`Tools/basalt_button_sounds.py`).</summary>
	const string ButtonCue = "nz.hex.button";

	// ══ the stones ══════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// LOCAL — each button's stone, and its lit sides' renderer, kept from when it was made: a lookup does not see the
	/// component of an object that is switched off (INSTRUCTIONS.md, `GetAllComponents`).
	/// </summary>
	GameObject[] _btnGo;
	ModelRenderer[] _btnSides;

	/// <summary>LOCAL — the button last pressed, and since when: its push.</summary>
	int _btnPush = -1;
	TimeSince _btnPushSince;

	/// <summary>How long a press pushes a button down and lets it up, in seconds, and how far down, in units.</summary>
	const float BtnPushSeconds = 0.2f, BtnPushDepth = 1.5f;

	/// <summary>
	/// How the buttons are drawn: ⚠️ BUMP IT WHEN THAT CHANGES. 1: six small hex stones, their tops the stones' concrete and
	/// their sides lit, 2026-09-27. A manager that has not built this layout builds them again — `OnUpdate` asks every frame —
	/// as the junctions' does.
	/// </summary>
	const int ButtonsLayout = 1;

	/// <summary>LOCAL — the layout this manager last built the buttons in; 0 before it has.</summary>
	int _btnLaid;

	/// <summary>
	/// The six stones, on basalt, all game: their sides dark until the buttons wake, then each its colour. Built once, and
	/// again at a new layout or size. LOCAL.
	/// </summary>
	void BuildButtons()
	{
		_btnLaid = ButtonsLayout;
		ClearButtons();
		if ( !OnBasalt || !Scene.IsValid() ) return;

		var stone = Material.Load( StoneMaterial );
		var light = Material.Load( LightMaterial );
		if ( stone is null || light is null )
		{
			Log.Warning( $"[nz-hex] {StoneMaterial} or {LightMaterial} not found — the teleporter's buttons are not built" );
			return;
		}

		var radius = ButtonAcross * 0.5f;
		var top = ButtonTopModel( radius, ButtonHeight, stone );
		var sides = ButtonSidesModel( radius, ButtonHeight, light );

		_btnGo = new GameObject[Buttons];
		_btnSides = new ModelRenderer[Buttons];
		for ( var b = 0; b < Buttons; b++ )
		{
			var root = Scene.CreateObject();
			root.Name = $"Basalt teleporter button ({ButtonName( b )})";
			root.Flags |= GameObjectFlags.NotSaved;
			root.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
			root.Tags.Add( PanelTag );
			root.WorldPosition = ButtonSpot( b );
			root.Components.Create<ModelRenderer>().Model = top;

			var go = Scene.CreateObject();
			go.Name = "sides";
			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.SetParent( root );
			go.LocalPosition = Vector3.Zero;
			go.LocalRotation = Rotation.Identity;

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

			_btnGo[b] = root;
			_btnSides[b] = r;
		}

		_btnPush = -1;
		DressButtons();
	}

	void ClearButtons()
	{
		if ( _btnGo is not null )
			foreach ( var go in _btnGo )
				if ( go.IsValid() ) go.Destroy();

		_btnGo = null;
		_btnSides = null;
	}

	/// <summary>The buttons' sides as this machine was told: dark before they wake, then each its colour. LOCAL.</summary>
	void DressButtons()
	{
		if ( _btnSides is null ) return;

		var awake = (_btnFlagsShown & BtnActive) != 0;
		var dim = JunctionDimMaterial();
		for ( var b = 0; b < Buttons; b++ )
		{
			if ( _btnSides[b] is not ModelRenderer r || !r.IsValid() ) continue;
			r.MaterialOverride = awake ? WheelMaterial( ButtonWheel( BtnColour( _btnColoursShown, b ) ) ) : dim;
		}
	}

	/// <summary>A pressed button pushed down and let up again. LOCAL — `OnUpdate`.</summary>
	void TickButtons()
	{
		if ( _btnPush < 0 ) return;
		if ( _btnPushSince >= BtnPushSeconds ) { EndButtonPush(); return; }

		var go = _btnGo is not null && _btnPush < _btnGo.Length ? _btnGo[_btnPush] : null;
		if ( go.IsValid() )
			go.WorldPosition = ButtonSpot( _btnPush ) + Vector3.Down * (BtnPushDepth * MathF.Sin( MathF.PI * (_btnPushSince / BtnPushSeconds) ));
	}

	/// <summary>A push over: the button back up where it stands. LOCAL.</summary>
	void EndButtonPush()
	{
		if ( _btnPush >= 0 && _btnGo is not null && _btnPush < _btnGo.Length && _btnGo[_btnPush].IsValid() )
			_btnGo[_btnPush].WorldPosition = ButtonSpot( _btnPush );
		_btnPush = -1;
	}

	/// <summary>A button's hexagon this far out to its corners — its points north and south, as the stones have theirs. Counter-clockwise from the north.</summary>
	static Vector2[] ButtonCorners( float radius )
	{
		var flat = radius * MathF.Sqrt( 3f ) * 0.5f;
		return new Vector2[]
		{
			new( 0f, radius ), new( -flat, radius * 0.5f ), new( -flat, -radius * 0.5f ),
			new( 0f, -radius ), new( flat, -radius * 0.5f ), new( flat, radius * 0.5f ),
		};
	}

	/// <summary>
	/// A button's top: its hexagon at its height, facing up, in the stones' concrete laid as the map lays it — one repeat per
	/// <see cref="StoneUnits"/>. Each wedge is wound counter-clockwise seen from above, so its normal points up.
	/// </summary>
	static Model ButtonTopModel( float radius, float height, Material stone )
	{
		var corners = ButtonCorners( radius );
		var verts = new List<Vertex> { new( new Vector3( 0f, 0f, height ), Vector3.Up, Vector3.Forward, new Vector4( 0f, 0f, 0f, 0f ) ) };
		foreach ( var c in corners )
			verts.Add( new Vertex( new Vector3( c.x, c.y, height ), Vector3.Up, Vector3.Forward,
				new Vector4( c.x / StoneUnits, -c.y / StoneUnits, 0f, 0f ) ) );

		var idx = new List<int>();
		for ( var i = 0; i < corners.Length; i++ )
		{
			idx.Add( 0 );
			idx.Add( 1 + i );
			idx.Add( 1 + (i + 1) % corners.Length );
		}

		return ButtonMesh( stone, verts, idx, new BBox( new Vector3( -radius, -radius, height - 0.5f ), new Vector3( radius, radius, height + 0.5f ) ) );
	}

	/// <summary>
	/// A button's six sides, from the floor to its top, facing out, in white001 — the colour is its renderer's override, and
	/// each side runs 0-1 across, a light's texture being flat. Wound as the tiles' panels are (`HexSidePanels.Build`).
	/// </summary>
	static Model ButtonSidesModel( float radius, float height, Material light )
	{
		var corners = ButtonCorners( radius );
		var verts = new List<Vertex>();
		var idx = new List<int>();
		for ( var i = 0; i < corners.Length; i++ )
		{
			var a = corners[i];
			var b = corners[(i + 1) % corners.Length];
			var d = b - a;
			var along = d * (1f / d.Length);
			var n = new Vector3( along.y, -along.x, 0f );             // outward: the edge turned right, the panels' rule
			var t = new Vector3( along.x, along.y, 0f );
			var s = verts.Count;

			verts.Add( new Vertex( new Vector3( a.x, a.y, 0f ), n, t, new Vector4( 0f, 1f, 0f, 0f ) ) );
			verts.Add( new Vertex( new Vector3( b.x, b.y, 0f ), n, t, new Vector4( 1f, 1f, 0f, 0f ) ) );
			verts.Add( new Vertex( new Vector3( b.x, b.y, height ), n, t, new Vector4( 1f, 0f, 0f, 0f ) ) );
			verts.Add( new Vertex( new Vector3( a.x, a.y, height ), n, t, new Vector4( 0f, 0f, 0f, 0f ) ) );

			idx.Add( s ); idx.Add( s + 1 ); idx.Add( s + 2 );
			idx.Add( s ); idx.Add( s + 2 ); idx.Add( s + 3 );
		}

		return ButtonMesh( light, verts, idx, new BBox( new Vector3( -radius, -radius, -0.5f ), new Vector3( radius, radius, height + 0.5f ) ) );
	}

	static Model ButtonMesh( Material material, List<Vertex> verts, List<int> idx, BBox bounds )
	{
		var mesh = new Mesh( material );
		mesh.CreateVertexBuffer( verts.Count, verts );
		mesh.CreateIndexBuffer( idx.Count, idx );

		// ⛔ SET BY HAND, AFTER THE BUFFERS: a runtime mesh gets empty bounds otherwise (`HexSidePanels.Build`)
		mesh.Bounds = bounds;
		return Model.Builder.AddMesh( mesh ).WithViewBounds( bounds ).Create();
	}

	// ══ in words ════════════════════════════════════════════════════════════════════════════

	static string BtnColoursText( int colours )
		=> string.Join( " · ", Enumerable.Range( 0, Buttons ).Select( b => $"{ButtonName( b )} {WheelName( ButtonWheel( BtnColour( colours, b ) ) )}" ) );

	static string BtnPressesText( int presses )
		=> string.Join( " · ", Enumerable.Range( 0, Buttons ).Select( b => $"{ButtonName( b )} ×{BtnTimes( presses, b )}" ) );

	/// <summary>Which buttons each turns besides itself. HOST.</summary>
	string BtnLinksText()
		=> string.Join( " · ", Enumerable.Range( 0, Buttons ).Select( b =>
			$"{ButtonName( b )} → " + string.Join( ", ", Enumerable.Range( 0, Buttons )
				.Where( j => j != b && (BtnLinksOf( _btnLinks, b ) >> j & 1) != 0 ).Select( ButtonName ) ) ) );

	/// <summary>Where they stand, in words. HOST.</summary>
	string ButtonsStateText()
	{
		if ( (_btnFlags & BtnActive) == 0 ) return "the teleporter's buttons: waiting for the energy to reach the teleporter";
		if ( (_btnFlags & BtnDone) != 0 ) return "the teleporter's buttons: DONE — all six light blue, its destination set";
		return $"the teleporter's buttons: {BtnColoursText( _btnColours )}";
	}

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

	/// <summary>
	/// `nz_hex_button_size [across] [height]` — how wide the teleporter's buttons are corner to corner, and how tall, in
	/// units; bare, it says. On this machine and until a restart: set the defaults in `ButtonAcross` and `ButtonHeight` once
	/// they are settled.
	/// </summary>
	[ConCmd( "nz_hex_button_size" )]
	public static void ButtonSizeCmd( float across = float.NaN, float height = float.NaN )
	{
		if ( !float.IsNaN( across ) ) ButtonAcross = across;
		if ( !float.IsNaN( height ) ) ButtonHeight = height;
		if ( (!float.IsNaN( across ) || !float.IsNaN( height )) && Instance.IsValid() ) Instance.BuildButtons();

		Log.Info( $"[nz-hex] the teleporter's buttons are {ButtonAcross:0.#}u across, corner to corner, and {ButtonHeight:0.#}u tall" );
	}

	/// <summary>
	/// `nz_hex_buttons [start|press b|links|answer|solve|done|reset]` — HOST: where the teleporter's buttons stand. `start`
	/// wakes them, as the energy reaching the teleporter does; `press b` presses button b — by its name or its number
	/// clockwise from the north (1-6) — from anywhere; `links` prints which each turns and `answer` how many presses each
	/// still needs — testing only, both give it away; `solve` presses them so, which sets the destination; `done` sets it at
	/// once; `reset` makes them not begun.
	/// </summary>
	[ConCmd( "nz_hex_buttons" )]
	public static void ButtonsCmd( string what = "", string which = "" )
	{
		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 refused = "";
		switch ( what.Trim().ToLowerInvariant() )
		{
			case "":
				break;

			case "start":
				if ( (m._jnFlags & JnDone) == 0 )
					Log.Warning( "[nz-hex] the energy is not through to the teleporter, which wakes them — woken by hand" );
				m.StartButtons();
				break;

			case "press":
			{
				var b = ButtonOf( which );
				if ( b < 0 )
				{
					Log.Warning( "[nz-hex] nz_hex_buttons press north, north-east, south-east, south, south-west or north-west — or 1-6" );
					return;
				}
				refused = m.PressButton( b, NZPlayer.Local, anywhere: true, anytime: true );
				break;
			}

			case "links":
				Log.Info( (m._btnFlags & BtnActive) != 0
					? $"[nz-hex] the buttons' links, each turning itself and: {m.BtnLinksText()} — testing only, it gives it away"
					: "[nz-hex] the buttons are not awake, so they have no links yet" );
				return;

			case "answer":
			{
				if ( (m._btnFlags & BtnActive) == 0 ) { Log.Info( "[nz-hex] the buttons are not awake, so they have no answer yet" ); return; }

				var (count, presses) = BtnAnswers( m._btnLinks, m._btnColours );
				Log.Info( count == 1
					? $"[nz-hex] the presses still needed: {BtnPressesText( presses )} — {BtnPressCount( presses )} in all, any order."
						+ " Testing only, it gives it away"
					: $"[nz-hex] {( count == 0 ? "no" : "more than one" )} answer from here — not a fair deal" );
				return;
			}

			case "solve":
			{
				if ( (m._btnFlags & BtnActive) == 0 ) { Log.Warning( "[nz-hex] the buttons are not awake — nz_hex_buttons start first" ); return; }

				var (count, presses) = BtnAnswers( m._btnLinks, m._btnColours );
				if ( count == 0 ) { Log.Warning( "[nz-hex] no answer from here — not a fair deal" ); return; }

				for ( var b = 0; b < Buttons && refused == ""; b++ )
					for ( var t = 0; t < BtnTimes( presses, b ) && refused == ""; t++ )
						refused = m.PressButton( b, NZPlayer.Local, anywhere: true, anytime: true );
				break;
			}

			case "done":
				m.StartButtons();
				m._btnColours = BtnAllBlue();
				m._btnFlags = BtnActive | BtnDone;
				m.SendButtons();
				break;

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

			default:
				Log.Warning( "[nz-hex] nz_hex_buttons start, press <name|1-6>, links, answer, solve, done or reset — or nothing, to see"
					+ " where they stand" );
				return;
		}

		if ( refused != "" ) Log.Warning( $"[nz-hex] no: {refused}" );
		Log.Info( $"[nz-hex] {m.ButtonsStateText()}" );
	}
}