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&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}" ); }
}
}