Framework/CardGame.cs

Base CardGame component for a networked card game. Manages synced metadata, seats, lifecycle (start/round/clear), RPCs for player interactions, turn timer, hints/announcements, sound playback, spawning cards, and package mounting for cloud-pulled games. Designed to be subclassed by seated or board game archetypes.

NetworkingExternal DownloadFile Access
using System.Threading.Tasks;

namespace CardGames;

public enum GameState
{
	Waiting,    // lobby, waiting for the host to start
	Betting,    // players placing wagers (games without betting never enter this)
	Dealing,    // cards going out
	PlayerTurn, // a seated player is acting
	Resolving,  // dealer/house plays, scoring
	GameOver    // round finished, results shown
}

/// <summary>
/// The shared spine for every card game. Lives on a "gamemode" GameObject and is host-authoritative:
/// the host owns the shoe and all rules; clients only send actions and render synced state.
///
/// Don't subclass this directly - pick an archetype: <see cref="SeatedGame"/> for turn-based seated
/// games (Blackjack, Poker) or <see cref="BoardGame"/> for pile/drag games (Solitaire). A game fills in
/// <see cref="OnRoundStart"/> + <see cref="OnPlayerAction"/> plus the hooks its archetype exposes.
/// See code/Framework/README.md for the "add a game" guide.
/// </summary>
public abstract class CardGame : Component
{
	/// <summary>
	/// Synced path to this game's <see cref="GameDefinition"/> (.cggame asset). Set by the director when
	/// the game spawns, so every client resolves the same metadata, deck and presentation settings.
	/// </summary>
	[Sync] public string DefinitionPath { get; set; }

	/// <summary>
	/// For a game pulled from the cloud: the package ident it came from (e.g. "org.mygame").
	/// Synced so remote clients can mount the same package and resolve its assets. Empty for built-in games.
	/// </summary>
	[Sync] public string PackageIdent { get; set; }

	private GameDefinition _definition;

	/// <summary>
	/// This game's metadata/presentation. Normally resolved from <see cref="DefinitionPath"/> (the .cggame
	/// asset); assignable so a test or tool can inject one in code without mounting an asset.
	/// </summary>
	public GameDefinition Definition
	{
		get => _definition ??= string.IsNullOrEmpty( DefinitionPath ) ? null : ResourceLibrary.Get<GameDefinition>( DefinitionPath );
		set => _definition = value;
	}

	/// <summary>
	/// Is this game's definition resolvable yet? Always true for built-in games; briefly false on a remote
	/// client while a game's cloud package is still downloading. The HUD shows a loading note until then.
	/// </summary>
	public bool DefinitionReady => Definition is not null;

	/// <summary>
	/// Player-facing name of this game. A game pulled from the cloud takes its title from the package it
	/// came from; a built-in game (no package) falls back to a readable name derived from its asset path.
	/// </summary>
	public string Title
	{
		get
		{
			if ( !string.IsNullOrEmpty( PackageIdent )
				&& Package.TryGetCached( PackageIdent, out var pkg )
				&& !string.IsNullOrEmpty( pkg?.Title ) )
				return pkg.Title;

			return GameDefinition.NiceName( DefinitionPath );
		}
	}

	// A client viewing a game pulled from the cloud needs its package mounted before its definition, deck
	// and prefab assets resolve locally. The host already mounted it when starting; everyone else does it once.
	private bool _mountStarted;

	// Client-local: how long this client has had this game's table open, for GameStats.RecordPlaytime when
	// it closes (OnDestroy) - not synced, since it's this player's own time spent, not a shared game fact.
	private RealTimeSince _sessionStarted;

	protected override void OnStart()
	{
		_sessionStarted = 0;

		if ( !string.IsNullOrEmpty( PackageIdent ) )
			_ = EnsurePackageMounted();
	}

	// Every client's copy of this game object is destroyed when the table closes (the host quitting to the
	// menu, or leaving the round some other way) - record the time this client spent at it either way.
	protected override void OnDestroy() => GameStats.RecordPlaytime( this, _sessionStarted );

