DiamondAchievements.cs
using System;
using System.Collections.Generic;
using Sandbox;

namespace Diamonds;

/// <summary>
/// Backend achievements, following Block Party's scheme. Each one is defined on the s&amp;box website (ident, title, icon,
/// unlock mode); the game only feeds stats or names manual unlocks, and the website's unlock mode decides which applies.
///
/// STAT achievements show a progress bar. The game submits a lifetime running total and the engine unlocks the achievement
/// once the stat crosses the website's max, so targets can be retuned without a game update. Every source stat needs
/// HIGHEST aggregation: totals go out whole through SetValue, never Increment, because the engine drops a failed flush
/// without retrying. A re-sent total heals on the next submit or boot, where a lost increment would be gone for good.
/// Stat names are unversioned, unlike the leaderboard's, so a leaderboard wipe never resets lifetime progress.
///
/// MANUAL achievements are one-shot deeds, unlocked the moment they are earned. Each must be MANUAL on the website: the
/// engine silently ignores a manual unlock aimed at a stat achievement. Earned idents are persisted and re-sent every boot,
/// since the engine never retries a lost unlock request; the backend drops repeats.
/// <list type="table">
/// <item><term>survive_10_minutes</term><description>MANUAL — <see cref="Survive10Minutes"/></description></item>
/// <item><term>survive_12_minutes</term><description>MANUAL — <see cref="Survive12Minutes"/></description></item>
/// <item><term>survive_15_minutes</term><description>MANUAL — <see cref="Survive15Minutes"/></description></item>
/// <item><term>damage_50_diamond_piece</term><description>MANUAL — <see cref="DamageHugePiece"/></description></item>
/// <item><term>score_20000</term><description>MANUAL — <see cref="Score20000"/></description></item>
/// <item><term>score_50000</term><description>MANUAL — <see cref="Score50000"/></description></item>
/// <item><term>score_100000</term><description>MANUAL — <see cref="Score100000"/></description></item>
/// <item><term>score_10000_in_1_minute</term><description>MANUAL — <see cref="QuickScore"/></description></item>
/// <item><term>break_3_colors_in_one_turn</term><description>MANUAL — <see cref="BreakThreeColors"/></description></item>
/// <item><term>lose_with_no_group_over_3</term><description>MANUAL — <see cref="LoseWithSmallGroups"/></description></item>
/// <item><term>break_10000_diamonds</term><description>STAT — <see cref="BrokenStat"/>, HIGHEST, max 10000</description></item>
/// <item><term>crush_100000_shards</term><description>STAT — <see cref="CrushedStat"/>, HIGHEST, max 100000</description></item>
/// <item><term>score_1000000_points</term><description>STAT — <see cref="PointsStat"/>, HIGHEST, max 1000000</description></item>
/// </list>
/// Debug games (a positive Starting Score, diamonds_fill, profiling runs) never count; DiamondGame decides that. The editor
/// runs under the #local package, which has no achievements, so it only logs what would happen and never touches the tallies.
/// </summary>
public static class DiamondAchievements
{
	public const string FilePath = "diamonds_achievements.json";

	/// <summary>Lifetime diamonds shattered by play (game-over cleanup excluded).</summary>
	public const string BrokenStat = "diamonds_broken";
	/// <summary>Lifetime shards crushed for points.</summary>
	public const string CrushedStat = "shards_crushed";
	/// <summary>Lifetime points scored, summed across games.</summary>
	public const string PointsStat = "points_scored";

	public const string Survive10Minutes = "survive_10_minutes";
	public const string Survive12Minutes = "survive_12_minutes";
	public const string Survive15Minutes = "survive_15_minutes";
	/// <summary>Damage an established same-color bonded group of at least <see cref="HugePieceSize"/> diamonds.</summary>
	public const string DamageHugePiece = "damage_50_diamond_piece";
	public const int HugePieceSize = 50;

