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