From a916afe9248a6b57316e0c8354823742715959ac Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 3 Jun 2026 19:33:58 -0400 Subject: [PATCH] test(battle-node): drive the conformance oracle via two-client PvP The golden-match oracle harvested all ten server-authored frames from a single Scripted client. Re-point it at a two-client PvP session (same shared builders for handshake/mulligan, real turn-cycle frames for TurnStart/TurnEnd/Judge) so the oracle survives removal of the scripted bot. Category-based shape check is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../Integration/CaptureConformanceTests.cs | 109 ++++++++++++------ 1 file changed, 75 insertions(+), 34 deletions(-) diff --git a/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs b/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs index 44f3c13..fc780b3 100644 --- a/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs @@ -52,51 +52,76 @@ public class CaptureConformanceTests }; [Test] - [Timeout(30000)] + [Timeout(60000)] public async Task ServerAuthoredFrames_MatchProdCaptureShapes() { await using var factory = new SVSimTestFactory(); var bridge = factory.Services.GetRequiredService(); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); var ct = cts.Token; + // Two-client PvP drive. PvP authors the same handshake/mulligan frames the old Scripted + // path did (via the shared server-frame builders) PLUS the turn-cycle frames + // (TurnStart/TurnEnd/Judge) the scripted bot used to fake — so a two-client session + // harvests all ten server-authored URIs. The shape check is category-based, so PvP's + // spin:0 still matches prod's spin:189. + const long vidA = 906243102L; + const long vidB = 847666884L; var pending = bridge.RegisterBattle( - new BattlePlayer(ViewerId, BattleNodeFlowTests.FixtureCtx()), - p2: null, - SVSim.BattleNode.Sessions.BattleType.Scripted); + new BattlePlayer(vidA, BattleNodeFlowTests.FixtureCtx()), + new BattlePlayer(vidB, BattleNodeFlowTests.FixtureCtx()), + SVSim.BattleNode.Sessions.BattleType.Pvp); var key = MakeKey(); - var encryptedVid = NodeCrypto.EncryptForNode(ViewerId.ToString(), key); - var wsUri = new Uri( - $"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket"); + 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)); - var wsClient = factory.Server.CreateWebSocketClient(); - var ws = await wsClient.ConnectAsync(wsUri, ct); - await using var client = new RawSocketIoTestClient(ws); - await client.ConsumeHandshakeAsync(ct); - - // Drive the full Scripted lifecycle, harvesting every server-pushed frame by URI. var harvested = new Dictionary(); + void Harvest(MsgEnvelope env) => harvested[env.Uri] = env; - async Task DriveAsync(NetworkBattleUri send, long pubSeq, int expectPushes, - Dictionary? body = null) - { - await client.SendMsgAsync(MakeEnvelope(send, pubSeq, body), key, ct); - for (var i = 0; i < expectPushes; i++) - { - var frame = await client.ReceiveSynchronizeAsync(ct); - harvested[frame.Uri] = frame; - } - } + long seqA = 1, seqB = 1; - await DriveAsync(NetworkBattleUri.InitNetwork, 1, expectPushes: 1); - await DriveAsync(NetworkBattleUri.InitBattle, 2, expectPushes: 1); // Matched - await DriveAsync(NetworkBattleUri.Loaded, 3, expectPushes: 2); // BattleStart + Deal - await DriveAsync(NetworkBattleUri.Swap, 4, expectPushes: 2, // Swap + Ready - body: new Dictionary { ["idxList"] = new List() }); - await DriveAsync(NetworkBattleUri.TurnEnd, 5, expectPushes: 3); // TurnStart + TurnEnd + Judge - await DriveAsync(NetworkBattleUri.Retire, 6, expectPushes: 1); // BattleFinish + // A walks the handshake; Ready is withheld by the mulligan barrier until B also swaps. + await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.InitNetwork, seqA++), key, ct); + Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // InitNetwork ack + await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.InitBattle, seqA++), key, ct); + Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Matched + await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Loaded, seqA++), key, ct); + Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // BattleStart + Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Deal + await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Swap, seqA++, + new Dictionary { ["idxList"] = new List() }), key, ct); + Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Swap response + + // B walks the handshake; B's Swap (the second) releases Ready to both sides. + await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.InitNetwork, seqB++), key, ct); + await clientB.ReceiveSynchronizeAsync(ct); // ack + await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.InitBattle, seqB++), key, ct); + await clientB.ReceiveSynchronizeAsync(ct); // Matched + await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.Loaded, seqB++), key, ct); + await clientB.ReceiveSynchronizeAsync(ct); // BattleStart + await clientB.ReceiveSynchronizeAsync(ct); // Deal + await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.Swap, seqB++, + new Dictionary { ["idxList"] = new List() }), key, ct); + await clientB.ReceiveSynchronizeAsync(ct); // B Swap response + Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Ready (released to A) + await clientB.ReceiveSynchronizeAsync(ct); // Ready to B + + // Turn cycle: A ends turn -> B receives TurnEnd{turnState}. B sends Judge -> Judge{spin} + // reflects to B. B sends TurnStart -> A receives TurnStart{spin}. + await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.TurnEnd, seqA++), key, ct); + Harvest(await clientB.ReceiveSynchronizeAsync(ct)); // TurnEnd + await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.Judge, seqB++), key, ct); + Harvest(await clientB.ReceiveSynchronizeAsync(ct)); // Judge + await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.TurnStart, seqB++), key, ct); + Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // TurnStart + + // BattleFinish: A retires. + await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Retire, seqA++), key, ct); + Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // BattleFinish // Compare each harvested frame's wire JSON against the prod capture fixture. using var fixtures = JsonDocument.Parse(ProdCaptureFixture.Json); @@ -107,7 +132,7 @@ public class CaptureConformanceTests var uri = Enum.Parse(uriName); if (!harvested.TryGetValue(uri, out var env)) { - failures.Add($"[{uriName}] our server never pushed this frame during the Scripted lifecycle."); + failures.Add($"[{uriName}] our server never pushed this frame during the PvP lifecycle."); continue; } @@ -124,6 +149,22 @@ public class CaptureConformanceTests } } + 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(); + 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 readonly string[] ExpectedUris = { "InitNetwork", "Matched", "BattleStart", "Deal", "Swap", "Ready", @@ -208,9 +249,9 @@ public class CaptureConformanceTests return s.Length > 40 ? s[..40] + "…" : s; } - private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri, long pubSeq, + private static MsgEnvelope MakeEnvelope(long vid, NetworkBattleUri uri, long pubSeq, Dictionary? body = null) => - new(uri, ViewerId: ViewerId, Uuid: "udid-test", Bid: null, Try: 0, + new(uri, ViewerId: vid, Uuid: "udid-test", Bid: null, Try: 0, Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General : uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching : EmitCategory.Battle,