	// Download + mount the package this game came from, so its assets resolve on this client.
	private async Task EnsurePackageMounted()
	{
		if ( _mountStarted ) return;
		_mountStarted = true;

		// Already available (the host, or anyone who mounted it for an earlier game)? Nothing to do.
		if ( Definition is not null ) return;

		var pkg = await Package.FetchAsync( PackageIdent, false );
		if ( pkg is null ) return;

		await pkg.MountAsync( withCode: true );

		_definition = null;                                    // force Definition to re-resolve now assets exist
		CardMesh.SetSize( CardMesh.DefaultWidth * CardScale );  // apply this game's card scale, now that we know it
	}

	public DeckDefinition Deck => Definition?.Deck;
	public int MinPlayers => Definition?.MinPlayers ?? 1;
	public int MaxPlayers => Definition?.MaxPlayers ?? 4;
	public bool SupportsMultiplayer => Definition?.Multiplayer ?? true;
	public float CameraHeight => Definition?.CameraHeight ?? 0f;
	public float CardScale => Definition?.CardScale ?? 1f;

	/// <summary>
	/// How many full decks make up the shoe. A gameplay tunable on the rules prefab.
	/// </summary>
	protected virtual int ShoeCount => 1;

	/// <summary>
	/// Where a seat's hand is laid out at, exposed so UI (SeatTag) can pin a player's tag over the same spot their cards are
	/// </summary>
	public virtual Transform? SeatHandSpot( Seat seat ) => null;

	/// <summary>
	/// Screen-space vertical offset (in scaled pixels, positive = below the hand) for a seat's name tag
	/// </summary>
	public virtual float SeatTagDrop( Seat seat, float defaultDrop ) => defaultDrop;

	/// <summary>Local predicate: can the local player click/drag this loose hand card right now?</summary>
	public virtual bool CanInteractCard( CardObject card ) => false;

	/// <summary>
	/// The text for this seat's on-table hand-value decal, as the local viewer should see it (null/empty =
	/// none). Default returns the synced <see cref="Seat.HandLabel"/>; a game overrides this to compute a
	/// private, local-only value (e.g. a Poker player's own hand before showdown).
	/// </summary>
	public virtual string HandValueText( Seat seat ) => seat?.HandLabel;

	/// <summary>
	/// Every hand-value decal this game wants drawn right now (a stable key, a world spot, and the text).
	/// Read on every client by <c>HandValueDecalRenderer</c>; default none. Seated games yield one per seat.
	/// </summary>
	public virtual IEnumerable<HandValueDecal> HandValueDecals() => Array.Empty<HandValueDecal>();

	/// <summary>
	/// Does this game's design permit flinging cards at all (a purely cosmetic toss that tumbles, then lerps
	/// home)? Default yes - override to <c>false</c> to opt a game out, which also hides the per-table
	/// "Card throwing" toggle below.
	/// </summary>
	protected virtual bool SupportsThrowing => true;

	/// <summary>
	/// Per-table toggle for the cosmetic card toss. Shown in the lobby for every game that
	/// <see cref="SupportsThrowing"/> (hidden otherwise, via <see cref="ShowSetting"/>).
	/// </summary>
	[Property, Sync, Setting]
	[Title( "Card throwing" ), Description( "Let players fling their own cards across the table." ), Group( "Table" )]
	public bool CardThrowing { get; set; } = true;

	/// <summary>
	/// Effective gate read by the drag UI and the throw RPCs: throwing happens only when the game supports
	/// it and the table has it switched on.
	/// </summary>
	public bool AllowThrowing => SupportsThrowing && CardThrowing;

	/// <summary>Local predicate: is this loose card one the local player owns (and so may throw)? Default no.</summary>
	public virtual bool OwnsCard( CardObject card ) => false;

	/// <summary>
	/// Client to host: fling a loose card the caller owns (a cosmetic toss). The host validates ownership
	/// (the card is in the caller's seat) and the opt-out, then turns it loose as physics.
	/// </summary>
	[Rpc.Host]
	public void RequestThrowCard( CardObject card, Vector3 velocity, Vector3 angularVelocity )
	{
		if ( !Networking.IsHost || !AllowThrowing || card is null ) return;
		if ( SeatOf( Rpc.Caller ) is not { } seat || !seat.Cards.Contains( card ) ) return; // only your own
		card.Throw( velocity, angularVelocity );
	}

