From 07eb6f1c05aae6476039ff74d7d7dc226f18bb18 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 2 Jun 2026 01:24:04 -0400 Subject: [PATCH] feat(rank-battle): AiStart returns ai_id + camelCase self/oppo_info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AiStartInternal builds the self MatchContext, picks a bot from IBotRoster, projects to the AiBattleStartResponseDto with camelCase wire keys (sleeveId, emblemId, ... — see ai-start.md). turnState=0 (player first) is the safe default per the ai-start.md TODO; live capture would clarify the enum. No deck → ai_id=-1 fallback (the documented "no AI assigned" sentinel per AIBattleStartTask.cs:21). 3 new wire-shape tests assert the camelCase keys land verbatim in the JSON, plus self/oppo info come from the right sources. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/RankBattleController.cs | 65 +++++++++++++++-- .../Controllers/RankBattleControllerTests.cs | 70 +++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs index 62d63bf..ba26878 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs @@ -157,11 +157,66 @@ public sealed class RankBattleController : ControllerBase }); } - // Filled in by Task 10. - private Task AiStartInternal(Format format, CancellationToken ct) + private async 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 })); + if (!TryGetViewerId(out var vid)) return Unauthorized(); + + MatchContext selfCtx; + try + { + selfCtx = await _ctxBuilder.BuildForRankBattleAsync(vid, format); + } + catch (InvalidOperationException ex) + { + // No deck → can't build a self profile. Surface as the "no AI assigned" + // sentinel; the client treats ai_id=-1 as a fallback/error condition. + _log.LogWarning(ex, "AiStart failed for viewer {Vid} format {Fmt}; returning ai_id=-1.", vid, format); + return Ok(new AiBattleStartResponseDto { AiId = -1 }); + } + + var bot = _botRoster.Pick(selfCtx); + + // Per spec, ai-start.md TODO: turnState semantics unclear. Default 0 (player first). + return Ok(new AiBattleStartResponseDto + { + AiId = bot.AiId, + TurnState = 0, + SelfInfo = new AiBattlePlayerInfo + { + CountryCode = selfCtx.CountryCode, + UserName = selfCtx.UserName, + SleeveId = int.TryParse(selfCtx.SleeveId, out var sId) ? sId : -1, + EmblemId = int.TryParse(selfCtx.EmblemId, out var eId) ? eId : -1, + DegreeId = int.TryParse(selfCtx.DegreeId, out var dId) ? dId : -1, + FieldId = selfCtx.FieldId, + IsOfficial = selfCtx.IsOfficial, + OppoId = bot.AiId, + Seed = 0, + Rank = 0, + BattlePoint = 0, + ClassId = int.TryParse(selfCtx.ClassId, out var cId) ? cId : -1, + CharaId = int.TryParse(selfCtx.CharaId, out var chId) ? chId : -1, + IsMasterRank = 0, + MasterPoint = 0, + }, + OppoInfo = new AiBattlePlayerInfo + { + CountryCode = bot.CountryCode, + UserName = bot.UserName, + SleeveId = bot.SleeveId, + EmblemId = bot.EmblemId, + DegreeId = bot.DegreeId, + FieldId = bot.FieldId, + IsOfficial = bot.IsOfficial, + OppoId = (int)vid, + Seed = 0, + Rank = bot.Rank, + BattlePoint = bot.BattlePoint, + ClassId = bot.ClassId, + CharaId = bot.CharaId, + IsMasterRank = bot.IsMasterRank, + MasterPoint = bot.MasterPoint, + }, + }); } } diff --git a/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs b/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs index 7a0f953..a9603f5 100644 --- a/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs +++ b/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs @@ -66,6 +66,76 @@ public class RankBattleControllerTests Assert.That(j3.GetProperty("battle_id").GetString(), Is.EqualTo(j2.GetProperty("battle_id").GetString())); } + [Test] + public async Task AiStart_rotation_returns_ai_id_plus_self_oppo_info_camelCase_keys() + { + await using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(displayName: "TestViewer"); + await factory.SeedGlobalsAsync(); + await factory.SeedDeckAsync(viewerId, Format.Rotation, 1); + var client = factory.CreateAuthenticatedClient(viewerId); + + var resp = await client.PostAsJsonAsync("/ai_rotation_rank_battle/start", new { }); + var raw = await resp.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(raw); + var data = doc.RootElement; + Assert.That(data.GetProperty("ai_id").GetInt32(), Is.InRange(4001, 4008)); + Assert.That(data.GetProperty("turnState").GetInt32(), Is.EqualTo(0)); + + // Literal camelCase wire-key checks — these MUST be present verbatim + // (client uses JsonData.Keys.Contains). + Assert.That(raw, Does.Contain("\"userName\""), "Wire key must be camelCase, not snake_case."); + Assert.That(raw, Does.Contain("\"sleeveId\"")); + Assert.That(raw, Does.Contain("\"emblemId\"")); + Assert.That(raw, Does.Contain("\"degreeId\"")); + Assert.That(raw, Does.Contain("\"fieldId\"")); + Assert.That(raw, Does.Contain("\"isOfficial\"")); + Assert.That(raw, Does.Contain("\"classId\"")); + Assert.That(raw, Does.Contain("\"charaId\"")); + Assert.That(raw, Does.Contain("\"isMasterRank\"")); + Assert.That(raw, Does.Contain("\"battlePoint\"")); + Assert.That(raw, Does.Contain("\"masterPoint\"")); + // self_info / oppo_info / country_code stay snake_case (the outliers per ai-start.md). + Assert.That(raw, Does.Contain("\"self_info\"")); + Assert.That(raw, Does.Contain("\"oppo_info\"")); + Assert.That(raw, Does.Contain("\"country_code\"")); + } + + [Test] + public async Task AiStart_self_info_reflects_caller_user_name() + { + await using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(displayName: "Alice"); + await factory.SeedGlobalsAsync(); + await factory.SeedDeckAsync(viewerId, Format.Rotation, 1); + var client = factory.CreateAuthenticatedClient(viewerId); + + var resp = await client.PostAsJsonAsync("/ai_rotation_rank_battle/start", new { }); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + var selfInfo = doc.RootElement.GetProperty("self_info"); + + Assert.That(selfInfo.GetProperty("userName").GetString(), Is.EqualTo("Alice")); + } + + [Test] + public async Task AiStart_oppo_info_reflects_roster_pick() + { + await using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(displayName: "PlayerA"); + await factory.SeedGlobalsAsync(); + await factory.SeedDeckAsync(viewerId, Format.Rotation, 1); + var client = factory.CreateAuthenticatedClient(viewerId); + + var resp = await client.PostAsJsonAsync("/ai_rotation_rank_battle/start", new { }); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + var oppoInfo = doc.RootElement.GetProperty("oppo_info"); + + // BotRoster's stub names contain "AI" — verify the roster was consulted. + Assert.That(oppoInfo.GetProperty("userName").GetString(), Does.Contain("AI")); + Assert.That(oppoInfo.GetProperty("classId").GetInt32(), Is.InRange(1, 8)); + } + [Test] public async Task Finish_emits_stubbed_zeros_with_battle_result_echo() {