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();