	/// <summary>Local predicate: can the local player click this pile right now (e.g. the draw pile)?</summary>
	public virtual bool CanInteractPile( Pile pile ) => false;

	/// <summary>The pile a dragged hand card is dropped onto to play it (e.g. the discard). Null = none.</summary>
	public virtual Pile InteractDropPile => null;

	/// <summary>Client to host: the local player clicked or dragged this hand card to play it.</summary>
	[Rpc.Host]
	public void InteractCard( CardObject card )
	{
		if ( !Networking.IsHost || card is null ) return;
		OnInteractCard( SeatOf( Rpc.Caller ), card );
	}

	/// <summary>Client to host: the local player clicked this pile (e.g. the draw pile to draw).</summary>
	[Rpc.Host]
	public void InteractPile( Pile pile )
	{
		if ( !Networking.IsHost || pile is null ) return;
		OnInteractPile( SeatOf( Rpc.Caller ), pile );
	}

	/// <summary>Host: handle a direct card interaction for the caller's seat. Default none.</summary>
	protected virtual void OnInteractCard( Seat seat, CardObject card ) { }

	/// <summary>Host: handle a direct pile interaction for the caller's seat. Default none.</summary>
	protected virtual void OnInteractPile( Seat seat, Pile pile ) { }

	/// <summary>
	/// Does this game use chips/betting? Drives whether the HUD shows the wallet. Default no.
	/// </summary>
	public virtual bool UsesBetting => false;

	[Sync] public GameState State { get; set; } = GameState.Waiting;
	[Sync] public int CurrentSeat { get; set; } = -1;
	[Sync] public string StatusText { get; set; } = "";

	/// <summary>Secondary status under the status bar - a small label (<see cref="Subtitle"/>) and a big value
	/// (<see cref="SubtitleValue"/>), e.g. "POT" / "75". Both empty = hidden.</summary>
	[Sync] public string Subtitle { get; set; } = "";
	[Sync] public string SubtitleValue { get; set; } = "";

	// - Turn timer (generic). A game with a positive TurnTimeLimit gets a per-turn clock for human players;
	//   when it runs out the host acts for them via OnTurnTimeout. The HUD shows TurnFraction as a bar. -

	/// <summary>Seconds a human has to act before the game acts for them (<see cref="OnTurnTimeout"/>). 0 = off.</summary>
	protected virtual float TurnTimeLimit => 0f;

	/// <summary>When the current turn's clock runs out. Synced (the sim clock is shared), so the bar animates
	/// on every client off the same value.</summary>
	[Sync] public TimeUntil TurnExpires { get; set; }
	private int _timerSeat = -1; // host: the seat the running clock belongs to

	// A clock is showing when it's a human's turn in a game that uses one (all from synced state).
	private bool TurnClockActive
	{
		get
		{
			if ( TurnTimeLimit <= 0f || State != GameState.PlayerTurn ) return false;
			return Seats.FirstOrDefault( s => s.Index == CurrentSeat ) is { Occupied: true, IsBot: false };
		}
	}

	/// <summary>Seconds left on the current turn's clock, or 0 when none is running.</summary>
	public float TurnSecondsLeft => TurnClockActive ? Math.Max( 0f, (float)TurnExpires ) : 0f;

	/// <summary>The turn clock as a 0..1 fraction, for a HUD progress bar. 0 = no clock running.</summary>
	public float TurnFraction => TurnClockActive ? Math.Clamp( (float)TurnExpires / TurnTimeLimit, 0f, 1f ) : 0f;

	/// <summary>Host: the acting player ran out of time. Default does nothing; games act for them (fold/check).</summary>
	protected virtual void OnTurnTimeout( Seat seat ) { }

	/// <summary>
	/// Host: set the game phase, and optionally the status line, in one call - so the two never drift
	/// apart and you can't forget to update one. Pass <paramref name="status"/> null to leave it unchanged.
	/// </summary>
	protected void SetPhase( GameState state, string status = null )
	{
		State = state;
		if ( status is not null )
			StatusText = status;
	}

