Phase 3 shipped a Bot dispatch table that ack'd InitBattle without pushing Matched and stayed silent on Loaded, per the architecture spec's inference that "the client uses AIBattleStart HTTP data instead of Matched in Bot mode." That inference was wrong. The client's matching state machine (Matching.ReactionReceiveUri, Matching.cs:400) gates StartBattleLoad() on the Matched envelope, and BattleStart at Matching.cs:417 triggers GotoBattle. Without those envelopes the client never transitions out of MatchingStatus.Connect — which renders as the "Waiting for opponent" hang on the loading screen. AIBattleStart HTTP only provides opponent cosmetics, not state-machine triggers. Fix: drop the Bot-specific InitBattle ack-only and Loaded silent arms; let Bot fall through to the existing handshake arms that push Matched and BattleStart + Deal. Only TurnEnd stays Bot-specific (Judge to sender, not broadcast — there's no real other side to broadcast to). Tests updated to match the corrected contract. ai-passive.md doc amended with a correction note. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
548 lines
29 KiB
C#
548 lines
29 KiB
C#
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);
|
||
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 → Matched. (Same handshake arm as Scripted/PvP; the client's
|
||
// matching state machine gates StartBattleLoad on receiving Matched, so the
|
||
// envelope MUST be sent. Opponent cosmetics in the body are placeholders;
|
||
// the client renders opponent UI from AIBattleStart HTTP data.)
|
||
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),
|
||
"Bot's InitBattle pushes Matched (the state-machine trigger).");
|
||
|
||
// Loaded → BattleStart + Deal (BattleStart triggers GotoBattle on the client).
|
||
await client.SendMsgAsync(MakeEnvelopeWith(vid, 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));
|
||
|
||
// Swap → SwapResponse + Ready.
|
||
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));
|
||
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();
|
||
}
|
||
}
|