Game/ClickController.cs

Client-side root component managing WebSocket lifecycle and game state for Splitclicker. It handles connection/auth, receives JSON control frames and binary tick frames, manages live board/buttons, opponent pips/cursors, sends clicks/cursors/touches/park/test answers, and drives UI-visible state and sounds.

NetworkingFile Access
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Sandbox;
using Splitclicker.Api;
using Splitclicker.Audio;
using Splitclicker.Ws;

namespace Splitclicker.Game;

public enum GamePhase
{
	Connecting,
	Waiting,      // connected mid-round: sitting out until the next arm (can't score this one)
	Pending,      // arming — button dormant
	Armed,        // live — race is open
	Result,       // round leaderboard
	GameOver,     // final standings
	Disconnected, // lost the socket; backing off to reconnect
}

// Root component: owns the WebSocket lifecycle and the authoritative client-side
// view of the game. The whole UI reads this single instance. The click path is
// deliberately trivial — capture the press, send the nonce frame, nothing else.
public sealed class ClickController : Component
{
	public static ClickController Instance { get; private set; }

	/// <summary>Backend root, editable in the scene inspector. Leave blank to use
	/// the baked-in production URL (<see cref="ApiClient.ProdUrl"/>); set it to e.g.
	/// http://localhost:8080 for a local play-test. Applied to ApiClient at startup.</summary>
	[Property] public string BackendUrl { get; set; } = "";

	/// <summary>API version to talk to, editable in the scene inspector. "v7" is this
	/// build (arming-phase cursors + arming-AFK, the new `touch` signal, and park/unpark
	/// deferral to the arming boundary, on top of v6's park / Pause protocol); "v6" is the
	/// previous live build (the park / Pause protocol on v5's live-window tick) and the
	/// supported N-1; "v5" and below exercise the legacy/troll path the server gives
	/// clients below its live version. LEAVE BLANK to use raw, unversioned paths
	/// (bare /ws) — for the live old master backend. Applied to
	/// <see cref="ApiClient.ApiVersion"/> at startup.</summary>
	[Property] public string ApiVersion { get; set; } = "v7";

	public GamePhase Phase { get; private set; } = GamePhase.Connecting;
	public int Round { get; private set; }
	public int Of { get; private set; }
	public string Tag { get; private set; } = "";
	public string Username { get; private set; } = "";
	public List<Standing> Standings { get; private set; } = new();
	public List<Standing> Winners { get; private set; } = new();

	/// <summary>Connected players (open server connections) and the scoring slots
	/// this round (N = a multiple of the player count). Shown pre-click.</summary>
	public int Players { get; private set; }
	public int ClicksToWin { get; private set; }

	/// <summary>Bumped whenever the client should re-fetch the bounty state (the
	/// skin/countdown + the previous winner): on every `hello` (so a fresh connect
	/// AND a reconnect both refresh) and on every `bounty_update` push (a rollover).
	/// The Hud watches this and reloads /config + /bounties/previous when it changes,
	/// so the client never sits in a stale post-rollover view.</summary>
	public int BountyRefreshSeq { get; private set; }

	/// <summary>Host-editable broadcast note (shown orange under the throttle line);
	/// empty = none. Set from the dev_note frame (once per game) and the hello
	/// snapshot, and only ever changed by those — it persists across rounds and
	/// reconnects until the server sends an empty note.</summary>
	public string DevNote { get; private set; } = "";

	/// <summary>Click frames actually sent to the API during the current/just-ended
	/// CLICK! phase. Reset on each arm; shown under the button.</summary>
	public int ClicksSent { get; private set; }

	/// <summary>Clicks remaining in the live window, counted down from the live tick
	/// frame (server-authoritative) so the player watches the race fill in real time.
	/// Reset to the full N on each arm; the descending counter is shown while armed.</summary>
	public int RemainingThisRound { get; private set; }

	/// <summary>Opponent click pips currently fading on screen: each a half-size
	/// button at a normalized x/y with the clicker's username, spawned from the
	/// jitter buffer at its true relative moment (see <see cref="OnData"/>). Read by
	/// the Hud, which renders + fades them. Own clicks are never added (self-dedupe).</summary>
	public List<PipButton> ActivePips { get; } = new();

	/// <summary>The live multi-button board: the buttons currently clickable this armed
	/// window, each at a server-placed normalized position. Seeded from the armed frame's
	/// buttons and kept in sync by the tick claim events (claimed buttons removed, their
	/// replacements added). Empty when not armed. The Hud renders one clickable button per
	/// entry.</summary>
	public List<LiveButton> LiveButtons { get; } = new();

	/// <summary>True while the multi-button board is the scoring surface. The board is the
	/// ONLY scoring surface and exists only while armed, so the client never draws its own
	/// button — during the arming wait, or for stray bad clicks, there is simply no
	/// button.</summary>
	public bool HasBoard => LiveButtons.Count > 0;

