diff --git a/SVSim.EmulatedEntrypoint/Services/JstPeriod.cs b/SVSim.EmulatedEntrypoint/Services/JstPeriod.cs new file mode 100644 index 0000000..572fadb --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/JstPeriod.cs @@ -0,0 +1,53 @@ +using System.Globalization; + +namespace SVSim.EmulatedEntrypoint.Services; + +/// +/// JST-anchored period bucketing for ViewerEventCounters. Day/week/month boundaries are +/// 02:00 JST (matching the real-game daily reset). Pure functions, no dependencies. +/// +public static class JstPeriod +{ + private static readonly TimeSpan Jst = TimeSpan.FromHours(9); + private const string DayPrefix = "day:"; + private const string WeekPrefix = "week:"; + private const string MonthPrefix = "month:"; + public const string AllTime = "all-time"; + + /// + /// Converts the given instant to a JST-anchored "effective date" by: + /// 1. Shifting to JST (+09:00) + /// 2. Subtracting 2 hours so anything before 02:00 JST belongs to the previous day + /// + private static DateTime EffectiveJstDate(DateTimeOffset utcOrAny) + { + var jst = utcOrAny.ToOffset(Jst); + return jst.AddHours(-2).Date; + } + + public static string DayKey(DateTimeOffset when) + { + var d = EffectiveJstDate(when); + return $"{DayPrefix}{d:yyyy-MM-dd}"; + } + + public static string WeekKey(DateTimeOffset when) + { + var d = EffectiveJstDate(when); + var iso = ISOWeek.GetWeekOfYear(d); + var year = ISOWeek.GetYear(d); + return $"{WeekPrefix}{year:D4}-W{iso:D2}"; + } + + public static string MonthKey(DateTimeOffset when) + { + var d = EffectiveJstDate(when); + return $"{MonthPrefix}{d:yyyy-MM}"; + } + + /// Returns [day, week, month, all-time] keys for the given instant. + public static IReadOnlyList AllPeriods(DateTimeOffset when) => new[] + { + DayKey(when), WeekKey(when), MonthKey(when), AllTime, + }; +} diff --git a/SVSim.UnitTests/Services/JstPeriodTests.cs b/SVSim.UnitTests/Services/JstPeriodTests.cs new file mode 100644 index 0000000..f243673 --- /dev/null +++ b/SVSim.UnitTests/Services/JstPeriodTests.cs @@ -0,0 +1,52 @@ +namespace SVSim.UnitTests.Services; + +public class JstPeriodTests +{ + private static DateTimeOffset Jst(int y, int m, int d, int h, int min, int s) => + new DateTimeOffset(y, m, d, h, min, s, TimeSpan.FromHours(9)); + + [Test] + public void DayKey_uses_jst_with_02_00_anchor() + { + // 01:59:59 JST on May 27 belongs to the May 26 "day". + Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.DayKey(Jst(2026, 5, 27, 1, 59, 59)), + Is.EqualTo("day:2026-05-26")); + + // 02:00:00 JST on May 27 belongs to the May 27 "day". + Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.DayKey(Jst(2026, 5, 27, 2, 0, 0)), + Is.EqualTo("day:2026-05-27")); + } + + [Test] + public void WeekKey_is_iso8601_monday_anchored() + { + // 2026-05-25 (Monday) 02:00 JST → ISO week 2026-W22. + Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.WeekKey(Jst(2026, 5, 25, 2, 0, 0)), + Is.EqualTo("week:2026-W22")); + + // The previous Sunday's late evening still belongs to W21 (week reset is Monday 02:00 JST). + Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.WeekKey(Jst(2026, 5, 25, 1, 59, 59)), + Is.EqualTo("week:2026-W21")); + } + + [Test] + public void MonthKey_uses_jst_day_anchor_for_month_rollover() + { + // 01:59 JST on June 1 still belongs to May (day-boundary is 02:00 JST). + Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.MonthKey(Jst(2026, 6, 1, 1, 59, 0)), + Is.EqualTo("month:2026-05")); + + Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.MonthKey(Jst(2026, 6, 1, 2, 0, 0)), + Is.EqualTo("month:2026-06")); + } + + [Test] + public void AllPeriods_returns_day_week_month_all_time() + { + var t = Jst(2026, 5, 27, 12, 0, 0); + var periods = SVSim.EmulatedEntrypoint.Services.JstPeriod.AllPeriods(t); + Assert.That(periods, Is.EquivalentTo(new[] { + "day:2026-05-27", "week:2026-W22", "month:2026-05", "all-time", + })); + } +}