Game/LocalReplays.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace BlockParty;
/// <summary>
/// One archived run: the timestamp it was recorded plus the deterministic replay payload. Stored
/// in the same per-user data folder as the settings (see <see cref="LocalReplays"/>).
/// </summary>
public sealed class LocalReplayEntry
{
/// <summary>When this replay was saved to the local archive (local clock); the list shows this
/// and sorts by it. Always the save moment — a downloaded replay's leaderboard submission date
/// is display-only metadata and never stored here.</summary>
public DateTimeOffset RecordedAt { get; set; }
/// <summary>SteamId of the player who recorded it, for the name + avatar shown on the row.</summary>
public long SteamId { get; set; }
/// <summary>Display name captured when this replay was saved; falls back to Steam lookup if empty.</summary>
public string DisplayName { get; set; }
/// <summary>Optional country code captured from leaderboard metadata for downloaded replays.</summary>
public string CountryCode { get; set; }
/// <summary>True when the player has hearted this local replay.</summary>
public bool IsFavorite { get; set; }
/// <summary>The run itself — duration, score, victory flag, and the recorded input for replay.</summary>
public RunData Run { get; set; }
}
/// <summary>
/// Local archive of saved runs so they can be rewatched offline (the My Replays screen), independent
/// of the online leaderboard. Persisted as JSON via <see cref="FileSystem.Data"/>, alongside
/// <see cref="GameSettings"/>. Capped to the most recent N non-favorites so the file can't grow forever
/// (favorites are exempt).
/// </summary>
public static class LocalReplays
{
public const string FilePath = "blockparty_replays.json";
/// <summary>Cap on stored NON-favorite runs; the oldest are dropped past it. Favorites sit outside
/// the cap entirely (never counted, never trimmed) because a heart is an explicit keep request.
/// Sized from measured entries (~1KB typical, ~11KB with an embedded test-play level): ~11MB
/// worst case for the capped part, and the file is rewritten whole on every Save, so it should
/// stay single-digit MB.</summary>
private const int MaxNonFavoriteEntries = 1000;
private static List<LocalReplayEntry> _entries;
private static int _version;
private static bool _favoritesOnlyFilter;
/// <summary>Changes whenever the archive mutates, so UI hashes can cheaply repaint.</summary>
public static int Version => _version;
/// <summary>Session-only Local Replays filter state. Not written to disk, so it resets on launch.</summary>
public static bool FavoritesOnlyFilter
{
get => _favoritesOnlyFilter;
set => _favoritesOnlyFilter = value;
}
/// <summary>All archived runs, most recent first. Loaded lazily on first access.</summary>
public static IReadOnlyList<LocalReplayEntry> All
{
get
{
if ( _entries is null ) Load();
return _entries;
}
}
/// <summary>Archive a finished run, newest first, trimming the oldest non-favorites past the cap.</summary>
public static void Add( RunData run )
{
Add( run, (long)Game.SteamId, new Friend( Game.SteamId ).Name );
}
/// <summary>Archive a replay if it is not already present. Duplicate saves are silent no-ops,
/// except that a duplicate carrying a level snapshot the archived copy lacks donates it.</summary>
public static LocalReplayEntry Add( RunData run, long steamId, string displayName = null, string countryCode = null )
{
if ( run is null )
return null;
if ( _entries is null ) Load();
if ( steamId == 0 )
steamId = (long)Game.SteamId;
// Dedup is keyed on the submitter too, so two players' identical-input runs (e.g. both idle
// to death on the same daily) don't collapse into a single archive entry.
var existing = Find( run, steamId );
if ( existing is not null )
{
// A workshop run is archived without a snapshot and greys out once the author republishes
// (CanReplay's content-hash gate). Its share code DOES carry the recorded level, so
// re-importing that code is how the run gets revived: merge the snapshot in instead of
// discarding it. Same revision is guaranteed — SameReplay matches LevelHash. Keyed on
// decodability, not null-ness, so a stored snapshot that no longer decodes is replaced too.
if ( existing.Run.LevelData is null && run.LevelData is not null )
{
existing.Run.LevelSnapshot = run.LevelSnapshot;
Save();
}
return existing;
}
var entry = new LocalReplayEntry
{
RecordedAt = DateTimeOffset.Now,
SteamId = steamId,
DisplayName = string.IsNullOrWhiteSpace( displayName ) ? null : displayName,
CountryCode = string.IsNullOrWhiteSpace( countryCode ) ? null : countryCode,
Run = run
};
_entries.Insert( 0, entry );
TrimOldNonFavorites();
Save();
// Editor-only trace: a transient save carries its level layout inline (an editor test-play run,
// whose level isn't in the persistent registry); a normal save resolves its level by id.
if ( Game.IsEditor )
Log.Info( $"BlockParty: saved local replay ({(run.LevelData is not null ? "transient" : "non-transient")}, level '{run.LevelId}', score {run.FinalScore})." );
return entry;
}
/// <summary>Import a replay code into the local archive. Returns false when the string is not a
/// replayable payload for the current simulation version.</summary>
public static bool TryImportCode( string code )
{
if ( !ReplayCodeCodec.TryDecode( code, out var run, out long steamId ) )
{
Log.Info( $"BlockParty replay code: import failed (invalid or incompatible code, {(code ?? "").Length} chars)." );
return false;
}
// Attributed to the player who did the run (carried in the code), not the importer. Name and
// avatar resolve from the id via Steam wherever the entry is shown.
Add( run, steamId );
Achievements.AwardReplayCodeUsed(); // either share-code surface counts — see the award's doc
Log.Info( $"BlockParty replay code: imported into local replays ({ReplayCodeCodec.Describe( run )}, steamid {steamId})." );
return true;
}
/// <summary>Find an archived entry matching this deterministic replay payload for the given
/// submitter. A run's identity includes who recorded it, so the same seed+input from two
/// different players is two distinct entries. <paramref name="steamId"/> of 0 means the local player.</summary>
public static LocalReplayEntry Find( RunData run, long steamId = 0 )
{
if ( run is null )
return null;
if ( _entries is null ) Load();
if ( steamId == 0 )
steamId = (long)Game.SteamId;
return _entries.FirstOrDefault( e => e.SteamId == steamId && SameReplay( e.Run, run ) );
}
/// <summary>True if this replay payload already exists in the local archive for the given submitter.</summary>
public static bool Contains( RunData run, long steamId = 0 ) => Find( run, steamId ) is not null;
/// <summary>Toggle an archived replay's heart state and persist it.</summary>
public static void ToggleFavorite( LocalReplayEntry entry )
{
if ( entry is null )
return;
if ( _entries is null ) Load();
var found = _entries.Contains( entry ) ? entry : Find( entry.Run, entry.SteamId );
if ( found is null )
return;
found.IsFavorite = !found.IsFavorite;
Save();
}
/// <summary>Delete a single archived run (the trash button on the My Replays row) and persist.</summary>
public static void Remove( LocalReplayEntry entry )
{
if ( entry is null )
return;
if ( _entries is null ) Load();
if ( _entries.Remove( entry ) )
Save();
}
/// <summary>Drop every run recorded before <see cref="Sim.MIN_REPLAY_VERSION"/> (favourites
/// included — see replay_purge_stale). Returns how many went; saves only if any did.</summary>
public static int PurgeBelowMinVersion( out int favourites )
{
if ( _entries is null ) Load();
favourites = _entries.Count( e => e.IsFavorite && e.Run.SimVersion < Sim.MIN_REPLAY_VERSION );
int removed = _entries.RemoveAll( e => e.Run.SimVersion < Sim.MIN_REPLAY_VERSION );
if ( removed > 0 )
Save();
return removed;
}
public static void Load()
{
_entries = FileSystem.Data.ReadJsonOrDefault<List<LocalReplayEntry>>( FilePath, new() ) ?? new();
// Defensive: keep the list ordered newest-first even if an older file wasn't.
// Stale-SimVersion runs are deliberately KEPT (they just fail CanReplay and hide from the
// list): a post-release sim fix ships a version gate that plays them back through the old
// code path, so deleting them here would destroy exactly the runs that gate revives.
// The non-favorite trim in Add still bounds the file.
_entries = _entries.Where( e => e?.Run is not null ).OrderByDescending( e => e.RecordedAt ).ToList();
}
public static void Save()
{
if ( _entries is null ) return;
_version++;
FileSystem.Data.WriteJson( FilePath, _entries );
}
/// <summary>Drop the oldest non-favorites until at most <see cref="MaxNonFavoriteEntries"/> remain.
/// Counting only non-favorites means a full heart list can't push the cap onto the run just
/// inserted at index 0: it is only ever trimmed when older non-favorites are exhausted, which
/// the count rules out.</summary>
private static void TrimOldNonFavorites()
{
int nonFavorites = _entries.Count( e => !e.IsFavorite );
for ( int i = _entries.Count - 1; i >= 0 && nonFavorites > MaxNonFavoriteEntries; i-- )
{
if ( _entries[i].IsFavorite )
continue;
_entries.RemoveAt( i );
nonFavorites--;
}
}
private static bool SameReplay( RunData a, RunData b )
{
if ( a is null || b is null )
return false;
// LevelHash is part of the key: the same seed+input on two revisions of one level id (a
// republished workshop item) is two distinct runs, and Add's snapshot merge relies on a match
// meaning the same revision.
return a.SimVersion == b.SimVersion
&& a.Seed == b.Seed
&& a.StepCount == b.StepCount
&& a.FinalScore == b.FinalScore
&& string.Equals( a.InputDeltas, b.InputDeltas, StringComparison.Ordinal )
&& string.Equals( a.DailyId ?? "", b.DailyId ?? "", StringComparison.Ordinal )
&& string.Equals( a.LevelId ?? "", b.LevelId ?? "", StringComparison.Ordinal )
&& string.Equals( a.LevelHash ?? "", b.LevelHash ?? "", StringComparison.Ordinal )
&& string.Equals( a.CharacterId ?? "", b.CharacterId ?? "", StringComparison.Ordinal );
}
}