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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user