diff --git a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs index 1ef1dba..81d86a4 100644 --- a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs @@ -399,6 +399,85 @@ public class BattleNodeFlowTests Assert.That(store.TryGetPending(pending.BattleId), Is.Null); } + // ------------------------------------------------------------------------- + // Bot integration test (Task 13). Single client, full Bot lifecycle: handshake + // through Swap (asserting NO Matched / BattleStart / Deal pushes), TurnEnd cycles + // each producing a single Judge back, Retire → BattleFinish. Reference: + // docs/api-spec/in-battle/ai-passive.md. + // ------------------------------------------------------------------------- + + [Test] + [Timeout(30000)] + public async Task BotBattle_FullLifecycle() + { + await using var factory = new SVSimTestFactory(); + var vid = await factory.SeedViewerAsync(); + await factory.SeedGlobalsAsync(); + await factory.SeedDeckAsync(vid, SVSim.Database.Enums.Format.Rotation, 1); + + var bridge = factory.Services.GetRequiredService(); + using var scope = factory.Services.CreateScope(); + var builder = scope.ServiceProvider.GetRequiredService(); + var ctx = await builder.BuildForRankBattleAsync(vid, SVSim.Database.Enums.Format.Rotation); + var pending = bridge.RegisterBattle( + new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx), + p2: null, + SVSim.BattleNode.Sessions.BattleType.Bot); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var ct = cts.Token; + var key = MakeKey(); + var encryptedVid = NodeCrypto.EncryptForNode(vid.ToString(), key); + var wsUri = new Uri($"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket"); + + var wsClient = factory.Server.CreateWebSocketClient(); + var ws = await wsClient.ConnectAsync(wsUri, ct); + await using var client = new RawSocketIoTestClient(ws); + await client.ConsumeHandshakeAsync(ct); + + // InitNetwork → ack. + await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct); + var ack1 = await client.ReceiveSynchronizeAsync(ct); + Assert.That(ack1.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); + + // InitBattle → ack (NOT Matched). + await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.InitBattle, pubSeq: 2), key, ct); + var ack2 = await client.ReceiveSynchronizeAsync(ct); + Assert.That(ack2.Uri, Is.EqualTo(NetworkBattleUri.InitBattle), + "Bot's InitBattle is ack-only — no Matched envelope."); + + // Loaded → silent. Send Swap right after; the next inbound must be SwapResponse + // (no orphan BattleStart / Deal in the queue). + await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.Loaded, pubSeq: 3), key, ct); + await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.Swap, pubSeq: 4, + body: new Dictionary { ["idxList"] = new List() }), key, ct); + var swapResp = await client.ReceiveSynchronizeAsync(ct); + Assert.That(swapResp.Uri, Is.EqualTo(NetworkBattleUri.Swap), + "Expected Swap response (mulligan ack). Got " + swapResp.Uri + " — Loaded may have leaked a frame."); + var readyResp = await client.ReceiveSynchronizeAsync(ct); + Assert.That(readyResp.Uri, Is.EqualTo(NetworkBattleUri.Ready)); + + // TurnEnd → Judge back. + await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.TurnEnd, pubSeq: 5), key, ct); + var judge1 = await client.ReceiveSynchronizeAsync(ct); + Assert.That(judge1.Uri, Is.EqualTo(NetworkBattleUri.Judge)); + + // Second TurnEnd → another Judge back. + await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.TurnEnd, pubSeq: 6), key, ct); + var judge2 = await client.ReceiveSynchronizeAsync(ct); + Assert.That(judge2.Uri, Is.EqualTo(NetworkBattleUri.Judge)); + + // Retire → BattleFinish. + await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.Retire, pubSeq: 7), key, ct); + var finish = await client.ReceiveSynchronizeAsync(ct); + Assert.That(finish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); + + // PendingBattle evicted at session start. + var store = factory.Services.GetRequiredService(); + Assert.That(store.TryGetPending(pending.BattleId), Is.Null, + "PendingBattle should be evicted at session start."); + } + // -- helpers ------------------------------------------------------------- private static async Task DriveHandshakeAsync(