Game/RunData.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Sandbox;
namespace BlockParty;
/// <summary>
/// Decoded per-block tally state for the leaderboard / replay row graphic: the block's type, its
/// final phase, and which of its four sides were pressed in that phase. Enough to reproduce the
/// score-tally end-state look (faded block, sides lit per phase, full colour + yellow max sides).
/// </summary>
public readonly record struct BlockTallyState( BlockType Type, int Phase, bool Left, bool Right, bool Up, bool Down );
/// <summary>
/// Small JSON payload attached to each leaderboard run via
/// <c>Stats.SetValue( name, score, data: ... )</c> and read back from <c>Board2.Entry.DataUrl</c>.
/// Carries the run's elapsed time so the high-score screen can show how long each run took (mm:ss).
/// Strongly typed so it round-trips through System.Text.Json. (Same approach SS2 uses for its richer
/// run payload — the score stat itself stays a plain number; anything extra rides along as data.)
/// </summary>
public class RunData
{
/// <summary>Schema version of this payload, so the reader can reject a shape it doesn't
/// understand. Older versions are simply not replayable — no legacy decoding is kept.</summary>
public const int CURRENT_VERSION = 8;
public int Version { get; set; } = CURRENT_VERSION;
/// <summary>Elapsed gameplay time in seconds for this run (precise; format on display).</summary>
public float TimeSeconds { get; set; }
/// <summary>True if the run was a full win (every block reached max phase).</summary>
public bool Victory { get; set; }
// --- replay (v2+) -------------------------------------------------------------------
// The run is deterministic at a fixed step with all randomness seeded, so it can be re-simulated
// from (Seed + the input consumed each step). SimVersion guards against replaying a run recorded
// by a gameplay build this sim no longer honours (which would desync); outside the supported
// range the run just isn't replayable.
/// <summary>Simulation seed the run was recorded with (reused verbatim on replay).</summary>
public int Seed { get; set; }
/// <summary>Simulation version the run was recorded on (<see cref="Sim.VERSION"/>); a replay only
/// reproduces on a build whose sim honours that version (post-release, older versions stay
/// playable through per-fix version gates — see the Sim.VERSION doc).</summary>
public int SimVersion { get; set; }
/// <summary>Number of fixed sim steps in the run (the decoded replay length).</summary>
public int StepCount { get; set; }
/// <summary>Base64 of the delta-encoded per-step input (see <see cref="RunInputCodec"/>).</summary>
public string InputDeltas { get; set; }
/// <summary>True if the run hit the recording cap (too long to replay).</summary>
public bool Truncated { get; set; }
/// <summary>Final integer score of the run, for an optional desync check at the end of playback.</summary>
public int FinalScore { get; set; }
// --- block tally state (v3+) --------------------------------------------------------
// Tiny per-block summary so the leaderboard / My Replays rows can draw a graphic of which
// block sides were pressed during the run (mirrors the score-tally screen). Blocks authored to
// start fully completed are omitted. Each int packs
// one block: (typeIndex << 6) | (phase << 4) | (L<<3 | R<<2 | U<<1 | D). Absent on v2 payloads,
// in which case the row simply omits the graphic.
/// <summary>Packed per-block tally state (see <see cref="PackBlocks"/> / <see cref="DecodeBlocks"/>).
/// Null/absent on pre-v3 payloads.</summary>
public List<int> Blocks { get; set; }
// --- daily challenge identity (v4+) ---------------------------------------------------
// DailyId is the only stored daily field. IsDaily is derived from it (below), and DailyId itself
// is omitted from the serialized payload unless set — so the common non-daily run carries no daily
// fields at all, keeping the leaderboard payload small.
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingDefault )]
public string DailyId { get; set; }
/// <summary>True when this run came from a daily challenge. Derived from <see cref="DailyId"/>, so
/// it needn't be stored or serialized separately.</summary>
[JsonIgnore]
public bool IsDaily => !string.IsNullOrEmpty( DailyId );
// --- level identity (v5+) -------------------------------------------------------------
// Which level the run was played on, so a replay re-enters the same level (the layout is
// re-simulated, not stored — an edited level definition silently desyncs its old replays).
public string LevelId { get; set; }
// --- level revision (v8; workshop-only originally, ALL runs since the sim/board version split) --
// The 8-hex content hash of the exact level revision the run was played on (LevelDef.ContentHash).
// Replays re-sim whatever level they resolve at watch time — registry JSON, regenerated daily,
// installed workshop copy — so they're gated on this matching: an edited level, a republished
// workshop item, or a re-rolled daily greys its old runs out instead of silently desyncing.
// Null only on payloads recorded before the stamp existed (tolerated: unverifiable, not invalid).
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingDefault )]
public string LevelHash { get; set; }
// --- embedded level layout (LOCAL replays only) ---------------------------------------
// Editor test-play runs ALWAYS play a transient snapshot of the in-memory editor model (never the
// disk file), so their level can't be reliably resolved by LevelId after the test ends — even a
// pristine, saved-to-disk level is played transiently, and could be edited on disk afterwards. So
// every test-play run embeds its exact level definition here and the replay re-simulates that
// identical layout. Absent (null) on every leaderboard run — those resolve their layout by
// LevelId — so the leaderboard payload shape is unchanged and no CURRENT_VERSION bump is needed
// (the field appears only in the local file and, base64-packed, in a transient run's share code —
// see ReplayCodeCodec).
// STORED as the canonical LevelJson wire form, never as a raw LevelDef: RectF doesn't JSON
// round-trip (only its computed getters serialize), so a raw def came back from disk with every
// obstacle / alt-rect zeroed and the next Save made that permanent. LevelData is decoded from the
// snapshot through the same FromJson/ToLevelDef path a share-code import takes, so a replay sims
// identical geometry whether watched in the recording session, after a restart or from a pasted
// code — and the regression key / ContentHash derived from it is stable across all three.
/// <summary>Canonical wire form of the embedded level (see the note above); null when the run
/// resolves its level by id. Set through <see cref="LevelData"/>.</summary>
[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingDefault )]
public LevelJson LevelSnapshot
{
get => _levelSnapshot;
set { _levelSnapshot = value; _levelData = null; _levelDecoded = false; }
}
[JsonIgnore]
private LevelJson _levelSnapshot;
[JsonIgnore]
private LevelDef _levelData;
[JsonIgnore]
private bool _levelDecoded;
/// <summary>The embedded level definition decoded from <see cref="LevelSnapshot"/>, or null when
/// there is none or it doesn't decode (a newer schema, or a hand-mangled file — the lenient DTO
/// parser throws on e.g. a null spike entry, and CanReplay is evaluated while rendering every
/// My Replays row, so a throw here would take the whole list down). Decoded once and cached
/// either way. Assigning stores the def's canonical wire form; the def read back is that form
/// decoded, not the instance set.</summary>
[JsonIgnore]
public LevelDef LevelData
{
get
{
if ( !_levelDecoded )
{
_levelDecoded = true;
try
{
_levelData = _levelSnapshot is null ? null : EditorLevel.FromVersionedJson( _levelSnapshot, out _ )?.ToLevelDef();
}
catch
{
_levelData = null;
}
}
return _levelData;
}
set
{
_levelSnapshot = value is null ? null : EditorLevel.FromLevelDef( value ).ToJson();
_levelData = null;
_levelDecoded = false;
}
}
/// <summary>The level this run plays: a daily run regenerates its level from <see cref="DailyId"/>
/// (deterministic per id within a leaderboard version); otherwise the embedded
/// <see cref="LevelData"/> when present (test-play local replays), otherwise resolved from
/// <see cref="LevelId"/> — null when that id names a level that no longer exists (such a run
/// can't be replayed).</summary>
[JsonIgnore]
public LevelDef Level => IsDaily ? DailyLevels.Get( DailyId )?.Level : LevelData ?? Levels.Get( LevelId );
// --- character identity (v6+) ---------------------------------------------------------
// Which character the run was played as. Purely visual today (all characters share movement),
// but recorded so a replay renders the right character — and so it stays correct once future
// characters have differing movement. An unknown/removed character falls back to the default,
// so it never blocks replay (unlike a missing level).
public string CharacterId { get; set; }
/// <summary>The character this run plays, resolved from <see cref="CharacterId"/> (null/unknown
/// → Original).</summary>
[JsonIgnore]
public CharacterDef Character => Characters.Get( CharacterId );
// Per-block packing layout (one int per block):
// bits 0-3 : sides (Left=8, Right=4, Up=2, Down=1)
// bits 4-5 : phase (0-3)
// bits 6-10 : BlockType (0-31) -- 5-bit field (widened from 4 in v7 when Siren became the 17th type)
// BlockType must stay within this field. Widening past 32 values needs a re-layout AND a
// CURRENT_VERSION bump (the guard in the static ctor enforces the ceiling).
private const int BLOCK_TYPE_SHIFT = 6;
private const int BLOCK_TYPE_MASK = 0x1F; // 5 bits -> up to 32 block types
static RunData()
{
// Ties BlockType's cardinality to the block-tally field width so adding a 9th+ type can't
// silently wrap (type 8 -> Dragon, etc.) in leaderboard graphics / replay-code round-trips.
if ( Enum.GetValues<BlockType>().Length > BLOCK_TYPE_MASK + 1 )
throw new InvalidOperationException(
$"BlockType has more than {BLOCK_TYPE_MASK + 1} values; widen the block-tally packing " +
"field in RunData and bump RunData.CURRENT_VERSION." );
}
/// <summary>Pack the final block results into the compact <see cref="Blocks"/> form, omitting blocks
/// authored to start fully completed and ordering the rest by block type so the row graphic always
/// shows them in the same order (independent of the order the player happened to press them).</summary>
public static List<int> PackBlocks( IReadOnlyList<BlockResult> blocks )
{
if ( blocks is null )
return null;
var list = new List<int>( blocks.Count );
foreach ( var b in blocks.OrderBy( b => (int)b.Type ) )
{
if ( b.ScoreStartPhase >= Block.NUM_PHASES - 1 )
continue;
int sides = (b.Left ? 8 : 0) | (b.Right ? 4 : 0) | (b.Up ? 2 : 0) | (b.Down ? 1 : 0);
list.Add( (((int)b.Type & BLOCK_TYPE_MASK) << BLOCK_TYPE_SHIFT) | (b.Phase << 4) | sides );
}
return list;
}
/// <summary>Decode <see cref="Blocks"/> back into render-ready states, ordered by block type.
/// Returns an empty list when there's no block data (pre-v3 payloads).</summary>
public List<BlockTallyState> DecodeBlocks()
{
var result = new List<BlockTallyState>();
if ( Blocks is null )
return result;
foreach ( var packed in Blocks )
{
var type = (BlockType)((packed >> BLOCK_TYPE_SHIFT) & BLOCK_TYPE_MASK);
int phase = (packed >> 4) & 0x3;
result.Add( new BlockTallyState(
type, phase,
(packed & 8) != 0, (packed & 4) != 0, (packed & 2) != 0, (packed & 1) != 0 ) );
}
return result.OrderBy( b => (int)b.Type ).ToList();
}
// Cached result of validating InputDeltas (it never mutates after deserialize/build). Every
// RunPlayback construction is gated on CanReplay, so this is what keeps a corrupt payload — a
// mangled board DataUrl response, a hand-edited local-replays file — from throwing out of
// RunInputCodec.Decode mid-launch instead of just greying the replay button out.
[JsonIgnore]
private bool? _inputDecodes;
/// <summary>Whether this entry carries a replayable run on the current build: the payload is
/// structurally intact (<see cref="ReplayIntact"/>) AND was recorded on the revision this build
/// would re-simulate (<see cref="ReplayCurrent"/>).</summary>
public bool CanReplay => ReplayIntact && ReplayCurrent;
/// <summary>An intact run that this build can no longer reproduce — recorded on an older sim
/// version or an edited level. Boards keep such scores; the row shows a greyed-out, unclickable
/// replay button rather than none so the score isn't mistaken for an unrecorded run.</summary>
public bool ReplayOutdated => ReplayIntact && !ReplayCurrent;
/// <summary>Why <see cref="ReplayOutdated"/> holds, for the greyed replay button's tooltip: the sim
/// version side (older than the oldest replayable, or newer than this build) or the level-revision
/// side (hash mismatch). Mirrors ReplayCurrent's clauses in order. The editor gets the numbers so a
/// stale row explains itself; a player build gets a short plain phrase. The generic text is also
/// the fallback for a row that isn't outdated at all.</summary>
public string ReplayOutdatedReason
{
get
{
bool editor = Game.IsEditor;
if ( SimVersion < Sim.MIN_REPLAY_VERSION )
return editor ? $"Replay out of date: recorded on sim v{SimVersion}, before v{Sim.MIN_REPLAY_VERSION}" : "Replay out of date";
if ( SimVersion > Sim.VERSION )
return editor ? $"Replay out of date: recorded on sim v{SimVersion}, this build is v{Sim.VERSION}" : "Recorded on newer version";
if ( LevelHash is not null && LevelData is null && Level is { } level && !level.MatchesContentHash( LevelHash ) )
return editor ? $"Replay out of date: level changed (run {LevelHash}, now {level.ContentHash})" : "Level has changed";
return "Replay out of date";
}
}
/// <summary>Why <see cref="CanReplay"/> is false, coarsely, for the regression harness's skip
/// tally. <see cref="ReplayUnavailableReason.None"/> when the run IS replayable.</summary>
public ReplayUnavailableReason UnavailableReason
{
get
{
if ( !ReplayIntact ) return ReplayUnavailableReason.Unusable;
if ( SimVersion < Sim.MIN_REPLAY_VERSION ) return ReplayUnavailableReason.TooOld;
if ( SimVersion > Sim.VERSION ) return ReplayUnavailableReason.TooNew;
if ( !ReplayCurrent ) return ReplayUnavailableReason.LevelChanged;
return ReplayUnavailableReason.None;
}
}
/// <summary>The payload itself is usable: a known level (a run recorded on a since-removed level
/// has no layout to re-simulate) and an input stream that actually decodes. StepCount is bounded
/// BEFORE the decode check (same cap the share-code path enforces): it's untrusted, and
/// Validate/Decode allocate a StepCount-sized buffer — a fabricated payload with a huge value would
/// otherwise stall the client with a giant synchronous allocation just from rendering a leaderboard
/// row.</summary>
private bool ReplayIntact =>
Version == CURRENT_VERSION
&& !Truncated
&& StepCount > 0
&& StepCount <= RunRecorder.MAX_STEPS
// Empty is the recorder's valid encoding for an all-idle run; null means no recording.
&& InputDeltas is not null
&& Level is not null
// A run that CARRIES a snapshot must decode it: falling back to the id-resolved level would
// sim whatever that id names today, not the recorded geometry. (Entries with no snapshot at
// all — every leaderboard run — resolve by id as normal.)
&& (_levelSnapshot is null || LevelData is not null)
&& (_inputDecodes ??= RunInputCodec.Validate( InputDeltas, StepCount ));
/// <summary>The run was recorded on what this build re-simulates. Only meaningful once
/// <see cref="ReplayIntact"/> holds (it dereferences Level).</summary>
private bool ReplayCurrent =>
// Any version this build's sim still honours: older versions replay through their version
// gates (the sim runs at the run's recorded version) — see Sim.VERSION.
SimVersion >= Sim.MIN_REPLAY_VERSION && SimVersion <= Sim.VERSION
// The run must re-sim the SAME level revision it was recorded on: the level resolved at
// watch time is content-hashed against the recorded hash, so a changed level greys out
// instead of desyncing. Runs carrying an embedded snapshot skip the check — the exact
// recorded level rides with the run, and there's nothing external to verify it against.
&& (LevelHash is null || LevelData is not null || Level.MatchesContentHash( LevelHash ));
}
/// <summary>Coarse reason a run can't be replayed on this build — see <see cref="RunData.UnavailableReason"/>.</summary>
public enum ReplayUnavailableReason
{
None,
/// <summary>Corrupt payload, or its level no longer exists (fails ReplayIntact).</summary>
Unusable,
/// <summary>Recorded before <see cref="Sim.MIN_REPLAY_VERSION"/> — no gate can revive it.</summary>
TooOld,
/// <summary>Recorded on a newer sim than this build.</summary>
TooNew,
/// <summary>Level revision changed since recording (content-hash mismatch).</summary>
LevelChanged,
}
/// <summary>
/// Text-only replay share/import format. The input stream stays in the existing compact
/// <see cref="RunInputCodec"/> base64 form; the surrounding fields are the deterministic context
/// needed to re-simulate it without any backend lookup.
/// </summary>
public static class ReplayCodeCodec
{
private const string Prefix = "BP6";
// The only accepted layout: BP6 prefix + 18 pipe-delimited fields (the current version).
// Blocks are bounded by the largest level. IsDaily is NOT serialized — it's derived from DailyId,
// so storing it separately was redundant (and let the flag disagree with the id); dropped in BP2.
// BP3 added the trailing embedded-level field so transient test-play runs get codes too: empty on
// every normal run (registry/daily levels resolve by id, keeping those codes short), otherwise
// base64 of the level's canonical LevelJson wire form — the code decodes to the exact recorded
// snapshot instead of desyncing against the on-disk layout.
// BP4 appended a checksum (FNV-1a over everything before it) so a hand-edited code — swapped
// character/level name, poked seed — reads as invalid instead of playing back as a silently wrong
// "replay" that is only (maybe) flagged desynced at the very end. Tamper-EVIDENT, not tamper-proof:
// the hash is recomputable by anyone reading this code, but it turns a casual notepad edit into a
// deliberate reverse-engineering exercise, which is the right bar for a share code.
// BP5 added the runner's SteamId so an imported code is attributed to the player who did the run,
// not whoever pasted it. It sits BEFORE the checksum, so swapping it in to claim someone else's
// run breaks the code just like any other edit.
// BP6 added the level-revision hash (RunData.LevelHash), so a code imported on a build that
// resolves a DIFFERENT revision of the run's level (an edited registry level, a re-rolled daily)
// reads as invalid instead of silently desyncing.
private const int FieldCount = 18;
// Registry max plus headroom for generated DAILY levels, which aren't in the registry and can
// exceed it (rolled count + template-pinned blocks). Purely an anti-garbage upper bound on
// pasted codes, so being generous is harmless.
private static int MaxBlocks => Levels.MaxBlockCount + 8;
/// <summary>Encode a run as a share code. <paramref name="steamId"/> is the player who DID the
/// run (the watched replay's submitter, not necessarily the local player); 0 means the local
/// player. Importers attribute the run to this id, and the trailing checksum covers it.</summary>
public static string Encode( RunData data, long steamId )
{
if ( data is null )
return "";
if ( steamId == 0 )
steamId = (long)Game.SteamId;
string blocks = data.Blocks is null ? "" : string.Join( ",", data.Blocks );
// A transient run (an editor test-play) rides its exact recorded level snapshot along in the
// code — its LevelId can't be trusted to resolve the same layout later (unsaved / since-edited).
// WORKSHOP runs ride a snapshot for the same reason from the other side: their id only resolves
// for players who installed the item, and the author can republish new content under it — the
// snapshot makes the code decodable anywhere and replay the exact recorded revision. Safe to
// resolve here: codes are only encoded mid-watch (CurrentReplayCode), and a watchable workshop
// run's installed level already passed CanReplay's content-hash gate, so the resolved def IS the
// recorded revision. Serialized via the canonical LevelJson wire form (the same round-trip the
// disk files and the regression hash use) because a raw LevelDef doesn't JSON-round-trip its
// RectF fields; base64 keeps the JSON safe inside the pipe-delimited layout. Empty on every
// normal (shipped-level) run.
var snapshot = data.LevelData;
if ( snapshot is null && WorkshopLevels.IsWorkshopId( data.LevelId ) )
snapshot = data.Level;
string levelData = snapshot is null
? ""
: Convert.ToBase64String( System.Text.Encoding.UTF8.GetBytes(
EditorLevel.FromLevelDef( snapshot ).ToJsonString() ) );
string payload = string.Join( "|",
Prefix,
data.Version,
data.SimVersion,
data.Seed,
data.StepCount,
BitConverter.SingleToInt32Bits( data.TimeSeconds ),
data.Victory ? 1 : 0,
data.Truncated ? 1 : 0,
data.FinalScore,
data.InputDeltas ?? "",
blocks,
data.DailyId ?? "",
data.LevelId ?? "",
data.CharacterId ?? "",
levelData,
data.LevelHash ?? "",
steamId );
return payload + "|" + Checksum( payload ).ToString( "x8", System.Globalization.CultureInfo.InvariantCulture );
}
/// <summary>FNV-1a 32 over the code's payload (both bytes of every char, so nothing rides above
/// the hash). See the BP4 note on <see cref="FieldCount"/> for what this does and doesn't defend.</summary>
private static uint Checksum( string payload )
{
uint h = 2166136261u;
foreach ( char c in payload )
{
h = (h ^ (byte)c) * 16777619u;
h = (h ^ (byte)(c >> 8)) * 16777619u;
}
return h;
}
/// <summary><paramref name="steamId"/> is the player who did the run, for attribution on import
/// (see <see cref="Encode"/>).</summary>
public static bool TryDecode( string code, out RunData data, out long steamId )
{
data = null;
steamId = 0;
if ( string.IsNullOrWhiteSpace( code ) )
return false;
var parts = code.Trim().Split( '|' );
if ( parts[0] != Prefix || parts.Length != FieldCount )
return false;
// parts[16] = checksum over every field before it. Verified up front so ANY hand-edit —
// including to fields that would otherwise decode fine, like the character id — reads as an
// invalid code rather than a subtly wrong replay.
if ( !uint.TryParse( parts[FieldCount - 1], System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out uint sum )
|| sum != Checksum( string.Join( "|", parts[..(FieldCount - 1)] ) ) )
return false;
if ( !TryInt( parts[1], out int version )
|| !TryInt( parts[2], out int simVersion )
|| !TryInt( parts[3], out int seed )
|| !TryInt( parts[4], out int stepCount )
|| !TryInt( parts[5], out int timeBits )
|| !TryBoolInt( parts[6], out bool victory )
|| !TryBoolInt( parts[7], out bool truncated )
|| !TryInt( parts[8], out int finalScore ) )
return false;
if ( version != RunData.CURRENT_VERSION || stepCount <= 0 || stepCount > RunRecorder.MAX_STEPS )
return false;
string inputDeltas = parts[9];
// parts[14] = embedded level snapshot, present on transient test-play runs AND workshop runs
// (see Encode). Decoded before the block tally so the tally bound can come from the snapshot
// itself — neither kind of level is covered by the registry-derived MaxBlocks (test-play levels
// aren't registered; workshop levels are excluded from the bound and needn't be installed here).
LevelDef levelData = null;
if ( !string.IsNullOrWhiteSpace( parts[14] ) )
{
try
{
string json = System.Text.Encoding.UTF8.GetString( Convert.FromBase64String( parts[14] ) );
levelData = EditorLevel.FromJsonString( json, out _ )?.ToLevelDef();
}
catch
{
return false;
}
if ( levelData is null )
return false;
}
int maxBlocks = levelData is not null ? levelData.Count + 8 : MaxBlocks;
List<int> blocks = null;
if ( !string.IsNullOrWhiteSpace( parts[10] ) )
{
blocks = new List<int>();
foreach ( var raw in parts[10].Split( ',', StringSplitOptions.RemoveEmptyEntries ) )
{
if ( !TryInt( raw, out int packed ) )
return false;
if ( blocks.Count >= maxBlocks )
return false;
blocks.Add( packed );
}
}
// parts[11] = daily id. Validated whenever present (IsDaily is derived from it, so a non-empty
// value MUST be a well-formed id); empty means a non-daily run. A daily run regenerates its
// level from the id, so a code claiming both daily AND an embedded snapshot is garbage.
string dailyId = string.IsNullOrWhiteSpace( parts[11] ) ? null : parts[11];
if ( dailyId is not null && (levelData is not null || !DailyChallenge.TryParseId( dailyId, out _ )) )
return false;
// parts[12] = level id. Unknown ids are rejected here rather than left to CanReplay, so a
// bad code reads as invalid instead of silently unreplayable. A daily run's level isn't in
// the registry — its id must match the (validated) daily id it regenerates from. A transient
// run's level isn't either (unsaved / since-edited) — its id must match the embedded snapshot,
// which the replay registers under that id (see Levels.RegisterTransient).
string levelId = string.IsNullOrWhiteSpace( parts[12] ) ? null : parts[12];
if ( dailyId is not null )
{
if ( levelId != DailyLevels.LevelIdFor( dailyId ) )
return false;
}
else if ( levelData is not null )
{
if ( !string.Equals( levelData.Id, levelId, StringComparison.Ordinal ) )
return false;
}
else if ( Levels.Get( levelId ) is null )
return false;
// parts[13] = character id. Any value is accepted (unknown → Original), so a character
// being removed later never makes an otherwise-valid run undecodable.
string characterId = string.IsNullOrWhiteSpace( parts[13] ) ? null : parts[13];
// parts[15] = level-revision hash the run was recorded on. Empty only on a payload predating
// the stamp; CanReplay (below) compares it against the level the code resolves here, so a
// stale code reads as invalid rather than desyncing.
string levelHash = string.IsNullOrWhiteSpace( parts[15] ) ? null : parts[15];
// parts[16] = SteamId of the player who did the run. Checksum-covered like everything else,
// so it can't be swapped to pass the run off as someone else's. 0 (a code encoded with no
// Steam session) attributes to whoever imports it, matching LocalReplays.Add's convention.
if ( !long.TryParse( parts[16], System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out steamId )
|| steamId < 0 )
return false;
// Validate the base64/varint payload before accepting the code. The dense buffer is discarded;
// actual playback decodes it again inside RunPlayback.
if ( !RunInputCodec.Validate( inputDeltas, stepCount ) )
return false;
data = new RunData
{
Version = version,
SimVersion = simVersion,
Seed = seed,
StepCount = stepCount,
TimeSeconds = BitConverter.Int32BitsToSingle( timeBits ),
Victory = victory,
Truncated = truncated,
FinalScore = finalScore,
InputDeltas = inputDeltas,
Blocks = blocks,
DailyId = dailyId,
LevelId = levelId,
LevelHash = levelHash,
CharacterId = characterId,
LevelData = levelData
};
return data.CanReplay;
}
public static string Describe( RunData data )
{
if ( data is null )
return "no run data";
var daily = data.IsDaily ? $", daily {data.DailyId}" : "";
var level = string.IsNullOrEmpty( data.LevelId ) ? "" : $", level {data.LevelId}";
var embedded = data.LevelData is not null ? " (embedded level)" : "";
return $"score {data.FinalScore}, steps {data.StepCount}, seed {data.Seed}, sim v{data.SimVersion}{daily}{level}{embedded}";
}
private static bool TryInt( string value, out int result )
{
return int.TryParse( value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out result );
}
private static bool TryBoolInt( string value, out bool result )
{
result = false;
if ( !TryInt( value, out int raw ) || (raw != 0 && raw != 1) )
return false;
result = raw == 1;
return true;
}
}
/// <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 so we don't spam retries.
/// (Mirrors SS2's <c>RunResultStore</c>.)
/// </summary>
public static class RunDataStore
{
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 ) =>
!string.IsNullOrEmpty( dataUrl ) && _cache.TryGetValue( dataUrl, out var cached ) ? cached : null;
/// <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;
_cache[dataUrl] = data;
// 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
{
_inFlight.Remove( dataUrl );
if ( _pending.Remove( dataUrl, out var list ) )
foreach ( var cb in list )
cb?.Invoke();
}
}
}