Game/Achievements.cs
namespace BlockParty;
/// <summary>
/// Backend achievement unlocks, wrapping <c>Sandbox.Services.Achievements</c>. Achievements are
/// defined on the s&box website (id, title, icon, unlock mode); the game only ever names one and
/// asks for it to be unlocked, so a new achievement is "add a constant here + one call site".
///
/// Achievements come in two flavours, and the website's unlock mode decides which:
///
/// MANUAL — the game names it and calls <see cref="Unlock"/>. <c>AchievementCollection.ManualUnlock</c>
/// silently ignores a request for a stat-driven one, so the mode must match. Unlocking is idempotent
/// and one-way (the backend drops a repeat, and an unknown id no-ops), so call sites can re-check
/// freely; <see cref="CheckProgress"/> is deliberately a full re-evaluation rather than an edge
/// trigger, so a profile that already met a condition before the achievement existed still earns it
/// on the next boot.
///
/// STAT — the game never unlocks it. It just feeds a player stat (see <see cref="SubmitRunCoins"/>)
/// and the engine polls roughly once a second, unlocking once the stat crosses the threshold set on
/// the website. Because the site holds the target, the number can be retuned without a game update.
///
/// A stat achievement can show a PROGRESS BAR and a manual one can't: the engine only fills
/// <c>ProgressionFraction</c> for entries naming a source stat. (The engine would happily do both —
/// its progress pass ignores unlock mode — but the website form won't attach a stat to a manual
/// achievement, so in practice it's one or the other.) The website shows a stat achievement's progress
/// as "value/max", so every "all of them" achievement feeds a plain COUNT whose website max is the set's
/// size — see the roster-size note above the stat idents.
///
/// The split follows one rule: an achievement that wants a PROGRESS BAR is a stat, and every one-shot
/// deed ("did the thing / didn't") is MANUAL so it pops the moment it's earned rather than on the
/// engine's next poll. Idents are lowercase (uppercase isn't allowed) and describe the deed; only the
/// manual ones appear in code (a stat achievement is matched by its source stat, never by name), so
/// the stat idents are listed here to keep this file the full roster:
/// <list type="table">
/// <item><term>unlock_all_characters</term><description>STAT — <see cref="CharactersUnlockedStat"/>, HIGHEST, max = roster size (16)</description></item>
/// <item><term>beat_all_levels</term><description>STAT — <see cref="MapLevelsBeatenStat"/>, HIGHEST, max = map size (73)</description></item>
/// <item><term>beat_25_map_levels</term><description>STAT — <see cref="MapLevelsBeatenStat"/>, HIGHEST, max 25</description></item>
/// <item><term>beat_50_map_levels</term><description>STAT — <see cref="MapLevelsBeatenStat"/>, HIGHEST, max 50</description></item>
/// <item><term>beat_roundabout_with_every_character</term><description>STAT — <see cref="CapstoneCharactersStat"/>, HIGHEST, max = roster size (16)</description></item>
/// <item><term>max_phase_every_block_type</term><description>STAT — <see cref="BlockTypesMaxedStat"/>, HIGHEST, max = BlockType count (27)</description></item>
/// <item><term>collect_10000_coins</term><description>STAT — <see cref="CoinsStat"/>, HIGHEST, max 10000</description></item>
/// <item><term>export_gif_of_3_replays</term><description>STAT — <see cref="GifReplaysExportedStat"/>, HIGHEST, max 3</description></item>
/// <item><term>die_1000_times</term><description>STAT — <see cref="DeathsStat"/>, HIGHEST, max 1000</description></item>
/// <item><term>beat_10_daily_challenges</term><description>STAT — <see cref="DailiesBeatenStat"/>, HIGHEST, max 10</description></item>
/// <item><term>play_100_daily_challenges</term><description>STAT — <see cref="DailiesPlayedStat"/>, HIGHEST, max 100</description></item>
/// <item><term>play_7_daily_challenges_in_a_row</term><description>STAT — <see cref="DailyStreakStat"/>, HIGHEST, max 7</description></item>
/// <item><term>MANUAL</term><description>the nine <c>const</c>s below</description></item>
/// </list>
/// </summary>
public static class Achievements
{
// ROSTER SIZES ARE WEBSITE MAXES. The "collect the whole set" achievements submit plain COUNTS, so
// the website max of each must equal the set's current size: map nodes (LevelMap.Nodes.Count),
// selectable characters (Characters.All.Count, twice) and BlockType entries. Every add or remove to
// one of those sets means hand-editing the matching max on the website — forgetting after a removal
// grants the achievement one short, permanently (unlocks are one-way). They used to be percents so
// the max could stay a fixed 100, but the website displays stat progress as "value/max", and
// "28/73" reads right where "38/100" doesn't.
//
// All go out via SetValue under HIGHEST aggregation, never Increment: the value is re-derived from
// scratch each time, so re-submitting is harmless and a progress wipe can't drag a player backwards.
// An Increment would double-count every level CloudProgress restores onto a second machine.
//
// The COUNTING stats (coins, deaths, dailies, GIF exports) follow the same idempotent scheme via a
// persisted lifetime tally — see SubmitTally. Nothing here ever calls Stats.Increment: the engine
// discards a failed flush batch without requeueing it, so an increment lost to one network blip is
// lost for good, while a re-sent running total heals on the next submit or boot.
/// <summary>Distinct map levels beaten (majors and sides alike). Drives the 25/50 milestones AND
/// beat_all_levels, whose website max must equal <see cref="LevelMap.Nodes"/>.Count. Re-derived from
/// the map on every submit, so it survives a progress wipe.</summary>
public const string MapLevelsBeatenStat = "map_levels_beaten";
/// <summary>Characters unlocked, driving unlock_all_characters (website max = roster size). Measured
/// over <see cref="Characters.All"/>, which is picker order and so excludes the non-selectable second
/// twin.</summary>
public const string CharactersUnlockedStat = "characters_unlocked";
/// <summary>The level the "beat it with every character" achievement is pinned to. A mid-late major
/// on the spine, so it's gated behind most of the campaign and can't be farmed on an easy level —
/// no exclusion rule needed.
/// <para>It must not be a forced-character level. <c>chaos</c>, the final major, is Mimic's unlock
/// level and forces Mimic — every run of it plays as Mimic, so pinning there would be unachievable.
/// Whatever this points at also has to be clearable by every moveset (no mandatory dash, say).</para></summary>
public const string CapstoneLevelId = "roundabout";
/// <summary>How many characters have beaten <see cref="CapstoneLevelId"/> (website max = roster size).
/// <para>The level name is baked in deliberately: re-pointing <see cref="CapstoneLevelId"/> should
/// change this name (and the website's source stat) too, or HIGHEST aggregation would carry the old
/// level's count straight over and unlock the new achievement instantly.</para></summary>
public const string CapstoneCharactersStat = "roundabout_characters";
/// <summary>How many <see cref="BlockType"/> entries the player has brought to max phase (website max
/// = enum length). Fed from <see cref="GameSettings.MaxPhasedBlockTypes"/>; a maxed block credits its
/// current type AND <see cref="BlockType.Mimic"/> when it originated as one (<see cref="BlockResult.WasMimic"/>),
/// because a mimic is always wearing a disguise by the time results are built. Every enum entry must
/// stay PLACED in some map level or the achievement silently caps one short — SquidPierce was the
/// last gap (fixed via wrapper-start).</summary>
public const string BlockTypesMaxedStat = "block_types_maxed";
// ---- MANUAL achievement idents ---------------------------------------------------------------
// One-shot deeds, unlocked directly so they pop the instant they're earned. EVERY id below must be
// set to MANUAL mode on the website: ManualUnlock silently ignores a request aimed at a stat-mode
// achievement, so a mismatch here fails completely and silently.
//
// The engine itself won't retry a lost request (it marks the entry unlocked locally before
// awaiting), so Unlock also persists each earned ident and CheckProgress re-sends them all every
// boot: a fresh boot re-fetches the REAL unlock state, so a deed the backend never heard about
// goes out again until one request lands. Idempotent — the backend drops repeats.
public const string BeatOnionAsTwins = "beat_onion_as_twins";
public const string BeatWingItAsGunner = "beat_wing_it_as_gunner";
public const string BeatWorkshopLevel = "beat_workshop_level";
public const string PublishWorkshopLevel = "publish_workshop_level";
public const string HijackAReplay = "hijack_a_replay";
public const string CollectEveryCoinInALevel = "collect_every_coin_in_a_level";
public const string WinDailyChallengeFirstTry = "win_daily_challenge_first_try";
public const string BeatDailyChallenge = "beat_daily_challenge";
public const string UseReplayShareCode = "use_replay_share_code";
// ---- level+character pairings ("beat X as Y") ------------------------------------------------
// Both halves are compared, so a win only counts on that level AS that character. Every level here
// must be FREE-CHOICE: a forced-character level (chaos forces Mimic, most side levels force their
// unlock character) can only ever be played as that one character, which would make the pairing
// unwinnable. Check Assets/levels/<id>.json for a "Character" key before adding one.
/// <summary>Beaten as Twins with NEITHER twin dying. A Twins run survives its first death (the
/// partner carries on), so the win alone isn't enough — see the flawless gate in
/// <see cref="AwardCharacterLevelChallenge"/>. Sits four majors after Twins unlocks, so it's
/// earnable in normal progression order.</summary>
public const string TwinsChallengeLevelId = "onion";
/// <summary>Beaten as Gunner. Note this level (WING IT) comes THREE majors before Gunner unlocks,
/// so unlike the Twins pairing it can't be earned on a first pass — the player has to come back
/// once they own Gunner.</summary>
public const string GunnerChallengeLevelId = "bird-spikes";
/// <summary>Lifetime coins collected. Feeds the STAT-mode coin achievement; the game never unlocks
/// that one itself. Unversioned on purpose: this is a lifetime tally that no progress
/// wipe should reset, and the website's achievement points at this exact name — a version suffix
/// would silently orphan it. Submitted as the whole running total (see <see cref="SubmitTally"/>),
/// so the achievement must use HIGHEST aggregation.</summary>
public const string CoinsStat = "coins_collected";
/// <summary>Distinct daily challenges beaten, feeding the stat-mode "beat ten" achievement (the
/// "beat one" is manual — see <see cref="SubmitDailyBeaten"/>). Counted per DAY, not per winning
/// run: <see cref="DailyChallengeAttempt.Won"/> is the once-per-day latch. Unversioned for the same
/// reason as <see cref="CoinsStat"/>.</summary>
public const string DailiesBeatenStat = "dailies_beaten";
/// <summary>Distinct days the daily challenge was PLAYED (a win isn't needed — launching a real run
/// counts). Latched once per day by <see cref="DailyChallengeAttempt.PlayCounted"/>. HIGHEST
/// aggregation, like the other counters.</summary>
public const string DailiesPlayedStat = "dailies_played";
/// <summary>DIFFERENT replays a GIF has been exported of, deduplicated by
/// <see cref="GameManager.CurrentReplayKey"/> — exporting five clips of one run counts once.</summary>
public const string GifReplaysExportedStat = "gif_replays_exported";
/// <summary>How many replay keys <see cref="GameSettings.GifExportedReplayKeys"/> retains. Past
/// this the oldest are dropped, so re-exporting a long-forgotten run could count a second time —
/// harmless, and it takes this many distinct exports before it's even reachable.</summary>
public const int MaxTrackedGifReplays = 100;
/// <summary>Lifetime deaths. Counted from the run's own death flag rather than "didn't win", so
/// only an actual death counts.</summary>
public const string DeathsStat = "deaths";
/// <summary>Longest run of CONSECUTIVE days the daily was played. A current streak can fall back to
/// zero, which an Increment-based counter could never express — so this one is submitted with
/// <c>SetValue</c> and must use HIGHEST aggregation on the website, making the stat "best streak
/// ever reached". That's monotonic even though the streak itself isn't, so a broken streak can
/// never revoke progress the player already earned.</summary>
public const string DailyStreakStat = "daily_streak_best";
/// <summary>Unlock a website-defined achievement. Safe to call repeatedly. The engine has already
/// awaited the package's achievement fetch before the game starts, so no readiness wait is needed.
/// <para>The achievement must be set to MANUAL mode on the website or this silently no-ops — see the
/// manual ident block above.</para></summary>
public static void Unlock( string achievementId )
{
if ( string.IsNullOrEmpty( achievementId ) )
return;
// The editor runs under the #local package ident, which has no achievements — the call would
// no-op anyway, so log instead. Achievements are only testable in a packaged build.
if ( Game.IsEditor )
{
Log.Info( $"[BlockParty] Achievement '{achievementId}' would unlock (editor: no backend achievements)." );
return;
}
// Persist the deed before sending, so a request lost offline stays owed — CheckProgress
// re-sends the whole list every boot (see the manual ident block above).
var earned = Settings.Current.EarnedManualAchievements;
if ( !earned.Contains( achievementId ) )
{
earned.Add( achievementId );
Settings.Save();
}
Sandbox.Services.Achievements.Unlock( achievementId );
}
/// <summary>Re-evaluate every achievement derived from persisted progression and unlock whatever
/// now qualifies. Called on boot (after settings load) and whenever progression changes.</summary>
public static void CheckProgress()
{
bool submitted = false;
var characters = Characters.All;
submitted |= SubmitHighest( CharactersUnlockedStat,
characters.Count( character => CharacterProgress.IsUnlocked( character.Id ) ) );
// A node whose level file is missing simply stays unbeaten; that's a broken map, not a win. An
// unbuilt map (boot check before the asset mount, missing levelmap.json) just submits 0.
submitted |= SubmitHighest( MapLevelsBeatenStat, LevelMap.Nodes.Count( node => node.Beaten ) );
// Counted by walking the CURRENT roster rather than the stored list's length, so an id left
// behind by a retired character can't inflate the numerator.
var capstoneWins = Settings.Current.CapstoneWinCharacterIds;
submitted |= SubmitHighest( CapstoneCharactersStat,
characters.Count( character => capstoneWins.Contains( character.Id ) ) );
// Same current-set walk for block types, so a renamed type's orphaned entry can't inflate it.
var maxedTypes = Settings.Current.MaxPhasedBlockTypes;
submitted |= SubmitHighest( BlockTypesMaxedStat,
Enum.GetValues<BlockType>().Count( type => maxedTypes.Contains( type.ToString() ) ) );
// Re-send every lifetime tally and every earned MANUAL deed. Both are idempotent (running
// totals under HIGHEST; the backend drops repeat unlocks), so this is what heals a submission
// lost to a network drop: the durable local copy goes out again on every boot and progression
// change until one request lands.
foreach ( var (stat, total) in Settings.Current.AchievementTallies )
submitted |= SubmitHighest( stat, total );
if ( !Game.IsEditor )
{
foreach ( var ident in Settings.Current.EarnedManualAchievements )
Unlock( ident );
}
if ( submitted )
Sandbox.Services.Stats.Flush();
}
/// <summary>Award any "beat X as Y" pairing this win satisfies. Character ids are read from the
/// registry at call time rather than captured as literals, so renaming a character can't silently
/// orphan a pairing. Adding one is a line here plus its two consts above.
/// <paramref name="anyPlayerDied"/> is true when ANY body died during the run, including a twin
/// whose partner went on to win; the Twins pairing demands a flawless run, so it's withheld then.</summary>
public static void AwardCharacterLevelChallenge( string levelId, string characterId, bool anyPlayerDied )
{
if ( string.IsNullOrEmpty( levelId ) || string.IsNullOrEmpty( characterId ) )
return;
if ( levelId == TwinsChallengeLevelId && characterId == Characters.Twins.Id && !anyPlayerDied )
Unlock( BeatOnionAsTwins );
else if ( levelId == GunnerChallengeLevelId && characterId == Characters.Gunner.Id )
Unlock( BeatWingItAsGunner );
}
/// <summary>Record that a character just beat a level, if that level is the capstone. Persists only;
/// the caller follows with <see cref="CheckProgress"/>, which is what actually submits the count
/// (and re-submits it on later boots, so a lost request heals).</summary>
public static void RecordCapstoneWin( string levelId, string characterId )
{
if ( levelId != CapstoneLevelId || string.IsNullOrEmpty( characterId ) )
return;
var wins = Settings.Current.CapstoneWinCharacterIds;
if ( wins.Contains( characterId ) )
return;
wins.Add( characterId );
Settings.Save();
}
/// <summary>Record which block types this run's results brought to max phase; returns true when a
/// NEW type landed, so the caller can run <see cref="CheckProgress"/> to submit — losing runs count
/// too (the feat is per-block, not per-run), and the victory-path call wouldn't cover those.
/// <para>Only blocks the player actually finished count: one authored to START at max phase
/// (<see cref="BlockResult.ScoreStartPhase"/>) was never engaged and must not gift its type. A maxed
/// block credits its current type, plus Mimic when it originated as one (<see cref="BlockResult.WasMimic"/>)
/// — covering the endgame edge where the last-maxed mimic never swaps to its final disguise because
/// the transform pass bails once the game is over.</para></summary>
public static bool RecordMaxPhasedTypes( IReadOnlyList<BlockResult> results )
{
if ( results is null )
return false;
var seen = Settings.Current.MaxPhasedBlockTypes;
bool added = false;
foreach ( var result in results )
{
if ( result.Phase < Block.NUM_PHASES - 1 || result.ScoreStartPhase >= Block.NUM_PHASES - 1 )
continue;
added |= AddUnique( seen, result.Type.ToString() );
if ( result.WasMimic )
added |= AddUnique( seen, nameof( BlockType.Mimic ) );
}
if ( added )
Settings.Save();
return added;
}
private static bool AddUnique( System.Collections.Generic.List<string> list, string value )
{
if ( list.Contains( value ) )
return false;
list.Add( value );
return true;
}
// Highest value submitted per stat this session. CheckProgress runs on every map entry and every
// win, and these values only climb, so this drops the redundant writes without a backend read.
private static readonly Dictionary<string, long> _sentValues = new();
/// <summary>Queue a SetValue for a HIGHEST-aggregated stat, if it beats what this session already
/// sent. Returns true when a value was queued (the caller flushes).</summary>
private static bool SubmitHighest( string statName, long value )
{
if ( Game.IsEditor || value <= 0 || (_sentValues.TryGetValue( statName, out long sent ) && value <= sent) )
return false;
_sentValues[statName] = value;
Sandbox.Services.Stats.SetValue( statName, value );
return true;
}
/// <summary>Add to a persisted lifetime tally and submit the new TOTAL. The tally in settings is
/// the durable source of truth: the stat goes out whole via SetValue (never Increment) under
/// HIGHEST website aggregation, so a submission lost to a network drop is healed by the next
/// submit — or the boot re-send in <see cref="CheckProgress"/> — where a re-sent increment would
/// double-count. Saved before submitting, so a crash can't lose or re-count the addition.
/// Returns true when a value was queued (the caller flushes).</summary>
private static bool SubmitTally( string statName, long add )
{
if ( Game.IsEditor || add <= 0 )
return false;
var tallies = Settings.Current.AchievementTallies;
long total = tallies.GetValueOrDefault( statName ) + add;
tallies[statName] = total;
Settings.Save();
return SubmitHighest( statName, total );
}
/// <summary>Add a finished run's coin haul to the lifetime <see cref="CoinsStat"/> tally. Banked
/// once per run rather than per pickup, so a coin-dense level doesn't fire a request per coin.
/// Losing runs still count — the achievement is for collecting coins, not for winning with them.
/// Flushed immediately so quitting straight off the tally can't lose the haul.</summary>
public static void SubmitRunCoins( int coins )
{
// SubmitTally self-gates in the editor: dev/test play must not pollute the lifetime tally.
if ( SubmitTally( CoinsStat, coins ) )
Sandbox.Services.Stats.Flush();
}
/// <summary>Award a newly beaten daily challenge AND count it toward <see cref="DailiesBeatenStat"/>.
/// The only place both modes fire together: the "beat one" achievement is manual so it pops
/// immediately, while the stat still has to climb for the "beat ten" achievement above it. The
/// caller owns the once-per-day guard (see <see cref="DailyChallengeProgress.MarkCompleted"/>) —
/// the increment is unconditional, so a second win on the same day must never reach it.</summary>
public static void SubmitDailyBeaten()
{
Unlock( BeatDailyChallenge ); // immediate; self-gates in the editor
if ( SubmitTally( DailiesBeatenStat, 1 ) )
Sandbox.Services.Stats.Flush();
}
/// <summary>Award one successful workshop publish.</summary>
public static void AwardWorkshopPublished() => Unlock( PublishWorkshopLevel );
/// <summary>Count one death.</summary>
public static void SubmitDeath()
{
if ( SubmitTally( DeathsStat, 1 ) )
Sandbox.Services.Stats.Flush();
}
/// <summary>Award a daily won without spending a second attempt. The caller owns both guards: that
/// this is the day's first WIN, and that only one attempt has been used.</summary>
public static void AwardDailyWonFirstTry() => Unlock( WinDailyChallengeFirstTry );
/// <summary>Award one replay take-over.</summary>
public static void AwardReplayHijacked() => Unlock( HijackAReplay );
/// <summary>Award using a pasted replay share code — EITHER surface: watching one directly from the
/// replay HUD, or importing one into My Replays. Only a successful use counts (a decode that fails,
/// or a watch that can't start, never reaches this); both callers gate on their own success flag.</summary>
public static void AwardReplayCodeUsed() => Unlock( UseReplayShareCode );
/// <summary>Minimum coins a level must HOLD for a clean sweep of it to count. Below this a sweep
/// is something a player falls into rather than sets out to do: the shipped map bottoms out at 5
/// (TIPSY) and the daily template pool at 4. Ten cuts those without narrowing the field much — 27
/// of the map's 30 coin levels and 37 of the 45 coin-bearing daily templates still qualify, the
/// earliest on the map being DUO at 14.</summary>
public const int AllCoinsMinimum = 10;
/// <summary>Award a run that collected every coin in its level. Fired the instant the LAST coin is
/// picked up rather than at game over: the feat is the collecting, so how the run ends afterwards
/// — death or win — doesn't matter. The caller owns every guard: the level holds at least
/// <see cref="AllCoinsMinimum"/> coins, and replays, editor test-plays, debug daily runs and
/// player-authored levels — workshop AND local, since either lets its author lay out a ten-coin
/// gimme — are all excluded.</summary>
public static void AwardAllCoinsCollected() => Unlock( CollectEveryCoinInALevel );
/// <summary>Count a finished GIF export, but only if it's the first one of THIS replay — the
/// achievement is about exporting different runs, not about exporting a lot. Owns its own dedupe
/// (unlike the other submitters) because the "have I seen this one" record is the persisted key
/// list, which nothing else needs.</summary>
public static void SubmitGifExport( string replayKey )
{
if ( Game.IsEditor || string.IsNullOrEmpty( replayKey ) )
return;
var seen = Settings.Current.GifExportedReplayKeys;
if ( seen.Contains( replayKey ) )
return;
seen.Add( replayKey );
if ( seen.Count > MaxTrackedGifReplays )
seen.RemoveRange( 0, seen.Count - MaxTrackedGifReplays );
// SubmitTally persists settings — the key list above included — before submitting, so a
// crash can't re-count this replay.
if ( SubmitTally( GifReplaysExportedStat, 1 ) )
Sandbox.Services.Stats.Flush();
}
/// <summary>Award a beaten workshop level. Unlocking is idempotent, so the caller's
/// "not already beaten" guard is now just an optimisation rather than a correctness requirement.</summary>
public static void AwardWorkshopBeaten() => Unlock( BeatWorkshopLevel );
/// <summary>Count one newly played day toward <see cref="DailiesPlayedStat"/> and report the
/// consecutive-day streak it extends. As with <see cref="SubmitDailyBeaten"/>, the caller owns the
/// once-per-day guard — this counts unconditionally.
/// <para>The streak is banked as a BEST-EVER tally of its own: the current streak re-derives from
/// the version-retired attempts file, so without a durable copy a lost submission could never be
/// re-sent. The backend keeps the maximum either way (HIGHEST aggregation).</para></summary>
public static void SubmitDailyPlayed( int streak )
{
if ( Game.IsEditor )
return;
var tallies = Settings.Current.AchievementTallies;
if ( streak > tallies.GetValueOrDefault( DailyStreakStat ) )
tallies[DailyStreakStat] = streak; // persisted by SubmitTally's save below
bool submitted = SubmitTally( DailiesPlayedStat, 1 );
submitted |= SubmitHighest( DailyStreakStat, tallies.GetValueOrDefault( DailyStreakStat ) );
if ( submitted )
Sandbox.Services.Stats.Flush();
}
}