From a4685a9188d418370195a134b9255e1a5a6411ce Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 2 Jun 2026 01:27:08 -0400 Subject: [PATCH] feat(battle-node): Bot dispatch arms in ComputeFrames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new arms gated on Type == BattleType.Bot, placed before the existing PvP / Scripted arms: - InitBattle → ack to sender (no Matched push — client uses AIBattleStart HTTP data) - Loaded → silent (no BattleStart, no Deal — client short-circuits to GotoBattle) - TurnEnd / TurnEndFinal → Judge to sender only (not broadcast) Other URIs in Bot mode fall through existing arms: Swap is Type-agnostic (per-sender SwapResponse + Ready), Retire/Kill hits the existing Scripted no-contest BattleFinish(Win), gameplay forwarders are gated on Pvp so Bot's PlayActions/Echo/etc. fall through default (drop). 8 new dispatch tests cover the wire contract. Reference: docs/api-spec/in-battle/ai-passive.md. Co-Authored-By: Claude Opus 4.7 --- SVSim.BattleNode/Sessions/BattleSession.cs | 32 +++++ .../Sessions/BattleSessionDispatchTests.cs | 134 ++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 8162142..913b70e 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -135,6 +135,38 @@ 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). + // 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 + when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady: + // Judge to sender ONLY (not broadcast — there's no real other side). + // The client's JudgeOperation → ControlTurnStartPlayer flips back to + // the local AI's turn after this Judge arrives. + result.Add((from, BuildJudgeBroadcast(), false)); + break; + case NetworkBattleUri.InitBattle when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle: // Phase 1: push Matched only to the "real" participant. The session reads // selfInfo from from.Context and oppoInfo from other.Context (the scripted diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index e6b5858..8a8b2fa 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -431,6 +431,140 @@ public class BattleSessionDispatchTests Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.Win)); } + private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession() + { + var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx()); + var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, NoOpBotContext()); + var s = new BattleSession("bid-bot-1", BattleType.Bot, a, b, NullLogger.Instance); + return (s, a, b); + } + + private static MatchContext NoOpBotContext() => new( + SelfDeckCardIds: Array.Empty(), + ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015", + CountryCode: "", UserName: "Bot", SleeveId: "0", + EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 0); + + [Test] + public void Bot_InitNetwork_acks_to_sender() + { + var (s, a, _) = NewBotSession(); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(a)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); + Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle)); + } + + [Test] + public void Bot_InitBattle_acks_to_sender_with_no_Matched_push() + { + 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."); + 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(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded)); + } + + [Test] + public void Bot_Loaded_produces_no_routes_but_advances_phase() + { + 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."); + } + + [Test] + public void Bot_Swap_per_sender_SwapResponse_plus_Ready() + { + var (s, a, _) = NewBotSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); + + Assert.That(routes.Select(r => r.Frame.Uri), + Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready })); + Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True); + Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); + } + + [Test] + public void Bot_TurnEnd_pushes_Judge_to_sender_only() + { + var (s, a, b) = NewBotSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); + + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd)); + + Assert.That(routes.Count, Is.EqualTo(1), "Bot TurnEnd → exactly one Judge frame back."); + Assert.That(routes[0].Target, Is.SameAs(a), "Judge target is the sender, not broadcast."); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge)); + } + + [Test] + public void Bot_TurnEndFinal_pushes_Judge_to_sender_only() + { + var (s, a, _) = NewBotSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); + + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal)); + + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge)); + Assert.That(routes[0].Target, Is.SameAs(a)); + } + + [Test] + public void Bot_PlayActions_drops_no_recipient() + { + var (s, a, _) = NewBotSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); + + // Bot's PlayActions falls through the default arm — the Pvp forwarder is gated + // on Type == Pvp, so Bot's gameplay frames have no routing rule and drop. + // (The PvP semantics would have been "forward to NoOp which swallows" — same + // observable result, but cleaner to leave them as default-drops.) + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions)); + + Assert.That(routes, Is.Empty); + } + + [Test] + public void Bot_Retire_pushes_BattleFinish_Win_to_sender() + { + var (s, a, _) = NewBotSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); + + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire)); + + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(a)); + Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); + } + private static (BattleSession, FakeRealParticipant, FakeRealParticipant) NewPvpSession() { var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx());