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.
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}";
}
}