Game/MedalStandings.cs
namespace BlockParty;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
/// <summary>Session cache for the centrally computed map podiums. Never queries individual boards.</summary>
public static class MedalStandings
{
private const string Endpoint = "https://blockparty-medals.ryly.workers.dev/v2/medals/";
private sealed class CachedBoard
{
public MedalSnapshot Snapshot;
public bool Loading;
public bool RefreshFailed;
public string Notice = "Loading standings...";
public DateTimeOffset NextFetch;
}
private static readonly Dictionary<string, CachedBoard> Boards = new();
private static CachedBoard Board( string characterId )
{
string key = characterId ?? "";
if ( !Boards.TryGetValue( key, out var board ) ) Boards[key] = board = new();
return board;
}
public static MedalSnapshot Current => GetSnapshot();
public static string Notice => GetNotice();
public static MedalSnapshot GetSnapshot( string characterId = null ) => Board( characterId ).Snapshot;
public static string GetNotice( string characterId = null ) => Board( characterId ).Notice;
public static int Revision { get; private set; }
private static DateTimeOffset _nextCheck;
private static string _collection;
private static int _generation;
private static bool _matches;
private static bool _charactersMatch;
/// <summary>Called while the panel is visible; revisiting the map reuses the session cache.</summary>
public static void Tick( string characterId = null )
{
var now = DateTimeOffset.UtcNow;
if ( now >= _nextCheck )
{
_nextCheck = now.AddSeconds( 1 );
_matches = Leaderboard.LEADERBOARD_VERSION == MedalStandingsManifest.Version
&& Levels.All.Count == MedalStandingsManifest.Stats.Length
&& new HashSet<string>( Levels.All.Select( level => Leaderboard.StatNameForLevel( level.Id ) ) )
.SetEquals( MedalStandingsManifest.Stats );
_charactersMatch = new HashSet<string>( Characters.All.Select( c => c.Id ) ).SetEquals( MedalStandingsManifest.CharacterIds );
string identity = _matches ? MedalStandingsManifest.CollectionId + "/" + (_charactersMatch ? MedalStandingsManifest.CharacterCollectionId : "") : null;
if ( identity != _collection )
{
_collection = identity;
_generation++;
Boards.Clear();
Revision++;
}
}
var board = Board( characterId );
// A hot-reloaded game may retain rows fetched under the previous policy.
if ( board.Snapshot is not null && board.Snapshot.Ranking != AwardPoints.Ranking )
{
board.Snapshot = null;
board.NextFetch = default;
board.Notice = "Loading standings...";
Revision++;
}
if ( !_matches || (characterId is not null && (!_charactersMatch || !MedalStandingsManifest.CharacterIds.Contains( characterId ))) )
{
board.Notice = "Standings are updating for this map.";
return;
}
if ( board.Loading || now < board.NextFetch ) return;
board.Loading = true;
Revision++;
string collection = characterId is null ? MedalStandingsManifest.CollectionId : MedalStandingsManifest.CharacterCollectionId;
_ = Fetch( board, collection, characterId, _generation );
}
private static async Task Fetch( CachedBoard board, string collection, string characterId, int generation )
{
string url = Endpoint + collection + (characterId is null ? "" : $"/characters/{characterId}");
try
{
using var timeout = new CancellationTokenSource( TimeSpan.FromSeconds( 15 ) );
var snapshot = await Http.RequestJsonAsync<MedalSnapshot>( url, cancellationToken: timeout.Token );
if ( generation != _generation ) return;
if ( !IsValid( snapshot, collection, characterId ) ) throw new InvalidOperationException( "Unexpected medal snapshot." );
board.Snapshot = snapshot;
board.Notice = null;
board.NextFetch = DateTimeOffset.UtcNow.AddMinutes( 5 );
if ( board.RefreshFailed )
Log.Info( $"BlockParty: medal refresh recovered. URL: {url}." );
board.RefreshFailed = false;
}
catch ( Exception e )
{
if ( generation != _generation ) return;
board.Notice = board.Snapshot is null ? "Medals unavailable. Retrying shortly." : "Showing the last available data.";
board.NextFetch = DateTimeOffset.UtcNow.AddMinutes( 1 );
// Keep the first failure for bug reports; repeated failures only need editor diagnostics.
if ( !board.RefreshFailed || Game.IsEditor )
Log.Warning( $"BlockParty: medal refresh failed. URL: {url}. "
+ $"Saved standings updated at: {(board.Snapshot is null ? "none" : board.Snapshot.UpdatedAt.ToString( "O" ))}. "
+ $"Retrying in 1 minute. Error: {(board.RefreshFailed ? e.Message : e.ToString())}" );
board.RefreshFailed = true;
}
finally
{
if ( generation == _generation )
{
board.Loading = false;
Revision++;
}
}
}
private static bool IsValid( MedalSnapshot snapshot, string collection, string characterId )
{
int count = MedalStandingsManifest.Stats.Length;
if ( snapshot is null || snapshot.SchemaVersion != 1 || snapshot.Ranking != AwardPoints.Ranking || snapshot.CollectionId != collection || snapshot.CharacterId != characterId
|| snapshot.LeaderboardVersion != Leaderboard.LEADERBOARD_VERSION || snapshot.LevelsCounted != count
|| snapshot.LevelsWithEntries < 0 || snapshot.LevelsWithEntries > count
|| snapshot.UpdatedAt == default || snapshot.UpdatedAt > DateTimeOffset.UtcNow.AddMinutes( 5 )
|| snapshot.Players is null || snapshot.Players.Count > count * 3 ) return false;
var seen = new HashSet<long>();
MedalPlayer previous = null;
int rank = 0;
for ( int i = 0; i < snapshot.Players.Count; i++ )
{
var player = snapshot.Players[i];
if ( player is null || player.AccountId <= 0 || !seen.Add( player.AccountId )
|| string.IsNullOrWhiteSpace( player.Name ) || player.Name.Length > 256
|| player.Gold < 0 || player.Gold > count || player.Silver < 0 || player.Silver > count
|| player.Bronze < 0 || player.Bronze > count || player.Gold + player.Silver + player.Bronze == 0
|| player.Points != AwardPoints.Total( player.Gold, player.Silver, player.Bronze ) ) return false;
if ( previous is not null && AwardPoints.Compare( previous.Gold, previous.Silver, previous.Bronze, player.Gold, player.Silver, player.Bronze ) > 0 ) return false;
if ( previous is null || player.Gold != previous.Gold || player.Silver != previous.Silver || player.Bronze != previous.Bronze ) rank = i + 1;
if ( player.Rank != rank ) return false;
previous = player;
}
return snapshot.Players.Sum( player => player.Gold ) == snapshot.LevelsWithEntries
&& snapshot.Players.Sum( player => player.Silver ) <= snapshot.LevelsWithEntries
&& snapshot.Players.Sum( player => player.Bronze ) <= snapshot.LevelsWithEntries;
}
}
public sealed class MedalSnapshot
{
public string Ranking { get; set; }
public int SchemaVersion { get; set; }
public string CollectionId { get; set; }
public string CharacterId { get; set; }
public int LeaderboardVersion { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public int LevelsCounted { get; set; }
public int LevelsWithEntries { get; set; }
public List<MedalPlayer> Players { get; set; }
}
public sealed class MedalPlayer
{
public long Points { get; set; }
public string SteamId { get; set; }
public string Name { get; set; }
public int Rank { get; set; }
public int Gold { get; set; }
public int Silver { get; set; }
public int Bronze { get; set; }
public long AccountId => long.TryParse( SteamId, out var id ) ? id : 0;
}