SoundPlayTracker.cs

A static tracker for sound pad plays. It records per-pad play counts, lifetime total clicks and loop re-fires, persists these values to Game.Cookies with throttled writes, updates platform stats and achievements, and builds a trending list from a pool with fallback.

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox.Services;

namespace SSoundboard;

/// <summary>
/// Per-pad press counts + lifetime total clicks (cookies) + trending list builder.
/// Cookie writes are throttled to avoid disk spam during mash/loop.
/// </summary>
public static class SoundPlayTracker
{
	const string CookieKey = "ssoundboard.pad-plays";
	const string TotalClicksCookieKey = "ssoundboard.total-clicks";
	const string TotalLoopPlaysCookieKey = "ssoundboard.total-loop-plays";
	const int TrendingCount = 15;
	const float CookieSaveInterval = 2f;

	static readonly Dictionary<string, int> _plays = new( StringComparer.OrdinalIgnoreCase );
	static long _totalClicks;
	static long _totalLoopPlays;
	static bool _loaded;
	static bool _dirty;
	static RealTimeSince _sinceSave;

	/// <summary>Lifetime pad presses across every button (persisted).</summary>
	public static long TotalClicks
	{
		get
		{
			EnsureLoaded();
			return _totalClicks;
		}
	}

	/// <summary>Lifetime LOOP re-fires (each time a looped layer plays again).</summary>
	public static long TotalLoopPlays
	{
		get
		{
			EnsureLoaded();
			return _totalLoopPlays;
		}
	}

	public static void EnsureLoaded()
	{
		if ( _loaded )
			return;

		var saved = Game.Cookies.Get( CookieKey, new Dictionary<string, int>() );
		_plays.Clear();
		long sum = 0;
		foreach ( var (soundId, count) in saved )
		{
			if ( count <= 0 )
				continue;
			_plays[soundId] = count;
			sum += count;
		}

		// Prefer dedicated total cookie; seed from per-pad sum for older installs.
		var storedTotal = 0L;
		try { storedTotal = Game.Cookies.Get( TotalClicksCookieKey, 0L ); }
		catch { storedTotal = 0L; }

		_totalClicks = storedTotal > 0 ? storedTotal : sum;
		if ( _totalClicks < sum )
			_totalClicks = sum;

		try { _totalLoopPlays = Game.Cookies.Get( TotalLoopPlaysCookieKey, 0L ); }
		catch { _totalLoopPlays = 0L; }

		_loaded = true;
		_dirty = storedTotal != _totalClicks;
		_sinceSave = 0;

		// Catch-up local + queue platform unlocks for milestones already earned.
		SoundBoardAchievements.OnTotalClicksChanged( _totalClicks );
		SoundBoardAchievements.OnLoopPlaysChanged( _totalLoopPlays );
		// Full platform resync runs from Bootstrap/Panel + delayed Tick (services ready).
	}

	public static int GetCount( string soundId )
	{
		EnsureLoaded();
		if ( string.IsNullOrWhiteSpace( soundId ) )
			return 0;

		return _plays.TryGetValue( soundId, out var n ) ? n : 0;
	}

	public static bool HasAnyPlays()
	{
		EnsureLoaded();
		return _plays.Count > 0 || _totalClicks > 0;
	}

	public static void Record( string soundId )
	{
		if ( string.IsNullOrWhiteSpace( soundId ) )
			return;

		EnsureLoaded();

		_plays[soundId] = _plays.TryGetValue( soundId, out var n ) ? n + 1 : 1;
		_totalClicks++;
		_dirty = true;

		SoundBoardAchievements.OnTotalClicksChanged( _totalClicks );

		// Persist every few seconds of activity, not every mash.
		if ( _sinceSave >= CookieSaveInterval )
			Flush();

		try
		{
			Stats.Map.SetValue( ToStatName( soundId ), 1.0, new Dictionary<string, object>() );
			Stats.Map.SetValue( "clicks.total", (double)_totalClicks, new Dictionary<string, object>() );
		}
		catch
		{
			// optional
		}
	}

	/// <summary>
	/// Count a LOOP layer re-fire (not the initial pad press — only timer repeats).
	/// </summary>
	public static void RecordLoopPlay()
	{
		EnsureLoaded();
		_totalLoopPlays++;
		_dirty = true;

		SoundBoardAchievements.OnLoopPlaysChanged( _totalLoopPlays );

		if ( _sinceSave >= CookieSaveInterval )
			Flush();

		try
		{
			Stats.Map.SetValue( "loops.total", (double)_totalLoopPlays, new Dictionary<string, object>() );
		}
		catch
		{
			// optional
		}
	}

	/// <summary>Force cookie write (call on kill-all / shutdown if needed).</summary>
	public static void Flush()
	{
		if ( !_loaded || !_dirty )
			return;

		try
		{
			Game.Cookies.Set( CookieKey, _plays );
			Game.Cookies.Set( TotalClicksCookieKey, _totalClicks );
			Game.Cookies.Set( TotalLoopPlaysCookieKey, _totalLoopPlays );
			_dirty = false;
			_sinceSave = 0;
		}
		catch
		{
			// ignore
		}
	}

	public static IReadOnlyList<SoundPad> BuildTrendingPads(
		IReadOnlyList<SoundPad> pool,
		IReadOnlyList<SoundPad> fallback )
	{
		EnsureLoaded();

		if ( !HasAnyPlays() )
			return fallback;

		var ranked = pool
			.OrderByDescending( pad => GetCount( pad.SoundId ) )
			.ThenBy( pad => pad.Label, StringComparer.OrdinalIgnoreCase )
			.Take( TrendingCount )
			.ToList();

		if ( ranked.Count >= TrendingCount )
			return ranked;

		foreach ( var pad in fallback )
		{
			if ( ranked.Any( x => x.SoundId.Equals( pad.SoundId, StringComparison.OrdinalIgnoreCase ) ) )
				continue;

			ranked.Add( pad );
			if ( ranked.Count >= TrendingCount )
				break;
		}

		return ranked;
	}

	static string ToStatName( string soundId )
	{
		var slug = soundId
			.Replace( "sounds/", "", StringComparison.OrdinalIgnoreCase )
			.Replace( '/', '.' )
			.Replace( ' ', '_' )
			.ToLowerInvariant();

		return $"pad.{slug}";
	}
}