test(battle-node): PvP integration tests (handshake, gameplay, Retire, disconnect, timeout)

Four end-to-end tests against two parallel RawSocketIoTestClients:
handshake to AfterReady on both sides with per-perspective Matched;
TurnEnd broadcast to both sides + Judge; A's PlayActions forwarded to
B; Retire flipped to Lose-for-sender, Win-for-other; A's abrupt WS
close cascades to BattleFinish(Win) for B with PendingBattle eviction;
waiting-room timeout closes the first arriver's WS (fallback long-wait
path — the 60s default is left in place; TestServer-side WS close is
observed via ReceiveAsync returning Close or throwing).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-01 22:30:01 -04:00
parent 225c20daeb
commit 43c0a6cf31

View File

@@ -173,10 +173,291 @@ public class BattleNodeFlowTests
}
}
private static MsgEnvelope MakeEnvelopeWith(long vid, NetworkBattleUri uri, long pubSeq) =>
private static MsgEnvelope MakeEnvelopeWith(long vid, NetworkBattleUri uri, long pubSeq,
Dictionary<string, object?>? 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(new Dictionary<string, object?>()));
PubSeq: pubSeq, PlaySeq: null, Body: new RawBody(body ?? new Dictionary<string, object?>()));
// -------------------------------------------------------------------------
// 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<IMatchingBridge>();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<SVSim.EmulatedEntrypoint.Services.IMatchContextBuilder>();
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<IMatchingBridge>();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<SVSim.EmulatedEntrypoint.Services.IMatchContextBuilder>();
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<IMatchingBridge>();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<SVSim.EmulatedEntrypoint.Services.IMatchContextBuilder>();
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<SVSim.BattleNode.Sessions.IBattleSessionStore>();
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<IMatchingBridge>();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<SVSim.EmulatedEntrypoint.Services.IMatchContextBuilder>();
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<byte>(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<SVSim.BattleNode.Sessions.IBattleSessionStore>();
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<string, object?> { ["idxList"] = new List<object?>() }), 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<long> deck)
{
using var seedScope = factory.Services.CreateScope();
var db = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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();
}
}