fix(battle-node): Bot mode must push Matched + BattleStart (client state-machine triggers)

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>
This commit is contained in:
gamer147
2026-06-02 09:56:22 -04:00
parent 45c4461515
commit 51e9dd2094
4 changed files with 46 additions and 37 deletions

View File

@@ -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

View File

@@ -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<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.");
Assert.That(swapResp.Uri, Is.EqualTo(NetworkBattleUri.Swap));
var readyResp = await client.ReceiveSynchronizeAsync(ct);
Assert.That(readyResp.Uri, Is.EqualTo(NetworkBattleUri.Ready));

View File

@@ -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]

View File

@@ -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