	public const string Score20000 = "score_20000";
	public const string Score50000 = "score_50000";
	public const string Score100000 = "score_100000";
	/// <summary>Reach <see cref="QuickScoreTarget"/> within the first <see cref="DiamondBoard.FirstMinuteSeconds"/> of unpaused play.</summary>
	public const string QuickScore = "score_10000_in_1_minute";
	public const long QuickScoreTarget = 10_000;
	/// <summary>Shatter diamonds of <see cref="BreakColorsTarget"/> different colors within one turn (a spawn until the next).</summary>
	public const string BreakThreeColors = "break_3_colors_in_one_turn";
	public const int BreakColorsTarget = 3;
	/// <summary>Reach game over with no bonded same-color group in the arena larger than <see cref="SmallGroupLimit"/>.
	/// Corner-only contacts never bond, so they never join a group.</summary>
	public const string LoseWithSmallGroups = "lose_with_no_group_over_3";
	public const int SmallGroupLimit = 3;

	/// <summary>Unpaused game time, as the timer shows it, that unlocks each survival achievement.</summary>
	public static readonly (string Ident, float Minutes)[] Survival =
		[(Survive10Minutes, 10), (Survive12Minutes, 12), (Survive15Minutes, 15)];

	/// <summary>The running game's score that unlocks each score achievement, the moment the score passes it.</summary>
	public static readonly (string Ident, long Score)[] ScoreTargets =
		[(Score20000, 20_000), (Score50000, 50_000), (Score100000, 100_000)];

	/// <summary>The durable local copy of lifetime tallies and earned manual idents, re-sent every boot.</summary>
	public sealed class SavedProgress
	{
		public Dictionary<string, long> Tallies { get; set; } = new();
		public List<string> Earned { get; set; } = new();
	}

	static SavedProgress progress;
	static SavedProgress Progress => progress ??= Load();
	// Manual idents requested this session, so a per-frame check sends each at most once.
	static readonly HashSet<string> requested = new();
	// Highest value submitted per stat this session; totals only climb, so lower re-sends are dropped.
	static readonly Dictionary<string, long> sent = new();
	static bool resent;

	static readonly string[] LifetimeStats = [BrokenStat, CrushedStat, PointsStat];

	/// <summary>Once per session: re-send every earned manual ident and lifetime tally, healing submissions lost offline.</summary>
	public static void CheckProgress()
	{
		if ( Game.IsEditor || resent ) return;
		resent = true;
		foreach ( var ident in Progress.Earned.ToArray() ) Unlock( ident );
		_ = ResendTalliesAsync();
	}

	// Local tallies start from zero on another machine or after a data wipe, and HIGHEST aggregation would ignore every
	// total until it passed the backend's. Adopt the backend's best first, so new progress adds on top of it.
	static async System.Threading.Tasks.Task ResendTalliesAsync()
	{
		try
		{
			var stats = Sandbox.Services.Stats.LocalPlayer;
			if ( stats is not null )
			{
				var before = new Dictionary<string, long>( Progress.Tallies );
				await stats.Refresh();
				bool raised = false;
				foreach ( string name in LifetimeStats )
				{
					long local = before.GetValueOrDefault( name );
					long backend = stats.TryGet( name, out var stat ) ? (long)stat.Max : 0;
					if ( backend <= local ) continue;
					// Keep anything banked while the refresh was in flight on top of the adopted total.
					Progress.Tallies[name] = backend + Progress.Tallies.GetValueOrDefault( name ) - local;
					raised = true;
				}
				if ( raised ) Save();
			}
		}
		catch ( Exception e ) { Log.Warning( $"Diamonds: could not read achievement stats; re-sending local totals. {e.Message}" ); }
		bool submitted = false;
		foreach ( string name in LifetimeStats ) submitted |= SubmitHighest( name, Progress.Tallies.GetValueOrDefault( name ) );
		if ( submitted ) Sandbox.Services.Stats.Flush();
	}

