Game/ReplayViewStats.cs
using System;
using System.Globalization;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;
namespace BlockParty;
/// <summary>
/// One replay-screen visit. The manager retains this across seeks, restarts and HUD rebuilds.
/// Only Begin submits a view and reads the global sum and distinct contributing accounts once.
/// </summary>
public sealed class ReplayViewStats
{
private readonly string _statName;
private readonly CancellationTokenSource _lifetime = new();
private bool _cancelled;
private bool _finished;
// These engine operations are shared across replay visits. Re-entering while a request is
// stalled must not launch another request (or another engine waiter) for the same global data.
private static Task _flushTask;
private static Task _refreshTask;
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds( 15 );
public double? Viewers { get; private set; }
public double? Views { get; private set; }
public bool IsLoading { get; private set; } = true;
public int Revision { get; private set; }
private ReplayViewStats( string statName ) => _statName = statName;
/// <summary>
/// Uses existing recorded fields, so old replays work without migrating their payloads.
/// Matches archive identity, including author, character and level revision. Display metadata,
/// board version, JSON schema version and optional embedded level snapshots do not change it.
/// Keep this encoding stable: changing it would start new counters for existing replays.
/// </summary>
public static string StatNameFor( RunData run, long steamId )
{
string identity = JsonSerializer.Serialize( new string[]
{
steamId.ToString( CultureInfo.InvariantCulture ),
run.SimVersion.ToString( CultureInfo.InvariantCulture ),
run.Seed.ToString( CultureInfo.InvariantCulture ),
run.StepCount.ToString( CultureInfo.InvariantCulture ),
run.FinalScore.ToString( CultureInfo.InvariantCulture ),
run.InputDeltas ?? "",
run.DailyId ?? "",
run.LevelId ?? "",
run.LevelHash ?? "",
run.CharacterId ?? ""
} );
return "replay_views_" + identity.Md5().ToLowerInvariant();
}
public static ReplayViewStats Begin( RunData run, long steamId )
{
// Matches share-code export and local saving when the caller has no author metadata.
if ( steamId == 0 ) steamId = (long)Game.SteamId;
var visit = new ReplayViewStats( StatNameFor( run, steamId ) );
_ = visit.LoadAsync( visit._lifetime.Token );
return visit;
}
/// <summary>
/// Stop waiting and updating this visit. GlobalStats.Refresh has no cancellation API,
/// so an engine-owned HTTP request already in flight may finish in the background. The queued
/// view remains valid even when the user leaves immediately; cancelling never resubmits it.
/// </summary>
public void Cancel()
{
if ( _cancelled ) return;
_cancelled = true;
if ( !_finished ) _lifetime.Cancel();
}
private async Task LoadAsync( CancellationToken token )
{
try
{
Sandbox.Services.Stats.Increment( _statName, 1 );
EditorLog( $"Queued view for '{_statName}'; sharing pending flush: {_flushTask is not null && !_flushTask.IsCompleted}." );
// Do not gate reads on the upload: the installed FlushAndWaitAsync doesn't wait for
// ingestion, and cancellation doesn't bound its HTTP request or semaphore wait.
if ( _flushTask is null || _flushTask.IsCompleted )
_flushTask = FlushAsync();
// Read once on entry. This snapshot may predate the view queued above; submission
// proceeds independently, with no follow-up reads while this replay stays open.
token.ThrowIfCancellationRequested();
var started = DateTime.UtcNow;
try
{
var stats = Sandbox.Services.Stats.Global;
if ( stats is null )
{
EditorLog( $"Read for '{_statName}': global stats unavailable." );
return;
}
var previousRefresh = stats.LastRefresh;
EditorLog( $"Read for '{_statName}' started; last refresh UTC: {previousRefresh:O}; sharing pending refresh: {_refreshTask is not null && !_refreshTask.IsCompleted}." );
if ( _refreshTask is null || _refreshTask.IsCompleted )
{
_refreshTask = stats.Refresh();
_ = ObserveAsync( _refreshTask );
}
await AwaitBoundedAsync( _refreshTask, token );
token.ThrowIfCancellationRequested();
bool found = stats.TryGet( _statName, out var stat );
if ( Application.IsEditor )
{
string age = stats.LastRefresh == default ? "never refreshed" : $"{(DateTime.UtcNow - stats.LastRefresh).TotalSeconds:F2}s";
string result = found ? $"present; viewers={stat.Players}, views={stat.Sum}" : "absent";
EditorLog( $"Read for '{_statName}' completed in {(DateTime.UtcNow - started).TotalSeconds:F2}s; stat {result}; last refresh UTC: {stats.LastRefresh:O}; cache age: {age}; refresh timestamp advanced: {stats.LastRefresh > previousRefresh}. An unchanged timestamp can mean a cached result or no successful fetch." );
}
if ( found )
{
Viewers = stat.Players;
Views = stat.Sum;
}
}
catch ( OperationCanceledException ) when ( token.IsCancellationRequested )
{
throw;
}
catch ( Exception e )
{
EditorLog( $"Read for '{_statName}' failed after {(DateTime.UtcNow - started).TotalSeconds:F2}s ({e.Message})." );
}
}
catch ( OperationCanceledException ) when ( token.IsCancellationRequested ) { }
catch ( Exception e )
{
EditorLog( $"View submission for '{_statName}' failed ({e.Message})." );
}
finally
{
if ( !token.IsCancellationRequested )
{
IsLoading = false;
Revision++;
}
_finished = true;
_lifetime.Dispose();
}
}
private static async Task FlushAsync()
{
try
{
await Sandbox.Services.Stats.FlushAsync();
EditorLog( "Flush call completed; this does not confirm backend ingestion or that pending views were included." );
}
catch ( Exception e )
{
// An uncertain submission must not be retried: it may already have counted remotely.
EditorLog( $"Flush failed ({e.Message})." );
}
}
private static void EditorLog( string message )
{
if ( Application.IsEditor ) Log.Info( $"[BlockParty] Replay views: {message}" );
}
private static async Task AwaitBoundedAsync( Task request, CancellationToken token )
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource( token );
timeout.CancelAfter( RequestTimeout );
try
{
// Task.WhenAny is not on s&box's whitelist. Yield between completion
// checks instead; this never starts another HTTP request and exit cancels the delay.
while ( !request.IsCompleted )
await Task.Delay( 50, timeout.Token );
token.ThrowIfCancellationRequested();
await request;
}
catch ( OperationCanceledException ) when ( timeout.IsCancellationRequested && !token.IsCancellationRequested )
{
throw new TimeoutException( "Replay stats read timed out." );
}
}
// Observe once per engine request, even if every visit waiting for it is cancelled. Late
// failures do not touch a retired visit or create another waiter per timed-out/cancelled visit.
private static async Task ObserveAsync( Task request )
{
try { await request; }
catch { }
}
}