Game/RunDataStore.cs

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.

NetworkingHttp Calls
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();
		}
	}
}