test(battle-node): BotBattle_FullLifecycle integration test

Single-client end-to-end Bot lifecycle: InitNetwork → ack, InitBattle →
ack (no Matched), Loaded → silent, Swap → SwapResponse + Ready,
two TurnEnd cycles each producing a single Judge frame back to sender,
Retire → BattleFinish. Pending battle evicted at session start.

Closes Phase 3 — battle-node v2's three-phase migration (Scripted → PvP →
Bot) is now complete. Test budget: 884 → 931 (+47 across Phase 3).
Next: matching-queue API rewrite + real rank progression, as separate
specs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-02 01:32:07 -04:00
parent fee84cca24
commit 8723cff998

View File

@@ -399,6 +399,85 @@ public class BattleNodeFlowTests
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 → ack (NOT Matched).
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(