Game/CompletionStandings.cs
namespace BlockParty;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
/// <summary>Reads the existing achievement total; no per-level queries or new stat writes.</summary>
public static class CompletionStandings
{
private const int PageSize = 100;
private const string Endpoint = "https://public.facepunch.com/sbox/package/facepunch.blockparty/leaderboard/";
public static IReadOnlyList<CompletionEntry> Players { get; private set; } = Array.Empty<CompletionEntry>();
public static CompletionEntry Personal { get; private set; }
public static long TotalPlayers { get; private set; }
public static DateTimeOffset UpdatedAt { get; private set; }
public static bool Loading { get; private set; }
public static string Notice { get; private set; }
public static int Revision { get; private set; }
public static bool HasMore => Players.Count < TotalPlayers;
public static bool IsComplete( double value ) => LevelMap.Nodes.Count > 0 && value == LevelMap.Nodes.Count;
private static DateTimeOffset _nextFetch;
private static int _rowLimit = PageSize;
public static void Tick()
{
if ( !Loading && DateTimeOffset.UtcNow >= _nextFetch ) _ = Fetch( false );
}
public static void LoadMore()
{
if ( !Loading && HasMore ) _ = Fetch( true );
}
private static async Task Fetch( bool more )
{
Loading = true;
Revision++;
try
{
// Re-read the whole visible range in one response. Separate cached pages can
// disagree when players join or move across the boundary between requests.
int count = Math.Max( _rowLimit, Players.Count ) + (more ? PageSize : 0);
using var timeout = new CancellationTokenSource( TimeSpan.FromSeconds( 15 ) );
var board = await ReadBoard( count, timeout.Token );
// Break ties within the loaded range only; Load More can reveal earlier submissions.
var players = board.Entries.Where( p => p.Rank <= count )
.OrderByDescending( p => p.Value ).ThenBy( p => p.Timestamp )
.ThenBy( p => p.SteamId ).ToArray();
for ( int i = 0; i < players.Length; i++ ) players[i].Rank = i + 1;
Players = players;
// Outside the loaded range, keep the personal row's backend rank.
Personal = board.Entries.FirstOrDefault( p => p.SteamId == (long)Game.SteamId );
TotalPlayers = board.TotalEntries;
UpdatedAt = DateTimeOffset.UtcNow;
_rowLimit = count;
Notice = null;
_nextFetch = DateTimeOffset.UtcNow.AddMinutes( 5 );
}
catch ( Exception e )
{
Notice = UpdatedAt == default ? "Completions unavailable. Retrying shortly." : "Showing saved completions. Retrying shortly.";
_nextFetch = DateTimeOffset.UtcNow.AddMinutes( 1 );
if ( Game.IsEditor ) Log.Info( $"BlockParty: completion standings unavailable ({e.Message})." );
}
finally
{
Loading = false;
Revision++;
}
}
private static async Task<CompletionBoard> ReadBoard( int count, CancellationToken cancellation )
{
string url = $"{Endpoint}{Achievements.MapLevelsBeatenStat}/?aggregation=max&sort=desc&datefilter=none&count={count}&offset=0&include={Game.SteamId}";
var board = await Http.RequestJsonAsync<CompletionBoard>( url, cancellationToken: cancellation );
if ( board is null || board.Stat != Achievements.MapLevelsBeatenStat || board.TotalEntries < 0
|| board.Entries is null || board.Entries.Count > count + 1 )
throw new InvalidOperationException( "Unexpected completion leaderboard." );
var seen = new HashSet<long>();
foreach ( var entry in board.Entries )
{
if ( entry is null || entry.SteamId <= 0 || !seen.Add( entry.SteamId )
|| entry.Rank < 1 || entry.Rank > board.TotalEntries
|| (entry.Rank > count && entry.SteamId != (long)Game.SteamId)
|| !double.IsFinite( entry.Value ) || entry.Value < 1 || entry.Value != Math.Truncate( entry.Value )
|| entry.Timestamp == default
|| string.IsNullOrWhiteSpace( entry.DisplayName ) || entry.DisplayName.Length > 256 )
throw new InvalidOperationException( "Invalid completion entry." );
}
var players = board.Entries.Where( p => p.Rank <= count ).OrderBy( p => p.Rank ).ToArray();
if ( players.Length != Math.Min( count, board.TotalEntries )
|| players.Where( (p, i) => p.Rank != i + 1 ).Any()
|| players.Skip( 1 ).Where( (p, i) => p.Value > players[i].Value ).Any() )
throw new InvalidOperationException( "Incomplete or unordered completion standings." );
return board;
}
}
public sealed class CompletionBoard
{
public string Stat { get; set; }
public long TotalEntries { get; set; }
public List<CompletionEntry> Entries { get; set; }
}
public sealed class CompletionEntry
{
public long SteamId { get; set; }
public string DisplayName { get; set; }
public long Rank { get; set; }
public double Value { get; set; }
public DateTimeOffset Timestamp { get; set; }
}