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();
	}
}