Files
SVSimServer/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs
gamer147 898b872edd fix(rank-battle): route ai-start through the queue-time MatchContext
Live-smoke bug 2026-06-02: queued Bloodcraft (deck #5), wire showed
classId=2 (Swordcraft) for self_info on the /ai_unlimited_rank_battle/start
response — client rendered the wrong leader.

Two layers of the same bug:

1. MatchContextBuilder.BuildForRankBattleAsync hardcoded deckNo=1 instead
   of taking it from the do_matching request — verified against
   data_dumps/captures/traffic.ndjson L17 where deck_no=5 was on the wire.
   Signature changes to (viewerId, format, deckNo); DoMatchingInternal
   passes req.DeckNo.

2. AiStartInternal rebuilt MatchContext from scratch — but the /ai_*/start
   request body is BaseRequest only, no deck_no on the wire. The fix uses
   the MatchContext the bridge already stored at do_matching resolution time
   (in the Bot PendingBattle), so deck/cosmetic data is consistent end-to-end.
   New IBattleSessionStore.TryFindPendingForViewer(viewerId) finds the
   viewer's pending battle for lookup. The store entry persists across
   ai_start (idempotent reads are fine — the WS handler removes on connect).
   No-pending sentinel: ai_id=-1 surfaces the "no AI assigned" error in the
   client.

Tests: 936 → 939 passing.
- MatchContextBuilderTests.BuildForRankBattle_uses_the_caller_supplied_deck_number
  seeds deck #1 (class 1) and deck #5 (class 6) and asserts the deckNo
  argument picks the right one.
- RankBattleControllerTests.AiStart_self_info_class_matches_queued_deck_number
  is the end-to-end regression: register Bot battle with deck #5, hit
  /ai_unlimited_rank_battle/start, assert self_info.classId == 6.
- RankBattleControllerTests.AiStart_without_pending_battle_returns_neg1_sentinel
  locks the defensive ai_id=-1 path.
- Existing AiStart_* tests bypass do_matching, so adapted to call a new
  RegisterBotBattleAsync helper that mirrors what InProcessPairUp does on
  AI-fallback resolution.

SeedDeckAsync gains an optional classId so test cases can differentiate
decks by class (was always picking Classes.First()).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 12:28:42 -04:00

545 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.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);
}
// -------------------------------------------------------------------------
// 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();
}
}