diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 913b70e..02be295 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -135,28 +135,20 @@ public sealed class BattleSession phaseFrom!.Phase = BattleSessionPhase.AwaitingInitBattle; break; - // --- Phase 3 Bot arms — placed BEFORE the existing handshake arms so they - // win pattern matching on Type == Bot. Bot mode: ack handshake, silent Loaded, - // Judge-to-sender on TurnEnd. The rest reuse Scripted's arms (Retire/Kill -> - // BattleFinishNoContest, Swap -> per-sender response, default -> drop). + // --- Phase 3 Bot arms — placed BEFORE the existing handshake arms so the + // Bot-specific TurnEnd wins pattern matching on Type == Bot. InitBattle and + // Loaded fall through to the regular handshake arms below (which push Matched + // and BattleStart+Deal) — the architecture spec's "no Matched in Bot mode" + // claim was wrong: the client's Matching.ReactionReceiveUri (Matching.cs:400) + // gates StartBattleLoad on receiving Matched, and BattleStart triggers + // GotoBattle. Without those envelopes the client hangs in matching status + // Connect ("Waiting for opponent"). AIBattleStart HTTP only provides + // opponent cosmetics, not the state-machine triggers. + // + // Bot's Swap arm reuses the existing Scripted Swap arm (per-sender + // SwapResponse + Ready), and Retire/Kill reuses BattleFinishNoContest. // Reference: docs/api-spec/in-battle/ai-passive.md. - case NetworkBattleUri.InitBattle - when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle: - // Ack only — NO Matched push. Client uses AIBattleStart HTTP data for - // the lobby plates instead of the Matched envelope. - result.Add((from, BuildAck(NetworkBattleUri.InitBattle), true)); - phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded; - break; - - case NetworkBattleUri.Loaded - when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded: - // Silent — no BattleStart, no Deal. Client's AINetworkBattleManager - // short-circuits to its local AI flow after Loaded; the server has - // nothing to contribute. - phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap; - break; - case NetworkBattleUri.TurnEnd when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady: case NetworkBattleUri.TurnEndFinal diff --git a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs index 81d86a4..1500ab7 100644 --- a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs @@ -440,20 +440,25 @@ public class BattleNodeFlowTests var ack1 = await client.ReceiveSynchronizeAsync(ct); Assert.That(ack1.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); - // InitBattle → ack (NOT Matched). + // 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 ack2 = await client.ReceiveSynchronizeAsync(ct); - Assert.That(ack2.Uri, Is.EqualTo(NetworkBattleUri.InitBattle), - "Bot's InitBattle is ack-only — no Matched envelope."); + var matched = await client.ReceiveSynchronizeAsync(ct); + Assert.That(matched.Uri, Is.EqualTo(NetworkBattleUri.Matched), + "Bot's InitBattle pushes Matched (the state-machine trigger)."); - // Loaded → silent. Send Swap right after; the next inbound must be SwapResponse - // (no orphan BattleStart / Deal in the queue). + // 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 { ["idxList"] = new List() }), 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."); + Assert.That(swapResp.Uri, Is.EqualTo(NetworkBattleUri.Swap)); var readyResp = await client.ReceiveSynchronizeAsync(ct); Assert.That(readyResp.Uri, Is.EqualTo(NetworkBattleUri.Ready)); diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 8a8b2fa..0d9d470 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -458,30 +458,40 @@ public class BattleSessionDispatchTests } [Test] - public void Bot_InitBattle_acks_to_sender_with_no_Matched_push() + public void Bot_InitBattle_pushes_Matched_to_sender() { var (s, a, _) = NewBotSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); - Assert.That(routes.Count, Is.EqualTo(1), "Bot InitBattle is ack-only, no Matched push."); + // Bot InitBattle reuses the regular handshake arm — pushes Matched. The + // client's Matching.ReactionReceiveUri gates StartBattleLoad on receiving + // Matched (Matching.cs:400), so without it the client hangs in + // "Waiting for opponent." Cosmetics in the Matched body come from + // NoOpBotParticipant.Context placeholders; the client renders opponent + // cosmetics from AIBattleStart HTTP data, not from Matched, so the + // placeholders are harmless here. + Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes[0].Target, Is.SameAs(a)); - Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitBattle), - "Expected an ack envelope for InitBattle, NOT a Matched envelope."); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched)); Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded)); } [Test] - public void Bot_Loaded_produces_no_routes_but_advances_phase() + public void Bot_Loaded_pushes_BattleStart_then_Deal_to_sender() { var (s, a, _) = NewBotSession(); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); - Assert.That(routes, Is.Empty, "Bot Loaded is silent."); - Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap), - "Phase still advances even though there are no outbound routes."); + // Bot Loaded reuses the regular handshake arm — pushes BattleStart + Deal. + // BattleStart triggers GotoBattle on the client (Matching.cs:417); without + // it the client hangs. + Assert.That(routes.Select(r => r.Frame.Uri), + Is.EqualTo(new[] { NetworkBattleUri.BattleStart, NetworkBattleUri.Deal })); + Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True); + Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap)); } [Test] diff --git a/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs b/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs index 94574c9..07853fb 100644 --- a/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs +++ b/SVSim.UnitTests/Controllers/RankBattleControllerTests.cs @@ -109,7 +109,9 @@ public class RankBattleControllerTests using var doc = JsonDocument.Parse(raw); var data = doc.RootElement; - Assert.That(data.GetProperty("ai_id").GetInt32(), Is.InRange(4001, 4008)); + // Series-1 ids from rm_ai_setting.csv — must be one of the real catalog entries. + Assert.That(data.GetProperty("ai_id").GetInt32(), + Is.AnyOf(1111, 1121, 1131, 1141, 1151, 1161, 1171, 1181)); Assert.That(data.GetProperty("turnState").GetInt32(), Is.EqualTo(0)); // Literal camelCase wire-key checks — these MUST be present verbatim