Game/CharacterLevelProgress.cs

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.

File Access
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++;
	}
}