Api/ApiClient.cs

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.

Http CallsNetworking
🌐 https://fart.notadomain.lol, https://fart.notadomain.lol/api/v1/auth, https://fart.notadomain.lol/api/v1/leaderboard/hourly?limit={limit}, https://fart.notadomain.lol/api/v1/leaderboard/hours-won?limit={limit}, https://fart.notadomain.lol/api/v1/leaderboard/sessions-won?limit={limit}
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>();
		}
	}
}