Static helper that tracks per-character level completion for a local campaign. It computes completion percent, caches results with lightweight invalidation, marks levels as beaten (persisting via Settings.Save), observes leaderboard runs to auto-mark clears, and supports reset of progress.
using System;
using System.Collections.Generic;
namespace BlockParty;
/// <summary>Local campaign clears per character. No replay scans or network requests.</summary>
public static class CharacterLevelProgress
{
public static int Version { get; private set; }
private static readonly Dictionary<string, int> CompletionCache = new();
private static (int Progress, int Map, int Levels, object Roster, object Settings, object Clears) _completionStamp;
public static bool IsEligible( string levelId, string characterId )
=> IsEligible( LevelMap.Find( levelId )?.Level, characterId );
private static bool IsEligible( LevelDef level, string characterId )
=> level is not null && Characters.TryGet( characterId, out _ )
&& (string.IsNullOrEmpty( level.ForcedCharacterId ) || level.ForcedCharacterId == characterId);
public static bool IsBeaten( string levelId, string characterId )
=> IsEligible( levelId, characterId )
&& Settings.Current.CharacterBeatenLevelIds.TryGetValue( characterId, out var levels )
&& levels?.Contains( levelId ) == true;
/// <summary>Current selectable characters at 100% eligible map completion. A missing level asset
/// cannot prove completion: wait for the whole map to load before submitting an irreversible achievement.</summary>
public static int CompletedCharacterCount()
{
if ( LevelMap.Nodes.Count == 0 ) return 0;
foreach ( var node in LevelMap.Nodes )
if ( node.Level is null ) return 0;
int completed = 0;
foreach ( var character in Characters.All )
if ( CompletionPercent( character.Id ) == 100 ) completed++;
return completed;
}
public static int CompletionPercent( string characterId )
{
if ( !Characters.TryGet( characterId, out _ ) ) return 0;
var settings = Settings.Current;
// Read lazy registry revisions before consulting the cache, so late asset mounts and
// editor reloads invalidate counts just like a newly recorded win or a settings reload.
var stamp = (Version, LevelMap.Revision, Levels.Revision, (object)Characters.All,
(object)settings, (object)settings.CharacterBeatenLevelIds);
if ( _completionStamp != stamp )
{
CompletionCache.Clear();
_completionStamp = stamp;
}
if ( CompletionCache.TryGetValue( characterId, out var cached ) ) return cached;
settings.CharacterBeatenLevelIds.TryGetValue( characterId, out var clears );
int total = 0;
int beaten = 0;
foreach ( var node in LevelMap.Nodes )
{
if ( !IsEligible( node.Level, characterId ) ) continue;
total++;
if ( clears?.Contains( node.LevelId ) == true ) beaten++;
}
int percent = total == 0 ? 0 : (int)Math.Floor( 100.0 * beaten / total );
CompletionCache[characterId] = percent;
return percent;
}
public static void MarkBeaten( string levelId, string characterId )
{
if ( !IsEligible( levelId, characterId ) ) return;
var settings = Settings.Current;
var progress = settings.CharacterBeatenLevelIds;
if ( !progress.TryGetValue( characterId, out var levels ) || levels is null )
progress[characterId] = levels = new HashSet<string>();
if ( levels.Add( levelId ) )
{
// The win counts immediately, even if disk persistence is temporarily unavailable.
Version++;
settings.ProgressSavePending = true;
}
if ( !settings.ProgressSavePending ) return;
try
{
Settings.Save();
}
catch ( Exception e )
{
// Retain the clear for any later settings save. A later win or board load can
// retry even for an already recorded clear; never retry from every UI update.
Log.Warning( $"BlockParty: couldn't save character completion [level '{levelId}', character '{characterId}']: {e.Message}" );
return;
}
if ( Game.IsEditor )
Log.Info( $"BlockParty: saved character completion [level '{levelId}', character '{characterId}']." );
}
/// <summary>Use only payloads already loaded by ordinary board display. Missing evidence never
/// removes a clear. The entry identity and its board must agree with the winning payload.</summary>
public static void ObserveLeaderboardRun( long steamId, string levelId, string characterId, double value, RunData data, DateTimeOffset submittedAt )
{
if ( steamId != (long)Game.SteamId || data?.Victory != true
|| !Leaderboard.IsLevelEntryConsistent( data, levelId, characterId, value ) ) return;
// This also rejects rows with an unknown/default timestamp after a reset.
if ( Settings.Current.CharacterProgressResetAt is { } resetAt && submittedAt <= resetAt ) return;
int previousVersion = Version;
MarkBeaten( data.LevelId, data.CharacterId );
if ( Version != previousVersion )
Achievements.CheckProgress();
}
public static void Reset( bool blockHistoricalRecovery = true )
{
Settings.Current.CharacterBeatenLevelIds.Clear();
Settings.Current.ProgressSavePending = true;
if ( blockHistoricalRecovery )
Settings.Current.CharacterProgressResetAt = DateTimeOffset.UtcNow;
Version++;
}
}