feat(rank-battle): RankBattleController shell + DTOs + routing smoke tests
Stands up the controller with all 13 rank-battle URL routes wired via explicit absolute [HttpPost] attributes (multi-prefix family — can't ride [Route(\"[controller]\")]). Real DoMatching / AiStart logic arrives in later tasks; finish + telemetry + force-finish are returnable stubs as of this task. DTOs cover the request + response shapes per the spec. Note the camelCase wire keys on AiBattlePlayerInfo (sleeveId, emblemId, ...) — the AI battle subsystem uses camelCase, not the project-default snake_case, per AIBattleStartTask.Parse's literal Keys.Contains lookups. DoMatchingResponseDto.NodeServerUrl is non-nullable + always-emit (with [JsonIgnore(Never)]) — matches Phase 2's TK2 fix because the client's DoMatchingBase parser calls .ToString() without a Keys.Contains guard. 13 routing smoke tests confirm each URL resolves to the controller. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
125
SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs
Normal file
125
SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<RankBattleController> _log;
|
||||||
|
|
||||||
|
public RankBattleController(
|
||||||
|
IMatchingPairUpService pairUp,
|
||||||
|
IMatchingBridge bridge,
|
||||||
|
IMatchContextBuilder ctxBuilder,
|
||||||
|
IBotRoster botRoster,
|
||||||
|
ILogger<RankBattleController> 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<IActionResult> DoMatchingRotation([FromBody] DoMatchingRequestDto req, CancellationToken ct)
|
||||||
|
=> DoMatchingInternal("rotation_rank_battle", Format.Rotation, req, ct);
|
||||||
|
|
||||||
|
[HttpPost("/unlimited_rank_battle/do_matching")]
|
||||||
|
public Task<IActionResult> DoMatchingUnlimited([FromBody] DoMatchingRequestDto req, CancellationToken ct)
|
||||||
|
=> DoMatchingInternal("unlimited_rank_battle", Format.Unlimited, req, ct);
|
||||||
|
|
||||||
|
[HttpPost("/ai_rotation_rank_battle/start")]
|
||||||
|
public Task<IActionResult> AiStartRotation(CancellationToken ct)
|
||||||
|
=> AiStartInternal(Format.Rotation, ct);
|
||||||
|
|
||||||
|
[HttpPost("/ai_unlimited_rank_battle/start")]
|
||||||
|
public Task<IActionResult> AiStartUnlimited(CancellationToken ct)
|
||||||
|
=> AiStartInternal(Format.Unlimited, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<IActionResult> DoMatchingInternal(string mode, Format format, DoMatchingRequestDto req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!TryGetViewerId(out var _)) return Task.FromResult<IActionResult>(Unauthorized());
|
||||||
|
// Placeholder; real impl arrives in Task 9.
|
||||||
|
return Task.FromResult<IActionResult>(Ok(new DoMatchingResponseDto { MatchingState = 3002 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filled in by Task 10.
|
||||||
|
private Task<IActionResult> AiStartInternal(Format format, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!TryGetViewerId(out var _)) return Task.FromResult<IActionResult>(Unauthorized());
|
||||||
|
// Placeholder; real impl arrives in Task 10.
|
||||||
|
return Task.FromResult<IActionResult>(Ok(new AiBattleStartResponseDto { AiId = -1 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.RankBattle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.RankBattle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
@@ -112,6 +112,19 @@ public class RoutingSmokeTests
|
|||||||
[TestCase("/arena/get_challenge_info")]
|
[TestCase("/arena/get_challenge_info")]
|
||||||
[TestCase("/arena/get_challenge_ranking_history")]
|
[TestCase("/arena/get_challenge_ranking_history")]
|
||||||
[TestCase("/check/check_time_slip_card_master_hash")]
|
[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)
|
public async Task Authenticated_route_resolves(string path)
|
||||||
{
|
{
|
||||||
using var factory = new TestFactory();
|
using var factory = new TestFactory();
|
||||||
|
|||||||
Reference in New Issue
Block a user