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,
+ }));
+ }
+}