diff --git a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs
new file mode 100644
index 0000000..95aa054
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs
@@ -0,0 +1,125 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using SVSim.BattleNode.Bridge;
+using SVSim.Database.Enums;
+using SVSim.EmulatedEntrypoint.Constants;
+using SVSim.EmulatedEntrypoint.Matching;
+using SVSim.EmulatedEntrypoint.Models.Dtos.RankBattle;
+using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
+using SVSim.EmulatedEntrypoint.Services;
+
+namespace SVSim.EmulatedEntrypoint.Controllers;
+
+///
+/// Rank battle family — covers rotation/unlimited human PvP + AI variants. Crossover
+/// is out of scope (no AI variant; human-only). Multi-prefix URLs (rotation_rank_battle/,
+/// unlimited_rank_battle/, ai_*_rank_battle/, rank_battle/) require explicit absolute
+/// route attributes on each action; the controller doesn't extend SVSimController's
+/// [Route("[controller]")] convention.
+///
+[ApiController]
+[Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)]
+public sealed class RankBattleController : ControllerBase
+{
+ private readonly IMatchingPairUpService _pairUp;
+ private readonly IMatchingBridge _bridge;
+ private readonly IMatchContextBuilder _ctxBuilder;
+ private readonly IBotRoster _botRoster;
+ private readonly ILogger _log;
+
+ public RankBattleController(
+ IMatchingPairUpService pairUp,
+ IMatchingBridge bridge,
+ IMatchContextBuilder ctxBuilder,
+ IBotRoster botRoster,
+ ILogger log)
+ {
+ _pairUp = pairUp;
+ _bridge = bridge;
+ _ctxBuilder = ctxBuilder;
+ _botRoster = botRoster;
+ _log = log;
+ }
+
+ private bool TryGetViewerId(out long viewerId)
+ {
+ viewerId = 0;
+ var claim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ViewerIdClaim)?.Value;
+ return claim is not null && long.TryParse(claim, out viewerId);
+ }
+
+ [HttpPost("/rotation_rank_battle/do_matching")]
+ public Task DoMatchingRotation([FromBody] DoMatchingRequestDto req, CancellationToken ct)
+ => DoMatchingInternal("rotation_rank_battle", Format.Rotation, req, ct);
+
+ [HttpPost("/unlimited_rank_battle/do_matching")]
+ public Task DoMatchingUnlimited([FromBody] DoMatchingRequestDto req, CancellationToken ct)
+ => DoMatchingInternal("unlimited_rank_battle", Format.Unlimited, req, ct);
+
+ [HttpPost("/ai_rotation_rank_battle/start")]
+ public Task AiStartRotation(CancellationToken ct)
+ => AiStartInternal(Format.Rotation, ct);
+
+ [HttpPost("/ai_unlimited_rank_battle/start")]
+ public Task AiStartUnlimited(CancellationToken ct)
+ => AiStartInternal(Format.Unlimited, ct);
+
+ ///
+ /// Shared finish handler — RankBattleFinishTask parses the same wire shape for
+ /// all four URLs and routes server-side by URL (vs IsAINetwork flag in the client).
+ /// Stubbed for Phase 3: echo battle_result, emit zeros elsewhere. Real rank
+ /// progression math is a separate spec.
+ ///
+ [HttpPost("/rotation_rank_battle/finish")]
+ [HttpPost("/unlimited_rank_battle/finish")]
+ [HttpPost("/ai_rotation_rank_battle/finish")]
+ [HttpPost("/ai_unlimited_rank_battle/finish")]
+ public IActionResult Finish([FromBody] RankBattleFinishRequestDto req)
+ {
+ if (!TryGetViewerId(out var _)) return Unauthorized();
+ return Ok(new RankBattleFinishResponseDto
+ {
+ BattleResult = req.BattleResult,
+ // All other fields default to 0 in the DTO (ClassLevel defaults to 1).
+ });
+ }
+
+ [HttpPost("/rank_battle/force_finish")]
+ public IActionResult ForceFinish()
+ {
+ if (!TryGetViewerId(out var _)) return Unauthorized();
+ return Ok(new { });
+ }
+
+ [HttpPost("/rank_battle/add_client_log")]
+ [HttpPost("/rank_battle/add_all_client_log")]
+ [HttpPost("/rank_battle/add_last_turn_log")]
+ public IActionResult AddClientLog()
+ {
+ if (!TryGetViewerId(out var _)) return Unauthorized();
+ return Ok(new { });
+ }
+
+ [HttpPost("/rank_battle/get_latest_master_point")]
+ public IActionResult GetLatestMasterPoint()
+ {
+ if (!TryGetViewerId(out var _)) return Unauthorized();
+ return Ok(new { });
+ }
+
+ // Filled in by Task 9.
+ private Task DoMatchingInternal(string mode, Format format, DoMatchingRequestDto req, CancellationToken ct)
+ {
+ if (!TryGetViewerId(out var _)) return Task.FromResult(Unauthorized());
+ // Placeholder; real impl arrives in Task 9.
+ return Task.FromResult(Ok(new DoMatchingResponseDto { MatchingState = 3002 }));
+ }
+
+ // Filled in by Task 10.
+ private Task AiStartInternal(Format format, CancellationToken ct)
+ {
+ if (!TryGetViewerId(out var _)) return Task.FromResult(Unauthorized());
+ // Placeholder; real impl arrives in Task 10.
+ return Task.FromResult(Ok(new AiBattleStartResponseDto { AiId = -1 }));
+ }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/AiBattleStartResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/AiBattleStartResponseDto.cs
new file mode 100644
index 0000000..d4c27f9
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/AiBattleStartResponseDto.cs
@@ -0,0 +1,94 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.RankBattle;
+
+[MessagePackObject(keyAsPropertyName: true)]
+public sealed class AiBattleStartResponseDto
+{
+ [JsonPropertyName("ai_id")]
+ [Key("ai_id")]
+ public int AiId { get; set; }
+
+ [JsonPropertyName("turnState")]
+ [Key("turnState")]
+ public int TurnState { get; set; }
+
+ [JsonPropertyName("self_info")]
+ [Key("self_info")]
+ public AiBattlePlayerInfo SelfInfo { get; set; } = new();
+
+ [JsonPropertyName("oppo_info")]
+ [Key("oppo_info")]
+ public AiBattlePlayerInfo OppoInfo { get; set; } = new();
+}
+
+///
+/// Per docs/api-spec/endpoints/post-login/rank-battle/ai-start.md — the AI battle
+/// subsystem uses camelCase keys (sleeveId, emblemId, ...), not the project-default
+/// snake_case. The [JsonPropertyName] overrides bypass the global SnakeCaseLower
+/// policy. country_code / self_info / oppo_info are the two outliers staying snake_case.
+///
+[MessagePackObject(keyAsPropertyName: true)]
+public sealed class AiBattlePlayerInfo
+{
+ [JsonPropertyName("country_code")]
+ [Key("country_code")]
+ public string CountryCode { get; set; } = "NONE";
+
+ [JsonPropertyName("userName")]
+ [Key("userName")]
+ public string UserName { get; set; } = "NONE";
+
+ [JsonPropertyName("sleeveId")]
+ [Key("sleeveId")]
+ public int SleeveId { get; set; } = -1;
+
+ [JsonPropertyName("emblemId")]
+ [Key("emblemId")]
+ public int EmblemId { get; set; } = -1;
+
+ [JsonPropertyName("degreeId")]
+ [Key("degreeId")]
+ public int DegreeId { get; set; } = -1;
+
+ [JsonPropertyName("fieldId")]
+ [Key("fieldId")]
+ public int FieldId { get; set; } = -1;
+
+ [JsonPropertyName("isOfficial")]
+ [Key("isOfficial")]
+ public int IsOfficial { get; set; } = -1;
+
+ [JsonPropertyName("oppoId")]
+ [Key("oppoId")]
+ public int OppoId { get; set; } = -1;
+
+ [JsonPropertyName("seed")]
+ [Key("seed")]
+ public int Seed { get; set; } = -1;
+
+ [JsonPropertyName("rank")]
+ [Key("rank")]
+ public int Rank { get; set; } = -1;
+
+ [JsonPropertyName("battlePoint")]
+ [Key("battlePoint")]
+ public int BattlePoint { get; set; } = -1;
+
+ [JsonPropertyName("classId")]
+ [Key("classId")]
+ public int ClassId { get; set; } = -1;
+
+ [JsonPropertyName("charaId")]
+ [Key("charaId")]
+ public int CharaId { get; set; } = -1;
+
+ [JsonPropertyName("isMasterRank")]
+ [Key("isMasterRank")]
+ public int IsMasterRank { get; set; } = -1;
+
+ [JsonPropertyName("masterPoint")]
+ [Key("masterPoint")]
+ public int MasterPoint { get; set; } = -1;
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/DoMatchingRequestDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/DoMatchingRequestDto.cs
new file mode 100644
index 0000000..2d98a3a
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/DoMatchingRequestDto.cs
@@ -0,0 +1,36 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.RankBattle;
+
+[MessagePackObject(keyAsPropertyName: true)]
+public sealed class DoMatchingRequestDto
+{
+ [JsonPropertyName("deck_no")]
+ [Key("deck_no")]
+ public int DeckNo { get; set; }
+
+ [JsonPropertyName("need_init")]
+ [Key("need_init")]
+ public int NeedInit { get; set; }
+
+ [JsonPropertyName("card_master_hash")]
+ [Key("card_master_hash")]
+ public string? CardMasterHash { get; set; }
+
+ [JsonPropertyName("log")]
+ [Key("log")]
+ public string? Log { get; set; }
+
+ [JsonPropertyName("use_stage_select")]
+ [Key("use_stage_select")]
+ public int UseStageSelect { get; set; }
+
+ [JsonPropertyName("excluded_field_id_list")]
+ [Key("excluded_field_id_list")]
+ public int[]? ExcludedFieldIdList { get; set; }
+
+ [JsonPropertyName("is_default_skin")]
+ [Key("is_default_skin")]
+ public int IsDefaultSkin { get; set; }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/DoMatchingResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/DoMatchingResponseDto.cs
new file mode 100644
index 0000000..4dd87d7
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/DoMatchingResponseDto.cs
@@ -0,0 +1,37 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.RankBattle;
+
+[MessagePackObject(keyAsPropertyName: true)]
+public sealed class DoMatchingResponseDto
+{
+ [JsonPropertyName("matching_state")]
+ [Key("matching_state")]
+ public int MatchingState { get; set; }
+
+ [JsonPropertyName("timeout_period")]
+ [Key("timeout_period")]
+ public int TimeoutPeriod { get; set; } = 60;
+
+ [JsonPropertyName("retry_period")]
+ [Key("retry_period")]
+ public int RetryPeriod { get; set; } = 3;
+
+ [JsonPropertyName("battle_id")]
+ [Key("battle_id")]
+ public string? BattleId { get; set; }
+
+ // Always emitted, even on RETRY. Client's DoMatchingBase.SettingDoMatchingData()
+ // calls .ToString() on this without a Keys.Contains guard, so absence throws
+ // KeyNotFoundException before the matching_state switch runs. Same Phase 2 fix
+ // pattern as TK2.
+ [JsonPropertyName("node_server_url")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ [Key("node_server_url")]
+ public string NodeServerUrl { get; set; } = "";
+
+ [JsonPropertyName("card_master_id")]
+ [Key("card_master_id")]
+ public int? CardMasterId { get; set; }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/RankBattleFinishRequestDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/RankBattleFinishRequestDto.cs
new file mode 100644
index 0000000..9eaf100
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/RankBattleFinishRequestDto.cs
@@ -0,0 +1,46 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.RankBattle;
+
+///
+/// Standard BattleFinishParam shape — see docs/api-spec/common/types.ts.md and
+/// docs/api-spec/endpoints/post-login/rank-battle/finish.md. Future: promote to
+/// a shared common DTO when a second finish endpoint reuses this.
+///
+[MessagePackObject(keyAsPropertyName: true)]
+public sealed class RankBattleFinishRequestDto
+{
+ [JsonPropertyName("battle_result")]
+ [Key("battle_result")]
+ public int BattleResult { get; set; }
+
+ [JsonPropertyName("is_retire")]
+ [Key("is_retire")]
+ public int IsRetire { get; set; }
+
+ [JsonPropertyName("recovery_data")]
+ [Key("recovery_data")]
+ public string? RecoveryData { get; set; }
+
+ [JsonPropertyName("class_id")]
+ [Key("class_id")]
+ public int ClassId { get; set; }
+
+ [JsonPropertyName("total_turn")]
+ [Key("total_turn")]
+ public int TotalTurn { get; set; }
+
+ [JsonPropertyName("evolve_count")]
+ [Key("evolve_count")]
+ public int EvolveCount { get; set; }
+
+ [JsonPropertyName("enemy_evolve_count")]
+ [Key("enemy_evolve_count")]
+ public int EnemyEvolveCount { get; set; }
+
+ // RankBattleFinishTask extends BattleFinishParam with SDTRB.
+ [JsonPropertyName("sdtrb")]
+ [Key("sdtrb")]
+ public int Sdtrb { get; set; }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/RankBattleFinishResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/RankBattleFinishResponseDto.cs
new file mode 100644
index 0000000..b32600a
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/RankBattle/RankBattleFinishResponseDto.cs
@@ -0,0 +1,59 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.RankBattle;
+
+///
+/// Stubbed Phase-3 rank-finish payload. Per RankBattleFinishTask.cs:57-63, the client
+/// uses GetValueOrDefault(key, 0) for the seven primary scalars and Keys.Contains
+/// for everything else — emitting zeros is safe. All-optional fields beyond these
+/// eleven are deliberately omitted (no mission/treasure-box/battle-pass evaluation
+/// in Phase 3 scope).
+///
+[MessagePackObject(keyAsPropertyName: true)]
+public sealed class RankBattleFinishResponseDto
+{
+ [JsonPropertyName("rank")]
+ [Key("rank")]
+ public int Rank { get; set; }
+
+ [JsonPropertyName("after_battle_point")]
+ [Key("after_battle_point")]
+ public int AfterBattlePoint { get; set; }
+
+ [JsonPropertyName("after_master_point")]
+ [Key("after_master_point")]
+ public int AfterMasterPoint { get; set; }
+
+ [JsonPropertyName("battle_point")]
+ [Key("battle_point")]
+ public int BattlePoint { get; set; }
+
+ [JsonPropertyName("master_point")]
+ [Key("master_point")]
+ public int MasterPoint { get; set; }
+
+ [JsonPropertyName("successive_win_number")]
+ [Key("successive_win_number")]
+ public int SuccessiveWinNumber { get; set; }
+
+ [JsonPropertyName("successive_win_bonus")]
+ [Key("successive_win_bonus")]
+ public int SuccessiveWinBonus { get; set; }
+
+ [JsonPropertyName("battle_result")]
+ [Key("battle_result")]
+ public int BattleResult { get; set; }
+
+ [JsonPropertyName("get_class_experience")]
+ [Key("get_class_experience")]
+ public int GetClassExperience { get; set; }
+
+ [JsonPropertyName("class_experience")]
+ [Key("class_experience")]
+ public int ClassExperience { get; set; }
+
+ [JsonPropertyName("class_level")]
+ [Key("class_level")]
+ public int ClassLevel { get; set; } = 1;
+}
diff --git a/SVSim.UnitTests/RoutingSmokeTests.cs b/SVSim.UnitTests/RoutingSmokeTests.cs
index 88807e2..7b3dc7e 100644
--- a/SVSim.UnitTests/RoutingSmokeTests.cs
+++ b/SVSim.UnitTests/RoutingSmokeTests.cs
@@ -112,6 +112,19 @@ public class RoutingSmokeTests
[TestCase("/arena/get_challenge_info")]
[TestCase("/arena/get_challenge_ranking_history")]
[TestCase("/check/check_time_slip_card_master_hash")]
+ [TestCase("/rotation_rank_battle/do_matching")]
+ [TestCase("/unlimited_rank_battle/do_matching")]
+ [TestCase("/ai_rotation_rank_battle/start")]
+ [TestCase("/ai_unlimited_rank_battle/start")]
+ [TestCase("/rotation_rank_battle/finish")]
+ [TestCase("/unlimited_rank_battle/finish")]
+ [TestCase("/ai_rotation_rank_battle/finish")]
+ [TestCase("/ai_unlimited_rank_battle/finish")]
+ [TestCase("/rank_battle/force_finish")]
+ [TestCase("/rank_battle/add_client_log")]
+ [TestCase("/rank_battle/add_all_client_log")]
+ [TestCase("/rank_battle/add_last_turn_log")]
+ [TestCase("/rank_battle/get_latest_master_point")]
public async Task Authenticated_route_resolves(string path)
{
using var factory = new TestFactory();