Game/Leaderboards.cs

Leaderboard helper for the Breakout game. Exposes fetching and submitting high scores, personal best lookup, and a fake-data generator for UI/testing; it wraps s&box Stats and Leaderboards services and formats results as ScoreEntry records.

NetworkingFile Access
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Sandbox;
using Sandbox.Services;

namespace Breakout;

/// <summary>
/// Which slice of the leaderboard to show.
/// </summary>
public enum ScoreFilter
{
	Global,
	Friends,
	Country,
	AroundMe
}

/// <summary>
/// One row of leaderboard data: a player's rank, score, identity and country.
/// </summary>
public readonly record struct ScoreEntry( long Rank, double Value, long SteamId, string DisplayName, string CountryCode );

/// <summary>
/// Talks to the s&amp;box stats and leaderboard services for high scores. Each game mode has its own
/// board, and Weekly gets a brand new board every week (the stat name includes the week number).
/// Setting UseFakeData swaps in a hand-written list of scores so the UI can be tried out offline.
/// </summary>
public static class BreakoutLeaderboards
{
	public const string ScoreStat = "score";

	public static bool UseFakeData = false;

	/// <summary>
	/// The stat name scores are stored/read under for this mode. Weekly tacks on the current week
	/// id so every week is a separate board.
	/// </summary>
	public static string StatFor( GameMode mode ) => mode switch
	{
		GameMode.Arcade => "score_arcade",
		GameMode.Weekly => $"score_weekly_{BreakoutGame.WeeklyId}",
		_ => ScoreStat
	};

	/// <summary>
	/// The local player's best score for the given mode.
	/// </summary>
	public static long PersonalBest( GameMode mode )
	{
		if ( UseFakeData )
			return mode == GameMode.Classic ? 9860 : 108460;

		return (long)Stats.LocalPlayer.Get( StatFor( mode ) ).Max;
	}

	/// <summary>
	/// Submits a score for the given mode, but only if it beats the player's previous best. Returns true when it was a new best.
	/// </summary>
	public static bool SubmitScore( int score, GameMode mode )
	{
		if ( score <= 0 )
			return false;

		var stat = StatFor( mode );

		double previousBest = Stats.LocalPlayer.Get( stat ).Max;
		bool isNewBest = score > previousBest;

		if ( isNewBest )
		{
			Stats.SetValue( stat, score );
			Stats.Flush();
		}

		Log.Info( $"[Breakout] Score {score} for stat '{stat}' (previous best {previousBest:N0}, new best: {isNewBest})" );
		return isNewBest;
	}

	/// <summary>
	/// Loads up to maxEntries leaderboard rows for the given mode and filter (Global, Friends, Country or AroundMe).
	/// </summary>
	public static async Task<ScoreEntry[]> Fetch( ScoreFilter filter, int maxEntries, GameMode mode )
	{
		if ( UseFakeData )
			return FakeEntries( filter, maxEntries, mode );

		var board = Leaderboards.GetFromStat( StatFor( mode ) );
		board.MaxEntries = maxEntries;
		board.SetAggregationMax();
		board.SetSortDescending();

		switch ( filter )
		{
			case ScoreFilter.Friends:
				board.SetFriendsOnly( true );
				break;
			case ScoreFilter.Country:
				board.SetCountryAuto();
				break;
			case ScoreFilter.AroundMe:
				board.CenterOnMe();
				break;
		}

		await board.Refresh();

		return (board.Entries ?? Enumerable.Empty<Leaderboards.Board2.Entry>())
			.Select( e => new ScoreEntry( e.Rank, e.Value, e.SteamId, e.DisplayName, e.CountryCode ) )
			.ToArray();
	}

	private static ScoreEntry[] FakeEntries( ScoreFilter filter, int maxEntries, GameMode mode )
	{
		long me = Sandbox.Utility.Steam.SteamId;

		var raw = new (double Value, string Name, string Country, long SteamId)[]
		{
			(18540, "PixelQueen",    "US", 76561197973858781),
			(17220, "BrickBuster",   "GB", 76561197996859119),
			(15980, "NeonNinja",     "JP", 76561190000000003),
			(14310, "QuantumQuokka", "DE", 76561190000000004),
			(13460, "ArcadeAce",     "US", 76561190000000012),
			(12750, "Volley",        "FR", 76561190000000005),
			(11940, "BlockBlitz",    "ES", 76561190000000013),
			(11090, "ComboKing",     "BR", 76561190000000006),
			(10310, "PaddlePro",     "IT", 76561190000000014),
			( 9860, "You",           "",   me),
			( 9210, "Deflecto",      "PL", 76561190000000015),
			( 8420, "Spinner",       "CA", 76561190000000007),
			( 7810, "RicochetRik",   "NO", 76561190000000016),
			( 7300, "TileTitan",     "AU", 76561190000000008),
			( 6720, "BumperBoss",    "FI", 76561190000000017),
			( 6110, "Bouncer",       "SE", 76561190000000009),
			( 5560, "CaromKid",      "PT", 76561190000000018),
			( 5240, "Retroid",       "KR", 76561190000000010),
			( 4720, "PixelPop",      "TR", 76561190000000019),
			( 4050, "Chip",          "NL", 76561190000000011),
			( 3560, "Wallbanger",    "IE", 76561190000000020),
			( 3120, "Ballistic",     "ZA", 76561190000000021),
			( 2680, "Knockout",      "AR", 76561190000000022),
			( 2290, "ReboundRex",    "CH", 76561190000000023),
			( 1870, "TinkerTot",     "DK", 76561190000000024),
			( 1450, "Rookie",        "IN", 76561190000000025),
		};

		double scale = mode == GameMode.Classic ? 1.0 : 11.0;

		var master = raw
			.Select( ( r, i ) => new ScoreEntry( i + 1, Math.Round( r.Value * scale ), r.SteamId, ArcadeName( mode, i, r.Name ), r.Country ) )
			.ToArray();

		long meRank = master.FirstOrDefault( e => e.SteamId == me ).Rank;

		IEnumerable<ScoreEntry> subset = filter switch
		{
			ScoreFilter.Friends => master.Where( e => e.SteamId == me || e.Rank is 2 or 4 or 6 or 9 or 13 or 18 ),
			ScoreFilter.Country => master.Where( e => e.SteamId == me || e.CountryCode is "US" or "GB" or "JP" ),
			ScoreFilter.AroundMe => master.Where( e => meRank > 0 && Math.Abs( e.Rank - meRank ) <= 2 ),
			_ => master,
		};

		return subset.Take( maxEntries ).ToArray();
	}

	private static string ArcadeName( GameMode mode, int index, string name ) => mode == GameMode.Arcade
		? index switch { 0 => "MegaByte", 2 => "PowerPlay", 4 => "ChainGang", 7 => "ComboCat", _ => name }
		: name;
}