A static cache and lazy fetcher for run payload JSONs referenced by leaderboard rows. It returns cached RunData, starts a single async fetch per URL, caches failures as null, supports callbacks for when data arrives, and allows priming or invalidating failed entries.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Sandbox;
namespace BlockParty;
/// <summary>
/// Lazy, cached fetcher for run payloads. Leaderboard entries expose a <c>DataUrl</c> (the JSON file
/// the payload was submitted with); this fetches/caches it per URL so the score list only loads each
/// row once and a repaint is cheap. Failures cache as null until a board reload permits a retry.
/// (Mirrors SS2's <c>RunResultStore</c>.)
/// </summary>
public static class RunDataStore
{
/// <summary>Changes when a payload/failure arrives, is replaced, or a failure is invalidated.</summary>
public static int Revision { get; private set; }
private static readonly Dictionary<string, RunData> _cache = new();
private static readonly HashSet<string> _inFlight = new();
// Every consumer waiting on an in-flight url (a row component + the host's ms-dedup can both want
// the same payload). All are notified when it lands — a single-callback store would leave the
// later callers (e.g. the child row component) un-repainted.
private static readonly Dictionary<string, List<Action>> _pending = new();
/// <summary>
/// Returns the cached payload for this DataUrl, or null while it loads (or if it has no data /
/// failed to load). Kicks off a one-time fetch on the first miss; <paramref name="onLoaded"/>
/// fires when it lands (pass a repaint callback so the row updates once the time arrives). Multiple
/// distinct callbacks for the same url are all invoked; the same callback isn't queued twice.
/// </summary>
public static RunData Get( string dataUrl, Action onLoaded = null )
{
if ( string.IsNullOrEmpty( dataUrl ) )
return null;
if ( _cache.TryGetValue( dataUrl, out var cached ) )
return cached;
if ( onLoaded is not null )
{
if ( !_pending.TryGetValue( dataUrl, out var list ) )
{
list = new List<Action>();
_pending[dataUrl] = list;
}
if ( !list.Contains( onLoaded ) )
list.Add( onLoaded );
}
if ( !_inFlight.Contains( dataUrl ) )
{
_inFlight.Add( dataUrl );
_ = Fetch( dataUrl );
}
return null;
}
/// <summary>
/// Cache-only lookup: the payload if it has already landed, otherwise null — unlike
/// <see cref="Get"/> this never starts a fetch. For callers that will happily use a payload that
/// happens to be cached but must not pull one in themselves (the level-select footer's ms-dedup
/// over its off-screen rows, which would otherwise fetch a whole board per highlighted level).
/// </summary>
public static RunData Peek( string dataUrl ) => TryPeek( dataUrl, out var cached ) ? cached : null;
/// <summary>Cache-only lookup that distinguishes a completed failure (true, null) from a
/// payload not yet loaded (false). Neither form starts a request.</summary>
public static bool TryPeek( string dataUrl, out RunData data )
{
data = null;
return !string.IsNullOrEmpty( dataUrl ) && _cache.TryGetValue( dataUrl, out data );
}
/// <summary>Allow one new lazy fetch after a board reload. Does not fetch off-screen rows,
/// evict successful payloads, or disturb requests already in flight. Never call per frame.</summary>
public static void InvalidateFailure( string dataUrl )
{
if ( string.IsNullOrEmpty( dataUrl ) || _inFlight.Contains( dataUrl )
|| !_cache.TryGetValue( dataUrl, out var data ) || data is not null ) return;
_cache.Remove( dataUrl );
Revision++;
}
/// <summary>
/// Seed the store with a payload we already hold in memory, keyed by a synthetic url that a row
/// then carries as its DataUrl. Used for a locally-spliced row — a run we submitted that the
/// board hasn't served back to us yet (see <see cref="LeaderboardRows.SpliceLocalBest"/>) — so it
/// resolves its time / character / block tally and offers replay exactly like a backend row, with
/// no fetch. Overwrites, so a later better run under the same key replaces the earlier one.
/// </summary>
public static void Prime( string dataUrl, RunData data )
{
if ( string.IsNullOrEmpty( dataUrl ) || data is null )
return;
if ( !_cache.TryGetValue( dataUrl, out var previous ) || !ReferenceEquals( previous, data ) )
{
_cache[dataUrl] = data;
Revision++;
}
// Belt-and-braces: if anything already asked for this key before we primed it, retire the
// fetch and repaint those waiters rather than leaving them hanging on a url that can't load.
_inFlight.Remove( dataUrl );
if ( _pending.Remove( dataUrl, out var waiting ) )
foreach ( var cb in waiting )
cb?.Invoke();
}
private static async Task Fetch( string dataUrl )
{
try
{
_cache[dataUrl] = await Http.RequestJsonAsync<RunData>( dataUrl );
}
catch ( Exception e )
{
Log.Warning( $"BlockParty: failed to fetch run data from {dataUrl}: {e.Message}" );
_cache[dataUrl] = null; // cache the failure so we don't retry it every frame
}
finally
{
Revision++;
_inFlight.Remove( dataUrl );
if ( _pending.Remove( dataUrl, out var list ) )
foreach ( var cb in list )
cb?.Invoke();
}
}
}