UI/LeaderboardData.cs
using System.Threading.Tasks;

namespace Monolith;

/// <summary>
/// Fetches one global leaderboard and flattens it into plain rows for the HUD.
///
/// All the s&amp;box-typed leaderboard code is deliberately confined to <see cref="Load"/> and
/// uses <c>var</c>, so if the API surface shifts there is exactly one method to correct rather
/// than a UI full of engine types.
/// </summary>
public sealed class LeaderboardData
{
	public struct Row
	{
		public int Rank;
		public string Name;

		/// <summary>Formatted for display.</summary>
		public string Value;

		/// <summary>
		/// The unformatted number. Kept alongside the string so callers can COMPARE against a
		/// board without reparsing "2:05.3", which is the kind of round trip that quietly breaks
		/// the first time a format changes.
		/// </summary>
		public double Raw;

		public bool IsMe;
	}

	public string Title { get; }
	public string StatName { get; }

	/// <summary>Speedrun boards rank by lowest time, and format seconds rather than counts.</summary>
	public bool LowestWins { get; init; }

	public List<Row> Rows { get; } = new();

	public bool Loading { get; private set; }
	public string Error { get; private set; }
	public bool HasLoaded { get; private set; }

	/// <summary>Bumped on every state change so the HUD's BuildHash notices.</summary>
	public int Revision { get; private set; }

	public LeaderboardData( string title, string statName )
	{
		Title = title;
		StatName = statName;
	}

	public async Task Load( int maxEntries = 15 )
	{
		if ( Loading ) return;

		Loading = true;
		Error = null;
		Revision++;

		try
		{
			var board = Sandbox.Services.Leaderboards.GetFromStat( MonolithStats.PackageIdent, StatName );
			board.MaxEntries = maxEntries;

			if ( LowestWins )
			{
				// A speedrun board wants the smallest submitted time, ordered ascending.
				board.SetAggregationMin();
				board.SetSortAscending();
			}

			await board.Refresh();

			Rows.Clear();

			// Compared as text so it does not matter whether the engine types these as
			// long or ulong, which differ between Connection and the leaderboard entry.
			var mySteamId = Connection.Local?.SteamId.ToString();

			foreach ( var entry in board.Entries )
			{
				Rows.Add( new Row
				{
					Rank = (int)entry.Rank,
					Name = string.IsNullOrWhiteSpace( entry.DisplayName ) ? "unknown" : entry.DisplayName,
					Value = LowestWins
						? Num.Duration( (double)entry.Value )
						: Num.Short( (double)entry.Value ),
					Raw = (double)entry.Value,
					IsMe = mySteamId != null && entry.SteamId.ToString() == mySteamId,
				} );
			}

			HasLoaded = true;
		}
		catch ( Exception e )
		{
			Error = e.Message;
			Log.Warning( $"Leaderboard '{StatName}' failed to load: {e.Message}" );
		}
		finally
		{
			Loading = false;
			Revision++;
		}
	}
}