From 80f249f8a21c865e0afcccc117255102dee04abe Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 10 Jun 2026 10:36:51 -0400 Subject: [PATCH] feat(ranking): add RankingPeriodSchedule helper Pure deterministic monthly period generator for the four ranking families. Anchor dates derived from prod capture (2026-06-09): id=1 is each family's launch month in JST; id=N is anchor + N-1 months. Used by /ranking/get_viewable_ranking_period_list to render the period picker and by per-family leaderboard endpoints to echo the requested period back. Co-Authored-By: Claude Opus 4.7 --- .../Services/RankingPeriodSchedule.cs | 95 +++++++++++++++++++ .../Services/RankingPeriodScheduleTests.cs | 79 +++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 SVSim.EmulatedEntrypoint/Services/RankingPeriodSchedule.cs create mode 100644 SVSim.UnitTests/Services/RankingPeriodScheduleTests.cs diff --git a/SVSim.EmulatedEntrypoint/Services/RankingPeriodSchedule.cs b/SVSim.EmulatedEntrypoint/Services/RankingPeriodSchedule.cs new file mode 100644 index 0000000..18e5980 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/RankingPeriodSchedule.cs @@ -0,0 +1,95 @@ +using System.Globalization; + +namespace SVSim.EmulatedEntrypoint.Services; + +/// +/// Pure deterministic monthly period schedule for /ranking/*. Each ranking family +/// (RankMatch, MasterPoint, TwoPick, Sealed) launched in a different month on the +/// live server; id=1 in each family is its launch month. id=current means "this +/// month in JST." The generator returns descending-by-id (newest first). +/// +/// Anchor dates derived from prod capture 2026-06-09 17:00 UTC: +/// RankMatch current id = 122 → launch month = 2026-06 minus 121 months = 2016-05 +/// MasterPoint current id = 120 → 2016-07 +/// TwoPick current id = 119 → 2016-08 +/// Sealed current id = 62 → 2021-05 +/// +/// See docs/superpowers/specs/2026-06-10-ranking-stubs-design.md for rationale. +/// +public static class RankingPeriodSchedule +{ + public enum Family { RankMatch, MasterPoint, TwoPick, Sealed } + + // (Year, Month) of each family's id=1 month, JST. + private static readonly Dictionary FamilyAnchors = new() + { + [Family.RankMatch] = (2016, 5), + [Family.MasterPoint] = (2016, 7), + [Family.TwoPick] = (2016, 8), + [Family.Sealed] = (2021, 5), + }; + + private static readonly TimeZoneInfo Jst = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time"); + + public static IReadOnlyList GenerateFor(Family family, DateTime nowUtc) + { + var nowJst = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, Jst); + var anchor = FamilyAnchors[family]; + int currentId = MonthsBetweenInclusive(anchor.Year, anchor.Month, nowJst.Year, nowJst.Month); + if (currentId < 1) return Array.Empty(); + + var result = new List(currentId); + for (int id = currentId; id >= 1; id--) + { + result.Add(BuildEntry(family, id, anchor)); + } + return result; + } + + public static PeriodEntry? TryFindById(Family family, int periodId, DateTime nowUtc) + { + if (periodId < 1) return null; + var nowJst = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, Jst); + var anchor = FamilyAnchors[family]; + int currentId = MonthsBetweenInclusive(anchor.Year, anchor.Month, nowJst.Year, nowJst.Month); + if (periodId > currentId) return null; + return BuildEntry(family, periodId, anchor); + } + + private static PeriodEntry BuildEntry(Family family, int id, (int Year, int Month) anchor) + { + // id=1 is the anchor month; id=N is anchor month + (N-1) months. + int totalMonths = (anchor.Year * 12 + (anchor.Month - 1)) + (id - 1); + int year = totalMonths / 12; + int month = (totalMonths % 12) + 1; + + var begin = new DateTime(year, month, 1, 2, 0, 0); + // End = first day of next month at 02:00:00 minus 1 second = "YYYY-MM+1-01 01:59:59" + var end = begin.AddMonths(1).AddSeconds(-1); + + int periodNum = family switch + { + // Captured offsets: RankMatch period_num = id - 1; MasterPoint period_num = id - 1. + // (Capture frame 64 shows rank_match[0] = { id:122, period_num:121 } and + // master_point[0] = { id:120, period_num:119 }, two_pick[0] = { id:119, period_num:119 }.) + Family.RankMatch => id - 1, + Family.MasterPoint => id - 1, + _ => id, + }; + + return new PeriodEntry( + Id: id.ToString(CultureInfo.InvariantCulture), + PeriodNum: periodNum.ToString(CultureInfo.InvariantCulture), + BeginTime: begin.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + EndTime: end.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); + } + + private static int MonthsBetweenInclusive(int startYear, int startMonth, int endYear, int endMonth) + => (endYear - startYear) * 12 + (endMonth - startMonth) + 1; +} + +public sealed record PeriodEntry( + string Id, + string PeriodNum, + string BeginTime, + string EndTime); diff --git a/SVSim.UnitTests/Services/RankingPeriodScheduleTests.cs b/SVSim.UnitTests/Services/RankingPeriodScheduleTests.cs new file mode 100644 index 0000000..fb60a6d --- /dev/null +++ b/SVSim.UnitTests/Services/RankingPeriodScheduleTests.cs @@ -0,0 +1,79 @@ +using SVSim.EmulatedEntrypoint.Services; + +namespace SVSim.UnitTests.Services; + +public class RankingPeriodScheduleTests +{ + // 2026-06-09 17:00 UTC = 2026-06-10 02:00 JST — clearly in JST's June 2026 month. + private static readonly DateTime CaptureNowUtc = + new(2026, 6, 9, 17, 0, 0, DateTimeKind.Utc); + + [Test] + public void RankMatch_at_capture_time_has_current_period_id_122() + { + var list = RankingPeriodSchedule.GenerateFor( + RankingPeriodSchedule.Family.RankMatch, CaptureNowUtc); + + Assert.That(list, Is.Not.Empty); + Assert.That(list[0].Id, Is.EqualTo("122")); + Assert.That(list[0].BeginTime, Is.EqualTo("2026-06-01 02:00:00")); + Assert.That(list[0].EndTime, Is.EqualTo("2026-07-01 01:59:59")); + Assert.That(list.Count, Is.EqualTo(122)); + } + + [Test] + public void MasterPoint_at_capture_time_has_current_period_id_120() + { + var list = RankingPeriodSchedule.GenerateFor( + RankingPeriodSchedule.Family.MasterPoint, CaptureNowUtc); + Assert.That(list[0].Id, Is.EqualTo("120")); + Assert.That(list.Count, Is.EqualTo(120)); + } + + [Test] + public void TwoPick_at_capture_time_has_current_period_id_119() + { + var list = RankingPeriodSchedule.GenerateFor( + RankingPeriodSchedule.Family.TwoPick, CaptureNowUtc); + Assert.That(list[0].Id, Is.EqualTo("119")); + Assert.That(list.Count, Is.EqualTo(119)); + } + + [Test] + public void Sealed_at_capture_time_has_current_period_id_62() + { + var list = RankingPeriodSchedule.GenerateFor( + RankingPeriodSchedule.Family.Sealed, CaptureNowUtc); + Assert.That(list[0].Id, Is.EqualTo("62")); + Assert.That(list.Count, Is.EqualTo(62)); + } + + [Test] + public void Schedule_is_descending_by_id() + { + var list = RankingPeriodSchedule.GenerateFor( + RankingPeriodSchedule.Family.RankMatch, CaptureNowUtc); + for (int i = 1; i < list.Count; i++) + { + Assert.That(int.Parse(list[i - 1].Id), Is.GreaterThan(int.Parse(list[i].Id)), + $"position {i - 1} ({list[i - 1].Id}) should be > position {i} ({list[i].Id})"); + } + } + + [Test] + public void TryFindById_returns_entry_when_id_in_range() + { + var entry = RankingPeriodSchedule.TryFindById( + RankingPeriodSchedule.Family.RankMatch, periodId: 122, CaptureNowUtc); + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.BeginTime, Is.EqualTo("2026-06-01 02:00:00")); + } + + [Test] + public void TryFindById_returns_null_for_unknown_id() + { + var entry = RankingPeriodSchedule.TryFindById( + RankingPeriodSchedule.Family.RankMatch, periodId: 9999, CaptureNowUtc); + Assert.That(entry, Is.Null); + } +}