	protected Shoe Shoe { get; set; }

	/// <summary>
	/// Debug/test hook: the exact face order (top first) the next round's shoe should deal, instead of a
	/// shuffle. Consumed by <see cref="StartRound"/> and cleared. Null = deal a normal shuffled shoe.
	/// </summary>
	public IReadOnlyList<int> RiggedDeal { get; set; }

	public IEnumerable<Seat> Seats => Scene.GetAllComponents<Seat>().OrderBy( s => s.Index );
	public Seat LocalSeat => Seats.FirstOrDefault( s => s.IsLocal );
	public bool IsMyTurn => State == GameState.PlayerTurn && LocalSeat is { } s && s.Index == CurrentSeat;

	protected TableAnchor Table => _table ??= Scene.Get<TableAnchor>();
	private TableAnchor _table;

	/// <summary>The scene's sound catalog (one instance, on the director). Null if none is placed.</summary>
	protected GameSounds Sounds => Scene.Get<GameSounds>();

	/// <summary>The director that spawned this game (for round/menu control from game code).</summary>
	protected GameDirector Director => Scene.Get<GameDirector>();

	protected override void OnEnabled()
	{
		CardMesh.SetSize( CardMesh.DefaultWidth * CardScale );
	}

	/// <summary>
	/// Host: sit down everyone already connected.
	/// </summary>
	public void HostSetup()
	{
		if ( !Networking.IsHost ) return;

		foreach ( var c in Connection.All )
			SeatPlayer( c );

		OnHostSetup();

		StatusText = "Waiting for players - host can start when ready.";
	}

	/// <summary>
	/// Hook: the host has seated the connected players. A game can fill the rest of the table here (e.g.
	/// poker seats AI bots) so it has enough players to start before the first deal.
	/// </summary>
	protected virtual void OnHostSetup() { }

	/// <summary>
	/// Host: give a connection a seat (one seat per player). Safe to call repeatedly.
	/// </summary>
	public bool SeatPlayer( Connection connection )
	{
		if ( !Networking.IsHost ) return false;
		if ( Seats.Any( s => s.Player == connection.Id ) ) return true; // already seated
		if ( Seats.Count() >= MaxPlayers ) return false;                // table full

		var go = new GameObject( true, "Seat" );
		var seat = go.Components.Create<Seat>();
		go.NetworkSpawn();

		seat.Index = Seats.Any() ? Seats.Max( s => s.Index ) + 1 : 0; // stable join-order id
		seat.Player = connection.Id;
		seat.Currency = Definition?.StartingStack ?? 0;               // seed the chip bankroll

		OnSeatsChanged();
		return true;
	}

	/// <summary>
	/// Host: remove a player's seat (and their cards) when they leave.
	/// </summary>
	public void RemovePlayer( Connection connection )
	{
		if ( !Networking.IsHost ) return;

		var seat = Seats.FirstOrDefault( s => s.Player == connection.Id );
		if ( seat is null ) return;

		foreach ( var card in seat.Cards )
			card.GameObject.Destroy();

		seat.GameObject.Destroy();

		OnSeatsChanged();
	}

	/// <summary>
	/// Hook: the set of seats changed (someone joined or left). Seated games relay their hands; games
	/// that don't keep seat hands (board games) ignore it.
	/// </summary>
	protected virtual void OnSeatsChanged() { }

	protected Seat SeatOf( Connection connection ) => Seats.FirstOrDefault( s => s.Player == connection.Id );

	/// <summary>
	/// Hook: should the lobby show the <c>[Setting]</c> named <paramref name="name"/> for this game right now?
	/// Default yes; the base hides the "Card throwing" toggle when the game doesn't <see cref="SupportsThrowing"/>.
	/// Override and call <c>base</c> to hide additional settings that don't currently apply.
	/// </summary>
	public virtual bool ShowSetting( string name )
		=> name != nameof( CardThrowing ) || (SupportsThrowing && SupportsMultiplayer);

