diff --git a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs index 64ad07d..1ef1dba 100644 --- a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs @@ -173,10 +173,291 @@ public class BattleNodeFlowTests } } - private static MsgEnvelope MakeEnvelopeWith(long vid, NetworkBattleUri uri, long pubSeq) => + private static MsgEnvelope MakeEnvelopeWith(long vid, NetworkBattleUri uri, long pubSeq, + Dictionary? body = null) => new(uri, ViewerId: vid, Uuid: "udid-test", Bid: null, Try: 0, Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General : uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching : EmitCategory.Battle, - PubSeq: pubSeq, PlaySeq: null, Body: new RawBody(new Dictionary())); + PubSeq: pubSeq, PlaySeq: null, Body: new RawBody(body ?? new Dictionary())); + + // ------------------------------------------------------------------------- + // PvP integration tests (Task 12). Drive two parallel RawSocketIoTestClient + // instances against the same TestServer to exercise the full PvP wire path: + // pair-handshake to AfterReady, gameplay-frame broadcasting, Retire flipping, + // mid-game disconnect cascade, and waiting-room timeout. + // ------------------------------------------------------------------------- + + [Test] + [Timeout(60000)] + public async Task PvpHandshakeAndGameplay() + { + await using var factory = new SVSimTestFactory(); + var vidA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_021UL); + var vidB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_022UL); + await SeedTwoPickRunAsync(factory, vidA, Enumerable.Range(1, 30).Select(i => 100_000_000L + i).ToList()); + await SeedTwoPickRunAsync(factory, vidB, Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList()); + + var bridge = factory.Services.GetRequiredService(); + using var scope = factory.Services.CreateScope(); + var builder = scope.ServiceProvider.GetRequiredService(); + var ctxA = await builder.BuildForTwoPickAsync(vidA); + var ctxB = await builder.BuildForTwoPickAsync(vidB); + + var pending = bridge.RegisterBattle( + new SVSim.BattleNode.Bridge.BattlePlayer(vidA, ctxA), + new SVSim.BattleNode.Bridge.BattlePlayer(vidB, ctxB), + SVSim.BattleNode.Sessions.BattleType.Pvp); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var ct = cts.Token; + var key = MakeKey(); + + var (clientA, clientB) = await ConnectBothAsync(factory, pending.BattleId, vidA, vidB, key, ct); + await using var _a = clientA; + await using var _b = clientB; + await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct)); + + await DriveHandshakeAsync(clientA, vidA, key, ct); + await DriveHandshakeAsync(clientB, vidB, key, ct); + + // Both are now AfterReady. A sends TurnEnd; both should receive TurnEnd + Judge. + await clientA.SendMsgAsync(MakeEnvelopeWith(vidA, NetworkBattleUri.TurnEnd, pubSeq: 5), key, ct); + + var aFirst = await clientA.ReceiveSynchronizeAsync(ct); + var aSecond = await clientA.ReceiveSynchronizeAsync(ct); + var bFirst = await clientB.ReceiveSynchronizeAsync(ct); + var bSecond = await clientB.ReceiveSynchronizeAsync(ct); + + Assert.That(new[] { aFirst.Uri, aSecond.Uri }, + Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge })); + Assert.That(new[] { bFirst.Uri, bSecond.Uri }, + Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge })); + + // PlayActions forwarding: B sends, A receives. + await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.PlayActions, pubSeq: 6), key, ct); + var aForwarded = await clientA.ReceiveSynchronizeAsync(ct); + Assert.That(aForwarded.Uri, Is.EqualTo(NetworkBattleUri.PlayActions)); + } + + [Test] + [Timeout(60000)] + public async Task PvpRetireFlipsResult() + { + await using var factory = new SVSimTestFactory(); + var vidA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_031UL); + var vidB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_032UL); + await SeedTwoPickRunAsync(factory, vidA, Enumerable.Range(1, 30).Select(i => 100_000_000L + i).ToList()); + await SeedTwoPickRunAsync(factory, vidB, Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList()); + + var bridge = factory.Services.GetRequiredService(); + using var scope = factory.Services.CreateScope(); + var builder = scope.ServiceProvider.GetRequiredService(); + var ctxA = await builder.BuildForTwoPickAsync(vidA); + var ctxB = await builder.BuildForTwoPickAsync(vidB); + + var pending = bridge.RegisterBattle( + new SVSim.BattleNode.Bridge.BattlePlayer(vidA, ctxA), + new SVSim.BattleNode.Bridge.BattlePlayer(vidB, ctxB), + SVSim.BattleNode.Sessions.BattleType.Pvp); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var ct = cts.Token; + var key = MakeKey(); + var (clientA, clientB) = await ConnectBothAsync(factory, pending.BattleId, vidA, vidB, key, ct); + await using var _a = clientA; + await using var _b = clientB; + await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct)); + await DriveHandshakeAsync(clientA, vidA, key, ct); + await DriveHandshakeAsync(clientB, vidB, key, ct); + + // A retires. + await clientA.SendMsgAsync(MakeEnvelopeWith(vidA, NetworkBattleUri.Retire, pubSeq: 5), key, ct); + + var aFinish = await clientA.ReceiveSynchronizeAsync(ct); + var bFinish = await clientB.ReceiveSynchronizeAsync(ct); + + Assert.That(aFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); + Assert.That(bFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); + var aBody = (RawBody)aFinish.Body; + var bBody = (RawBody)bFinish.Body; + // BattleResult.Lose = 0, Win = 1. + Assert.That((long)aBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Lose)); + Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Win)); + } + + [Test] + [Timeout(60000)] + public async Task PvpMidGameDisconnect_FullCascade() + { + await using var factory = new SVSimTestFactory(); + var vidA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_041UL); + var vidB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_042UL); + await SeedTwoPickRunAsync(factory, vidA, Enumerable.Range(1, 30).Select(i => 100_000_000L + i).ToList()); + await SeedTwoPickRunAsync(factory, vidB, Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList()); + + var bridge = factory.Services.GetRequiredService(); + using var scope = factory.Services.CreateScope(); + var builder = scope.ServiceProvider.GetRequiredService(); + var ctxA = await builder.BuildForTwoPickAsync(vidA); + var ctxB = await builder.BuildForTwoPickAsync(vidB); + + var pending = bridge.RegisterBattle( + new SVSim.BattleNode.Bridge.BattlePlayer(vidA, ctxA), + new SVSim.BattleNode.Bridge.BattlePlayer(vidB, ctxB), + SVSim.BattleNode.Sessions.BattleType.Pvp); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var ct = cts.Token; + var key = MakeKey(); + var (clientA, clientB) = await ConnectBothAsync(factory, pending.BattleId, vidA, vidB, key, ct); + await using var _b = clientB; + await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct)); + await DriveHandshakeAsync(clientA, vidA, key, ct); + await DriveHandshakeAsync(clientB, vidB, key, ct); + + // Abruptly close A's WS (no Retire). + await clientA.DisposeAsync(); + + // B should receive BattleFinish(Win) within a few seconds. + var bFinish = await clientB.ReceiveSynchronizeAsync(ct); + Assert.That(bFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); + var bBody = (RawBody)bFinish.Body; + Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Win)); + + // PendingBattle should be evicted by the second arriver's RemovePending. + var store = factory.Services.GetRequiredService(); + Assert.That(store.TryGetPending(pending.BattleId), Is.Null); + } + + [Test] + [Timeout(75000)] + public async Task PvpWaitingRoomTimeout() + { + // The factory uses BattleNodeOptions.WaitingRoomTimeout default = 60s. We wait the + // full 60s + grace. The plan permits this fallback if WithWebHostBuilder doesn't + // play nicely with SVSimTestFactory's SQLite-bound override (the factory's + // ConfigureWebHost replaces the DbContext per-instance against a private SqliteConnection; + // composing WithWebHostBuilder on top creates a second host that shares the connection + // but re-runs CreateHost — risking double EnsureCreated / re-seed against the same DB). + await using var factory = new SVSimTestFactory(); + var vidA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_051UL); + var vidB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_052UL); + await SeedTwoPickRunAsync(factory, vidA, Enumerable.Range(1, 30).Select(i => 100_000_000L + i).ToList()); + await SeedTwoPickRunAsync(factory, vidB, Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList()); + + var bridge = factory.Services.GetRequiredService(); + using var scope = factory.Services.CreateScope(); + var builder = scope.ServiceProvider.GetRequiredService(); + var ctxA = await builder.BuildForTwoPickAsync(vidA); + var ctxB = await builder.BuildForTwoPickAsync(vidB); + + var pending = bridge.RegisterBattle( + new SVSim.BattleNode.Bridge.BattlePlayer(vidA, ctxA), + new SVSim.BattleNode.Bridge.BattlePlayer(vidB, ctxB), + SVSim.BattleNode.Sessions.BattleType.Pvp); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(70)); + var ct = cts.Token; + var key = MakeKey(); + var encA = NodeCrypto.EncryptForNode(vidA.ToString(), key); + var wsClient = factory.Server.CreateWebSocketClient(); + var wsA = await wsClient.ConnectAsync( + new Uri($"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encA)}&EIO=3&transport=websocket"), + ct); + // NOTE: ConsumeHandshakeAsync is NOT called here. The EIO Open frame is sent inside + // RealParticipant.RunAsync, which only runs once the session is constructed by the + // SECOND arriver. The first arriver who times out never receives that frame — the + // handler parks them in AwaitSessionFinishedAsync, the waiting-room timer fires, the + // handler's HTTP method returns, and the TestServer-side WS shuts down. ReceiveAsync + // observes the shutdown either by returning a Close message or throwing. + bool closeObserved = false; + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (!closeObserved && sw.Elapsed < TimeSpan.FromSeconds(65)) + { + try + { + var rr = await wsA.ReceiveAsync(new ArraySegment(new byte[1024]), ct); + if (rr.MessageType == System.Net.WebSockets.WebSocketMessageType.Close) + { + closeObserved = true; + break; + } + } + catch + { + // Aborted / cancelled / WebSocketException — server-side close observed. + closeObserved = true; + break; + } + } + Assert.That(closeObserved, Is.True, + "A's WS should close (or ReceiveAsync should fail) after the waiting-room timeout."); + wsA.Dispose(); + + var store = factory.Services.GetRequiredService(); + Assert.That(store.TryGetPending(pending.BattleId), Is.Null); + } + + // -- helpers ------------------------------------------------------------- + + private static async Task DriveHandshakeAsync( + RawSocketIoTestClient client, long vid, string key, CancellationToken ct) + { + long pubSeq = 1; + await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.InitNetwork, pubSeq++), key, ct); + await client.ReceiveSynchronizeAsync(ct); // InitNetwork ack + await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.InitBattle, pubSeq++), key, ct); + var matched = await client.ReceiveSynchronizeAsync(ct); + Assert.That(matched.Uri, Is.EqualTo(NetworkBattleUri.Matched)); + await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.Loaded, pubSeq++), key, ct); + await client.ReceiveSynchronizeAsync(ct); // BattleStart + await client.ReceiveSynchronizeAsync(ct); // Deal + await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.Swap, pubSeq++, + body: new Dictionary { ["idxList"] = new List() }), key, ct); + await client.ReceiveSynchronizeAsync(ct); // Swap response + await client.ReceiveSynchronizeAsync(ct); // Ready + } + + private static async Task<(RawSocketIoTestClient, RawSocketIoTestClient)> ConnectBothAsync( + SVSimTestFactory factory, string battleId, long vidA, long vidB, string key, CancellationToken ct) + { + var encA = NodeCrypto.EncryptForNode(vidA.ToString(), key); + var encB = NodeCrypto.EncryptForNode(vidB.ToString(), key); + var uriA = new Uri($"ws://localhost/socket.io/?BattleId={battleId}&viewerId={Uri.EscapeDataString(encA)}&EIO=3&transport=websocket"); + var uriB = new Uri($"ws://localhost/socket.io/?BattleId={battleId}&viewerId={Uri.EscapeDataString(encB)}&EIO=3&transport=websocket"); + + var wsClient = factory.Server.CreateWebSocketClient(); + // A's HTTP handler will Park (block in AwaitSessionFinishedAsync) until B connects. + // TestServer's CreateWebSocketClient returns once the WS upgrade response is flushed, + // which happens at AcceptWebSocketAsync — well before Park. But to be safe against + // any in-process buffering, start A's connect and yield briefly so its request thread + // reaches Park before B's connect arrives. + var connectATask = wsClient.ConnectAsync(uriA, ct); + await Task.Delay(50, ct); + var wsB = await wsClient.ConnectAsync(uriB, ct); + var wsA = await connectATask; + return (new RawSocketIoTestClient(wsA), new RawSocketIoTestClient(wsB)); + } + + private static async Task SeedTwoPickRunAsync(SVSimTestFactory factory, long vid, IReadOnlyList deck) + { + using var seedScope = factory.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + db.ViewerArenaTwoPickRuns.Add(new ViewerArenaTwoPickRun + { + ViewerId = vid, + EntryId = 1, + ClassId = 1, + LeaderSkinId = 1, + SelectedCardIdsJson = JsonSerializer.Serialize(deck), + IsSelectCompleted = true, + MaxBattleCount = 5, + CandidateClassIdsJson = "[1,2,3]", + PendingPickSetsJson = "[]", + ResultListJson = "[]", + NextCandidateId = 1, + }); + await db.SaveChangesAsync(); + } }