diff --git a/SVSim.EmulatedEntrypoint/Controllers/RankingController.cs b/SVSim.EmulatedEntrypoint/Controllers/RankingController.cs
new file mode 100644
index 0000000..5009793
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Controllers/RankingController.cs
@@ -0,0 +1,86 @@
+using Microsoft.AspNetCore.Mvc;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Ranking;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
+using SVSim.EmulatedEntrypoint.Services;
+
+namespace SVSim.EmulatedEntrypoint.Controllers;
+
+///
+/// /ranking/* — Rankings menu. Stub: the period picker renders a real
+/// deterministic monthly schedule, but every leaderboard returns an empty
+/// `ranking: []`. See docs/superpowers/specs/2026-06-10-ranking-stubs-design.md.
+///
+[Route("ranking")]
+public sealed class RankingController : SVSimController
+{
+ [HttpPost("get_viewable_ranking_period_list")]
+ public IActionResult GetViewablePeriodList([FromBody] BaseRequest req)
+ {
+ if (!TryGetViewerId(out _)) return Unauthorized();
+ var now = DateTime.UtcNow;
+ return Ok(new PeriodListResponseDto
+ {
+ RankMatch = ToBase(RankingPeriodSchedule.GenerateFor(RankingPeriodSchedule.Family.RankMatch, now)),
+ MasterPoint = ToMasterPoint(RankingPeriodSchedule.GenerateFor(RankingPeriodSchedule.Family.MasterPoint, now)),
+ TwoPick = ToTwoPick(RankingPeriodSchedule.GenerateFor(RankingPeriodSchedule.Family.TwoPick, now)),
+ Sealed = ToBase(RankingPeriodSchedule.GenerateFor(RankingPeriodSchedule.Family.Sealed, now)),
+ // Crossover arrays stay empty — captured prod returned [] for both.
+ });
+ }
+
+ [HttpPost("master_point_rotation_info")]
+ public IActionResult MasterPointRotation([FromBody] MasterPointInfoRequestDto req)
+ => RankingFor(RankingPeriodSchedule.Family.MasterPoint, req.PeriodId);
+
+ [HttpPost("master_point_unlimited_info")]
+ public IActionResult MasterPointUnlimited([FromBody] MasterPointInfoRequestDto req)
+ => RankingFor(RankingPeriodSchedule.Family.MasterPoint, req.PeriodId);
+
+ [HttpPost("rank_match_class_win_rotation_info")]
+ public IActionResult RankMatchClassWinRotation([FromBody] ClassWinInfoRequestDto req)
+ => RankingFor(RankingPeriodSchedule.Family.RankMatch, req.PeriodId);
+
+ [HttpPost("rank_match_class_win_unlimited_info")]
+ public IActionResult RankMatchClassWinUnlimited([FromBody] ClassWinInfoRequestDto req)
+ => RankingFor(RankingPeriodSchedule.Family.RankMatch, req.PeriodId);
+
+ [HttpPost("two_pick_win_info")]
+ public IActionResult TwoPickWin([FromBody] TwoPickWinInfoRequestDto req)
+ => RankingFor(RankingPeriodSchedule.Family.TwoPick, req.PeriodId);
+
+ private IActionResult RankingFor(RankingPeriodSchedule.Family family, int periodId)
+ {
+ if (!TryGetViewerId(out _)) return Unauthorized();
+ var entry = RankingPeriodSchedule.TryFindById(family, periodId, DateTime.UtcNow);
+ var periodDto = entry is null
+ ? new PeriodEntryDto { Id = periodId.ToString() }
+ : new PeriodEntryDto
+ {
+ Id = entry.Id,
+ PeriodNum = entry.PeriodNum,
+ BeginTime = entry.BeginTime,
+ EndTime = entry.EndTime,
+ };
+ return Ok(new MonthlyRankingResponseDto { Period = periodDto, Ranking = new() });
+ }
+
+ private static List ToBase(IReadOnlyList src)
+ => src.Select(e => new PeriodEntryDto
+ {
+ Id = e.Id, PeriodNum = e.PeriodNum, BeginTime = e.BeginTime, EndTime = e.EndTime,
+ }).ToList();
+
+ private static List ToMasterPoint(IReadOnlyList src)
+ => src.Select(e => new MasterPointPeriodEntryDto
+ {
+ Id = e.Id, PeriodNum = e.PeriodNum, BeginTime = e.BeginTime, EndTime = e.EndTime,
+ NecessaryScore = "0",
+ }).ToList();
+
+ private static List ToTwoPick(IReadOnlyList src)
+ => src.Select(e => new TwoPickPeriodEntryDto
+ {
+ Id = e.Id, PeriodNum = e.PeriodNum, BeginTime = e.BeginTime, EndTime = e.EndTime,
+ Type = "2", Over460 = "1",
+ }).ToList();
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/ClassWinInfoRequestDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/ClassWinInfoRequestDto.cs
new file mode 100644
index 0000000..392fa58
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/ClassWinInfoRequestDto.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Ranking;
+
+[MessagePackObject]
+public sealed class ClassWinInfoRequestDto : BaseRequest
+{
+ [JsonPropertyName("period_id"), Key("period_id")]
+ public int PeriodId { get; set; }
+
+ [JsonPropertyName("class_id"), Key("class_id")]
+ public int ClassId { get; set; }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/MasterPointInfoRequestDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/MasterPointInfoRequestDto.cs
new file mode 100644
index 0000000..e408d97
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/MasterPointInfoRequestDto.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Ranking;
+
+[MessagePackObject]
+public sealed class MasterPointInfoRequestDto : BaseRequest
+{
+ [JsonPropertyName("period_id"), Key("period_id")]
+ public int PeriodId { get; set; }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/MasterPointPeriodEntryDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/MasterPointPeriodEntryDto.cs
new file mode 100644
index 0000000..db9f328
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/MasterPointPeriodEntryDto.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Ranking;
+
+[MessagePackObject]
+public sealed class MasterPointPeriodEntryDto : PeriodEntryDto
+{
+ [JsonPropertyName("necessary_score"), Key("necessary_score")]
+ public string NecessaryScore { get; set; } = "0";
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/MonthlyRankingResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/MonthlyRankingResponseDto.cs
new file mode 100644
index 0000000..1c4c6e3
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/MonthlyRankingResponseDto.cs
@@ -0,0 +1,16 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Ranking;
+
+[MessagePackObject]
+public sealed class MonthlyRankingResponseDto
+{
+ [JsonPropertyName("period"), Key("period")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ public PeriodEntryDto Period { get; set; } = new();
+
+ [JsonPropertyName("ranking"), Key("ranking")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ public List Ranking { get; set; } = new();
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/PeriodEntryDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/PeriodEntryDto.cs
new file mode 100644
index 0000000..56ca976
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/PeriodEntryDto.cs
@@ -0,0 +1,26 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Ranking;
+
+///
+/// Base period-entry shape used by /ranking/get_viewable_ranking_period_list's
+/// rank_match[] and crossover_* arrays. Master-point and two-pick variants
+/// add fields — see and
+/// .
+///
+[MessagePackObject]
+public class PeriodEntryDto
+{
+ [JsonPropertyName("id"), Key("id")]
+ public string Id { get; set; } = "0";
+
+ [JsonPropertyName("period_num"), Key("period_num")]
+ public string PeriodNum { get; set; } = "0";
+
+ [JsonPropertyName("begin_time"), Key("begin_time")]
+ public string BeginTime { get; set; } = "";
+
+ [JsonPropertyName("end_time"), Key("end_time")]
+ public string EndTime { get; set; } = "";
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/PeriodListResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/PeriodListResponseDto.cs
new file mode 100644
index 0000000..bc921c6
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/PeriodListResponseDto.cs
@@ -0,0 +1,33 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Ranking;
+
+[MessagePackObject]
+public sealed class PeriodListResponseDto
+{
+ // All required per spec; emit empty list, never null.
+ [JsonPropertyName("rank_match"), Key("rank_match")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ public List RankMatch { get; set; } = new();
+
+ [JsonPropertyName("master_point"), Key("master_point")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ public List MasterPoint { get; set; } = new();
+
+ [JsonPropertyName("two_pick"), Key("two_pick")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ public List TwoPick { get; set; } = new();
+
+ [JsonPropertyName("sealed"), Key("sealed")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ public List Sealed { get; set; } = new();
+
+ [JsonPropertyName("crossover_rank_match"), Key("crossover_rank_match")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ public List CrossoverRankMatch { get; set; } = new();
+
+ [JsonPropertyName("crossover_master_point"), Key("crossover_master_point")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ public List CrossoverMasterPoint { get; set; } = new();
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/RankingEntryDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/RankingEntryDto.cs
new file mode 100644
index 0000000..fc7e29e
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/RankingEntryDto.cs
@@ -0,0 +1,44 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Ranking;
+
+///
+/// One row in a /ranking/* leaderboard's `ranking[]` array. Stub server never
+/// emits these; the type exists so the DTO compiles and so wire-shape tests can
+/// parse captured prod frames into it. Wire-type quirks (per capture frame 65):
+/// viewer_id/score/ranking_rank are STRINGS; rank/emblem_id/degree_id are NUMBERS.
+///
+[MessagePackObject]
+public sealed class RankingEntryDto
+{
+ [JsonPropertyName("viewer_id"), Key("viewer_id")]
+ public string ViewerId { get; set; } = "0";
+
+ [JsonPropertyName("score"), Key("score")]
+ public string Score { get; set; } = "0";
+
+ [JsonPropertyName("ranking_rank"), Key("ranking_rank")]
+ public string RankingRank { get; set; } = "0";
+
+ [JsonPropertyName("name"), Key("name")]
+ public string Name { get; set; } = "";
+
+ [JsonPropertyName("country_code"), Key("country_code")]
+ public string CountryCode { get; set; } = "";
+
+ [JsonPropertyName("rank"), Key("rank")]
+ public int Rank { get; set; }
+
+ [JsonPropertyName("emblem_id"), Key("emblem_id")]
+ public long EmblemId { get; set; }
+
+ [JsonPropertyName("degree_id"), Key("degree_id")]
+ public long DegreeId { get; set; }
+
+ [JsonPropertyName("last_play_time"), Key("last_play_time")]
+ public string LastPlayTime { get; set; } = "";
+
+ [JsonPropertyName("guild_name"), Key("guild_name")]
+ public string GuildName { get; set; } = "";
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/TwoPickPeriodEntryDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/TwoPickPeriodEntryDto.cs
new file mode 100644
index 0000000..5dbfbec
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/TwoPickPeriodEntryDto.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Ranking;
+
+[MessagePackObject]
+public sealed class TwoPickPeriodEntryDto : PeriodEntryDto
+{
+ [JsonPropertyName("type"), Key("type")]
+ public string Type { get; set; } = "2";
+
+ [JsonPropertyName("over_460"), Key("over_460")]
+ public string Over460 { get; set; } = "1";
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/TwoPickWinInfoRequestDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/TwoPickWinInfoRequestDto.cs
new file mode 100644
index 0000000..deae94b
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Ranking/TwoPickWinInfoRequestDto.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Ranking;
+
+[MessagePackObject]
+public sealed class TwoPickWinInfoRequestDto : BaseRequest
+{
+ [JsonPropertyName("period_id"), Key("period_id")]
+ public int PeriodId { get; set; }
+}
diff --git a/SVSim.UnitTests/Controllers/RankingControllerTests.cs b/SVSim.UnitTests/Controllers/RankingControllerTests.cs
new file mode 100644
index 0000000..72d596f
--- /dev/null
+++ b/SVSim.UnitTests/Controllers/RankingControllerTests.cs
@@ -0,0 +1,146 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Controllers;
+
+public class RankingControllerTests
+{
+ private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
+
+ private const string EmptyBody = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
+
+ [Test]
+ public async Task GetViewablePeriodList_returns_six_family_arrays()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_030_001UL);
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+
+ var resp = await client.PostAsync("/ranking/get_viewable_ranking_period_list", JsonBody(EmptyBody));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+
+ var raw = await resp.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(raw);
+ var data = doc.RootElement;
+
+ foreach (var key in new[] { "rank_match", "master_point", "two_pick", "sealed",
+ "crossover_rank_match", "crossover_master_point" })
+ {
+ Assert.That(data.TryGetProperty(key, out _), Is.True, $"missing key {key}");
+ }
+ Assert.That(data.GetProperty("rank_match").GetArrayLength(), Is.GreaterThan(0));
+ Assert.That(data.GetProperty("crossover_rank_match").GetArrayLength(), Is.EqualTo(0));
+ Assert.That(data.GetProperty("crossover_master_point").GetArrayLength(), Is.EqualTo(0));
+
+ // Master-point entries carry the extra "necessary_score" field per capture.
+ var mp0 = data.GetProperty("master_point")[0];
+ Assert.That(mp0.GetProperty("necessary_score").GetString(), Is.EqualTo("0"));
+
+ // Two-pick entries carry "type" and "over_460".
+ var tp0 = data.GetProperty("two_pick")[0];
+ Assert.That(tp0.GetProperty("type").GetString(), Is.EqualTo("2"));
+ Assert.That(tp0.GetProperty("over_460").GetString(), Is.EqualTo("1"));
+ }
+
+ [Test]
+ public async Task MasterPointRotationInfo_returns_empty_ranking_with_period_echoed()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_030_001UL);
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+
+ var resp = await client.PostAsync("/ranking/master_point_rotation_info",
+ JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","period_id":1}"""));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+
+ var raw = await resp.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("ranking").GetArrayLength(), Is.EqualTo(0));
+ Assert.That(doc.RootElement.GetProperty("period").GetProperty("id").GetString(), Is.EqualTo("1"));
+ }
+
+ [Test]
+ public async Task MasterPointUnlimitedInfo_returns_empty_ranking()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_030_001UL);
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+
+ var resp = await client.PostAsync("/ranking/master_point_unlimited_info",
+ JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","period_id":1}"""));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ var raw = await resp.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("ranking").GetArrayLength(), Is.EqualTo(0));
+ }
+
+ [Test]
+ public async Task RankMatchClassWinRotationInfo_accepts_class_id_and_returns_empty()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_030_001UL);
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+
+ var resp = await client.PostAsync("/ranking/rank_match_class_win_rotation_info",
+ JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","period_id":1,"class_id":3}"""));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ var raw = await resp.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("ranking").GetArrayLength(), Is.EqualTo(0));
+ }
+
+ [Test]
+ public async Task RankMatchClassWinUnlimitedInfo_returns_empty()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_030_001UL);
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+
+ var resp = await client.PostAsync("/ranking/rank_match_class_win_unlimited_info",
+ JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","period_id":1,"class_id":1}"""));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ }
+
+ [Test]
+ public async Task TwoPickWinInfo_returns_empty_ranking()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_030_001UL);
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+
+ var resp = await client.PostAsync("/ranking/two_pick_win_info",
+ JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","period_id":1}"""));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ var raw = await resp.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("ranking").GetArrayLength(), Is.EqualTo(0));
+ }
+
+ [Test]
+ public async Task MasterPointRotationInfo_unknown_period_returns_empty_period()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_030_001UL);
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+
+ var resp = await client.PostAsync("/ranking/master_point_rotation_info",
+ JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","period_id":99999}"""));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ var raw = await resp.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("ranking").GetArrayLength(), Is.EqualTo(0));
+ }
+
+ [Test]
+ public async Task Unauthenticated_get_viewable_period_list_returns_401()
+ {
+ using var factory = new SVSimTestFactory();
+ var client = factory.CreateClient();
+
+ var resp = await client.PostAsync("/ranking/get_viewable_ranking_period_list", JsonBody(EmptyBody));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+ }
+}