	/// <summary>
	/// Host UI → game: a <c>[Setting]</c> was just changed in the Waiting lobby. Only the host edits the
	/// (authoritative) instance directly; the new value replicates to clients via <c>[Sync]</c>. Forwards to
	/// <see cref="OnSettingsChanged"/> so a game can react live (e.g. re-seat bots when the opponent count changes).
	/// </summary>
	public void NotifySettingsChanged()
	{
		if ( Networking.IsHost )
			OnSettingsChanged();
	}

	/// <summary>
	/// Hook: a player-facing setting changed in the lobby (host only). Default does nothing. Don't read the
	/// value here - read your <c>[Setting]</c> properties directly; this just tells you they may have moved.
	/// </summary>
	protected virtual void OnSettingsChanged() { }

	/// <summary>
	/// Host: deal a fresh round.
	/// </summary>
	public void StartRound()
	{
		if ( !Networking.IsHost ) return;
		if ( Seats.Count( s => s.Occupied ) < MinPlayers ) return;

		CardMesh.SetSize( CardMesh.DefaultWidth * CardScale ); // before layout/cards so spacing matches
		ClearTable();
		Shoe = RiggedDeal is null ? new Shoe( Deck, ShoeCount ) : new Shoe( RiggedDeal );
		RiggedDeal = null; // a rig is one round only
		State = GameState.Dealing;
		OnRoundStart();
	}

	/// <summary>
	/// Host: clear the table from outside (e.g. the director quitting the game).
	/// </summary>
	public void ClearTablePublic()
	{
		if ( Networking.IsHost )
		{
			ClearTable();
		}
	}

	/// <summary>
	/// Host: clear all cards and piles from the table. Archetypes extend this (e.g. to drop history).
	/// </summary>
	protected virtual void ClearTable()
	{
		foreach ( var card in Scene.GetAll<CardObject>().ToList() )
			card.GameObject.Destroy();

		foreach ( var pile in Scene.GetAll<Pile>().ToList() )
			pile.GameObject.Destroy();

		foreach ( var seat in Seats )
			seat.Cards.Clear();
	}

	/// <summary>
	/// Host: deal the opening cards and set the first turn.
	/// </summary>
	protected abstract void OnRoundStart();

	/// <summary>
	/// Host: everyone has acted - play out the house and score the round. Optional (Solitaire skips it).
	/// </summary>
	protected virtual void OnRoundEnd() { }

	/// <summary>
	/// The buttons the given seat may press right now (derived from synced state, so the HUD can call
	/// it on any client). Default: none. Subclasses return e.g. Hit/Stand or Draw.
	/// </summary>
	public virtual IReadOnlyList<GameAction> ActionsFor( Seat seat ) => Array.Empty<GameAction>();

	/// <summary>
	/// Per-seat tags for the generic players panel (dealer button, all-in, ready, a hand total, …). Read on
	/// every client, so derive them from synced state only. Default: none.
	/// </summary>
	public virtual IReadOnlyList<SeatBadge> SeatBadges( Seat seat ) => Array.Empty<SeatBadge>();

	/// <summary>
	/// Client to host. Resolves the caller's seat, then applies the action host-side. <paramref name="amount"/>
	/// carries a button's <see cref="GameAction.Amount"/> (e.g. a chip value); 0 for plain actions.
	/// </summary>
	[Rpc.Host]
	public void SendAction( string id, long amount = 0 )
	{
		var seat = SeatOf( Rpc.Caller );
		if ( seat is not null )
			ApplyAction( seat, id, amount );
	}

	/// <summary>
	/// Host: validate an action is currently allowed for <paramref name="seat"/>, then apply it. The RPC
	/// entry point resolves the seat from the network caller; host-side callers can apply directly. The
	/// matched <see cref="GameAction"/> is handed to the game, so it never parses the id. A
	/// <see cref="GameAction.Variable"/> action matches by id alone and receives the caller's amount.
	/// </summary>
	public void ApplyAction( Seat seat, string id, long amount = 0 )
	{
		if ( !Networking.IsHost || seat is null ) return;

		foreach ( var action in ActionsFor( seat ) )
		{
			if ( action.Id != id || action.Disabled ) continue;
			if ( !action.Variable && action.Amount != amount ) continue;

			// A variable action carries the caller's amount (the listed Amount is just its minimum/default).
			OnPlayerAction( seat, action.Variable ? action with { Amount = amount } : action );
			return;
		}
	}

