Framework/GameStats.cs

Utility for per-game statistics namespacing and formatting. It prefixes stat keys with a game slug, records common stats (games played, wins, win streak, best time, time played), exposes Increment/SetValue wrappers to the Stats service, reads values for display, and formats labels and units for the launcher UI. Also defines a StatDisplay struct and default display list.

NetworkingFile Access
using Sandbox.Services;

namespace CardGames;

/// <summary>
/// Wraps <see cref="Stats"/> with a per-game namespace. Every card game - local dev games and games
/// pulled from the cloud alike - currently runs inside this same hub process, so a raw
/// <c>Stats.Increment("games_played", 1)</c> from any two games would collide into one hub-wide counter.
/// This prefixes every stat name with a slug identifying which specific game it belongs to, records the
/// generic ones (round played, win/loss, best time, time played) automatically from <see cref="CardGame"/>'s
/// lifecycle, and lets a game record its own on top via <see cref="Increment"/>/<see cref="SetValue"/> - a
/// game's stats aren't a fixed set we predict in advance.
/// </summary>
public static class GameStats
{
	/// <summary>
	/// The namespacing slug for a game: a cloud game's package ident (dots replaced), or a local game's
	/// <c>.cggame</c> file name (same source <see cref="GameDefinition.NiceName"/> reads its title from).
	/// </summary>
	public static string KeyFor( string definitionPath, string packageIdent )
	{
		if ( !string.IsNullOrEmpty( packageIdent ) )
			return Slugify( packageIdent );

		var name = string.IsNullOrEmpty( definitionPath )
			? ""
			: System.IO.Path.GetFileNameWithoutExtension( definitionPath );
		return Slugify( name );
	}

	public static string KeyFor( CardGame game ) => KeyFor( game.DefinitionPath, game.PackageIdent );

	/// <summary>
	/// For the launcher, which only ever has a <see cref="GameDefinition"/> (local) or a
	/// <see cref="Package"/> (a game fetched from the cloud), never a live <see cref="CardGame"/>.
	/// </summary>
	public static string KeyFor( GameDefinition definition, Package package )
		=> KeyFor( definition?.ResourcePath, package?.FullIdent );

	private static string Slugify( string raw )
	{
		if ( string.IsNullOrEmpty( raw ) ) return "game";

		var chars = raw.ToLowerInvariant().Select( c => char.IsLetterOrDigit( c ) ? c : '_' );
		return new string( chars.ToArray() );
	}

	private static string StatName( string gameKey, string stat ) => $"{gameKey}_{stat}";

	// The handful of stat names the framework itself maintains, so display code can special-case how
	// they're read/computed without a game ever needing to know these details.
	private const string GamesPlayed = "games_played";
	private const string Wins = "wins";
	private const string WinRate = "win_rate";
	private const string BestTime = "best_time";
	private const string WinStreak = "win_streak";
	private const string TimePlayed = "time_played";

	/// <summary>Host or client: this game just finished a round - bump its per-game play count.</summary>
	public static void RecordRoundPlayed( CardGame game ) => Increment( game, GamesPlayed );

	/// <summary>
	/// Record the local player's result for a finished round: bumps "wins" on a win, and maintains a
	/// running win streak (reset to 0 on a loss).
	/// </summary>
	public static void RecordResult( CardGame game, bool won )
	{
		if ( won )
			Increment( game, Wins );

		SetValue( game, WinStreak, won ? CurrentStreak( game ) + 1 : 0 );
	}

	/// <summary>Record how long a finished round took, keeping only the best (lowest) time seen.</summary>
	public static void RecordDuration( CardGame game, float seconds )
	{
		if ( seconds <= 0f ) return;

		var current = GetGaugeValue( KeyFor( game ), BestTime );
		if ( current <= 0 || seconds < current )
			SetValue( game, BestTime, seconds );
	}

	/// <summary>Add to the total time spent at this game's table, from when it opened to when it closed.</summary>
	public static void RecordPlaytime( CardGame game, float seconds )
	{
		if ( seconds <= 0f ) return;
		Increment( game, TimePlayed, seconds );
	}

	/// <summary>
	/// A game's own stat, on top of the generic ones the framework already tracks - e.g.
	/// <c>GameStats.Increment(this, "biggest_pot_won", potSize)</c>. Accumulates like a counter; use
	/// <see cref="SetValue"/> instead for a "current value" gauge (like a personal best).
	/// </summary>
	public static void Increment( CardGame game, string stat, double amount = 1 )
		=> Stats.Increment( StatName( KeyFor( game ), stat ), amount );

	/// <summary>A game's own "current value" stat (a gauge, not a running total) - e.g. a personal best.</summary>
	public static void SetValue( CardGame game, string stat, double value )
		=> Stats.SetValue( StatName( KeyFor( game ), stat ), value );

	private static int CurrentStreak( CardGame game ) => (int)GetGaugeValue( KeyFor( game ), WinStreak );

	// A backend-confirmed .Value takes priority; right after our own SetValue (before a refresh comes back)
	// the locally-predicted .LastValue carries it instead. See Stats.PlayerStats.Predict in the engine source.
	private static double GetGaugeValue( string gameKey, string stat )
	{
		var s = Stats.LocalPlayer.Get( StatName( gameKey, stat ) );
		return s.Value != 0 ? s.Value : s.LastValue;
	}