	/// <summary>Unlock whichever manual achievements the running game has earned. Called every frame of a counted game.</summary>
	public static void CheckGame( DiamondBoard board )
	{
		foreach ( var (ident, minutes) in Survival )
			if ( board.ElapsedSeconds >= minutes * 60 ) Unlock( ident );
		if ( board.LargestDamagedGroup >= HugePieceSize ) Unlock( DamageHugePiece );
		// Score counts as each crush is awarded, not when its flying number reaches the displayed total.
		foreach ( var (ident, score) in ScoreTargets )
			if ( board.Score >= score ) Unlock( ident );
		if ( board.FirstMinuteScore >= QuickScoreTarget ) Unlock( QuickScore );
		if ( board.MostColorsBrokenInTurn >= BreakColorsTarget ) Unlock( BreakThreeColors );
		if ( board.GameOver && board.LargestGroupAtGameOver <= SmallGroupLimit ) Unlock( LoseWithSmallGroups );
	}

	/// <summary>Add a counted game's new progress to the lifetime tallies and submit the totals. Saved before submitting, so a
	/// crash can neither lose nor double-count it.</summary>
	public static void Bank( long broken, long crushed, long points )
	{
		if ( broken <= 0 && crushed <= 0 && points <= 0 ) return;
		if ( Game.IsEditor )
		{
			Log.Info( $"Diamonds: would add {broken:N0} broken, {crushed:N0} crushed, {points:N0} points to achievement stats (editor: no backend achievements)." );
			return;
		}
		var tallies = Progress.Tallies;
		void Add( string stat, long amount ) { if ( amount > 0 ) tallies[stat] = tallies.GetValueOrDefault( stat ) + amount; }
		Add( BrokenStat, broken );
		Add( CrushedStat, crushed );
		Add( PointsStat, points );
		Save();
		bool submitted = SubmitHighest( BrokenStat, tallies.GetValueOrDefault( BrokenStat ) );
		submitted |= SubmitHighest( CrushedStat, tallies.GetValueOrDefault( CrushedStat ) );
		submitted |= SubmitHighest( PointsStat, tallies.GetValueOrDefault( PointsStat ) );
		if ( submitted ) Sandbox.Services.Stats.Flush();
	}

	/// <summary>Unlock a manual achievement. Safe to call repeatedly; only the first call per session does anything.</summary>
	public static void Unlock( string ident )
	{
		if ( string.IsNullOrEmpty( ident ) || !requested.Add( ident ) ) return;
		if ( Game.IsEditor )
		{
			Log.Info( $"Diamonds: achievement '{ident}' would unlock (editor: no backend achievements)." );
			return;
		}
		// Persist before sending, so a request lost offline stays owed until a later boot re-sends it.
		if ( !Progress.Earned.Contains( ident ) )
		{
			Progress.Earned.Add( ident );
			Save();
		}
		Sandbox.Services.Achievements.Unlock( ident );
	}

	static bool SubmitHighest( string stat, long value )
	{
		if ( value <= 0 || (sent.TryGetValue( stat, out long previous ) && value <= previous) ) return false;
		sent[stat] = value;
		Sandbox.Services.Stats.SetValue( stat, value );
		return true;
	}

	static SavedProgress Load()
	{
		try
		{
			var loaded = FileSystem.Data?.ReadJsonOrDefault( FilePath, new SavedProgress() ) ?? new SavedProgress();
			// A hand-edited or truncated file can null either collection.
			loaded.Tallies ??= new();
			loaded.Earned ??= new();
			return loaded;
		}
		catch ( Exception e )
		{
			Log.Warning( $"Could not read {FilePath}; starting achievement progress fresh. {e.Message}" );
			return new SavedProgress();
		}
	}

	static void Save()
	{
		try { FileSystem.Data?.WriteJson( FilePath, Progress ); }
		catch ( Exception e ) { Log.Warning( $"Could not save {FilePath}. {e.Message}" ); }
	}
}