Thin HTTP API client for the game's backend. It builds auth requests using the local Steam identity and Facepunch auth token, requests a WebSocket ticket, and fetches various leaderboards as JSON.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Sandbox;
namespace Splitclicker.Api;
// Thin HTTP client. Identity is the Steam account: the client mints a Facepunch
// auth token and the backend validates it (no GUID enrollment, no OpenID). One
// call — Auth — proves identity and returns a single-use WebSocket ticket.
public static class ApiClient
{
public const string ProdUrl = "https://fart.notadomain.lol";
/// <summary>Backend root. Mutable so a dev build can point at localhost.</summary>
public static string BaseUrl { get; set; } = ProdUrl;
/// <summary>WebSocket URL for the given ticket, derived from BaseUrl
/// (https→wss, http→ws). The ticket is the only thing on the URL.</summary>
public static string WsUrl( string ticket )
{
var scheme = BaseUrl.StartsWith( "https" ) ? "wss" : "ws";
var host = BaseUrl.Substring( BaseUrl.IndexOf( "://" ) + 3 );
return $"{scheme}://{host}/ws?ticket={Uri.EscapeDataString( ticket )}";
}
static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
/// <summary>Local SteamID64 as a string, or null on non-Steam/web builds.
/// Sent as a string — it exceeds JS/double precision.</summary>
static string LocalSteamId()
{
ulong id = Connection.Local?.SteamId ?? 0UL;
return id != 0 ? id.ToString() : null;
}
/// <summary>Local Steam display name, or null if unavailable. Reported at auth
/// so the board can show a real name instead of the opaque hex tag.</summary>
static string LocalSteamName()
{
var name = Connection.Local?.DisplayName;
return string.IsNullOrWhiteSpace( name ) ? null : name;
}
/// <summary>Facepunch token proving Steam ownership. Returns null (never throws)
/// when none can be minted (web/non-Steam) so the caller fails cleanly.</summary>
static async Task<string> AuthToken()
{
try { return await Sandbox.Services.Auth.GetToken( "splitclicker" ); }
catch ( Exception e )
{
Log.Warning( $"[Splitclicker] auth token unavailable: {e.Message}" );
return null;
}
}
/// <summary>Prove the Steam identity and get a WS ticket. username is optional
/// (sets/updates the display name). Returns null on any failure — the caller
/// should surface "couldn't connect" rather than proceed unauthenticated.</summary>
public static async Task<AuthResponse> Auth( string username = null )
{
var steamId = LocalSteamId();
if ( steamId == null )
{
Log.Warning( "[Splitclicker] no SteamID — Steam is required to play" );
return null;
}
var token = await AuthToken();
if ( string.IsNullOrEmpty( token ) ) return null;
var body = new Dictionary<string, string> { ["steam_id"] = steamId, ["token"] = token };
if ( !string.IsNullOrEmpty( username ) ) body["username"] = username;
var steamName = LocalSteamName();
if ( steamName != null ) body["display_name"] = steamName;
try
{
var resp = await Http.RequestAsync( BaseUrl + "/api/v1/auth", "POST", Http.CreateJsonContent( body ) );
if ( !resp.IsSuccessStatusCode )
{
Log.Warning( $"[Splitclicker] auth failed: HTTP {(int)resp.StatusCode}" );
return null;
}
return JsonSerializer.Deserialize<AuthResponse>( await resp.Content.ReadAsStringAsync(), JsonOpts );
}
catch ( Exception e )
{
Log.Warning( $"[Splitclicker] auth request error: {e.Message}" );
return null;
}
}
/// <summary>Current UTC-hour leaderboard (top `limit`). Empty list on failure.</summary>
public static Task<List<Standing>> GetHourlyLeaderboard( int limit = 100 ) =>
GetLeaderboard( "hourly", limit );
/// <summary>Career "hours won" leaderboard (top `limit`). Empty list on failure.
/// Each Standing's Points is the hours-won count.</summary>
public static Task<List<Standing>> GetHoursWonLeaderboard( int limit = 100 ) =>
GetLeaderboard( "hours-won", limit );
/// <summary>Career "sessions won" leaderboard (top `limit`). Empty list on
/// failure. Each Standing's Points is the games-won count.</summary>
public static Task<List<Standing>> GetSessionsWonLeaderboard( int limit = 100 ) =>
GetLeaderboard( "sessions-won", limit );
static async Task<List<Standing>> GetLeaderboard( string board, int limit )
{
try
{
var resp = await Http.RequestAsync( BaseUrl + $"/api/v1/leaderboard/{board}?limit={limit}", "GET", null );
if ( !resp.IsSuccessStatusCode ) return new List<Standing>();
return JsonSerializer.Deserialize<List<Standing>>( await resp.Content.ReadAsStringAsync(), JsonOpts )
?? new List<Standing>();
}
catch ( Exception e )
{
Log.Warning( $"[Splitclicker] {board} leaderboard fetch failed: {e.Message}" );
return new List<Standing>();
}
}
}