Systems/LapTimeService.cs

Service for submitting lap times and querying leaderboards, with support for attaching and fetching ghost recordings. It builds stat names, posts stats (including ghost data), fetches leaderboards, finds the next-above entry relative to the local player, and downloads/deserializes ghost JSON from a leaderboard entry DataUrl.

NetworkingHttp Calls
using Machines.Ghost;
using Sandbox.Services;

namespace Machines.Systems;

/// <summary>
/// Lap time stat submission and leaderboard queries with ghost recording support.
/// </summary>
public static class LapTimeService
{
	private const string StatPrefix = "laptime-";

	/// <summary>
	/// Stat name for a track, e.g. "laptime-desert_loop". No -map- prefix for leaderboard compat.
	/// </summary>
	public static string GetTrackStatName( string mapResourcePath )
	{
		return $"{StatPrefix}{GameStats.Slug( mapResourcePath )}";
	}

	/// <summary>
	/// Submit a lap time to the stats service with the ghost recording attached as data.
	/// </summary>
	public static void SubmitLapTime( string mapResourcePath, float lapTime, GhostRecording recording )
	{
		var statName = GetTrackStatName( mapResourcePath );

		if ( recording != null )
		{
			var data = new Dictionary<string, object>
			{
				["CarResourcePath"] = recording.CarResourcePath,
				["ClipJson"] = recording.ClipJson
			};

			Stats.SetValue( statName, lapTime, null, data );
		}
		else
		{
			Stats.SetValue( statName, lapTime );
		}
	}

	/// <summary>
	/// Fetch leaderboard entries for a track.
	/// </summary>
	public static async Task<Leaderboards.Board2.Entry[]> GetLeaderboard( string map, int max = 10, bool friendsOnly = false )
	{
		var statName = GetTrackStatName( map );

		var board = Leaderboards.GetFromStat( statName );
		board.SetAggregationMin();
		board.SetSortAscending();
		board.SetFriendsOnly( friendsOnly );
		board.MaxEntries = max;

		await board.Refresh();

		return board.Entries;
	}

	/// <summary>
	/// Entry ranked just above the local player, or their own entry if #1. Null on failure.
	/// </summary>
	public static async Task<Leaderboards.Board2.Entry?> GetNextAbove( string map )
	{
		var statName = GetTrackStatName( map );

		try
		{
			var board = Leaderboards.GetFromStat( statName );
			board.SetAggregationMin();
			board.SetSortAscending();
			board.CenterOnMe();
			board.MaxEntries = 5;

			await board.Refresh();

			var entries = board.Entries;
			if ( entries is null || entries.Length == 0 )
			{
				Log.Info( $"[LapTimeService] No entries returned for '{statName}'" );
				return null;
			}

			Log.Trace( $"[LapTimeService] Got {entries.Length} entries for '{statName}'" );

			var localSteamId = (long)Connection.Local.SteamId;
			var myIndex = Array.FindIndex( entries, e => e.SteamId == localSteamId );
			if ( myIndex < 0 )
			{
				Log.Info( $"[LapTimeService] Local player not found in entries" );
				return null;
			}

			var target = myIndex > 0 ? entries[myIndex - 1] : entries[myIndex];
			Log.Trace( $"[LapTimeService] Target ghost: {target.DisplayName} ({target.Value:F2}s), rank {target.Rank}" );
			return target;
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[LapTimeService] Leaderboard query failed: {ex.Message}" );
			return null;
		}
	}

	/// <summary>
	/// Download and deserialize ghost data from a leaderboard entry's DataUrl.
	/// </summary>
	public static async Task<GhostRecording> FetchRecording( Leaderboards.Board2.Entry entry )
	{
		if ( string.IsNullOrWhiteSpace( entry.DataUrl ) )
		{
			Log.Info( $"[LapTimeService] FetchRecording: no DataUrl on entry" );
			return null;
		}

		try
		{
			Log.Trace( $"[LapTimeService] FetchRecording: downloading from {entry.DataUrl}..." );
			var json = await Http.RequestStringAsync( entry.DataUrl );
			if ( string.IsNullOrWhiteSpace( json ) )
			{
				Log.Warning( $"[LapTimeService] FetchRecording: empty response" );
				return null;
			}

			Log.Trace( $"[LapTimeService] FetchRecording: got {json.Length} chars, deserializing..." );
			var recording = Json.Deserialize<GhostRecording>( json );

			if ( recording != null && string.IsNullOrEmpty( recording.PlayerName ) )
				recording.PlayerName = entry.DisplayName;

			Log.Trace( $"[LapTimeService] FetchRecording: success (valid={recording?.IsValid})" );
			return recording;
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[LapTimeService] FetchRecording failed: {ex.Message}" );
			return null;
		}
	}
}