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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-03 19:33:58 -04:00
parent 3b6b8d3c94
commit a916afe924

View File

@@ -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<IMatchingBridge>();
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<NetworkBattleUri, MsgEnvelope>();
void Harvest(MsgEnvelope env) => harvested[env.Uri] = env;
async Task DriveAsync(NetworkBattleUri send, long pubSeq, int expectPushes,
Dictionary<string, object?>? 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<string, object?> { ["idxList"] = new List<object?>() });
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<string, object?> { ["idxList"] = new List<object?>() }), 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<string, object?> { ["idxList"] = new List<object?>() }), 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<NetworkBattleUri>(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<string, object?>? 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,