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