Files
SVSimServer/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs
gamer147 0a8a84b2cc refactor(battle-node): unify TurnEndFinal / Retire-Kill / gameplay-forwarder dispatch across types
Three dispatch arms had Type-based branching that was either wrong or
unnecessary. Unified per the audit doc's recommended order, grounded in
verified facts about each participant's PushAsync.

(1) TurnEndFinal — was branched: PvP broadcast TurnEnd+Judge (wrong on a
game-end signal); Scripted pushed BattleFinish(LifeWin). Unified:
  - forward the envelope to other (matches prod TK2 capture
    battle-traffic_tk2_regular.ndjson:273 — loser receives TurnEndFinal
    from server before BattleFinish)
  - push BattleFinish(LifeWin) to from (winner)
  - push BattleFinish(LifeLose) to other (loser)
  - Phase → Terminal

  Requires ScriptedBotParticipant.PushAsync to no longer fire its 3-frame
  burst on TurnEndFinal (previously it reacted to both TurnEnd and
  TurnEndFinal). The dispatch arm now owns TurnEndFinal's response; the
  bot reacting too would race with the BattleFinish push. Bot still
  fires on regular TurnEnd as before.

(2) Retire / Kill — was branched: PvP pushed Lose=0 (NotFinish) /
Win=1 (NoContest); Scripted pushed BuildBattleFinishNoContest() (Win=1).
Both shipped wrong RESULT_CODE values; the audit doc's outstanding item
documented this. Unified:
  - push BattleFinish(RetireLose=106) to from (the retirer)
  - push BattleFinish(RetireWin=105) to other (the survivor)
  - Phase → Terminal

  Added RetireWin=105 / RetireLose=106 to BattleResult enum with the
  same player-perspective convention.

