feat(calendar): add IGameCalendarService — UTC reset-boundary primitive

New config-driven service that replaces JstPeriod. All timestamps are UTC;
one config value (GameCalendarConfig.DailyResetUtcHour, default 0 = midnight
UTC) drives both ResetReady checks and DayKey/WeekKey/MonthKey bucket keys.
Because both derive from the same MostRecentBoundary helper, they can never
disagree.

Prod parity is a config flip: set DailyResetUtcHour=17 in GameConfigs to get
the 02:00 JST boundary. No JST-flavored math anywhere in the code.

JstPeriod callsite migration + deletion follow in subsequent commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-04 11:46:12 -04:00
parent 81fe842c15
commit 90d420d97a
4 changed files with 235 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
namespace SVSim.Database.Models.Config;
/// <summary>
/// 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.
/// </summary>
[ConfigSection("GameCalendar")]
public class GameCalendarConfig
{
/// <summary>
/// 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.
/// </summary>
public int DailyResetUtcHour { get; set; } = 0;
}

View File

@@ -127,6 +127,7 @@ public class Program
builder.Services.AddScoped<IMatchContextBuilder, MatchContextBuilder>();
builder.Services.AddScoped<IStoryService, StoryService>();
builder.Services.AddScoped<ILoginBonusService, LoginBonusService>();
builder.Services.AddScoped<IGameCalendarService, GameCalendarService>();
builder.Services.AddScoped<IDeckListBuilder, DeckListBuilder>();
builder.Services.AddSingleton<IRandom, SystemRandom>();
builder.Services.AddSingleton<PuzzleMissionEvaluator>();

View File

@@ -0,0 +1,88 @@
using System.Globalization;
using SVSim.Database.Models.Config;
using SVSim.Database.Services;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Reset-boundary primitive. All timestamps are UTC. The boundary hour is a config
/// value (<see cref="GameCalendarConfig.DailyResetUtcHour"/>); everything else — daily
/// reset checks, bucket keys for <c>ViewerEventCounters</c> — derives from it.
/// Because bucket keys and <see cref="ResetReady"/> share the same
/// <see cref="MostRecentBoundary"/> math, they can never disagree.
/// </summary>
public interface IGameCalendarService
{
/// <summary>
/// True if a reset boundary has passed since <paramref name="lastCheckpointUtc"/>.
/// Null → true (never checked in).
/// </summary>
bool ResetReady(DateTime? lastCheckpointUtc);
/// <summary>Bucket key for the current daily reset window: <c>"day:yyyy-MM-dd"</c>.</summary>
string DayKey(DateTimeOffset when);
/// <summary>Bucket key for the ISO week containing the current daily window's start.</summary>
string WeekKey(DateTimeOffset when);
/// <summary>Bucket key for the calendar month containing the current daily window's start.</summary>
string MonthKey(DateTimeOffset when);
/// <summary>Returns [day, week, month, all-time] for the given instant.</summary>
IReadOnlyList<string> 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<string> AllPeriods(DateTimeOffset when) => new[]
{
DayKey(when), WeekKey(when), MonthKey(when), AllTime,
};
private DateTime MostRecentBoundary(DateTime nowUtc)
{
int hour = _config.Get<GameCalendarConfig>().DailyResetUtcHour;
var todayBoundary = nowUtc.Date.AddHours(hour);
return nowUtc >= todayBoundary ? todayBoundary : todayBoundary.AddDays(-1);
}
private DateTime WindowStartDate(DateTimeOffset when) =>
MostRecentBoundary(when.UtcDateTime);
}

View File

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