	/// <summary>
	/// Host: apply a validated action (Hit, Stand, a chip bet, ...) for the given seat. Switch on
	/// <see cref="GameAction.Id"/> and read <see cref="GameAction.Amount"/> for parameterised actions.
	/// </summary>
	protected abstract void OnPlayerAction( Seat seat, GameAction action );

	// - End-of-game overlay -

	/// <summary>
	/// The overlay shown at <see cref="GameState.GameOver"/>. Override to set the title, tone and actions;
	/// the default is a neutral "Game Over" with Play Again / Keep Playing / Main Menu. Read on every client
	/// (the HUD calls it), so only touch synced state.
	/// </summary>
	protected virtual EndScreen GetEndScreen() => new(
		Title: "Game Over",
		Detail: StatusText,
		Tone: EndTone.Neutral,
		Actions: new[] { EndAction.PlayAgain, EndAction.KeepPlaying, EndAction.MainMenu } );

	/// <summary>The current end-screen spec, for the HUD.</summary>
	public EndScreen EndScreen => GetEndScreen();

	/// <summary>
	/// Client to host: run a <see cref="EndActionKind.Custom"/> end-screen action. Play Again / Main Menu /
	/// Keep Playing are handled by the HUD directly; only custom ids reach here.
	/// </summary>
	[Rpc.Host]
	public void SendEndAction( string id )
	{
		if ( !Networking.IsHost || State != GameState.GameOver ) return;
		OnEndAction( id );
	}

	/// <summary>
	/// Host: handle a game-specific end-screen action (the <see cref="EndAction.Custom"/> id). Default none.
	/// </summary>
	protected virtual void OnEndAction( string id ) { }

	// - Sound -

	/// <summary>
	/// Play a table sound on every client, at <paramref name="position"/> in the world. Call from host logic
	/// so a sound triggered by host-authoritative state (a card dealt, a bet placed) is heard at every seat,
	/// not just on the host. No-ops where the scene has no <see cref="GameSounds"/> or the slot is unassigned.
	/// </summary>
	[Rpc.Broadcast]
	public void PlaySound( TableSound sound, Vector3 position ) => Sounds?.Play( sound, position );

	/// <summary>
	/// Play a sound at <paramref name="position"/>, but only on the client of <paramref name="seat"/>'s player.
	/// For personal results (win/loss/push), where a shared broadcast would play everyone's outcome on every machine.
	/// </summary>
	[Rpc.Broadcast]
	public void PlaySoundForSeat( Seat seat, TableSound sound, Vector3 position )
	{
		if ( seat.IsValid() && seat.IsLocal )
			Sounds?.Play( sound, position );
	}

	/// <summary>
	/// Host: spawn a card in the world. Pass <paramref name="privateTo"/> for an owner-only hand.
	/// </summary>
	protected CardObject SpawnCard( Card card, Transform at, bool faceUp, Connection privateTo = null )
	{
		var go = new GameObject( true, "Card" );
		go.WorldPosition = at.Position;
		go.WorldRotation = at.Rotation;

		var co = go.Components.Create<CardObject>();
		co.Value = card;
		go.NetworkSpawn( privateTo ?? Connection.Local );

		co.FaceUp = faceUp;
		co.FaceIndex = faceUp ? card.FaceIndex : -1;

		// A hidden card that belongs to a specific player: tell only them what it is.
		if ( !faceUp && privateTo is not null )
			co.RevealToOwner( card.FaceIndex );

		// Start lifted above the slot, then let the caller's layout animate it down with a flip.
		co.SnapTo( new Transform( at.Position + Vector3.Up * 12f, at.Rotation ) );

		return co;
	}

	/// <summary>
	/// A "do this next" suggestion shown in the HUD pill, set on demand. Empty = no hint showing.
	/// </summary>
	[Sync] public string HintText { get; set; } = "";

	private const float AnnounceSeconds = 2.6f;
	private TimeUntil _announceExpires;

