Services/AchievementTracker.cs

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.

File AccessHttp Calls
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 );
	}
}