Game/CharacterProgress.cs
namespace BlockParty;
/// <summary>Per-player character unlock progression backed by <see cref="GameSettings"/>.</summary>
public static class CharacterProgress
{
/// <summary>Changes whenever unlock state is mutated, allowing UI components to rebuild.</summary>
public static int Version { get; private set; }
public static bool IsUnlocked( string characterId )
=> Characters.TryGet( characterId, out _ )
&& Settings.Current.UnlockedCharacterIds.Contains( characterId );
public static bool IsNew( string characterId )
=> characterId != Characters.OriginalId
&& IsUnlocked( characterId )
&& !Settings.Current.EverSelectedCharacterIds.Contains( characterId );
public static int NewCharacterCount
=> Characters.All.Count( character => IsNew( character.Id ) );
public static bool HasSeenHelp( string characterId )
=> Settings.Current.SeenCharacterHelpIds.Contains( characterId );
/// <summary>Persist that automatic first-play help was shown, without changing unlock state.</summary>
public static void MarkHelpSeen( string characterId )
{
if ( !Characters.TryGet( characterId, out var character ) || HasSeenHelp( character.Id ) )
return;
Settings.Current.SeenCharacterHelpIds.Add( character.Id );
Settings.Save();
}
/// <summary>Whether the in-run "?" help button should pulse to teach the player it exists. Arms
/// the first time an automatic first-play help overlay is shown — i.e. the first run PLAYING AS a
/// character beyond Original, owned or not (forced-character levels count; Original's help is not
/// auto-shown, so SeenCharacterHelpIds only ever gains non-Original ids). Retires permanently —
/// across all future runs — the first time the button is actually clicked
/// (<see cref="MarkHelpButtonUsed"/>); only a progress reset re-arms it.</summary>
public static bool ShouldNudgeHelpButton
=> !Settings.Current.CharacterHelpButtonUsed
&& Settings.Current.SeenCharacterHelpIds.Count > 0;
/// <summary>The player manually opened the character help; the nudge pulse is done teaching.
/// Persists immediately so quitting right after the click doesn't resurrect the pulse.</summary>
public static void MarkHelpButtonUsed()
{
if ( Settings.Current.CharacterHelpButtonUsed )
return;
Settings.Current.CharacterHelpButtonUsed = true;
Settings.Save();
}
/// <summary>Remember that a character has been selected and persist immediately.</summary>
public static void MarkSelected( string characterId )
{
if ( !Characters.TryGet( characterId, out var character )
|| Settings.Current.EverSelectedCharacterIds.Contains( character.Id ) )
return;
Settings.Current.EverSelectedCharacterIds.Add( character.Id );
Version++;
Settings.Save();
}
/// <summary>Unlock a known character and persist. Returns true only when progression changed.
/// A real unlock also queues the character's "YOU UNLOCKED" reveal — this is the single choke
/// point for progression unlocks, so no unlock can slip through without the player being told.</summary>
public static bool Unlock( string characterId )
{
if ( !Characters.TryGet( characterId, out var character ) || IsUnlocked( character.Id ) )
return false;
Settings.Current.UnlockedCharacterIds.Add( character.Id );
if ( !Settings.Current.PendingUnlockRevealIds.Contains( character.Id ) )
Settings.Current.PendingUnlockRevealIds.Add( character.Id );
Version++;
Settings.Save();
Achievements.CheckProgress(); // this may have been the last character
return true;
}
/// <summary>Quiet unlock for progress RECOVERY (see <see cref="CloudProgress"/>): the character was
/// earned on another install, so no "YOU UNLOCKED" reveal is owed. Returns true when the roster
/// changed.</summary>
internal static bool Restore( string characterId )
{
if ( !Characters.TryGet( characterId, out var character ) || IsUnlocked( character.Id ) )
return false;
Settings.Current.UnlockedCharacterIds.Add( character.Id );
Version++;
Settings.Save();
return true;
}
// --- owed "YOU UNLOCKED" reveals --------------------------------------------------------
// A queue rather than a single slot: the reveal is delivered by the score tally on the way out
// of the winning run, but a player who abandons a won run (or quits the game) before that never
// sees it, so it waits — persisted — until the next menu screen can show it. See UnlockReveal.
/// <summary>Whether any character is still owed a reveal that can actually be shown. Defined via
/// <see cref="PeekPendingReveal"/> rather than the raw count so an id that no longer resolves (a
/// character retired from the registry mid-session) doesn't read as permanently pending.</summary>
public static bool HasPendingReveal => PeekPendingReveal() is not null;
/// <summary>The oldest character still owed a reveal, or null. Stays queued until
/// <see cref="ConsumePendingReveal"/> marks it seen.</summary>
public static CharacterDef PeekPendingReveal()
{
foreach ( var id in Settings.Current.PendingUnlockRevealIds )
{
if ( Characters.TryGet( id, out var character ) )
return character;
}
return null;
}
/// <summary>Mark a character's reveal as delivered and persist immediately, so quitting part-way
/// through the reveal's fade doesn't show it again next launch.</summary>
public static void ConsumePendingReveal( string characterId )
{
if ( Settings.Current.PendingUnlockRevealIds.Remove( characterId ) )
Settings.Save();
}
/// <summary>Debug: owe the player a "YOU UNLOCKED" reveal for a character (unlocking it first if
/// needed) so the overlay can be checked without winning that character's level. It appears on the
/// next menu screen, or behind the score tally's next RESTART / CONTINUE.</summary>
[ConCmd( "unlock_reveal" )]
public static void QueueUnlockRevealCmd( string characterId )
{
if ( !Game.IsEditor ) return;
if ( !Characters.TryGet( characterId, out var character ) )
{
Log.Info( $"[BlockParty] Unknown character '{characterId}'. Known: {string.Join( ", ", Characters.All.Select( c => c.Id ) )}" );
return;
}
// Unlock() queues the reveal itself; an already-owned character just needs the queue entry.
if ( !Unlock( character.Id ) && !Settings.Current.PendingUnlockRevealIds.Contains( character.Id ) )
{
Settings.Current.PendingUnlockRevealIds.Add( character.Id );
Settings.Save();
}
Log.Info( $"[BlockParty] Queued unlock reveal for '{character.Id}'." );
}
/// <summary>Set a character's unlock state and persist. Locking the final unlocked character also
/// unlocks the next registry entry so debug toggling can target any character without leaving the
/// profile unable to start a voluntary run.</summary>
public static bool SetUnlocked( string characterId, bool unlocked )
{
if ( !Characters.TryGet( characterId, out var character ) )
return false;
var unlockedIds = Settings.Current.UnlockedCharacterIds;
bool currentlyUnlocked = unlockedIds.Contains( character.Id );
if ( currentlyUnlocked == unlocked )
return false;
if ( unlocked )
unlockedIds.Add( character.Id );
else
{
if ( unlockedIds.Count <= 1 )
{
var fallback = Characters.All.First( candidate => candidate.Id != character.Id );
unlockedIds.Add( fallback.Id );
}
unlockedIds.Remove( character.Id );
// Debug re-lock: drop any reveal still owed for it, so the overlay can't announce a
// character the profile no longer owns.
Settings.Current.PendingUnlockRevealIds.Remove( character.Id );
}
RepairSelectedCharacter();
Version++;
Settings.Save();
return true;
}
public static bool ToggleUnlocked( string characterId )
=> SetUnlocked( characterId, !IsUnlocked( characterId ) );
/// <summary>Restore the starter roster and selection. The caller owns persistence so this can be
/// combined atomically with other profile-progression changes.</summary>
internal static void ResetToOriginal()
{
var settings = Settings.Current;
settings.UnlockedCharacterIds.Clear();
settings.UnlockedCharacterIds.Add( Characters.OriginalId );
settings.EverSelectedCharacterIds.Clear();
settings.EverSelectedCharacterIds.Add( Characters.OriginalId );
settings.PendingUnlockRevealIds.Clear();
settings.SeenCharacterHelpIds.Clear();
settings.CharacterHelpButtonUsed = false;
settings.SelectedCharacterId = Characters.OriginalId;
Version++;
}
/// <summary>Return the requested character when unlocked, otherwise Original or the first unlocked
/// definition. Forced levels and replays intentionally do not call this helper.</summary>
public static CharacterDef ResolveSelectable( string characterId )
{
if ( IsUnlocked( characterId ) )
return Characters.Get( characterId );
if ( IsUnlocked( Characters.OriginalId ) )
return Characters.Original;
return Characters.All.First( character => IsUnlocked( character.Id ) );
}
public static void RepairSelectedCharacter()
{
var selected = ResolveSelectable( Settings.Current.SelectedCharacterId );
Settings.Current.SelectedCharacterId = selected.Id;
}
}