	/// <summary>The current announcement banner text (empty when nothing is showing).</summary>
	public string AnnounceText { get; private set; } = "";

	/// <summary>Bumped on every new announcement so the HUD can restart its pop-in animation.</summary>
	public int AnnounceId { get; private set; }

	/// <summary>True while an announcement banner should be shown in the centre of the screen.</summary>
	public bool AnnounceActive => !string.IsNullOrEmpty( AnnounceText ) && !_announceExpires;

	/// <summary>
	/// Host → all clients: flash a transient banner in the centre of the screen (e.g. a special-effect
	/// callout). Safe to call from host game logic; in solo play the host is the only client and shows it too.
	/// </summary>
	[Rpc.Broadcast]
	public void Announce( string text )
	{
		AnnounceText = text;
		AnnounceId++;
		_announceExpires = AnnounceSeconds;
	}

	/// <summary>
	/// Host: work out the best next move for the player. Game-specific; null = nothing to suggest.
	/// </summary>
	protected virtual GameHint? ComputeHint() => null;

	private const float HintSeconds = 6f; // a hint fades on its own after this long
	private TimeUntil _hintExpires;

	/// <summary>
	/// Host: show the current best move in the HUD pill and flash its cards; clears when acted on or after a few seconds.
	/// </summary>
	protected void ShowHint()
	{
		if ( !Networking.IsHost ) return;

		var hint = ComputeHint();
		HintText = hint?.Text ?? "No moves available - you may be stuck.";

		if ( hint?.Cards is { } cards )
			foreach ( var card in cards )
				card?.Pulse();

		_hintExpires = HintSeconds;
	}

	/// <summary>
	/// Host: hide any showing hint (call when the board changes - the hint is now stale).
	/// </summary>
	protected void ClearHint() => HintText = "";

	// Client-local (not synced): every client watches its own view of State to record its own outcome via
	// GameStats, the same way EndScreen/GetEndScreen is computed per-client from synced state.
	private GameState _lastObservedState;
	private RealTimeSince _roundStarted;

	protected override void OnUpdate()
	{
		WatchStateForStats();

		if ( !Networking.IsHost ) return;

		// Fade a shown hint once its timer runs out.
		if ( !string.IsNullOrEmpty( HintText ) && _hintExpires )
			HintText = "";

		UpdateTurnTimer();
	}

	// A new round started - reset the clock; a round just ended - record it (played, win/loss, duration).
	private void WatchStateForStats()
	{
		if ( State == _lastObservedState ) return;

		// The lobby's browsable "state" tag - back to Waiting between rounds means the table's open to
		// join again; anything else means a hand's in progress and new joiners should stay out of it.
		// GameDirector seeds this "joinable" when the game first starts; this keeps it live from there.
		if ( Networking.IsHost )
			Networking.SetData( "state", State == GameState.Waiting ? "joinable" : "in_progress" );

		if ( _lastObservedState == GameState.Waiting && State != GameState.Waiting )
			_roundStarted = 0;

		if ( State == GameState.GameOver )
		{
			GameStats.RecordRoundPlayed( this );
			if ( EndScreen.Tone != EndTone.Neutral )
				GameStats.RecordResult( this, EndScreen.Tone == EndTone.Win );
			GameStats.RecordDuration( this, _roundStarted );
		}

		_lastObservedState = State;
	}

	// Host: start the per-turn clock when a human's turn begins (the synced TurnExpires animates the bar on
	// every client) and act for them when it runs out.
	private void UpdateTurnTimer()
	{
		if ( TurnTimeLimit <= 0f ) return;

		var seat = Seats.FirstOrDefault( s => s.Index == CurrentSeat );
		bool ticking = State == GameState.PlayerTurn && seat is { Occupied: true, IsBot: false };
		if ( !ticking )
		{
			_timerSeat = -1;
			return;
		}

		if ( _timerSeat != seat.Index ) // a new player's turn - start the clock
		{
			_timerSeat = seat.Index;
			TurnExpires = TurnTimeLimit;
		}
		else if ( TurnExpires ) // ran out of time
		{
			_timerSeat = -1;
			OnTurnTimeout( seat );
		}
	}
}