DiamondLeaderboard.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Sandbox;

namespace Diamonds;

/// <summary>
/// The all-time boards: highest score and longest survival. Each game over submits its total score and
/// whole elapsed seconds as two stat values; each board is read with Max aggregation, highest first, so
/// every player shows their personal best. The stats are created on first submit, with no dashboard setup.
/// </summary>
public static class DiamondLeaderboard
{
	/// <summary>Bump to start fresh boards: it is baked into <see cref="StatName"/>, so older entries stop showing.</summary>
	public const int LEADERBOARD_NUM = 1;
	public const int MaxEntries = 200;

	public enum Mode { Score, Time }
	public static string StatName( Mode mode ) => mode == Mode.Time ? $"time_v{LEADERBOARD_NUM}" : $"score_v{LEADERBOARD_NUM}";

	/// <summary>A player's best: a score, or whole seconds survived.</summary>
	public readonly record struct Row( long SteamId, string Name, long Value );

	// This session's best submissions. Board responses are cached upstream for a few minutes, so a
	// fresh entry can be missing from them; splicing it in shows the new rank right away. Session-only
	// is enough, as it only has to bridge that cache window.
	static long sessionBestScore, sessionBestSeconds;
	static ref long SessionBest( Mode mode ) => ref mode == Mode.Time ? ref sessionBestSeconds : ref sessionBestScore;

	/// <summary>The in-game timer's MM:SS, which the time board shows too.</summary>
	public static string FormatTime( long seconds ) => $"{seconds / 60:00}:{seconds % 60:00}";
	public static string Format( Mode mode, long value ) =>
		mode == Mode.Time ? FormatTime( value ) : value.ToString( "N0", CultureInfo.InvariantCulture );

	/// <summary>Submit a finished game's total score and whole seconds survived. Max aggregation keeps only each player's best.</summary>
	public static void Submit( long score, long seconds )
	{
		SubmitStat( Mode.Score, score );
		SubmitStat( Mode.Time, seconds );
		Sandbox.Services.Stats.Flush();
	}

	static void SubmitStat( Mode mode, long value )
	{
		string stat = StatName( mode );
		if ( value <= 0 )
		{
			Log.Info( $"Diamonds: skipped '{stat}' submission for {Format( mode, value )}." );
			return;
		}
		// Submit every game: the engine drops a failed upload without retrying, so skipping values
		// below a best we only submitted could leave the board without it. Send the session best rather than
		// this game's value: SetValue replaces a queued, not yet uploaded value (Flush can hold one back ~20 s),
		// so a quick low game straight after a high one would otherwise erase the best.
		ref long best = ref SessionBest( mode );
		best = Math.Max( best, value );
		Sandbox.Services.Stats.SetValue( stat, best );
		Log.Info( best == value ? $"Diamonds: submitted {Format( mode, value )} to '{stat}'."
			: $"Diamonds: submitted session best {Format( mode, best )} to '{stat}' for a game with {Format( mode, value )}." );
	}

	/// <summary>The top <see cref="MaxEntries"/> players on <paramref name="mode"/>'s board, including this session's best when the
	/// response predates it. <paramref name="testPlayers"/> mixes generated players in by value, for previewing a busy board.</summary>
	public static async Task<List<Row>> FetchTop( Mode mode, int testPlayers = 0 )
	{
		var rows = new List<Row>();
		string stat = StatName( mode );
		try
		{
			var board = Sandbox.Services.Leaderboards.GetFromStat( stat );
			board.SetAggregationMax();
			board.SetSortDescending();
			board.FilterByNone();
			board.MaxEntries = MaxEntries;
			await board.Refresh();
			foreach ( var entry in board.Entries ?? [] )
				rows.Add( new Row( entry.SteamId, entry.DisplayName, (long)entry.Value ) );
		}
		catch ( Exception e )
		{
			// A stat with no entries omits them from the response, and the engine throws while reading it.
			// Treat that, and an unreachable backend, as an empty board.
			Log.Info( $"Diamonds: leaderboard empty or unavailable ({e.Message}) [stat '{stat}']." );
			rows.Clear();
		}
		if ( testPlayers > 0 )
		{
			rows.AddRange( TestRows( testPlayers, mode ) );
			// Stable, so real rows stay ahead of equal test values.
			rows = rows.OrderByDescending( r => r.Value ).Take( MaxEntries ).ToList();
		}
		long me = (long)Game.SteamId;
		Splice( rows, me, SessionBest( mode ), () => new Friend( me ).Name );
		return rows;
	}

	/// <summary>
	/// Deterministic generated players, highest first, with short, long, lowercase and non-Latin names.
	/// Scores fall from about 250,000 and times from 90 minutes, so a real entry lands among them. Their
	/// ids are in the engine's fake range, which draws generated avatars instead of asking Steam.
	/// </summary>
	public static List<Row> TestRows( int count, Mode mode = Mode.Score )
	{
		string[] names = ["crystalfan", "An Extremely Long Steam Username That Must Be Truncated", "Garry", "ダイヤモンド職人", "x",
			"GemGrinder_2000", "Ωmega Δiamond", "lowercase only name", "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", "Шлифовщик",
			"prism", "Sapphire Queen", "💎 facet 💎", "not_a_bot", "Tetrahedron", "Obsidian"];
		count = Math.Clamp( count, 0, MaxEntries );
		var rows = new List<Row>( count );
		float top = mode == Mode.Time ? 5400 : 250_000;
		long value = long.MaxValue;
		for ( int i = 0; i < count; i++ )
		{
			float t = count > 1 ? i / (float)(count - 1) : 0;
			// Geometric spacing, kept strictly descending where it flattens near the bottom.
			value = Math.Min( value - 1, (long)(top * MathF.Pow( (10 + count) / top, t )) );
			string name = names[i % names.Length] + (i >= names.Length ? $" {i / names.Length + 1}" : "");
			rows.Add( new Row( 90071996842377216 + 1000 + i, name, value ) );
		}
		return rows;
	}

	/// <summary>
	/// Show <paramref name="best"/> for <paramref name="me"/> when the board has a lower value or none:
	/// the old row is removed and the new one takes its rank. Ties go after existing rows, which were
	/// submitted earlier. A new entrant that falls outside a full window is left off.
	/// </summary>
	public static void Splice( List<Row> rows, long me, long best, Func<string> localName )
	{
		if ( best <= 0 ) return;
		int existing = rows.FindIndex( r => r.SteamId == me );
		if ( existing >= 0 && rows[existing].Value >= best ) return;
		string name = existing >= 0 ? rows[existing].Name : localName();
		if ( existing >= 0 ) rows.RemoveAt( existing );
		int index = rows.FindIndex( r => r.Value < best );
		if ( index < 0 ) index = rows.Count;
		if ( existing < 0 && index >= MaxEntries ) return;
		rows.Insert( index, new Row( me, name, best ) );
		if ( rows.Count > MaxEntries ) rows.RemoveRange( MaxEntries, rows.Count - MaxEntries );
	}
}