UI/LeaderboardRows.cs
using System;
using System.Collections.Generic;
using Sandbox;
namespace BlockParty;
/// <summary>
/// One renderable leaderboard line, shared by the High Scores page and the level-select footer so
/// the two feed identical data into the shared <c>LeaderboardRow</c> component. Debug rows set
/// <see cref="TimeSeconds"/> directly; real rows leave it NaN and carry a <see cref="DataUrl"/> whose
/// payload (time / character / block tally / replay input) is fetched and cached lazily on render by
/// <see cref="RunDataStore"/>.
/// </summary>
public struct LeaderboardRowData
{
public long SteamId;
public string Name;
/// <summary>The score as displayed — the whole-number part of <see cref="Value"/>.</summary>
public int Score;
/// <summary>The board entry's full value. Its fractional part is the hidden finish-time tiebreaker
/// (see <see cref="Leaderboard.Submit"/>), so this — not <see cref="Score"/> — is what the board
/// actually sorts on, and what a spliced row has to be ordered against.</summary>
public double Value;
public string DataUrl;
public string CountryCode;
public DateTimeOffset Timestamp;
/// <summary>Debug/inline rows carry their time here directly. NaN means "resolve from the payload",
/// which every backend row wants — and it is NOT the default, so a row built from a board entry has
/// to set it explicitly or it renders a bogus 00:00 instead of waiting for its DataUrl.</summary>
public float TimeSeconds;
}
/// <summary>
/// Helpers for post-processing a fetched board before it's displayed.
/// </summary>
public static class LeaderboardRows
{
/// <summary>
/// Order rows by value and submission date, then show the local player's just-submitted run
/// even when the board response predates it.
///
/// Submitting is immediate, but the leaderboard query is an HTTP GET whose response is cached
/// upstream per-URL, so for a few minutes after a run the board can still hand back your OLD
/// score — while a differently-shaped query (a different scope, or the center-on-me page) returns
/// the new one. That's what produces the confusing state where two screens disagree.
///
/// So if we submitted something better this session than what came back, we patch it in from
/// <see cref="Leaderboard.TryGetLocalBestRun"/>. The payload is primed into <see cref="RunDataStore"/>
/// under a synthetic url, which means the spliced row is indistinguishable from a real one: it
/// renders its time, character and block tally, and its replay button works.
///
/// <paramref name="maxEntries"/> is the size of the window the caller asked the backend for.
/// <paramref name="confirmedLocalScore"/> comes from this response BEFORE trimming the display
/// window. A confirmed run outside that window must not be reinserted using a local timestamp.
/// <paramref name="allowLocalPending"/> is false for closed daily boards: only server-confirmed rows count.
/// </summary>
public static void SpliceLocalBest( List<LeaderboardRowData> rows, string statName, int maxEntries,
double? confirmedLocalScore = null, bool allowLocalPending = true )
{
if ( rows is null ) return;
if ( !allowLocalPending ) rows.RemoveAll( r => r.DataUrl?.StartsWith( "local-run://", StringComparison.Ordinal ) == true );
rows.Sort( Compare );
if ( !allowLocalPending ) return;
if ( !Leaderboard.TryGetLocalBestRun( statName, out var best ) || best.Data is null )
return;
if ( confirmedLocalScore.HasValue && confirmedLocalScore.Value >= best.Score )
return;
long me = (long)Game.SteamId;
int existing = rows.FindIndex( r => r.SteamId == me );
// The window we're allowed to fill. Normally maxEntries, but a caller can legitimately hold a
// LONGER list than it asked the backend for — a debug fan-out, or a backend that returned more
// than requested — and silently truncating that would be a regression, not a fix. Captured
// before any mutation so the patch path (remove + re-insert) can't shrink the list either.
int limit = Math.Max( maxEntries, rows.Count );
// The response already reflects this run (or something better we didn't submit here) — the
// cache has caught up, so keep its authoritative timestamp and payload.
if ( existing >= 0 && rows[existing].Value >= best.Score )
return;
string dataUrl = LocalDataUrl( statName, me );
RunDataStore.Prime( dataUrl, best.Data );
LeaderboardRowData row;
if ( existing >= 0 )
{
// We're listed, just with a stale run. Keep the backend's own metadata for us: the name it
// has on file, and the country code, which the row doesn't draw but does hand to
// ReplaySubmitter for the replay HUD. Only the run itself is out of date.
row = rows[existing];
rows.RemoveAt( existing );
}
else
{
row = new LeaderboardRowData
{
SteamId = me,
Name = new Friend( Game.SteamId ).Name,
CountryCode = null, // not known locally; only affects the replay HUD, not the row
};
}
row.Value = best.Score;
row.Score = best.Data.FinalScore;
row.DataUrl = dataUrl;
row.Timestamp = best.SubmittedAt;
row.TimeSeconds = float.NaN; // resolved from the primed payload, exactly like a real row
// Use the same earlier-submission tiebreaker as fetched rows.
int index = rows.FindIndex( r => Compare( row, r ) < 0 );
if ( index < 0 )
index = rows.Count;
// Falling outside a full window is a legitimate outcome, not staleness: this is either a
// top-N page (the level-select footer) or one centred somewhere else, and we don't make the
// cut. Only applies to a row we're adding — one that was already listed always goes back,
// and can only have moved UP, since its score just improved.
if ( existing < 0 && index >= limit )
return;
rows.Insert( index, row );
// Only ever trim the overflow this insert caused. The patch path is already back to its
// original length here, so it never reaches this.
if ( rows.Count > limit )
rows.RemoveRange( limit, rows.Count - limit );
}
private static int Compare( LeaderboardRowData a, LeaderboardRowData b ) =>
LeaderboardOrder.Compare( a.Value, a.Timestamp, a.SteamId, b.Value, b.Timestamp, b.SteamId );
/// <summary>
/// Indices of rows whose whole-second mm:ss matches the row immediately above or below. Those rows
/// reveal their milliseconds so tied times can be told apart. Shared by the High Scores page and
/// the level-select footer so both agree on when ms show.
///
/// Computed over the whole list, not just the visible window, so neighbors across a window boundary
/// can still match. <paramref name="resolveTime"/>
/// hands back a row's time by index — null while its payload is still loading — which leaves the
/// caller in charge of which rows it's willing to fetch a payload for.
/// </summary>
public static HashSet<int> ComputeMsRows( List<LeaderboardRowData> rows, Func<int, float?> resolveTime )
{
var dupes = new HashSet<int>();
string previousKey = null;
for ( int i = 0; i < rows.Count; i++ )
{
var time = resolveTime( i );
var key = time is null ? null : LeaderboardRow.FormatTime( time.Value );
if ( key != null && key == previousKey )
{
dupes.Add( i - 1 );
dupes.Add( i );
}
previousKey = key;
}
return dupes;
}
/// <summary>Synthetic <c>DataUrl</c> for a spliced row. Never fetched — <see cref="RunDataStore.Prime"/>
/// puts the payload in the cache under this key first. Stable per (stat, player) so re-entering a
/// screen reuses the same cache slot instead of growing a new one each time.</summary>
private static string LocalDataUrl( string statName, long steamId ) => $"local-run://{statName}/{steamId}";
}