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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user