using System.Text.Json; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Wire; using SVSim.Database; using SVSim.Database.Models; using SVSim.EmulatedEntrypoint; using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.BattleNode.Integration; [TestFixture] public class BattleNodeFlowTests { /// /// End-to-end smoke for the v1.2 scripted lifecycle. Boots the EmulatedEntrypoint via /// SVSimTestFactory, mints a battle through IMatchingBridge with a fixture MatchContext, /// opens a raw Socket.IO v2 client against the in-process TestServer, and drives /// InitNetwork → Loaded → Swap → TurnEnd × 2, asserting the right scripted frames come /// back in order including the two-cycle three-frame opponent-turn loop (TurnStart + /// TurnEnd + Judge per cycle). /// [Test] [Timeout(30000)] public async Task ClientWalksHandshakeToReady_ReceivesAllScriptedFrames() { await using var factory = new SVSimTestFactory(); var bridge = factory.Services.GetRequiredService(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var ct = cts.Token; var pending = bridge.RegisterBattle( new SVSim.BattleNode.Bridge.BattlePlayer(906243102, FixtureCtx()), p2: null, SVSim.BattleNode.Sessions.BattleType.Scripted); var key = MakeKey(); var encryptedVid = NodeCrypto.EncryptForNode("906243102", 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); await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitBattle, pubSeq: 2), key, ct); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Matched)); await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Loaded, pubSeq: 3), key, ct); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.BattleStart)); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Deal)); await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Swap, pubSeq: 4, body: new Dictionary { ["idxList"] = new List() }), key, ct); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Swap)); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Ready)); // --- v1.2 opponent turn loop: drive two consecutive cycles --- // Cycle 1: player ends turn -> server pushes opponent TurnStart + TurnEnd + Judge. await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.TurnEnd, pubSeq: 5), key, ct); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnStart)); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnEnd)); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Judge)); // Cycle 2: same burst again -- session phase reset to AfterReady, so the next TurnEnd matches. await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.TurnEnd, pubSeq: 6), key, ct); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnStart)); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnEnd)); Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Judge)); } private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri, long pubSeq, Dictionary? body = null) => new(uri, ViewerId: 906243102, 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(body ?? new Dictionary())); private static string MakeKey() { var seq = 0; return NodeCrypto.GenerateKey(() => (seq++ * 13) % 16); } internal static MatchContext FixtureCtx(IReadOnlyList? deck = null) => new( SelfDeckCardIds: deck ?? Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(), ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, BattleType: 11); /// /// End-to-end: a viewer with a real TK2 run sees their drafted card-ids in the Matched /// frame's selfDeck. This is the "visible win" — proves the full plumbing chain works /// against an actual seeded viewer. /// [Test] [Timeout(30000)] public async Task Matched_frame_contains_drafted_deck_cards() { await using var factory = new SVSimTestFactory(); var vid = await factory.SeedViewerAsync(); var draftedDeck = Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList(); 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(draftedDeck), IsSelectCompleted = true, MaxBattleCount = 5, CandidateClassIdsJson = "[1,2,3]", PendingPickSetsJson = "[]", ResultListJson = "[]", NextCandidateId = 1, }); await db.SaveChangesAsync(); } using var scope = factory.Services.CreateScope(); var builder = scope.ServiceProvider.GetRequiredService(); var ctx = await builder.BuildForTwoPickAsync(vid); var bridge = factory.Services.GetRequiredService(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var ct = cts.Token; var pending = bridge.RegisterBattle( new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx), p2: null, SVSim.BattleNode.Sessions.BattleType.Scripted); 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); await client.ReceiveSynchronizeAsync(ct); // InitBattle → Matched (this is the frame we care about) await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.InitBattle, pubSeq: 2), key, ct); var matched = await client.ReceiveSynchronizeAsync(ct); Assert.That(matched.Uri, Is.EqualTo(NetworkBattleUri.Matched)); // MsgEnvelope.FromJson always inflates Body as a RawBody dictionary — selfDeck is a // List of nested dicts with int "idx" + long "cardId" keys. var body = ((RawBody)matched.Body).Entries; var selfDeck = (List)body["selfDeck"]!; Assert.That(selfDeck.Count, Is.EqualTo(30)); for (int i = 0; i < 30; i++) { var entry = (Dictionary)selfDeck[i]!; Assert.That((long)entry["idx"]!, Is.EqualTo(i + 1L), $"slot {i}: idx should be 1-based position"); Assert.That((long)entry["cardId"]!, Is.EqualTo(draftedDeck[i]), $"slot {i}: cardId should match the drafted card"); } } 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(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(); } }