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.
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; }
}