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>
545 lines
29 KiB
C#
545 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, 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();
|
||
}
|
||
}
|