	private static double GetCounterValue( string gameKey, string stat ) => Stats.LocalPlayer.Get( StatName( gameKey, stat ) ).Sum;

	/// <summary>
	/// Read one stat for display. <see cref="WinRate"/> is derived (wins / games played, NaN if never
	/// played - there's no such literal stat); <see cref="BestTime"/> and <see cref="WinStreak"/> are
	/// gauges maintained via <see cref="SetValue"/>; everything else - the rest of the framework's own
	/// stats, and any custom stat a game increments - is a plain accumulated total.
	/// </summary>
	public static double GetValue( string gameKey, string stat )
	{
		if ( stat == WinRate )
		{
			var played = GetCounterValue( gameKey, GamesPlayed );
			return played <= 0 ? double.NaN : GetCounterValue( gameKey, Wins ) * 100.0 / played;
		}

		if ( stat is BestTime or WinStreak )
			return GetGaugeValue( gameKey, stat );

		return GetCounterValue( gameKey, stat );
	}

	/// <summary>How a <see cref="StatDisplay"/>'s value should be rendered.</summary>
	public enum StatFormat
	{
		Number,   // plain integer, e.g. "42" - shown even when 0
		Percent,  // e.g. "62%" - "-" only when the underlying value is undefined (see GetValue's WinRate case)
		Time,     // mm:ss, e.g. "1:35" - "-" when 0 or unset (a round time of exactly 0 isn't meaningful)
		Duration, // e.g. "2h 15m" - "-" when 0 or unset
	}

	/// <summary>Every game gets this set on its hero panel unless its <see cref="GameDefinition.Stats"/> picks its own.</summary>
	public static readonly IReadOnlyList<StatDisplay> DefaultDisplay = new[]
	{
		new StatDisplay { Key = GamesPlayed },
		new StatDisplay { Key = WinRate, Format = StatFormat.Percent },
		new StatDisplay { Key = TimePlayed, Format = StatFormat.Duration },
		new StatDisplay { Key = BestTime, Format = StatFormat.Time },
		new StatDisplay { Key = WinStreak },
	};

	/// <summary>
	/// "this-stat" or "this_stat" → "This Stat" - a game's custom stat keys get a readable label for free,
	/// without every game needing to spell one out via <see cref="StatDisplay.Label"/>.
	/// </summary>
	public static string PrettyLabel( string key )
	{
		if ( string.IsNullOrWhiteSpace( key ) ) return "";

		var words = key.Split( new[] { '_', '-' }, StringSplitOptions.RemoveEmptyEntries );
		return string.Join( " ", words.Select( w => char.ToUpperInvariant( w[0] ) + w[1..] ) );
	}

	/// <summary>The label to show for a stat: its explicit override, or an auto-generated one from its key.</summary>
	public static string Label( StatDisplay display ) => string.IsNullOrEmpty( display.Label ) ? PrettyLabel( display.Key ) : display.Label;

	/// <summary>Read and render one stat the way the launcher's stats panel expects, e.g. "62%", "1:35", "-" when unplayed.</summary>
	public static string Format( string gameKey, StatDisplay display )
	{
		var value = GetValue( gameKey, display.Key );

		return display.Format switch
		{
			StatFormat.Percent => double.IsNaN( value ) ? "-" : $"{(int)Math.Round( value )}%",
			StatFormat.Time => FormatTime( value ),
			StatFormat.Duration => FormatDuration( value ),
			_ => ((int)Math.Round( value )).ToString(),
		};
	}

	private static string FormatTime( double seconds )
	{
		var secs = (int)seconds;
		return secs <= 0 ? "-" : $"{secs / 60}:{secs % 60:00}";
	}

	private static string FormatDuration( double seconds )
	{
		var total = (int)seconds;
		if ( total <= 0 ) return "-";

		var days = total / 86400;
		var hours = total % 86400 / 3600;
		var mins = total % 3600 / 60;

		if ( days > 0 ) return $"{days} day{(days == 1 ? "" : "s")} {hours} hr{(hours == 1 ? "" : "s")}";
		if ( hours > 0 ) return $"{hours} hr{(hours == 1 ? "" : "s")} {mins} min{(mins == 1 ? "" : "s")}";
		if ( mins > 0 ) return $"{mins} min{(mins == 1 ? "" : "s")}";
		return $"{total}s";
	}
}

/// <summary>
/// One stat shown on a game's hero panel: which stat (see <see cref="GameStats.KeyFor(CardGame)"/> and
/// <see cref="GameStats.Increment"/>/<see cref="GameStats.SetValue"/> for how a game names its own), how
/// to render it, and an optional label override (empty = auto-generated from <see cref="Key"/> via
/// <see cref="GameStats.PrettyLabel"/>). A <see cref="GameDefinition"/> picks a custom ordered list of
/// these via <see cref="GameDefinition.Stats"/>; games that don't fall back to <see cref="GameStats.DefaultDisplay"/>.
/// </summary>
public struct StatDisplay
{
	[Property] public string Key { get; set; }

	[Property] public GameStats.StatFormat Format { get; set; }

	[Property] public string Label { get; set; }
}