	/// <summary>Opponent cursors to draw this frame: each a labelled dot at a normalized
	/// position, refreshed from the tick's cursor sample and expired shortly after (so a
	/// cursor that drops out of the sample fades rather than freezing). Armed-only;
	/// cleared at the arming stage and on round/game end.</summary>
	public List<CursorDot> Cursors { get; } = new();

	/// <summary>The arming-window bounds (seconds) from the server config; the
	/// per-round delay itself stays secret. Shown while the round is arming.</summary>
	public int ArmMinSec { get; private set; }
	public int ArmMaxSec { get; private set; }

	/// <summary>Anticheat test gate. When HasTest is true the player failed an
	/// end-of-round check and is benched until they answer TestPrompt correctly: the
	/// server withholds the armed signal until then. TestId must be echoed in the
	/// answer. TestMessage explains which check fired. Set by the `test` frame;
	/// cleared by it (correct answer) or on arm.</summary>
	public bool HasTest { get; private set; }
	public string TestId { get; private set; } = "";
	public string TestPrompt { get; private set; } = "";
	public string TestMessage { get; private set; } = "";

	/// <summary>Anticheat sanction ladder (beyond the test gate). SanctionState is
	/// "cooldown" (a timed cooldown after too many flags) or "ignored" (sidelined for
	/// the rest of the bounty), or "" when not sanctioned. SanctionUntilMs is the
	/// epoch ms the state ends — the UI shows a countdown to it — and SanctionMessage
	/// is the line to display. Set/cleared by the `test` frame's state field.</summary>
	public string SanctionState { get; private set; } = "";
	public long SanctionUntilMs { get; private set; }
	public string SanctionMessage { get; private set; } = "";

	/// <summary>True while the player has stepped away ("parked"): set when the player
	/// hits Pause OR when the server auto-parks them off an afk_idle verdict (the `park`
	/// frame). While parked the client sends no clicks/cursors and the server withholds
	/// every frame; the Hud shows the Pause control engaged + a WAIT state. Cleared when
	/// the player hits Pause again (rejoin) or, defensively, when a fresh round arrives
	/// (the server resumed sending = we're back in play). Park is a v6 capability.</summary>
	public bool Parked { get; private set; }

	/// <summary>True while a manual PAUSE pressed during the live window is waiting to take
	/// effect: the server defers a park requested mid-armed to the next arming boundary, so
	/// we have asked to park but aren't parked yet. Drives the "pausing after this round"
	/// PAUSE-bar state; cleared when the server's forced `park {on:true}` frame lands (which
	/// flips <see cref="Parked"/>) or if the player cancels by pressing again. (v7)</summary>
	public bool ParkPending { get; private set; }

	/// <summary>True while a RESUME is in flight: we asked the server to unpark but stay on
	/// the parked / STAND BY surface ("rejoining next round") until the next round_pending
	/// arrives, since an unpark requested mid-armed is also deferred to the arming boundary.
	/// Cleared when that round_pending lands (which clears <see cref="Parked"/>). (v7)</summary>
	public bool Resuming { get; private set; }

	/// <summary>True only while a valid click can score — drives the button's enabled
	/// state and scoring eligibility from one source. Armed with a live board, and never
	/// while parked (away).</summary>
	public bool CanClick => !Parked && Phase == GamePhase.Armed && HasBoard;

