Client-side achievement bookkeeping for the owning player. Tracks per-account progress (escaped unique levels and manually unlocked event achievements) in a local per-SteamID file, reports stat increments to GameStats and forwards manual unlocks to the Services unlock API.
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace BrickJam;
/// <summary>
/// Client-side achievement bookkeeping. s&box <see cref="Sandbox.Services.Achievements"/> exposes a manual
/// <c>Unlock(ident)</c> AND dashboard-defined "Stat" unlock mode, where an achievement auto-unlocks once a
/// backing stat crosses a configured threshold/range.
///
/// The lifetime/threshold achievements (file_explorer, the_one_percent, grand_tour) are configured as STAT-mode
/// on the dashboard, so here we only ever REPORT their backing stats - the backend does the unlocking:
/// file_explorer -> stat "saves_loaded" (unlock at 50)
/// the_one_percent -> stat "money_earned" (unlock at 10,000) - reported by SellArea, nothing here
/// grand_tour -> stat "levels_escaped_unique" (unlock at 3)
/// One-shot EVENT achievements still unlock manually via <see cref="Unlock"/>, which dedupes against a small
/// per-account local file so repeats don't spam the backend.
///
/// Everything runs on the OWNING client (per-account state + local FileSystem), reached from gameplay via the
/// <c>[Rpc.Owner]</c> hops on <see cref="Player"/>.
/// </summary>
public static class AchievementTracker
{
public struct Progress
{
/// <summary>Distinct level types we've reported as escaped, so grand_tour's stat counts each only once.</summary>
public List<string> EscapedLevels { get; set; }
/// <summary>Manually-unlocked event achievements, for local dedup.</summary>
public List<string> Unlocked { get; set; }
}
private static Progress? cached;
private static string FileName => $"{Connection.Local.SteamId}.achievements";
private static Progress Current
{
get
{
if ( cached is { } c )
return c;
var loaded = Load();
cached = loaded;
return loaded;
}
}
private static Progress Load()
{
Progress p = default;
if ( FileSystem.Data.FileExists( FileName ) )
{
try { p = Json.Deserialize<Progress>( FileSystem.Data.ReadAllText( FileName ) ); }
catch { p = default; }
}
p.EscapedLevels ??= new List<string>();
p.Unlocked ??= new List<string>();
return p;
}
private static void Save( Progress p )
{
cached = p;
try { FileSystem.Data.WriteJson( FileName, p ); }
catch ( System.Exception e ) { Log.Warning( $"Couldn't save achievement progress: {e.Message}" ); }
}
/// <summary>
/// Unlock an EVENT achievement for the local account, once. Records it locally so repeat events don't re-hit
/// the backend, then forwards to Services (a no-op until the game is published, harmless in dev).
/// </summary>
public static void Unlock( string ident )
{
if ( string.IsNullOrEmpty( ident ) )
return;
var p = Current;
if ( p.Unlocked.Contains( ident ) )
return;
p.Unlocked.Add( ident );
Save( p );
GameStats.Unlock( ident );
Log.Info( $"[achievement] unlocked '{ident}'" );
}
/// <summary>File Explorer: report a successful save-file load (backs the stat-mode file_explorer achievement).</summary>
public static void OnSaveLoaded()
{
GameStats.Increment( GameStats.SavesLoaded, 1 );
}
/// <summary>
/// Grand Tour: report an escaped level. Increments "levels_escaped_unique" only the FIRST time each distinct
/// gameplay level is escaped, so the stat reaches 3 exactly when Mansion + Dungeon + Bathrooms have all been
/// escaped (the dashboard unlocks grand_tour at 3).
/// </summary>
public static void OnLevelEscaped( LevelType level )
{
if ( level is not (LevelType.Mansion or LevelType.Dungeon or LevelType.Bathrooms) )
return;
var p = Current;
var key = level.ToString();
if ( p.EscapedLevels.Contains( key ) )
return;
p.EscapedLevels.Add( key );
Save( p );
GameStats.Increment( GameStats.LevelsEscapedUnique, 1 );
}
}