Game/CloudProgress.cs
using System.Collections.Generic;
using System.Linq;
namespace BlockParty;
/// <summary>
/// Cross-machine recovery mirror for map progression. The local <see cref="GameSettings.BeatenLevelIds"/>
/// file stays the source of truth (offline play works unchanged); every campaign victory ALSO stamps a
/// per-player backend stat, and on boot the account's stats are fetched — one request returns all of
/// them — and any mirrored levels are merged back into the local list. A second computer, a reinstall
/// or a lost data folder then restores the player's map instead of restarting it. Character unlocks
/// aren't mirrored separately: a restored forced-character level implies its character was earned, so
/// the merge re-derives those (daily-only unlocks are the one thing not recovered). Stat names carry
/// <see cref="LevelProgress.PROGRESS_VERSION"/>: player stats can never be deleted, so an unversioned
/// mirror would resurrect progress a version bump deliberately wiped — versioned names orphan the old
/// mirror together with the local wipe.
/// </summary>
public static class CloudProgress
{
private static string Prefix => $"level_beaten_v{LevelProgress.PROGRESS_VERSION}_";
/// <summary>Mirror a campaign victory to the player's backend stats, flushed immediately so
/// quitting right after a win can't lose it. Called on EVERY victory, not just the
/// unbeaten→beaten flip: the merge only reads a stat's existence, and re-submitting covers a
/// first win whose submit was lost offline. Editor sessions never submit — the map's debug
/// beaten-toggle and dev wins would pollute the account's mirror.</summary>
public static void SubmitBeaten( string levelId )
{
if ( Game.IsEditor || string.IsNullOrEmpty( levelId ) )
return;
Sandbox.Services.Stats.Increment( Prefix + levelId, 1 );
Sandbox.Services.Stats.Flush();
}
/// <summary>Fetch the account's stats and mark any mirrored level beaten locally. Best-effort:
/// offline or failed fetches leave the local file as this session's only record (call sites retry
/// on each level-select entry; the engine throttles the underlying fetch). When anything was
/// restored, the unlock-reveal snapshot absorbs the healed map — recovered progress must not
/// replay a node-by-node unlock ceremony — and an open level-select refreshes in place.</summary>
public static async System.Threading.Tasks.Task MergeAsync()
{
// The editor runs under the #local package ident, a different stat namespace from the shipped
// game — a merge here could never see real progress, and SubmitBeaten never writes to it.
if ( Game.IsEditor )
return;
try
{
var stats = Sandbox.Services.Stats.LocalPlayer;
if ( stats is null )
return;
await stats.Refresh();
var restored = new List<string>();
foreach ( var stat in stats )
{
// Increments accumulate into a stat's SUM, not Value (see RefreshBackendAttempts).
if ( stat.Sum <= 0 || !stat.Name.StartsWith( Prefix, System.StringComparison.OrdinalIgnoreCase ) )
continue;
string id = stat.Name[Prefix.Length..];
if ( Levels.Get( id ) is null || LevelProgress.IsBeaten( id ) )
continue; // renamed/removed level, or already recorded locally
LevelProgress.MarkBeaten( id );
restored.Add( id );
}
if ( restored.Count == 0 )
return;
// Only the ids restored THIS merge: the victory path unlocks a forced level's character
// before marking it beaten, so a cloud-beaten forced level always means an earned character.
// Locally beaten ids are left alone so this can never override a debug re-lock.
var restoredCharacters = new List<string>();
foreach ( var id in restored )
{
string characterId = Levels.Get( id ).ForcedCharacterId;
if ( CharacterProgress.Restore( characterId ) )
restoredCharacters.Add( characterId );
}
if ( LevelMap.Nodes.Count > 0 ) // empty-map guard, mirroring PrepareUnlockQueue
LevelProgress.CaptureNewlyUnlocked( LevelMap.Nodes.Where( n => n.Selectable ).Select( n => n.LevelId ) );
if ( GameManager.Instance?.Stage is LevelSelectStage levelSelect )
levelSelect.RefreshProgress();
Log.Info( $"[BlockParty] Restored {restored.Count} beaten level(s) from backend stats: {string.Join( ", ", restored )}." );
if ( restoredCharacters.Count > 0 )
Log.Info( $"[BlockParty] Restored {restoredCharacters.Count} character unlock(s) implied by them: {string.Join( ", ", restoredCharacters )}." );
}
catch ( System.Exception e )
{
Log.Info( $"[BlockParty] Beaten-level stat merge failed ({e.Message}); local progress gates this session." );
}
}
}