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