Game/DailyTrophyStandings.cs
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace BlockParty;
/// <summary>Session cache for finalized daily trophies. Only the visible daily hub polls this service.</summary>
public static class DailyTrophyStandings
{
private const string Endpoint = "https://blockparty-trophies.ryly.workers.dev/v2/trophies";
public static DailyTrophySnapshot Current { get; private set; }
// Loading text belongs to the empty view, not the cached error state.
public static string Notice => _errorNotice;
private static string _errorNotice;
public static int Revision { get; private set; }
private static bool _loading;
private static bool _refreshFailed;
private static DateTimeOffset _nextFetch;
public static void Tick()
{
// Hot reload can preserve a snapshot from the previous ranking policy.
// Its new Points fields default to zero; don't wait for the hourly refresh.
if ( Current is not null && Current.Ranking != AwardPoints.Ranking )
{
Current = null;
_nextFetch = default;
_errorNotice = null;
Revision++;
}
if ( _loading || DateTimeOffset.UtcNow < _nextFetch ) return;
_loading = true;
_ = Fetch();
}
private static async Task Fetch()
{
try
{
using var timeout = new CancellationTokenSource( TimeSpan.FromSeconds( 15 ) );
var snapshot = await Http.RequestJsonAsync<DailyTrophySnapshot>( Endpoint, cancellationToken: timeout.Token );
if ( !IsValid( snapshot ) ) throw new InvalidOperationException( "Unexpected daily trophy snapshot." );
if ( Current is not null && (string.CompareOrdinal( snapshot.ThroughDay, Current.ThroughDay ) < 0
|| snapshot.DaysCounted < Current.DaysCounted || snapshot.UpdatedAt < Current.UpdatedAt) )
throw new InvalidOperationException( "Daily trophy snapshot went backwards." );
Current = snapshot;
_errorNotice = null;
_nextFetch = NextFetchAt( DateTimeOffset.UtcNow, snapshot );
if ( _refreshFailed )
Log.Info( $"BlockParty: daily trophy refresh recovered. URL: {Endpoint}." );
_refreshFailed = false;
}
catch ( Exception error )
{
_errorNotice = Current is null ? "Trophies unavailable. Retrying shortly." : "Showing the last available data.";
_nextFetch = DateTimeOffset.UtcNow.AddMinutes( 1 );
// Keep the first failure for bug reports; repeated failures only need editor diagnostics.
if ( !_refreshFailed || Game.IsEditor )
Log.Warning( $"BlockParty: daily trophy refresh failed. URL: {Endpoint}. Saved through: {Current?.ThroughDay ?? "none"}. Error: {(_refreshFailed ? error.Message : error.ToString())}" );
_refreshFailed = true;
}
finally { _loading = false; Revision++; }
}
internal static DateTimeOffset NextFetchAt( DateTimeOffset now, DailyTrophySnapshot snapshot )
{
var due = new DateTimeOffset( now.UtcDateTime.Date, TimeSpan.Zero ).AddMinutes( 16 );
if ( due <= now ) due = due.AddDays( 1 );
var expectedDay = DailyChallenge.IdFromDate( now.AddMinutes( -15 ).AddDays( -1 ) );
var next = now.AddMinutes( snapshot.DaysPending > 0 || string.CompareOrdinal( snapshot.ThroughDay, expectedDay ) < 0 ? 5 : 60 );
return due < next ? due : next;
}
private static bool IsValid( DailyTrophySnapshot snapshot )
{
if ( snapshot is null || snapshot.SchemaVersion is not (1 or 2) || snapshot.Ranking != AwardPoints.Ranking
|| !DailyChallenge.TryParseId( snapshot.FirstDay, out var first ) || !DailyChallenge.TryParseId( snapshot.ThroughDay, out var last )
|| first > last || last >= DateTime.UtcNow.Date || snapshot.DaysCounted < 1 || snapshot.DaysPending < 0
|| (snapshot.SchemaVersion == 1 && snapshot.DaysPending != 0)
|| (long)snapshot.DaysCounted + snapshot.DaysPending != (last - first).Days + 1
|| snapshot.UpdatedAt == default || snapshot.UpdatedAt > DateTimeOffset.UtcNow.AddMinutes( 5 )
|| snapshot.DaysWithEntries < 0 || snapshot.DaysWithEntries > snapshot.DaysCounted
|| snapshot.Players is null || snapshot.Players.Count > (long)snapshot.DaysCounted * 3 ) return false;
var seen = new HashSet<long>();
DailyTrophyPlayer previous = null;
int rank = 0;
long gold = 0, silver = 0, bronze = 0;
for ( int i = 0; i < snapshot.Players.Count; i++ )
{
var player = snapshot.Players[i];
if ( player is null || player.SteamId?.Length != 17 || player.AccountId <= 0 || !seen.Add( player.AccountId )
|| string.IsNullOrWhiteSpace( player.Name ) || player.Name.Length > 256
|| player.Gold < 0 || player.Silver < 0 || player.Bronze < 0
|| (long)player.Gold + player.Silver + player.Bronze < 1
|| (long)player.Gold + player.Silver + player.Bronze > snapshot.DaysCounted
|| 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;
gold += player.Gold; silver += player.Silver; bronze += player.Bronze;
previous = player;
}
return gold == snapshot.DaysWithEntries && silver <= gold && bronze <= silver;
}
}
public sealed class DailyTrophySnapshot
{
public int SchemaVersion { get; set; }
public string Ranking { get; set; }
public string FirstDay { get; set; }
public string ThroughDay { get; set; }
public int DaysCounted { get; set; }
public int DaysPending { get; set; }
public int DaysWithEntries { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public List<DailyTrophyPlayer> Players { get; set; }
}
public sealed class DailyTrophyPlayer
{
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;
}