Game/DailyMusic.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Sandbox;
namespace BlockParty;
/// <summary>
/// Picks each daily challenge's song. Deliberately NOT part of the generator's Random stream: the
/// day and the song name are hashed together and the highest-scoring song wins ("rendezvous
/// hashing"), which consumes no rng draws and buys two things:
///
/// • ADDING SONGS IS SAFE — dropping a track into Assets/music only changes the days that track
/// outright wins (~1 day in N for an N-song library); every other past day keeps its song, and no
/// generation draw shifts at all because none of this touches the generator's Random.
/// • NO RECENT REPEATS — a day skips whatever the previous <see cref="RepeatWindowDays"/> days
/// picked, so a track can't come back the next morning.
///
/// The skip list is built from earlier days' one-level-shallower picks rather than chaining back
/// through all history, the same best-effort trick <see cref="DailyLevelGenerator"/> uses for
/// templates: every day stays a pure function of its own date. The cost is a rare miss — sweeps over
/// 5000 days with the current library show no back-to-back repeat at all and about one repeat inside
/// the window per decade.
/// </summary>
public static class DailyMusic
{
/// <summary>How many previous days' songs a day avoids, capped at a third of the library so a
/// small music folder still has room to pick from.</summary>
public const int RepeatWindowDays = 10;
/// <summary>Songs the daily never picks (shelve a track without moving the file). Bare names, no
/// extension.</summary>
private static readonly string[] Excluded = Array.Empty<string>();
// Song library + a matching table of name hashes, latched on the first scan that finds files (the
// mounted filesystem can lag at boot — see DailyTemplates.EnsureLoaded for the same race).
private static List<string> _pool;
private static uint[] _poolHashes;
private static float _lastScanTime = float.NegativeInfinity;
// Memoised per-day picks: one day's answer needs ~3 windows' worth of earlier-day picks, and the
// hub/daily_dump ask for neighbouring days constantly.
private const int CacheLimit = 4096;
private static readonly Dictionary<string, string> _rawPicks = new();
private static readonly Dictionary<string, string> _windowPicks = new();
/// <summary>Songs the daily draws from, in a stable order (empty until the library mounts).</summary>
public static IReadOnlyList<string> Pool
{
get
{
EnsureLoaded();
return (IReadOnlyList<string>)_pool ?? Array.Empty<string>();
}
}
public static bool IsLoaded => _pool is not null;
/// <summary>Re-scan Assets/music (hotload hygiene / after dropping in new tracks).</summary>
public static void Reload()
{
_pool = null;
_poolHashes = null;
_lastScanTime = float.NegativeInfinity;
_rawPicks.Clear();
_windowPicks.Clear();
}
/// <summary>The song for a day, or null when the library hasn't mounted yet (the caller then
/// falls back to <see cref="Audio.DefaultMusic"/>).</summary>
public static string For( string dailyId )
{
EnsureLoaded();
if ( _pool is null || _pool.Count == 0 ) return null;
return Best( dailyId, Recent( dailyId, WindowPick ) );
}
/// <summary>The window of days a repeat is avoided across — the constant, kept under a third of
/// the library so the skip list can never crowd out the pool.</summary>
private static int Window => Math.Max( 1, Math.Min( RepeatWindowDays, _pool.Count / 3 ) );
/// <summary>What the previous <see cref="Window"/> days picked, at the given level of detail.</summary>
private static HashSet<string> Recent( string dailyId, Func<string, string> pickFor )
{
var recent = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
string id = dailyId;
for ( int i = 0; i < Window; i++ )
{
id = DailyChallenge.AddDays( id, -1 );
recent.Add( pickFor( id ) );
}
return recent;
}
// One level shallower than the real pick: skips the previous days' RAW songs. Only used to build
// the skip list, which is why the recursion stops here instead of running back through history.
private static string WindowPick( string dailyId )
{
if ( _windowPicks.TryGetValue( dailyId, out var cached ) ) return cached;
var pick = Best( dailyId, Recent( dailyId, RawPick ) );
if ( _windowPicks.Count >= CacheLimit ) _windowPicks.Clear();
_windowPicks[dailyId] = pick;
return pick;
}
// The day's song with nothing skipped.
private static string RawPick( string dailyId )
{
if ( _rawPicks.TryGetValue( dailyId, out var cached ) ) return cached;
var pick = Best( dailyId, null );
if ( _rawPicks.Count >= CacheLimit ) _rawPicks.Clear();
_rawPicks[dailyId] = pick;
return pick;
}
/// <summary>Highest-scoring song for a day, ignoring anything in <paramref name="recent"/>.</summary>
private static string Best( string dailyId, HashSet<string> recent )
{
uint seed = DaySeed( dailyId );
string best = null;
uint bestScore = 0;
for ( int i = 0; i < _pool.Count; i++ )
{
if ( recent is not null && recent.Contains( _pool[i] ) ) continue;
uint score = Mix( _poolHashes[i] ^ seed );
// Ties break on name so the result never depends on scan order alone.
if ( best is null || score > bestScore
|| (score == bestScore && string.CompareOrdinal( _pool[i], best ) < 0) )
{
best = _pool[i];
bestScore = score;
}
}
return best ?? _pool[0];
}
// Day seed, mixed from the yyyyMMdd number alone: unlike the generator seed this deliberately does
// NOT fold in LEADERBOARD_VERSION, so a version bump re-rolls the layouts and leaves the songs.
private static uint DaySeed( string dailyId )
{
if ( !int.TryParse( dailyId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int day ) )
day = 0;
return Mix( (uint)day ^ 0x50C0DA11u );
}
// FNV-1a over the lower-cased name: a fixed hash, unlike string.GetHashCode (randomised per
// process), so every machine scores the same song identically.
private static uint NameHash( string name )
{
unchecked
{
uint h = 2166136261u;
foreach ( char c in name )
{
h ^= char.ToLowerInvariant( c );
h *= 16777619u;
}
return h;
}
}
private static uint Mix( uint x )
{
unchecked
{
x ^= x >> 16;
x *= 0x7FEB352Du;
x ^= x >> 15;
x *= 0x846CA68Bu;
x ^= x >> 16;
return x;
}
}
private static void EnsureLoaded()
{
if ( _pool is not null ) return;
// Retry at most once a second: a missing library must not cost a filesystem scan per frame
// (DailyLevels.Get sits on hot paths).
if ( RealTime.Now - _lastScanTime < 1f ) return;
_lastScanTime = RealTime.Now;
// Sorted, extension-free, identical on every machine; the menus' default song is excluded.
var names = Audio.RandomizableMusicNames();
names.RemoveAll( n => Excluded.Contains( n, StringComparer.OrdinalIgnoreCase ) );
if ( names.Count == 0 ) return; // nothing mounted yet (or nothing left): retry later
_pool = names;
_poolHashes = names.Select( NameHash ).ToArray();
}
}