Game/DailyChallenge.cs
using System;
using System.Globalization;
namespace BlockParty;
/// <summary>
/// Stable identity helpers for the global daily challenge. The day boundary is UTC so every player
/// sees the same challenge at the same moment, and the seed is derived with an explicit mixer rather
/// than runtime hash functions whose output can change between processes/builds.
/// </summary>
public static class DailyChallenge
{
private const uint SeedSalt = 0xB10CDA25u;
// First UTC daily challenge available in the browser. Bump this to the release day's yyyyMMdd when
// dailies go live.
public const string EarliestId = "20260914";
public static string TodayId => IdFromDate( DateTimeOffset.UtcNow );
/// <summary>Time remaining until the next UTC day begins (i.e. until <see cref="TodayId"/> rolls
/// over and today's board window closes — see <c>Leaderboard.GetDailyBoard</c>).</summary>
public static TimeSpan TimeUntilDayEnd
{
get
{
var now = DateTimeOffset.UtcNow.UtcDateTime;
return now.Date.AddDays( 1 ) - now;
}
}
public static string IdFromDate( DateTimeOffset date ) => date.UtcDateTime.ToString( "yyyyMMdd", CultureInfo.InvariantCulture );
public static bool TryParseId( string id, out DateTime date ) =>
DateTime.TryParseExact( id, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out date );
public static string AddDays( string id, int days )
{
if ( !TryParseId( id, out var date ) )
return TodayId;
return date.AddDays( days ).ToString( "yyyyMMdd", CultureInfo.InvariantCulture );
}
public static bool IsFuture( string id )
{
if ( !TryParseId( id, out var date ) )
return false;
return date.Date > DateTimeOffset.UtcNow.UtcDateTime.Date;
}
public static bool IsBeforeEarliest( string id )
{
if ( !TryParseId( id, out var date ) || !TryParseId( EarliestId, out var earliest ) )
return false;
return date.Date < earliest.Date;
}
public static string ClampId( string id )
{
if ( string.IsNullOrEmpty( id ) || !TryParseId( id, out _ ) )
id = TodayId;
if ( IsFuture( id ) ) return TodayId;
if ( IsBeforeEarliest( id ) ) return EarliestId;
return id;
}
public static int SeedFor( string id )
{
if ( !int.TryParse( id, NumberStyles.Integer, CultureInfo.InvariantCulture, out int day ) )
day = int.Parse( TodayId, CultureInfo.InvariantCulture );
unchecked
{
uint x = (uint)day;
x ^= SeedSalt;
x ^= (uint)Leaderboard.LEADERBOARD_VERSION * 0x9E3779B9u;
x ^= x >> 16;
x *= 0x7FEB352Du;
x ^= x >> 15;
x *= 0x846CA68Bu;
x ^= x >> 16;
return (int)(x & 0x7FFFFFFF);
}
}
/// <summary>Seed for the day's LEVEL GENERATION (see <see cref="DailyLevelGenerator"/>) — the run
/// seed (<see cref="SeedFor"/>) remixed with a distinct salt so the generator's draws don't
/// correlate with the run's sim/cosmetic Rng streams, which are seeded from the un-remixed value.</summary>
public static int GenSeedFor( string id )
{
unchecked
{
uint x = (uint)SeedFor( id );
x ^= 0xDA11F00Du;
x ^= x >> 16;
x *= 0x7FEB352Du;
x ^= x >> 15;
x *= 0x846CA68Bu;
x ^= x >> 16;
return (int)(x & 0x7FFFFFFF);
}
}
public static string StatNameFor( string id ) => $"score_daily_v{Leaderboard.LEADERBOARD_VERSION}_{id}";
/// <summary>Per-day backend counter of the local player's launched attempts (incremented via
/// Sandbox.Services.Stats). Version-scoped like the score stat, so a version bump (which re-rolls
/// every day's layout and board) also resets attempt counts.</summary>
public static string AttemptsStatName( string id ) => $"attempts_daily_v{Leaderboard.LEADERBOARD_VERSION}_{id}";
public static string DisplayDate( string id )
{
if ( !TryParseId( id, out var date ) )
return id ?? "";
return date.ToString( "dd MMM yyyy", CultureInfo.InvariantCulture ).ToUpperInvariant();
}
}