Game/AchievementTracker.cs

Static helper that records server-sent progress into s&box Services (Stats and Achievements). It handles round results, game over events and direct achievement pushes, guards stat increments with per-round/game IDs to avoid double-counting, and calls Sandbox.Services APIs to increment stats or unlock achievements.

Networking
using System;
using Sandbox;

namespace Splitclicker.Game;

// Maps server-pushed `you.*` facts to s&box Services (Stats + Achievements).
// The server is the source of truth; we just record. Stat increments are NOT
// idempotent, so guard them with the round_id/game_id the server stamps; manual
// Unlock calls are idempotent and need no guard (PLAN §7.1).
//
// Named *Tracker to avoid colliding with Sandbox.Services.Achievements.
public static class AchievementTracker
{
	// round_result → bump the `points` stat once per unseen round. Stat-threshold
	// achievements (first_point, points_50, points_100) then fire automatically.
	public static void OnRoundResult( int pointsDelta, string roundId )
	{
		if ( pointsDelta <= 0 || string.IsNullOrEmpty( roundId ) ) return;

		// "Ahead of the Curve": more than 5 scoring clicks in a single round. Manual
		// Unlock is idempotent, so it's safe to attempt on every delivery (no round_id
		// guard needed) — unlike the `points` increment below.
		if ( pointsDelta > 5 ) Unlock( "aotc" );

		var pd = PlayerData.Load();
		if ( pd.LastPointsRoundId == roundId ) return; // duplicate delivery / reconnect replay
		pd.LastPointsRoundId = roundId;
		pd.Save();
		try { Sandbox.Services.Stats.Increment( "points", pointsDelta ); }
		catch ( Exception e ) { Log.Warning( $"[Splitclicker] points stat failed: {e.Message}" ); }
	}

	// game_over → placement achievements (idempotent Unlock) + the `wins` stat
	// once per unseen game (drives first_win / wins_5 / wins_10).
	public static void OnGameOver( int placement, bool won, string gameId )
	{
		if ( placement >= 1 && placement <= 5 ) Unlock( "top_5" );
		if ( placement >= 1 && placement <= 3 ) Unlock( "top_3" );
		// "Chicken Dinner": win a session (finish #1 in the game's final standings).
		if ( won ) Unlock( "firstwin" );

		if ( !won || string.IsNullOrEmpty( gameId ) ) return;
		var pd = PlayerData.Load();
		if ( pd.LastWinGameId == gameId ) return;
		pd.LastWinGameId = gameId;
		pd.Save();
		try { Sandbox.Services.Stats.Increment( "wins", 1 ); }
		catch ( Exception e ) { Log.Warning( $"[Splitclicker] wins stat failed: {e.Message}" ); }
	}

	// Out-of-band achievement push from the server (the `achievement` WS frame):
	// fire the named unlock straight through. Used for feats the server detects off
	// the game socket — `fart` (poked the backend into a 404) and `hackerman`
	// (fumbled the admin password) — matched back to this client by IP. The Unlock
	// is idempotent, so a repeat push is a harmless no-op.
	public static void OnAchievement( string ident )
	{
		if ( string.IsNullOrEmpty( ident ) ) return;
		Unlock( ident );
	}

	// Idempotent achievement unlock with a console trace. Manual unlocks give no
	// visible feedback in the editor (the ident must be defined on the published
	// package to actually pop), so the log line is how we confirm it fired.
	static void Unlock( string ident )
	{
		Log.Info( $"[Splitclicker] fired achievement {ident}" );
		try { Sandbox.Services.Achievements.Unlock( ident ); }
		catch ( Exception e ) { Log.Warning( $"[Splitclicker] unlock {ident} failed: {e.Message}" ); }
	}
}