Game/Leaderboard.cs
namespace BlockParty;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
/// <summary>
/// Thin wrapper around sbox's stats/leaderboard services for the single "high score"
/// board. We submit each completed run's score as a stat event; the board is queried with
/// <c>Max</c> aggregation + descending sort, so every player shows their personal best.
///
/// The <see cref="StatName"/> stat is created on first submit — no dashboard setup needed —
/// and submitting/querying works from the editor, so playing in-editor populates the board
/// with your own scores. (The first query of a brand-new stat can come back empty while the
/// backend warms up; the UI just shows "NO ENTRIES YET" until the next refresh.)
/// </summary>
public static class Leaderboard
{
// All-time personal bests CONFIRMED by board reads this session. Missing means the backend result
// is unknown, while NegativeInfinity means a successful query found no entry for the local player.
// Submitted values are deliberately NOT merged: the engine drops a failed upload without retrying,
// so caching a submit as the best would suppress every later run that beat the real board entry.
// The cost is a redundant submit when a run beats the confirmed best but not this session's, which
// Max aggregation absorbs.
private static readonly Dictionary<string, double> _personalBests = new();
private static readonly HashSet<string> _personalBestQueries = new();
// The actual run behind each stat's best score submitted THIS SESSION, kept so the UI can splice
// it into a board response that predates the submission. Separate from _personalBests, which only
// board reads raise — those carry no payload, and a value we didn't submit ourselves is already on
// the board by definition. Session-only is enough: it only has to cover the gap until the backend
// stops serving a cached response, which is minutes at most.
private static readonly Dictionary<string, LocalBestRun> _personalBestRuns = new();
/// <summary>
/// Bump this to start a fresh board — it's baked into <see cref="StatName"/>, so a new
/// version queries/submits a brand-new stat and the old scores simply stop being shown. Also
/// folded into the daily gen seed, so a bump re-rolls every day's layout. Sim behaviour changes
/// bump <see cref="Sim.VERSION"/> instead — the two are independent (see that constant's doc).
/// To wipe ONE level's boards, bump its entry in <see cref="LevelBoardRevisions"/> instead.
/// </summary>
public const int LEADERBOARD_VERSION = 40;
/// <summary>Per-level board revisions. Bumping a level's entry wipes that level's boards alone
/// (its main board and every character board, which derive from it) without rolling every board
/// via LEADERBOARD_VERSION — for a sim bug only one level's runs hit, or a level edited after
/// scores landed. Folded into <see cref="StatNameForLevel"/> as <c>score_vN_levelid_r2</c>; unlisted
/// levels stay unsuffixed. Workshop levels are excluded (their hash suffix already rolls per
/// revision). Removing an entry brings the old board back; clear the table on the next
/// LEADERBOARD_VERSION bump. Keyed by registry id, e.g. <c>["lanes"] = 1</c>.</summary>
private static readonly Dictionary<string, int> LevelBoardRevisions = new()
{
//["lanes"] = 1,
};
/// <summary>Base stat name; the live <see cref="StatName"/> appends the version suffix.</summary>
private const string StatBaseName = "score";
/// <summary>Versioned base stat name; every level's board is <c>score_vN_levelid</c> (see
/// <see cref="StatNameForLevel"/>).</summary>
public static string StatName => $"{StatBaseName}_v{LEADERBOARD_VERSION}";
/// <summary>Per-level stat name (<c>score_vN_levelid</c>). Null/empty maps to classic. Falls back
/// to the raw id if the level registry hasn't loaded yet (early boot), so it never NREs. Workshop
/// levels append their content hash (<c>score_vN_ws123-hash8</c>), so an author republish starts a
/// fresh board — and an UNREGISTERED workshop id keeps its own name rather than falling back to
/// classic, which would silently query/poison classic's board.</summary>
public static string StatNameForLevel( string levelId )
{
var resolved = Levels.Get( levelId );
if ( resolved is null && WorkshopLevels.IsWorkshopId( levelId ) )
{
Log.Warning( $"BlockParty: stat requested for unregistered workshop level '{levelId}'." );
return $"{StatName}_{levelId}";
}
resolved ??= Levels.Classic;
var id = resolved?.Id ?? (string.IsNullOrEmpty( levelId ) ? Levels.ClassicId : levelId);
if ( resolved?.IsWorkshop == true && !string.IsNullOrEmpty( resolved.WorkshopContentHash ) )
return $"{StatName}_{id}-{resolved.WorkshopContentHash}";
return LevelBoardRevisions.TryGetValue( id, out int rev ) && rev > 0
? $"{StatName}_{id}_r{rev}"
: $"{StatName}_{id}";
}
/// <summary>Per-level, per-character stat name. Character ids are resolved through the registry so
/// null, unknown, or retired ids consistently share the Original character's board.</summary>
public static string StatNameForLevelAndCharacter( string levelId, string characterId ) =>
$"{StatNameForLevel( levelId )}_character_{Characters.Get( characterId ).Id}";
/// <summary>Per-day stat name for daily challenges. One stat per UTC date lets old daily boards be
/// browsed forever without mixing them into the normal high-score table.</summary>
public static string DailyStatName( string dailyId ) => DailyChallenge.StatNameFor( dailyId );
/// <summary>How many entries the highscore screen requests.</summary>
public const int MaxEntries = 200;
/// <summary>Start loading the local player's relevant all-time bests. Failed or unfinished queries
/// deliberately leave a stat unknown so a finished run submits to that board as a safe fallback.</summary>
public static void PrimePersonalBests( RunContext context )
{
if ( context == default )
context = RunContext.Normal;
if ( context.IsDaily )
{
PrimePersonalBest( DailyStatName( context.DailyId ) );
return;
}
PrimePersonalBest( StatNameForLevel( context.LevelId ) );
PrimePersonalBest( StatNameForLevelAndCharacter( context.LevelId, context.CharacterId ) );
}
private static void PrimePersonalBest( string statName )
{
if ( _personalBests.ContainsKey( statName ) || !_personalBestQueries.Add( statName ) )
return;
_ = FetchPersonalBest( statName );
}
private static async Task FetchPersonalBest( string statName )
{
try
{
var board = GetBoard( statName );
await board.Refresh();
var localEntry = board.Entries?.FirstOrDefault( entry => entry.SteamId == (long)Game.SteamId );
RecordPersonalBest( statName, localEntry?.Value );
}
catch ( System.Exception e )
{
Log.Info( $"BlockParty: personal-best query unavailable ({e.Message}) [stat '{statName}']; submission will use the safe fallback." );
}
finally
{
_personalBestQueries.Remove( statName );
}
}
/// <summary>A run we submitted this session: the full packed <paramref name="Score"/> (including its
/// fractional time tiebreaker, so it sorts against board entries exactly), the payload itself, and
/// when we submitted it. See <see cref="TryGetLocalBestRun"/>.</summary>
public readonly record struct LocalBestRun( double Score, RunData Data, System.DateTimeOffset SubmittedAt );
/// <summary>The best run this session submitted to <paramref name="statName"/>, if any. The UI uses
/// it to show your new score immediately on a board response that was cached before you submitted.</summary>
public static bool TryGetLocalBestRun( string statName, out LocalBestRun run )
{
run = default;
return !string.IsNullOrEmpty( statName ) && _personalBestRuns.TryGetValue( statName, out run );
}
/// <summary>Merge a successful board query into the session cache. A null score means the query
/// completed but the local player had no entry; a known value is never lowered.</summary>
public static void RecordPersonalBest( string statName, double? score )
{
double known = score ?? double.NegativeInfinity;
if ( !_personalBests.TryGetValue( statName, out double current ) || known > current )
_personalBests[statName] = known;
}
/// <summary>
/// Submit a finished run's score, then flush so it's queryable promptly. The value is a
/// <c>double</c>: callers may pack a sub-integer remainder into it as a leaderboard tiebreaker
/// (e.g. precise finish time on a win) — the board sorts by the full value, while the UI shows
/// only the integer part, so the fraction is invisible but still breaks ties. The run's elapsed
/// <paramref name="timeSeconds"/>, <paramref name="victory"/> flag, and the recorded input needed
/// to replay the run all ride along as a JSON data payload (read back via <c>Entry.DataUrl</c>).
/// The final block states (<paramref name="blocks"/>) ride along too so the row can draw a graphic
/// of which sides were pressed (mirrors the score-tally screen).
/// </summary>
public static RunData Submit( double score, float timeSeconds, bool victory, int finalScore, IReadOnlyList<BlockResult> blocks, RunContext context = default )
{
if ( context == default )
context = RunContext.Normal;
var data = BuildRunData( timeSeconds, victory, finalScore, blocks, context );
string levelStat = StatNameForLevel( context.LevelId );
string characterStat = context.IsDaily ? null : StatNameForLevelAndCharacter( context.LevelId, context.CharacterId );
// Keep a local copy so the player can rewatch their own runs offline (My Replays screen),
// including when every known leaderboard already has a better score.
LocalReplays.Add( data );
// Named 'data:' arg binds the (name, amount, string context = null, object data = null)
// overload — passing the object positionally would fail to bind (it converts to neither
// string nor Dictionary). The engine serializes it to JSON and exposes it on Entry.DataUrl.
//
// Normal runs count toward the level's overall board and the board for their selected character.
// Daily runs stay isolated on their per-day board because each day plays its own generated layout.
// All boards use Max aggregation. Unknown stats submit as a safe fallback; known stats only
// submit when this run improves the CONFIRMED value, including its hidden time tiebreaker.
var stats = context.IsDaily
? new List<string> { DailyStatName( context.DailyId ) }
: new List<string> { levelStat, characterStat };
var submittedStats = new List<string>( stats.Count );
foreach ( string statName in stats )
{
if ( _personalBests.TryGetValue( statName, out double best ) && score <= best )
continue;
Sandbox.Services.Stats.SetValue( statName, score, data: data );
// Hold onto the run itself: the board can keep serving a pre-submit response for a few
// minutes, and the UI splices this in so you see your new score right away. Only the
// session's best: a later run can still submit below it (see _personalBests).
if ( !_personalBestRuns.TryGetValue( statName, out var bestRun ) || score > bestRun.Score )
_personalBestRuns[statName] = new LocalBestRun( score, data, System.DateTimeOffset.Now );
submittedStats.Add( statName );
}
if ( submittedStats.Count > 0 )
{
Sandbox.Services.Stats.Flush();
Log.Info( $"BlockParty: submitted score {score} (time {timeSeconds:0.00}s, victory {victory}, {RunRecorder.StepCount} steps, seed {RunRecorder.Seed}) to {string.Join( ", ", submittedStats )}." );
}
else
{
Log.Info( $"BlockParty: saved score {score} locally; confirmed personal bests already meet or exceed it on every relevant leaderboard." );
}
return data;
}
/// <summary>Build the deterministic replay payload for the run that just finished (seed + per-step
/// input read from <see cref="RunRecorder"/>, plus the block tally and run context). Shared by
/// <see cref="Submit"/> (leaderboard runs) and the editor test-play path, which saves a LOCAL
/// replay without submitting. The recorder holds the finished run until the next run begins.</summary>
public static RunData BuildRunData( float timeSeconds, bool victory, int finalScore, IReadOnlyList<BlockResult> blocks, RunContext context )
{
if ( context == default )
context = RunContext.Normal;
return new RunData
{
TimeSeconds = timeSeconds,
Victory = victory,
Seed = RunRecorder.Seed,
SimVersion = Sim.VERSION,
StepCount = RunRecorder.StepCount,
InputDeltas = RunRecorder.Encode(),
Truncated = RunRecorder.Truncated,
FinalScore = finalScore,
Blocks = RunData.PackBlocks( blocks ),
DailyId = context.IsDaily ? context.DailyId : null,
LevelId = context.Level.Id,
// Every run records which level REVISION it was played on: replays re-sim the level they
// resolve at watch time (registry / regenerated daily / installed workshop copy) and are
// only watchable while its content hash still matches (see RunData.CanReplay).
LevelHash = context.Level.ContentHash,
CharacterId = context.Character.Id,
};
}
/// <summary>Build the all-time high-score board for the player's last-viewed level, applying the
/// last-used Global/Friends scope. Highest score first.</summary>
public static Sandbox.Services.Leaderboards.Board2 GetBoard() =>
GetBoard( StatNameForLevel( Settings.Current.LeaderboardLevelId ),
Settings.Current.LeaderboardScope );
/// <summary>Build an all-time board centered on the local player's best score for a specific
/// stat, optionally limited to friends.</summary>
public static Sandbox.Services.Leaderboards.Board2 GetBoard( string statName,
LeaderboardScope scope = LeaderboardScope.Global, int maxEntries = MaxEntries )
{
var board = Sandbox.Services.Leaderboards.GetFromStat( statName );
board.SetAggregationMax();
board.SetSortDescending();
board.FilterByNone();
board.SetFriendsOnly( scope == LeaderboardScope.Friends );
board.CenterOnMe();
board.MaxEntries = maxEntries;
return board;
}
/// <summary>Build a compact all-time board of the TOP entries for a stat (highest first), applying
/// the same scope filter as <see cref="GetBoard(string, LeaderboardScope, int)"/> but WITHOUT center-on-me.
/// <paramref name="maxEntries"/> keeps the query small.</summary>
public static Sandbox.Services.Leaderboards.Board2 GetTopBoard( string statName, int maxEntries,
LeaderboardScope scope = LeaderboardScope.Global )
{
var board = Sandbox.Services.Leaderboards.GetFromStat( statName );
board.SetAggregationMax();
board.SetSortDescending();
board.FilterByNone();
board.SetFriendsOnly( scope == LeaderboardScope.Friends );
board.MaxEntries = maxEntries;
return board;
}
/// <summary>Build the leaderboard for a daily challenge day. The current sbox Board2 API exposes
/// Sum/Avg/Min/Max/Last aggregation but not First, so repeat-submission protection still relies on
/// local locking plus checking whether this player already appears on the day board. If First is added
/// later, this is the single place to switch daily aggregation from Max to First.
/// <para>
/// The board is DAY-FILTERED server-side: the backend buckets every stat write by the UTC date it
/// was INGESTED (PlayerStatDaily), and FilterByDay ranks only the challenge day's bucket. So a
/// client patched to play a past day and submit to its stat lands in the wrong bucket and simply
/// never appears here — enforced by the backend's clock, which no client edit can reach. Honest
/// runs always land in-bucket because PLAY is only offered on the day itself. Known edge: a run
/// submitted just after UTC midnight (started ~23:59, or delayed by the engine's ~30s stat
/// batching) lands in the next day's bucket and misses its board.
/// </para></summary>
public static Sandbox.Services.Leaderboards.Board2 GetDailyBoard( string dailyId )
{
var board = GetTopBoard( DailyStatName( dailyId ), MaxEntries );
if ( DailyChallenge.TryParseId( dailyId, out var day ) )
{
board.FilterByDay();
board.SetDatePeriod( day );
}
return board;
}
// Shared core of the entry↔payload sanity checks below: the submitted stat value is exactly
// FinalScore + TimeTiebreaker(TimeSeconds) (see GameStage.SubmitRunOnce — same float in, same code),
// and the pipeline is double end-to-end, so for an honest entry this reproduces bit-for-bit. The
// tolerance is far below the tiebreaker's resolution (~2.8e-6 per second of run time), so it only
// absorbs float↔JSON round-trip noise, not a forged time.
private static bool ValueMatchesPayload( RunData data, double value )
=> System.Math.Abs( value - (data.FinalScore + ScoreCalc.TimeTiebreaker( data.TimeSeconds )) ) < 1e-9;
/// <summary>Cheap display-side sanity check between a daily-board entry and its run payload; rows
/// failing it are hidden (see the daily screen's pruning). Catches lazily fabricated writes — an
/// arbitrary Stats.SetValue with a missing, borrowed, or mismatched payload: the payload must claim
/// this exact day, carry the day's deterministic seed, and the entry's submitted value must equal
/// the payload's own score + time tiebreaker (the exact value <c>GameStage</c> submits). It does NOT
/// re-simulate the input stream — a payload that passes here but was never really played still
/// desyncs visibly when watched.</summary>
public static bool IsDailyEntryConsistent( RunData data, string dailyId, double value )
=> data is not null
&& data.DailyId == dailyId
&& data.Seed == DailyChallenge.SeedFor( dailyId )
&& ValueMatchesPayload( data, value );
/// <summary>The same cheap sanity check for the NORMAL boards (per-level, and per-level-per-character
/// when <paramref name="characterId"/> is non-null): the payload must be a non-daily run on the
/// board's level (resolved exactly like <see cref="StatNameForLevel"/> resolves the stat name, so the
/// expectation always matches the stat actually being browsed), played as the board's character on a
/// character board, and the entry's value must equal the payload's score + tiebreaker. Level boards
/// have no daily-style server-side window, so this is their only tamper check — and a forged entry
/// here would otherwise sit on an all-time board forever. No seed check: normal runs draw a random
/// seed per run.</summary>
public static bool IsLevelEntryConsistent( RunData data, string levelId, string characterId, double value )
{
if ( data is null || data.IsDaily )
return false;
// Workshop boards: never resolve through the classic fallback (an uninstalled ws id would
// otherwise prune EVERY row as "not classic"), and bind each entry to the board's level
// revision — same spirit as the daily board's seed check.
if ( WorkshopLevels.IsWorkshopId( levelId ) )
{
var ws = Levels.Get( levelId );
return ws is not null
&& data.LevelId == ws.Id
&& data.LevelHash == ws.WorkshopContentHash
&& (characterId is null || Characters.Get( data.CharacterId ).Id == Characters.Get( characterId ).Id)
&& ValueMatchesPayload( data, value );
}
var resolved = Levels.Get( levelId ) ?? Levels.Classic;
var expectedLevelId = resolved?.Id ?? (string.IsNullOrEmpty( levelId ) ? Levels.ClassicId : levelId);
if ( data.LevelId != expectedLevelId )
return false;
// Character boards resolve both sides through the registry, mirroring
// StatNameForLevelAndCharacter (null/unknown → Original).
if ( characterId is not null && Characters.Get( data.CharacterId ).Id != Characters.Get( characterId ).Id )
return false;
return ValueMatchesPayload( data, value );
}
}