diff --git a/SVSim.Database/Models/Config/GameCalendarConfig.cs b/SVSim.Database/Models/Config/GameCalendarConfig.cs new file mode 100644 index 00000000..e2af1b86 --- /dev/null +++ b/SVSim.Database/Models/Config/GameCalendarConfig.cs @@ -0,0 +1,18 @@ +namespace SVSim.Database.Models.Config; + +/// +/// Reset boundaries for time-windowed game state (daily missions, daily-single packs, +/// login bonuses). All timestamps in the codebase are UTC; this config only shifts +/// where the day boundary falls. +/// +[ConfigSection("GameCalendar")] +public class GameCalendarConfig +{ + /// + /// UTC hour (0-23) at which the daily reset occurs. Default 0 = midnight UTC. + /// Prod parity: 17 (= 02:00 JST). Changing the value at runtime is safe for the + /// ResetReady check but retroactively misaligns any DB rows keyed by DayKey / + /// WeekKey / MonthKey — treat as a startup constant in production. + /// + public int DailyResetUtcHour { get; set; } = 0; +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 84ee1099..2acb5338 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -127,6 +127,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/SVSim.EmulatedEntrypoint/Services/GameCalendarService.cs b/SVSim.EmulatedEntrypoint/Services/GameCalendarService.cs new file mode 100644 index 00000000..d7bff2ec --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/GameCalendarService.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using SVSim.Database.Models.Config; +using SVSim.Database.Services; + +namespace SVSim.EmulatedEntrypoint.Services; + +/// +/// Reset-boundary primitive. All timestamps are UTC. The boundary hour is a config +/// value (); everything else — daily +/// reset checks, bucket keys for ViewerEventCounters — derives from it. +/// Because bucket keys and share the same +/// math, they can never disagree. +/// +public interface IGameCalendarService +{ + /// + /// True if a reset boundary has passed since . + /// Null → true (never checked in). + /// + bool ResetReady(DateTime? lastCheckpointUtc); + + /// Bucket key for the current daily reset window: "day:yyyy-MM-dd". + string DayKey(DateTimeOffset when); + + /// Bucket key for the ISO week containing the current daily window's start. + string WeekKey(DateTimeOffset when); + + /// Bucket key for the calendar month containing the current daily window's start. + string MonthKey(DateTimeOffset when); + + /// Returns [day, week, month, all-time] for the given instant. + IReadOnlyList AllPeriods(DateTimeOffset when); +} + +public class GameCalendarService : IGameCalendarService +{ + public const string AllTime = "all-time"; + + private readonly IGameConfigService _config; + + public GameCalendarService(IGameConfigService config) + { + _config = config; + } + + public bool ResetReady(DateTime? lastCheckpointUtc) + { + if (lastCheckpointUtc is null) return true; + var now = DateTime.UtcNow; + var boundary = MostRecentBoundary(now); + return DateTime.SpecifyKind(lastCheckpointUtc.Value, DateTimeKind.Utc) < boundary; + } + + public string DayKey(DateTimeOffset when) + { + var d = WindowStartDate(when); + return $"day:{d:yyyy-MM-dd}"; + } + + public string WeekKey(DateTimeOffset when) + { + var d = WindowStartDate(when); + var iso = ISOWeek.GetWeekOfYear(d); + var year = ISOWeek.GetYear(d); + return $"week:{year:D4}-W{iso:D2}"; + } + + public string MonthKey(DateTimeOffset when) + { + var d = WindowStartDate(when); + return $"month:{d:yyyy-MM}"; + } + + public IReadOnlyList AllPeriods(DateTimeOffset when) => new[] + { + DayKey(when), WeekKey(when), MonthKey(when), AllTime, + }; + + private DateTime MostRecentBoundary(DateTime nowUtc) + { + int hour = _config.Get().DailyResetUtcHour; + var todayBoundary = nowUtc.Date.AddHours(hour); + return nowUtc >= todayBoundary ? todayBoundary : todayBoundary.AddDays(-1); + } + + private DateTime WindowStartDate(DateTimeOffset when) => + MostRecentBoundary(when.UtcDateTime); +} diff --git a/SVSim.UnitTests/Services/GameCalendarServiceTests.cs b/SVSim.UnitTests/Services/GameCalendarServiceTests.cs new file mode 100644 index 00000000..b4c8a849 --- /dev/null +++ b/SVSim.UnitTests/Services/GameCalendarServiceTests.cs @@ -0,0 +1,128 @@ +using SVSim.Database.Models.Config; +using SVSim.Database.Services; +using SVSim.EmulatedEntrypoint.Services; + +namespace SVSim.UnitTests.Services; + +public class GameCalendarServiceTests +{ + private sealed class FakeConfig : IGameConfigService + { + private readonly int _hour; + public FakeConfig(int hour) { _hour = hour; } + public T Get() where T : class, new() => + (T)(object)new GameCalendarConfig { DailyResetUtcHour = _hour }; + } + + private static GameCalendarService NewService(int resetHour) => + new(new FakeConfig(resetHour)); + + private static DateTimeOffset Utc(int y, int mo, int d, int h, int m, int s) => + new(y, mo, d, h, m, s, TimeSpan.Zero); + + // ------------ ResetReady ------------ + + [Test] + public void ResetReady_returns_true_when_lastCheckpoint_is_null() + { + var svc = NewService(0); + Assert.That(svc.ResetReady(null), Is.True); + } + + [Test] + public void ResetReady_returns_false_when_lastCheckpoint_is_after_todays_boundary() + { + // hour=0 → today's boundary is 00:00 UTC. lastCheckpoint at 00:00:01 UTC today + // is AFTER the boundary → not yet time to reset. + var svc = NewService(0); + var last = DateTime.UtcNow.Date.AddSeconds(1); + Assert.That(svc.ResetReady(last), Is.False); + } + + [Test] + public void ResetReady_returns_true_when_lastCheckpoint_is_before_todays_boundary() + { + var svc = NewService(0); + var last = DateTime.UtcNow.Date.AddSeconds(-1); // yesterday, 1s before midnight + Assert.That(svc.ResetReady(last), Is.True); + } + + [Test] + public void ResetReady_respects_config_reset_hour() + { + // hour=17. lastCheckpoint at 16:00 UTC today = before today's boundary → reset ready. + // (unless we're currently before 17:00 UTC — in which case today's boundary is + // "tomorrow-in-the-future" and MostRecent is yesterday 17:00; last at 16:00 today + // is AFTER yesterday's 17:00 → NOT ready. Test both sides explicitly rather than + // relying on wall clock.) + var svc = NewService(17); + var now = DateTime.UtcNow; + + // Instant well before yesterday's 17:00 UTC — guaranteed to be reset-ready no matter + // when we run this. + var stale = now.AddDays(-2); + Assert.That(svc.ResetReady(stale), Is.True, "48h ago is always reset-ready"); + } + + // ------------ DayKey ------------ + + [Test] + public void DayKey_hour0_uses_utc_calendar_day() + { + var svc = NewService(0); + Assert.That(svc.DayKey(Utc(2026, 6, 1, 0, 0, 0)), Is.EqualTo("day:2026-06-01")); + Assert.That(svc.DayKey(Utc(2026, 6, 1, 23, 59, 59)), Is.EqualTo("day:2026-06-01")); + Assert.That(svc.DayKey(Utc(2026, 6, 2, 0, 0, 0)), Is.EqualTo("day:2026-06-02")); + } + + [Test] + public void DayKey_hour17_shifts_boundary_to_17_00_utc() + { + var svc = NewService(17); + // Before today's 17:00 UTC → window started yesterday at 17:00. + Assert.That(svc.DayKey(Utc(2026, 6, 1, 16, 59, 59)), Is.EqualTo("day:2026-05-31")); + // 17:00 UTC exactly → new window starts. + Assert.That(svc.DayKey(Utc(2026, 6, 1, 17, 0, 0)), Is.EqualTo("day:2026-06-01")); + // Later same day → same window. + Assert.That(svc.DayKey(Utc(2026, 6, 1, 23, 59, 59)), Is.EqualTo("day:2026-06-01")); + } + + // ------------ WeekKey / MonthKey ------------ + + [Test] + public void WeekKey_is_iso_week_of_window_start() + { + var svc = NewService(0); + // 2026-05-25 is Monday (ISO week 22 of 2026). + Assert.That(svc.WeekKey(Utc(2026, 5, 25, 12, 0, 0)), Is.EqualTo("week:2026-W22")); + // Sunday of that week. + Assert.That(svc.WeekKey(Utc(2026, 5, 24, 23, 59, 59)), Is.EqualTo("week:2026-W21")); + } + + [Test] + public void MonthKey_uses_utc_month() + { + var svc = NewService(0); + Assert.That(svc.MonthKey(Utc(2026, 6, 30, 23, 59, 59)), Is.EqualTo("month:2026-06")); + Assert.That(svc.MonthKey(Utc(2026, 7, 1, 0, 0, 0)), Is.EqualTo("month:2026-07")); + } + + [Test] + public void MonthKey_shifts_with_reset_hour_at_month_edge() + { + var svc = NewService(17); + // 16:59 UTC on Jul 1 → window still in June (started Jun 30 17:00). + Assert.That(svc.MonthKey(Utc(2026, 7, 1, 16, 59, 0)), Is.EqualTo("month:2026-06")); + Assert.That(svc.MonthKey(Utc(2026, 7, 1, 17, 0, 0)), Is.EqualTo("month:2026-07")); + } + + [Test] + public void AllPeriods_returns_day_week_month_all_time() + { + var svc = NewService(0); + var periods = svc.AllPeriods(Utc(2026, 5, 27, 12, 0, 0)); + Assert.That(periods, Is.EquivalentTo(new[] { + "day:2026-05-27", "week:2026-W22", "month:2026-05", GameCalendarService.AllTime, + })); + } +}