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]
|
[Test]
|
||||||
[Timeout(30000)]
|
[Timeout(60000)]
|
||||||
public async Task ServerAuthoredFrames_MatchProdCaptureShapes()
|
public async Task ServerAuthoredFrames_MatchProdCaptureShapes()
|
||||||
{
|
{
|
||||||
await using var factory = new SVSimTestFactory();
|
await using var factory = new SVSimTestFactory();
|
||||||
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
|
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;
|
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(
|
var pending = bridge.RegisterBattle(
|
||||||
new BattlePlayer(ViewerId, BattleNodeFlowTests.FixtureCtx()),
|
new BattlePlayer(vidA, BattleNodeFlowTests.FixtureCtx()),
|
||||||
p2: null,
|
new BattlePlayer(vidB, BattleNodeFlowTests.FixtureCtx()),
|
||||||
SVSim.BattleNode.Sessions.BattleType.Scripted);
|
SVSim.BattleNode.Sessions.BattleType.Pvp);
|
||||||
|
|
||||||
var key = MakeKey();
|
var key = MakeKey();
|
||||||
var encryptedVid = NodeCrypto.EncryptForNode(ViewerId.ToString(), key);
|
var (clientA, clientB) = await ConnectBothAsync(factory, pending.BattleId, vidA, vidB, key, ct);
|
||||||
var wsUri = new Uri(
|
await using var _a = clientA;
|
||||||
$"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket");
|
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>();
|
var harvested = new Dictionary<NetworkBattleUri, MsgEnvelope>();
|
||||||
|
void Harvest(MsgEnvelope env) => harvested[env.Uri] = env;
|
||||||
|
|
||||||
async Task DriveAsync(NetworkBattleUri send, long pubSeq, int expectPushes,
|
long seqA = 1, seqB = 1;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await DriveAsync(NetworkBattleUri.InitNetwork, 1, expectPushes: 1);
|
// A walks the handshake; Ready is withheld by the mulligan barrier until B also swaps.
|
||||||
await DriveAsync(NetworkBattleUri.InitBattle, 2, expectPushes: 1); // Matched
|
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.InitNetwork, seqA++), key, ct);
|
||||||
await DriveAsync(NetworkBattleUri.Loaded, 3, expectPushes: 2); // BattleStart + Deal
|
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // InitNetwork ack
|
||||||
await DriveAsync(NetworkBattleUri.Swap, 4, expectPushes: 2, // Swap + Ready
|
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.InitBattle, seqA++), key, ct);
|
||||||
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() });
|
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Matched
|
||||||
await DriveAsync(NetworkBattleUri.TurnEnd, 5, expectPushes: 3); // TurnStart + TurnEnd + Judge
|
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Loaded, seqA++), key, ct);
|
||||||
await DriveAsync(NetworkBattleUri.Retire, 6, expectPushes: 1); // BattleFinish
|
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.
|
// Compare each harvested frame's wire JSON against the prod capture fixture.
|
||||||
using var fixtures = JsonDocument.Parse(ProdCaptureFixture.Json);
|
using var fixtures = JsonDocument.Parse(ProdCaptureFixture.Json);
|
||||||
@@ -107,7 +132,7 @@ public class CaptureConformanceTests
|
|||||||
var uri = Enum.Parse<NetworkBattleUri>(uriName);
|
var uri = Enum.Parse<NetworkBattleUri>(uriName);
|
||||||
if (!harvested.TryGetValue(uri, out var env))
|
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;
|
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 =
|
private static readonly string[] ExpectedUris =
|
||||||
{
|
{
|
||||||
"InitNetwork", "Matched", "BattleStart", "Deal", "Swap", "Ready",
|
"InitNetwork", "Matched", "BattleStart", "Deal", "Swap", "Ready",
|
||||||
@@ -208,9 +249,9 @@ public class CaptureConformanceTests
|
|||||||
return s.Length > 40 ? s[..40] + "…" : s;
|
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) =>
|
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
|
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
|
||||||
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
||||||
: EmitCategory.Battle,
|
: EmitCategory.Battle,
|
||||||
|
|||||||
Reference in New Issue
Block a user