Game/DailyChallengeProgress.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace BlockParty;
public sealed class DailyChallengeAttempt
{
public string DailyId { get; set; }
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
/// <summary>True once ANY run on this day was a full victory. Distinct from
/// <see cref="CompletedAt"/>, which only means a run finished (a death completes an attempt too).
/// Latches on the first win and is never cleared, so a day counts once toward the daily
/// achievements no matter how many of its attempts are won.</summary>
public bool Won { get; set; }
/// <summary>True once this day has been counted toward the "played" achievements. Latches on the
/// day's FIRST real attempt, so extra attempts on the same day never re-count it. Also the marker
/// <see cref="DailyChallengeProgress.PlayedStreakEndingAt"/> reads to walk consecutive days.</summary>
public bool PlayCounted { get; set; }
/// <summary>How many runs were launched on this day (a day's limit comes from its generated
/// <see cref="DailyDef.MaxAttempts"/>). Absorbs the backend count on each launch, so it can exceed
/// the runs started on this machine.</summary>
public int AttemptsUsed { get; set; }
/// <summary>Best completed run's score (the stored <see cref="Run"/> is the best run, not the last).</summary>
public int FinalScore { get; set; }
public RunData Run { get; set; }
}
/// <summary>
/// Per-player daily attempt counter and best-replay store. Attempt limits are enforced by TWO
/// counters merged via <see cref="AttemptsUsed"/>: the local file (offline / fast path) and a
/// per-day backend stat (<see cref="DailyChallenge.AttemptsStatName"/>, incremented on every run
/// launch and fetched by the daily hub), so deleting the local file does not re-open a limited day
/// once the backend has the count. The backend fetch is best-effort — offline or cold-start
/// sessions fall back to the local file alone.
/// <para>
/// For a 1-attempt day the daily leaderboard adds a third, fully authoritative lock:
/// <c>DailyChallengeScreen</c> checks whether the local player's SteamId already has a board entry
/// (see <c>LocalPlayerOnBoard</c>).
/// </para>
/// </summary>
public static class DailyChallengeProgress
{
public static string FilePath => $"blockparty_daily_challenges_v{Leaderboard.LEADERBOARD_VERSION}.json";
private static List<DailyChallengeAttempt> _attempts;
private static int _loadedVersion = -1;
public static IReadOnlyList<DailyChallengeAttempt> All
{
get
{
EnsureLoaded();
return _attempts;
}
}
public static DailyChallengeAttempt Get( string dailyId )
{
if ( string.IsNullOrEmpty( dailyId ) ) return null;
EnsureLoaded();
return _attempts.FirstOrDefault( a => a.DailyId == dailyId );
}
public static bool IsAttempted( string dailyId ) => Get( dailyId ) is not null;
// Backend attempt counts last fetched this session (see RefreshBackendAttempts), merged with the
// local file so the stricter of the two gates.
private static readonly Dictionary<string, int> _backendAttempts = new();
public static int AttemptsUsed( string dailyId )
=> Math.Max( Get( dailyId )?.AttemptsUsed ?? 0, _backendAttempts.GetValueOrDefault( dailyId ?? "" ) );
/// <summary>Whether another run may be launched for the day. <paramref name="maxAttempts"/> is the
/// day's generated limit (null = unlimited).</summary>
public static bool HasAttemptsRemaining( string dailyId, int? maxAttempts )
=> maxAttempts is not int max || AttemptsUsed( dailyId ) < max;
/// <summary>Count one launched run against the day, locally and on the backend stat. Called ONLY
/// from <see cref="GameManager"/>'s daily start/restart paths so an attempt is counted exactly
/// once per run.</summary>
public static void RegisterAttemptStart( string dailyId )
{
if ( string.IsNullOrEmpty( dailyId ) )
return;
// Count from the MERGED total, not the local record: on a new machine the file is 0 while the
// backend already holds the day's spent attempts, and 0→1 locally leaves the merged gate parked
// at the backend value until the file catches up — re-issuing the whole limit. Writing the merged
// total back also lets the file remember the backend count for later offline sessions.
var attempt = GetOrCreate( dailyId );
int used = AttemptsUsed( dailyId ) + 1;
attempt.AttemptsUsed = used;
// A win isn't needed to have "played" a day — launching a real run is enough (it has already
// burned an attempt against the day's limit). Latched so the day's later attempts don't
// re-count it, and persisted before the submit so a crash in between can't either.
bool firstPlayToday = !attempt.PlayCounted;
attempt.PlayCounted = true;
Save();
if ( firstPlayToday )
Achievements.SubmitDailyPlayed( PlayedStreakEndingAt( dailyId ) );
// Mirror the increment into the session's backend view immediately — the real fetch may lag
// behind the backend's ingestion, and the local file could be deleted mid-session. `used`
// already exceeds the cached backend value, so this is a plain +1 on the merged view.
_backendAttempts[dailyId] = used;
Sandbox.Services.Stats.Increment( DailyChallenge.AttemptsStatName( dailyId ), 1 );
Sandbox.Services.Stats.Flush();
}
/// <summary>How many consecutive days of daily challenges were played up to and INCLUDING
/// <paramref name="dailyId"/> — 1 on a lone day, 0 if that day itself wasn't played. Walks backwards
/// a UTC day at a time (daily ids are <c>yyyyMMdd</c>) until it finds a gap, so it terminates at the
/// first unplayed day rather than scanning the whole history. Call it AFTER latching the day's
/// <see cref="DailyChallengeAttempt.PlayCounted"/>, or the day in question won't count itself.
/// <para>Reads the local attempt file only, so a <see cref="Leaderboard.LEADERBOARD_VERSION"/> bump
/// (which retires that file) restarts streaks from 1. The backend stat keeps the best streak already
/// reached, so a wipe can't take earned progress away.</para></summary>
public static int PlayedStreakEndingAt( string dailyId )
{
if ( !DailyChallenge.TryParseId( dailyId, out var date ) )
return 0;
EnsureLoaded();
var played = _attempts
.Where( a => a.PlayCounted && !string.IsNullOrEmpty( a.DailyId ) )
.Select( a => a.DailyId )
.ToHashSet();
int streak = 0;
while ( played.Contains( DailyChallenge.IdFromDate( date ) ) )
{
streak++;
date = date.AddDays( -1 );
}
return streak;
}
/// <summary>Fetch the backend attempt count for a day into the session cache (best-effort; the
/// daily hub awaits this before enabling PLAY on a limited day). Offline or failed fetches leave
/// the local file as the only gate for the session.</summary>
public static async System.Threading.Tasks.Task RefreshBackendAttempts( string dailyId )
{
if ( string.IsNullOrEmpty( dailyId ) )
return;
try
{
var stats = Sandbox.Services.Stats.LocalPlayer;
await stats.Refresh();
// The attempt counter is Increment-based, and increments accumulate into a stat's SUM —
// Value is untouched by them (the engine's own Predict path adds to Sum only), so reading
// Value here would load 0 after a restart and re-open a limited day.
int value = (int)stats.Get( DailyChallenge.AttemptsStatName( dailyId ) ).Sum;
if ( value > 0 )
_backendAttempts[dailyId] = Math.Max( _backendAttempts.GetValueOrDefault( dailyId ), value );
}
catch ( Exception e )
{
Log.Info( $"BlockParty: daily attempts stat fetch failed for {dailyId} ({e.Message}); the local file gates this session." );
}
}
/// <summary>Record a finished run, keeping the BEST run's score/replay across the day's attempts
/// (higher score wins; equal score prefers the faster run, mirroring the board tiebreaker). A first
/// VICTORY on the day also latches <see cref="DailyChallengeAttempt.Won"/> and banks the day toward
/// the daily achievements — the latch is what keeps a second winning attempt from counting twice.</summary>
public static void MarkCompleted( string dailyId, RunData run )
{
if ( string.IsNullOrEmpty( dailyId ) || run is null )
return;
var attempt = GetOrCreate( dailyId );
attempt.CompletedAt = DateTimeOffset.Now;
if ( attempt.Run is null || run.FinalScore > attempt.FinalScore
|| (run.FinalScore == attempt.FinalScore && run.TimeSeconds < attempt.Run.TimeSeconds) )
{
attempt.FinalScore = run.FinalScore;
attempt.Run = run;
}
bool firstWin = run.Victory && !attempt.Won;
if ( firstWin )
attempt.Won = true;
Save(); // persist the latch BEFORE submitting, so a crash can't re-count the day
if ( firstWin )
{
Achievements.SubmitDailyBeaten();
// Won without spending a second attempt. Read through the MERGED counter rather than the
// record's own field, so a deleted local file can't pass a later attempt off as a first try.
if ( AttemptsUsed( dailyId ) == 1 )
Achievements.AwardDailyWonFirstTry();
}
}
private static DailyChallengeAttempt GetOrCreate( string dailyId )
{
var existing = Get( dailyId );
if ( existing is not null )
return existing;
var attempt = new DailyChallengeAttempt
{
DailyId = dailyId,
StartedAt = DateTimeOffset.Now,
};
_attempts.Insert( 0, attempt );
return attempt;
}
public static RunData GetReplay( string dailyId ) => Get( dailyId )?.Run;
// --- DEBUG attempt counter (daily_debug_nav) ---------------------------------------------------
// Per-day counter of DEBUG daily runs (daily_play / DEBUG PLAY / their restarts), kept in its own
// clearly-named local file so the attempts UI can be exercised without touching real progress.
// DISPLAY-ONLY: debug runs are never limited, and real gating never reads these.
public const string DebugAttemptsFilePath = "blockparty_daily_DEBUG_attempts.json";
private static Dictionary<string, int> _debugAttempts;
private static Dictionary<string, int> DebugAttempts =>
_debugAttempts ??= FileSystem.Data.ReadJsonOrDefault<Dictionary<string, int>>( DebugAttemptsFilePath, new() ) ?? new();
/// <summary>Count one launched DEBUG run against the day's debug counter (see
/// <see cref="GameManager.StartDailyChallengeDebug"/> / the debug restart path).</summary>
public static void RegisterDebugAttemptStart( string dailyId )
{
if ( string.IsNullOrEmpty( dailyId ) )
return;
DebugAttempts[dailyId] = DebugAttempts.GetValueOrDefault( dailyId ) + 1;
FileSystem.Data.WriteJson( DebugAttemptsFilePath, DebugAttempts );
}
/// <summary>Attempt count for UI DISPLAY: the real merged counter normally, or the debug-file
/// counter while daily_debug_nav is on (so debug runs exercise the attempts UI). Gating always
/// uses <see cref="AttemptsUsed"/> — this never limits anything.</summary>
public static int DisplayedAttemptsUsed( string dailyId )
=> DailyChallengeStage.DebugNavigation
? DebugAttempts.GetValueOrDefault( dailyId ?? "" )
: AttemptsUsed( dailyId );
private static void EnsureLoaded()
{
// Static state survives hot reload, so discard the old version's cached attempts on a bump.
if ( _attempts is null || _loadedVersion != Leaderboard.LEADERBOARD_VERSION )
Load();
}
public static void Load()
{
_attempts = FileSystem.Data.ReadJsonOrDefault<List<DailyChallengeAttempt>>( FilePath, new() ) ?? new();
_loadedVersion = Leaderboard.LEADERBOARD_VERSION;
_attempts = _attempts
.Where( a => !string.IsNullOrEmpty( a?.DailyId ) )
.GroupBy( a => a.DailyId )
.Select( g => g.OrderByDescending( a => a.StartedAt ).First() )
.OrderByDescending( a => a.DailyId )
.ToList();
}
public static void Save()
{
if ( _attempts is null ) return;
FileSystem.Data.WriteJson( FilePath, _attempts );
}
}