(3) PvP gameplay forwarder (TurnStart / PlayActions / Echo /
TurnEndActions / JudgeResult) — had a redundant `Type == BattleType.Pvp`
guard. Verified that BothAfterReady() is naturally only true when both
participants are RealParticipant (ScriptedBot / NoOpBot don't implement
IHasHandshakePhase per RealParticipant.cs:20-23 / Participants/*.cs grep).
Dropped the redundant guard.

Bot type still has its dedicated InitBattle/Loaded/TurnEnd arms above
the unified ones, so Bot-specific behavior is unchanged.

Tests: 177 battle-node tests passing.
- Updated 9 tests to match the unified dispatch (paired BattleFinish
  pushes, correct RESULT_CODE values, forwarded TurnEndFinal envelope).
- ScriptedBotParticipantTests.PushAsync_TurnEndFinal_* rewritten to
  assert the bot does NOT fire on TurnEndFinal (was asserting it did).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:37:24 -04:00

555 lines
29 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Wire;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.BattleNode.Integration;
[TestFixture]
public class BattleNodeFlowTests
{
/// <summary>
/// End-to-end smoke for the v1.2 scripted lifecycle. Boots the EmulatedEntrypoint via
/// SVSimTestFactory, mints a battle through IMatchingBridge with a fixture MatchContext,
/// opens a raw Socket.IO v2 client against the in-process TestServer, and drives
/// InitNetwork → Loaded → Swap → TurnEnd × 2, asserting the right scripted frames come
/// back in order including the two-cycle three-frame opponent-turn loop (TurnStart +
/// TurnEnd + Judge per cycle).
/// </summary>
[Test]
[Timeout(30000)]
public async Task ClientWalksHandshakeToReady_ReceivesAllScriptedFrames()
{
await using var factory = new SVSimTestFactory();
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var ct = cts.Token;
var pending = bridge.RegisterBattle(
new SVSim.BattleNode.Bridge.BattlePlayer(906243102, FixtureCtx()),
p2: null,
SVSim.BattleNode.Sessions.BattleType.Scripted);
var key = MakeKey();
var encryptedVid = NodeCrypto.EncryptForNode("906243102", key);
var wsUri = new Uri($"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket");
var wsClient = factory.Server.CreateWebSocketClient();
var ws = await wsClient.ConnectAsync(wsUri, ct);
await using var client = new RawSocketIoTestClient(ws);
await client.ConsumeHandshakeAsync(ct);
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct);
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitBattle, pubSeq: 2), key, ct);
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Matched));
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Loaded, pubSeq: 3), key, ct);
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.BattleStart));
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Deal));
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Swap, pubSeq: 4,
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Swap));
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Ready));
// --- v1.2 opponent turn loop: drive two consecutive cycles ---
// Cycle 1: player ends turn -> server pushes opponent TurnStart + TurnEnd + Judge.
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.TurnEnd, pubSeq: 5), key, ct);
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Judge));
// Cycle 2: same burst again -- session phase reset to AfterReady, so the next TurnEnd matches.
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.TurnEnd, pubSeq: 6), key, ct);
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Judge));
}
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri, long pubSeq, Dictionary<string, object?>? body = null) =>
new(uri, ViewerId: 906243102, 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(body ?? new Dictionary<string, object?>()));
private static string MakeKey()
{
var seq = 0;
return NodeCrypto.GenerateKey(() => (seq++ * 13) % 16);
}
internal static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new(
SelfDeckCardIds: deck ?? Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11);
/// <summary>
/// End-to-end: a viewer with a real TK2 run sees their drafted card-ids in the Matched
/// frame's selfDeck. This is the "visible win" — proves the full plumbing chain works
/// against an actual seeded viewer.
/// </summary>
[Test]
[Timeout(30000)]
public async Task Matched_frame_contains_drafted_deck_cards()
{
await using var factory = new SVSimTestFactory();
var vid = await factory.SeedViewerAsync();
var draftedDeck = Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList();
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(draftedDeck),
IsSelectCompleted = true,
MaxBattleCount = 5,
CandidateClassIdsJson = "[1,2,3]",
PendingPickSetsJson = "[]",
ResultListJson = "[]",
NextCandidateId = 1,
});
await db.SaveChangesAsync();
}
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<SVSim.EmulatedEntrypoint.Services.IMatchContextBuilder>();
var ctx = await builder.BuildForTwoPickAsync(vid);
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var ct = cts.Token;
var pending = bridge.RegisterBattle(
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
p2: null,
SVSim.BattleNode.Sessions.BattleType.Scripted);
var key = MakeKey();
var encryptedVid = NodeCrypto.EncryptForNode(vid.ToString(), key);
var wsUri = new Uri($"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket");
var wsClient = factory.Server.CreateWebSocketClient();
var ws = await wsClient.ConnectAsync(wsUri, ct);
await using var client = new RawSocketIoTestClient(ws);
await client.ConsumeHandshakeAsync(ct);
// InitNetwork → ack
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct);
await client.ReceiveSynchronizeAsync(ct);
// InitBattle → Matched (this is the frame we care about)
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.InitBattle, pubSeq: 2), key, ct);
var matched = await client.ReceiveSynchronizeAsync(ct);
Assert.That(matched.Uri, Is.EqualTo(NetworkBattleUri.Matched));
// MsgEnvelope.FromJson always inflates Body as a RawBody dictionary — selfDeck is a
// List<object?> of nested dicts with int "idx" + long "cardId" keys.
var body = ((RawBody)matched.Body).Entries;
var selfDeck = (List<object?>)body["selfDeck"]!;
Assert.That(selfDeck.Count, Is.EqualTo(30));
for (int i = 0; i < 30; i++)
{
var entry = (Dictionary<string, object?>)selfDeck[i]!;
Assert.That((long)entry["idx"]!, Is.EqualTo(i + 1L),
$"slot {i}: idx should be 1-based position");
Assert.That((long)entry["cardId"]!, Is.EqualTo(draftedDeck[i]),
$"slot {i}: cardId should match the drafted card");
}
}
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(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.RetireLose = 106 (retirer), RetireWin = 105 (survivor). Player-
// perspective codes per the FinishBattleEffect trace.
Assert.That((long)aBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.RetireLose));
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.RetireWin));
}
[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, and
// the polite-close path emits an EIO "1" Close text frame followed by a clean
// WebSocket close handshake before the handler returns.
bool politeFrameObserved = false;
bool closeObserved = false;
var sw = System.Diagnostics.Stopwatch.StartNew();
var buf = new byte[1024];
while (!closeObserved && sw.Elapsed < TimeSpan.FromSeconds(65))
{
try
{
var rr = await wsA.ReceiveAsync(new ArraySegment<byte>(buf), ct);
if (rr.MessageType == System.Net.WebSockets.WebSocketMessageType.Close)
{
closeObserved = true;
break;
}
if (rr.MessageType == System.Net.WebSockets.WebSocketMessageType.Text)
{
var text = System.Text.Encoding.UTF8.GetString(buf, 0, rr.Count);
if (text == "1") politeFrameObserved = true;
}
}
catch
{
// Aborted / cancelled / WebSocketException — server-side close observed.
closeObserved = true;
break;
}
}
Assert.That(politeFrameObserved, Is.True,
"A's WS should receive an EIO '1' Close text frame before teardown (polite-close contract).");
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);
}
// -------------------------------------------------------------------------
// Bot integration test (Task 13). Single client, full Bot lifecycle: handshake
// through Swap (asserting NO Matched / BattleStart / Deal pushes), TurnEnd cycles
// each producing a single Judge back, Retire → BattleFinish. Reference:
// docs/api-spec/in-battle/ai-passive.md.
// -------------------------------------------------------------------------
[Test]
[Timeout(30000)]
public async Task BotBattle_FullLifecycle()
{
await using var factory = new SVSimTestFactory();
var vid = await factory.SeedViewerAsync();
await factory.SeedGlobalsAsync();
await factory.SeedDeckAsync(vid, SVSim.Database.Enums.Format.Rotation, 1);
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<SVSim.EmulatedEntrypoint.Services.IMatchContextBuilder>();
var ctx = await builder.BuildForRankBattleAsync(vid, SVSim.Database.Enums.Format.Rotation, deckNo: 1);
var pending = bridge.RegisterBattle(
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
p2: null,
SVSim.BattleNode.Sessions.BattleType.Bot);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var ct = cts.Token;
var key = MakeKey();
var encryptedVid = NodeCrypto.EncryptForNode(vid.ToString(), key);
var wsUri = new Uri($"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket");
var wsClient = factory.Server.CreateWebSocketClient();
var ws = await wsClient.ConnectAsync(wsUri, ct);
await using var client = new RawSocketIoTestClient(ws);
await client.ConsumeHandshakeAsync(ct);
// InitNetwork → ack.
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct);
var ack1 = await client.ReceiveSynchronizeAsync(ct);
Assert.That(ack1.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
// InitBattle → ack (NOT Matched). The client's AI flow doesn't gate on
// Matched and pushing BattleStart later corrupts OppoBattleStartInfo, so
// Bot mode keeps the handshake silent (just an ack).
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.InitBattle, pubSeq: 2), key, ct);
var ack2 = await client.ReceiveSynchronizeAsync(ct);
Assert.That(ack2.Uri, Is.EqualTo(NetworkBattleUri.InitBattle),
"Bot's InitBattle is ack-only — no Matched envelope.");
// Loaded → silent. Send Swap right after; the next inbound must be SwapResponse
// (no orphan BattleStart / Deal in the queue).
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.Loaded, pubSeq: 3), key, ct);
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.Swap, pubSeq: 4,
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
var swapResp = await client.ReceiveSynchronizeAsync(ct);
Assert.That(swapResp.Uri, Is.EqualTo(NetworkBattleUri.Swap),
"Expected Swap response (mulligan ack). Got " + swapResp.Uri + " — Loaded may have leaked a frame.");
var readyResp = await client.ReceiveSynchronizeAsync(ct);
Assert.That(readyResp.Uri, Is.EqualTo(NetworkBattleUri.Ready));
// TurnEnd → Judge back.
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.TurnEnd, pubSeq: 5), key, ct);
var judge1 = await client.ReceiveSynchronizeAsync(ct);
Assert.That(judge1.Uri, Is.EqualTo(NetworkBattleUri.Judge));
// Second TurnEnd → another Judge back.
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.TurnEnd, pubSeq: 6), key, ct);
var judge2 = await client.ReceiveSynchronizeAsync(ct);
Assert.That(judge2.Uri, Is.EqualTo(NetworkBattleUri.Judge));
// Retire → BattleFinish.
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.Retire, pubSeq: 7), key, ct);
var finish = await client.ReceiveSynchronizeAsync(ct);
Assert.That(finish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
// PendingBattle evicted at session start.
var store = factory.Services.GetRequiredService<SVSim.BattleNode.Sessions.IBattleSessionStore>();
Assert.That(store.TryGetPending(pending.BattleId), Is.Null,
"PendingBattle should be evicted at session start.");
}
// -- 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();
}
}