	static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };

	// Buttons already `touch`ed this armed window (by button id == slot), so each
	// enter-transition fires the `touch` frame at most once per button per window.
	// Cleared at the arming stage and on arm (see SendTouch).
	readonly HashSet<int> _touchedButtons = new();

	WsClient _ws;
	bool _connecting;
	int _reconnectAttempt;
	float _reconnectAt;

	// ── live-window pips (the opponent click visualization) ──
	// _roster maps a player's public tag → username, delivered in round_pending so a
	// pip (which carries only the 4-byte tag) can be labelled. _tickMs is the server
	// tick interval (sizes the jitter-buffer delay). _localArmReceive is RealTime.Now
	// when this client received `armed` — the origin each pip's t_arm offset is added
	// to, so per-client latency just shifts everything uniformly (invisible).
	readonly Dictionary<string, string> _roster = new();
	int _tickMs;
	float _localArmReceive;

	// The jitter buffer: sampled opponent clicks waiting to play at their true
	// relative moment (FireAt). Pips trail real time by PipDelay so timestamps that
	// are already in the past on arrival don't all clump at "fire now". Drains over
	// round_result; cleared at the next round_pending (the arming stage).
	readonly List<PendingPip> _pipBuffer = new();

	const float PipLifetime = 0.45f;  // how long a spawned pip button fades over
	const int PipActiveCap = 16;      // max concurrent fading buttons (drop oldest)
	const int PipBufferCap = 96;      // max buffered pending pips (drop overflow)
	const int PipMarginMs = 40;       // jitter margin added on top of one tick interval

	// One buffered opponent click awaiting its scheduled play moment.
	struct PendingPip
	{
		public float X, Y;     // normalized −1..1 click position (0 = centre)
		public string Name;    // resolved username (may be "" if unknown)
		public float FireAt;   // RealTime.Now at which to spawn it
	}

	// One opponent pip currently on screen (fading). Born drives the fade/expiry.
	public sealed class PipButton
	{
		public float X, Y;
		public string Name;
		public RealTimeSince Born;
	}

	// One live, clickable button on the v5 board. Slot is the wire handle (tick claims
	// reference it); Nonce is what a scoring click on it echoes; X,Y is its normalized
	// −1..1 position (same box-% space as the pips).
	public sealed class LiveButton
	{
		public ushort Slot;
		public string Nonce;
		public float X, Y;
	}

	// One opponent cursor to draw. Seen drives expiry when it stops being sampled.
	public sealed class CursorDot
	{
		public uint Tag;
		public float X, Y;
		public string Name;
		public RealTimeSince Seen;
	}

	const float CursorTtl = 0.3f; // drop a cursor this long after its last tick sample

	// Outbound cursor throttle: send our pointer position at most this often while armed
	// (matches the server's ~25/s ceiling; the server samples a subset into each tick).
	const float CursorSendInterval = 1f / 15f;
	float _lastCursorSent;

	// Playback delay D: at least one tick interval + a jitter margin, so the trailing
	// real-time replay never collapses to "fire now". Falls back to a small fixed
	// delay if the server didn't advertise a cadence.
	float PipDelay() => ( _tickMs > 0 ? _tickMs + PipMarginMs : 60 ) / 1000f;

	protected override void OnAwake()
	{
		Instance = this;
		if ( !string.IsNullOrWhiteSpace( BackendUrl ) )
			ApiClient.BaseUrl = BackendUrl.TrimEnd( '/' );
		// Always apply, even when blank: an empty version means raw, unversioned
		// paths (/api/… and /ws) for talking to a legacy backend like the live old
		// master (its socket is bare /ws). Skipping blank would keep the v2 default.
		ApiClient.ApiVersion = (ApiVersion ?? "").Trim().Trim( '/' );
		_ws = GameObject.Components.GetOrCreate<WsClient>();
		_ws.OnMessage = OnMessage;
		_ws.OnData = OnData;
		_ws.OnDone = OnDisconnected;
	}

	protected override void OnStart() => _ = ConnectFlow();

	protected override void OnUpdate()
	{
		// Jittered reconnect: only re-attempt once the backoff window elapses.
		if ( Phase == GamePhase.Disconnected && !_connecting && RealTime.Now >= _reconnectAt )
			_ = ConnectFlow();

		ProcessPips();
	}

	// Drive the pip jitter buffer: spawn any buffered pip whose scheduled moment has
	// arrived (playing its sound), and retire any on-screen pip past its fade. Run
	// every frame so replay timing is frame-accurate and fades end on time.
	void ProcessPips()
	{
		float now = RealTime.Now;
		for ( int i = _pipBuffer.Count - 1; i >= 0; i-- )
		{
			if ( now >= _pipBuffer[i].FireAt )
			{
				var p = _pipBuffer[i];
				_pipBuffer.RemoveAt( i );
				SpawnPip( p );
			}
		}
		for ( int i = ActivePips.Count - 1; i >= 0; i-- )
		{
			if ( ActivePips[i].Born >= PipLifetime )
				ActivePips.RemoveAt( i );
		}
		// Expire opponent cursors that stopped being sampled, so they fade rather than
		// freezing at a stale position.
		for ( int i = Cursors.Count - 1; i >= 0; i-- )
		{
			if ( Cursors[i].Seen >= CursorTtl )
				Cursors.RemoveAt( i );
		}
	}

	// Move a due pip onto the screen and play its blip. Caps the concurrent count by
	// dropping the oldest (same spirit as the server-side sample cap) so a burst can't
	// flood the layer or the voices.
	void SpawnPip( PendingPip p )
	{
		if ( ActivePips.Count >= PipActiveCap )
			ActivePips.RemoveAt( 0 );
		ActivePips.Add( new PipButton { X = p.X, Y = p.Y, Name = p.Name, Born = 0f } );
		SoundPlayer.PlayPip();
	}

	// OnData decodes the binary live-window `tick` frame (the only binary frame; see the
	// server's ws/tick.go for the layout):
	//   u8 opcode | u16 round | u32 remaining | u16 claimCount
	//   claimCount × ( u16 slot, u32 claimer_tag, u16 t_arm, u8 spawned,
	//                  [if spawned] u16 new_slot, u64 new_nonce, i16 x, i16 y )
	//   u8 cursorCount | cursorCount × ( u32 tag, i16 x, i16 y )
	// It updates the descending counter, applies the board mutations (removing each
	// claimed button and adding its replacement) while scheduling a pip at the claimed
	// button's position, and refreshes the opponent cursors. Own clicks/cursors are
	// skipped (self-dedupe). Runs on the socket's sync context (the main thread, same as
	// OnMessage), so it touches the buffers without locking.
	const byte TickOpcode = 1;

	void OnData( byte[] b )
	{
		try
		{
			if ( b == null || b.Length < 9 || b[0] != TickOpcode ) return;
			int round = b[1] | (b[2] << 8);
			long remaining = (uint)(b[3] | (b[4] << 8) | (b[5] << 16) | (b[6] << 24));

			// Only the current armed round's state is meaningful; a late tick from a
			// closed round must not stomp the next round's counter or board.
			bool currentRound = Phase == GamePhase.Armed && round == Round;
			if ( currentRound )
				RemainingThisRound = (int)remaining;

			int claimCount = b[7] | (b[8] << 8);
			int off = 9;
			for ( int i = 0; i < claimCount; i++ )
			{
				if ( off + 9 > b.Length ) return; // truncated frame
				ushort slot = (ushort)(b[off] | (b[off + 1] << 8));
				uint claimerTag = (uint)(b[off + 2] | (b[off + 3] << 8) | (b[off + 4] << 16) | (b[off + 5] << 24));
				ushort tArm = (ushort)(b[off + 6] | (b[off + 7] << 8));
				byte spawned = b[off + 8];
				off += 9;

				// Remove the claimed button (board stays in sync with the server) and, for
				// someone else's click in the current round, schedule a pip where it sat.
				var claimed = TakeButton( slot );
				string tagHex = Hex8( claimerTag );
				if ( currentRound && tagHex != Tag && _pipBuffer.Count < PipBufferCap )
				{
					_roster.TryGetValue( tagHex, out var name );
					_pipBuffer.Add( new PendingPip
					{
						X = claimed?.X ?? 0f,
						Y = claimed?.Y ?? 0f,
						Name = name ?? "",
						// At the click's true moment relative to our local arm receipt, trailed
						// by D so past-dated timestamps don't all fire at once.
						FireAt = _localArmReceive + tArm / 1000f + PipDelay(),
					} );
				}

				if ( spawned != 0 )
				{
					if ( off + 14 > b.Length ) return; // truncated frame
					ushort newSlot = (ushort)(b[off] | (b[off + 1] << 8));
					ulong nonce = ReadU64( b, off + 2 );
					short nx = (short)(b[off + 10] | (b[off + 11] << 8));
					short ny = (short)(b[off + 12] | (b[off + 13] << 8));
					off += 14;
					if ( currentRound )
						AddButton( newSlot, HexNonce( nonce ), nx / 32767f, ny / 32767f );
				}
			}

			// Cursor sample: refresh each named opponent cursor to this tick's position
			// (expired by ProcessPips if it stops being sampled). Skip our own.
			if ( off < b.Length )
			{
				int cursorCount = b[off];
				off += 1;
				for ( int i = 0; i < cursorCount && off + 8 <= b.Length; i++, off += 8 )
				{
					uint tag = (uint)(b[off] | (b[off + 1] << 8) | (b[off + 2] << 16) | (b[off + 3] << 24));
					short cx = (short)(b[off + 4] | (b[off + 5] << 8));
					short cy = (short)(b[off + 6] | (b[off + 7] << 8));
					if ( !currentRound || Hex8( tag ) == Tag ) continue;
					UpdateCursor( tag, cx / 32767f, cy / 32767f );
				}
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Splitclicker] bad tick frame: {e.Message}" );
		}
	}

	// ── board + cursor helpers (touched only on the socket sync context) ──

	// TakeButton removes and returns the live button with this slot, or null if it's
	// already gone (a duplicate/late claim, or our own optimistic state).
	LiveButton TakeButton( ushort slot )
	{
		for ( int i = 0; i < LiveButtons.Count; i++ )
		{
			if ( LiveButtons[i].Slot == slot )
			{
				var btn = LiveButtons[i];
				LiveButtons.RemoveAt( i );
				return btn;
			}
		}
		return null;
	}

	void AddButton( ushort slot, string nonce, float x, float y )
	{
		LiveButtons.Add( new LiveButton { Slot = slot, Nonce = nonce, X = x, Y = y } );
	}

	// FindButton resolves the live board button currently at this slot, or null if it's
	// already been claimed/replaced. The Hud's click handler addresses buttons by slot
	// (not a captured object) so a click always scores the button live at that slot —
	// never a stale closure capture. Same socket sync context as the tick decode/the Hud
	// click, so it touches the list without locking.
	public LiveButton FindButton( ushort slot )
	{
		foreach ( var b in LiveButtons )
			if ( b.Slot == slot )
				return b;
		return null;
	}

	// Refresh (or add) an opponent cursor by tag, resetting its expiry timer.
	void UpdateCursor( uint tag, float x, float y )
	{
		for ( int i = 0; i < Cursors.Count; i++ )
		{
			if ( Cursors[i].Tag == tag )
			{
				Cursors[i].X = x;
				Cursors[i].Y = y;
				Cursors[i].Seen = 0f;
				return;
			}
		}
		_roster.TryGetValue( Hex8( tag ), out var name );
		Cursors.Add( new CursorDot { Tag = tag, X = x, Y = y, Name = name ?? "", Seen = 0f } );
	}

	static ulong ReadU64( byte[] b, int off )
	{
		ulong v = 0;
		for ( int i = 7; i >= 0; i-- )
			v = (v << 8) | b[off + i];
		return v;
	}

	// Lower-case hex of a 64-bit nonce. The server reads it back with ParseUint(_,16,64),
	// so leading zeros are harmless — a fixed 16-char form is simplest and locale-safe.
	static string HexNonce( ulong v )
	{
		const string h = "0123456789abcdef";
		var c = new char[16];
		for ( int i = 15; i >= 0; i--, v >>= 4 )
			c[i] = h[(int)(v & 0xF)];
		return new string( c );
	}

	// Lower-case, zero-padded 8-char hex of a 32-bit tag — matches the server's
	// PlayerTag (first 8 hex chars of a sha256). Built by hand to stay clear of any
	// culture-sensitive number formatting the sandbox doesn't whitelist.
	static string Hex8( uint v )
	{
		const string h = "0123456789abcdef";
		var c = new char[8];
		for ( int i = 7; i >= 0; i--, v >>= 4 )
			c[i] = h[(int)(v & 0xF)];
		return new string( c );
	}

	// SendClick sends an idle (bad) click — a press that did NOT land on a live board
	// button (empty space, or a button that vanished as the window closed). It scores
	// nothing; the server simply read-and-drops it. Plays the dormant-click "nope" sound so
	// the press still has feedback. No-op while parked or with no socket. Scoring goes
	// through SendButtonClick — the board is the only live scoring surface.
	public void SendClick()
	{
		// Parked (stepped away): swallow the input entirely — no sound. The player rejoins
		// via Pause, not by clicking through the overlay.
		if ( Parked ) return;

		// Local "nope", independent of socket state (plays even while disconnected).
		SoundPlayer.PlayThrottle();

		if ( _ws == null || !_ws.Connected ) return;
		_ = _ws.Send( "{\"t\":\"click\",\"nonce\":\"\"}" );
	}

	// SendButtonClick is the hot path: a click that landed on board button `btn`. It
	// echoes that button's nonce (scores) plus the button's position (for the opponent
	// pip), plays the click blip, and counts it. It deliberately does NOT remove the
	// button locally — the authoritative tick claim does that, so a lost race just
	// resolves when the claim shows someone else took it. A null button or a non-armed
	// phase falls through to the idle path (read-and-dropped server-side).
	public void SendButtonClick( LiveButton btn )
	{
		if ( Parked ) return; // away: ignore board clicks (see SendClick)
		if ( btn == null || Phase != GamePhase.Armed )
		{
			SendClick();
			return;
		}

		SoundPlayer.PlayClick();
		if ( _ws == null || !_ws.Connected ) return;
		int xi = (int)Math.Clamp( btn.X * 32767f, -32767f, 32767f );
		int yi = (int)Math.Clamp( btn.Y * 32767f, -32767f, 32767f );
		_ = _ws.Send( $"{{\"t\":\"click\",\"nonce\":\"{btn.Nonce}\",\"x\":{xi},\"y\":{yi}}}" );
		ClicksSent++;
	}

	// SendCursor reports the local pointer to the server (so others see our roaming cursor
	// AND so the server's arming-AFK pass can tell a present player from an away one),
	// throttled to CursorSendInterval. The Hud calls it each frame with the pointer in the
	// same −1..1 box space the buttons/pips use. Sent during BOTH the arming wait (Pending)
	// and the live window (Armed) as of v7 — the server now watches for cursor movement in
	// the arming phase, not just while armed. A no-op in any other phase or while parked.
	public void SendCursor( float nx, float ny )
	{
		if ( Parked || (Phase != GamePhase.Armed && Phase != GamePhase.Pending) || _ws == null || !_ws.Connected ) return;
		if ( RealTime.Now - _lastCursorSent < CursorSendInterval ) return;
		_lastCursorSent = RealTime.Now;
		int xi = (int)Math.Clamp( nx * 32767f, -32767f, 32767f );
		int yi = (int)Math.Clamp( ny * 32767f, -32767f, 32767f );
		_ = _ws.Send( $"{{\"t\":\"cursor\",\"x\":{xi},\"y\":{yi}}}" );
	}

	// SendTouch reports that the pointer just ENTERED a live button's hitbox (the enter
	// transition only — `_touchedButtons` de-dupes so it fires at most once per button per
	// window). Armed-only and NOT routed through the cursor throttle: these are rare events
	// and the one that matters must not be dropped. `buttonId` is the button's id (== its
	// board slot), carried on the wire under `b` (a JSON number — the server reserves the
	// string `id` for test_answer). No-op off-armed, while parked, or without a socket. (v7)
	public void SendTouch( int buttonId )
	{
		if ( Parked || Phase != GamePhase.Armed || _ws == null || !_ws.Connected ) return;
		if ( !_touchedButtons.Add( buttonId ) ) return; // already touched this window
		_ = _ws.Send( $"{{\"t\":\"touch\",\"b\":{buttonId}}}" );
	}

	// Submit the player's answer to the current anticheat test. Fire-and-forget over
	// the socket, echoing the test id. A correct answer earns a `test` cleared frame
	// (un-benched); a wrong one earns a fresh `test`. No-op without an active test.
	public void SubmitTestAnswer( string answer )
	{
		if ( !HasTest || _ws == null || !_ws.Connected ) return;
		// JsonSerializer quotes/escapes both fields so arbitrary answer text is safe.
		var id = JsonSerializer.Serialize( TestId );
		var ans = JsonSerializer.Serialize( answer ?? "" );
		_ = _ws.Send( $"{{\"t\":\"test_answer\",\"id\":{id},\"answer\":{ans}}}" );
	}

	// Toggle the away/parked state from the Pause control. The server DEFERS a park/unpark
	// requested while the live window is open to the next arming boundary (and applies it
	// at once otherwise), so the client UX follows suit (v7):
	//   • RESUME (we're parked / a park is mid-flight): always ask the server to unpark, but
	//     stay on the parked / STAND BY surface ("rejoining next round") until the next
	//     round_pending clears Parked — never optimistically clear it here.
	//   • PAUSE while Armed: ask to park but do NOT park locally yet; show "pausing after
	//     this round". We become parked when the server's forced `park {on:true}` frame
	//     lands at the arming boundary (the inbound park handler flips Parked).
	//   • PAUSE outside Armed: the server applies it immediately and sends no park frame, so
	//     park locally now.
	// No-op without a live socket. Park is a v6+ capability — an older backend just ignores
	// the frame, but we still suppress our own clicks while parked.
	public void TogglePark()
	{
		if ( _ws == null || !_ws.Connected ) return;
		if ( Parked || ParkPending )
		{
			// Coming back (or cancelling an in-flight deferred park): unpark.
			ParkPending = false;
			Resuming = true;
			_ = _ws.Send( "{\"t\":\"park\",\"on\":false}" );
		}
		else if ( Phase == GamePhase.Armed )
		{
			// Deferred park: lands at the next arming boundary via a forced park frame.
			ParkPending = true;
			Resuming = false;
			_ = _ws.Send( "{\"t\":\"park\",\"on\":true}" );
		}
		else
		{
			// Immediate park: the server applies it now and won't echo a park frame.
			Parked = true;
			ParkPending = false;
			Resuming = false;
			_ = _ws.Send( "{\"t\":\"park\",\"on\":true}" );
		}
	}

	void ClearTest()
	{
		HasTest = false;
		TestId = "";
		TestPrompt = "";
		TestMessage = "";
	}

	void ClearSanction()
	{
		SanctionState = "";
		SanctionUntilMs = 0;
		SanctionMessage = "";
	}

	async Task ConnectFlow()
	{
		if ( _connecting ) return;
		_connecting = true;
		Phase = GamePhase.Connecting;

		try
		{
			var pd = PlayerData.Load();
			var auth = await ApiClient.Auth( string.IsNullOrEmpty( pd.Username ) ? null : pd.Username );
			if ( auth == null )
			{
				Fail();
				return;
			}
			Tag = auth.Tag;
			// Show the resolved display name; persist ONLY a genuinely-claimed
			// handle. Saving the display-name fallback here is what caused the
			// Steam name to be re-sent as a username and 422 on every reconnect.
			Username = string.IsNullOrEmpty( auth.DisplayName ) ? auth.Username : auth.DisplayName;
			pd.Username = auth.Username ?? "";
			pd.PlayerTag = auth.Tag;
			pd.Save();

			await _ws.Connect( ApiClient.WsUrl( auth.Ticket ) );
			_reconnectAttempt = 0;
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Splitclicker] connect failed: {e.Message}" );
			Fail();
			return;
		}
		finally
		{
			_connecting = false;
		}
	}

	void Fail()
	{
		Phase = GamePhase.Disconnected;
		ScheduleReconnect();
		_connecting = false;
	}

	void OnDisconnected()
	{
		Phase = GamePhase.Disconnected;
		LiveButtons.Clear();
		Cursors.Clear();
		ScheduleReconnect();
	}

	void ScheduleReconnect()
	{
		_reconnectAttempt++;
		// Exponential base capped at 15s, plus up to 50% jitter so a server restart
		// doesn't land every client's reconnect in the same instant (PLAN §3.5e).
		float baseDelay = MathF.Min( MathF.Pow( 2f, _reconnectAttempt - 1 ), 15f );
		float jitter = baseDelay * 0.5f * System.Random.Shared.NextSingle();
		_reconnectAt = RealTime.Now + baseDelay + jitter;
	}

	void OnMessage( string json )
	{
		try
		{
			using var doc = JsonDocument.Parse( json );
			if ( !doc.RootElement.TryGetProperty( "t", out var tEl ) ) return;
			switch ( tEl.GetString() )
			{
				case "hello":
					var h = Deser<HelloMsg>( json );
					Tag = h.You.Tag;
					Username = h.You.Username;
					Round = h.Game.Round;
					Of = h.Game.Of;
					Players = h.Game.Players;
					ClicksToWin = h.Game.Clicks;
					ArmMinSec = h.Game.ArmMin;
					ArmMaxSec = h.Game.ArmMax;
					_tickMs = h.Game.TickMs;
					DevNote = h.Game.DevNote ?? "";
					Phase = PhaseFrom( h.Game.Phase );
					// A (re)connect is a fresh server-side connection (never parked) — clear
					// any parked state (and a deferred park/resume mid-flight) from before it.
					Parked = false;
					ParkPending = false;
					Resuming = false;
					// A (re)connect: refresh the bounty state so a client that was offline
					// across a rollover picks up the new skin + previous winner on rejoin.
					BountyRefreshSeq++;
					break;

				case "round_pending":
					var p = Deser<PendingMsg>( json );
					Round = p.Round;
					Of = p.Of;
					Players = p.Players;
					ClicksToWin = p.Clicks;
					// Refresh the tag→username roster for this round's pips, and clear the
					// pip jitter buffer + any on-screen pips at the arming stage: there are
					// several seconds here, so the previous round's tail has long since
					// drained over its result, and this guarantees no stale pip survives into
					// the next live window (the deliberate "clear at arming, not at armed").
					_roster.Clear();
					if ( p.Roster != null )
					{
						foreach ( var e in p.Roster )
							if ( !string.IsNullOrEmpty( e.Tag ) ) _roster[e.Tag] = e.Username ?? "";
					}
					_pipBuffer.Clear();
					ActivePips.Clear();
					// The live board + opponent cursors belong to a window; drop any held from
					// the last one so nothing stale shows through this arming gap.
					LiveButtons.Clear();
					Cursors.Clear();
					// A new game's first round is arming: clear the previous game's session
					// standings now (the arming signal for round 1) so the board doesn't keep
					// showing the last game's totals through this game's first round — it's
					// repopulated by this round's round_result. Fixes the session board
					// "lagging a game behind" after game_over.
					if ( p.Round == 1 ) Standings = new();
					// A round_pending only reaches us when the server is sending us frames
					// again, i.e. we're not parked — clear any stale parked state. (A parked
					// client never receives round_pending; the server withholds it.)
					Parked = false;
					ParkPending = false;
					// New window: forget which buttons were touched, and allow the first arming
					// cursor to send immediately (cursors now report during the arming wait too).
					_touchedButtons.Clear();
					_lastCursorSent = 0f;
					// A RESUME that was deferred to this boundary lands here: drop the parked
					// surface to STAND BY (rejoin on the NEXT button) rather than this round's
					// WAIT, matching the existing "stand by until next armed" rejoin UX.
					if ( Resuming )
					{
						Resuming = false;
						Phase = GamePhase.Waiting;
					}
					else
					{
						Phase = GamePhase.Pending;
					}
					SoundPlayer.PlayArming();
					break;

				case "armed":
					var a = Deser<ArmedMsg>( json );
					Round = a.Round;
					Players = a.Players;
					ClicksToWin = a.Clicks;
					ClicksSent = 0;  // fresh CLICK! phase: start the sent tally over
					RemainingThisRound = a.Clicks; // start the live counter at full N; ticks count it down
					_localArmReceive = RealTime.Now; // origin for every pip's t_arm replay offset
					_lastCursorSent = 0f;          // allow the first cursor send immediately
					_touchedButtons.Clear();       // fresh window: every button can be touched anew
					// Seed the live board from the armed buttons; tick claims then keep it in sync.
					LiveButtons.Clear();
					Cursors.Clear();
					if ( a.Buttons != null )
					{
						foreach ( var bt in a.Buttons )
							if ( !string.IsNullOrEmpty( bt.Nonce ) )
								AddButton( (ushort)bt.Id, bt.Nonce, bt.X / 32767f, bt.Y / 32767f );
					}
					Phase = GamePhase.Armed;
					// Receiving an arm means we're no longer benched — clear any stale test.
					ClearTest();
					// The server withholds frames from a parked client, so an arm reaching us
					// proves we're back in play — clear any stale parked/deferred state.
					Parked = false;
					ParkPending = false;
					Resuming = false;
					SoundPlayer.PlayArmed();
					break;

				case "round_result":
					var r = Deser<RoundResultMsg>( json );
					Round = r.Round;
					Of = r.Of;
					Winners = r.Winners ?? new();
					Standings = r.Standings ?? new();
					Phase = GamePhase.Result;
					// Round closed: the remaining (now meaningless) buttons + cursors disappear.
					LiveButtons.Clear();
					Cursors.Clear();
					SoundPlayer.PlayDisarm();
					AchievementTracker.OnRoundResult( r.You.PointsDelta, r.You.RoundId );
					break;

				case "game_over":
					var g = Deser<GameOverMsg>( json );
					Standings = g.Standings ?? new();
					Phase = GamePhase.GameOver;
					LiveButtons.Clear();
					Cursors.Clear();
					SoundPlayer.PlayDisarm();
					// The final round folds into game_over (no round_result of its own), so
					// credit that round's points here too — same once-per-round-id guard.
					AchievementTracker.OnRoundResult( g.You.PointsDelta, g.You.RoundId );
					AchievementTracker.OnGameOver( g.You.Placement, g.You.Won, g.You.GameId );
					break;

				case "dev_note":
					var dn = Deser<DevNoteMsg>( json );
					DevNote = dn.Note ?? "";
					break;

				case "test":
					// Anticheat: we failed end-of-round checks. The state field picks the
					// rung — a cleared frame dismisses everything; "cooldown"/"ignored" show
					// a countdown (no test); anything else (incl. an empty state) is the math
					// test gate. The three states are mutually exclusive, so each path clears
					// the others.
					var tm = Deser<TestMsg>( json );
					if ( tm.Cleared )
					{
						ClearTest();
						ClearSanction();
					}
					else if ( tm.State == "cooldown" || tm.State == "ignored" )
					{
						ClearTest();
						SanctionState = tm.State;
						SanctionUntilMs = tm.UntilMs;
						SanctionMessage = tm.Message ?? "";
					}
					else
					{
						ClearSanction();
						HasTest = true;
						TestId = tm.Id ?? "";
						TestPrompt = tm.Prompt ?? "";
						TestMessage = tm.Message ?? "";
					}
					break;

				case "achievement":
					// Out-of-band unlock the server pushed for a feat it detected off the
					// game socket (e.g. fart / hackerman), matched to us by IP.
					AchievementTracker.OnAchievement( Deser<AchievementMsg>( json ).Ident );
					break;

				case "park":
					// The server parked us at an arming boundary: either an auto-park off an
					// afk_idle verdict (we sat still and scored nothing — legitimately away), or
					// a manual PAUSE we pressed mid-armed that the server deferred to here. Either
					// way engage the Pause control; the server now withholds every frame until we
					// hit RESUME to rejoin, so the Hud shows the parked surface. The server only
					// ever sends on:true (it never unparks us — that's the client's call), but
					// honour the field. This is what makes a deferred PAUSE actually land.
					Parked = Deser<ParkMsg>( json ).On;
					if ( Parked ) ParkPending = false; // the deferred/auto park took effect
					break;

				case "bounty_update":
					// The active bounty rolled over: nudge the Hud to re-fetch /config +
					// /bounties/previous so the new skin/countdown and the just-settled
					// winner appear immediately (no payload — the HTTP endpoints are truth).
					BountyRefreshSeq++;
					break;
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Splitclicker] bad ws frame: {e.Message}" );
		}
	}

	// Map the hello snapshot to a starting phase. Only `pending` (a round that is
	// still arming) is safe to join straight into — the client will receive that
	// round's `armed` frame, nonce and all, and can score. Every other phase means
	// we connected mid-round (armed/result) or between games (intermission): we
	// can't score the round in flight, so we sit in Waiting until the next `armed`.
	static GamePhase PhaseFrom( string s ) => s switch
	{
		"pending" => GamePhase.Pending,
		_ => GamePhase.Waiting,
	};

	static T Deser<T>( string json ) => JsonSerializer.Deserialize<T>( json, JsonOpts );
}