diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index c8630fc..81f1b15 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -1,7 +1,5 @@ using Microsoft.Extensions.Logging; -using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; -using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Sessions.Dispatch; using SVSim.BattleNode.Sessions.Dispatch.Handlers; using SVSim.BattleNode.Sessions.Participants; @@ -31,8 +29,8 @@ public sealed class BattleSession public IBattleParticipant B { get; } public BattleSessionPhase Phase => _state.SessionPhase; - // Per-URI handlers consulted before the legacy switch. Populated incrementally (Tasks 5-14); - // each registered URI is served by its handler and its legacy switch arm goes dead. + // Per-URI dispatch table. All 14 inbound URIs are registered (Tasks 5-14); unknown + // URIs are dropped with a LogDebug in ComputeFrames. private static readonly IReadOnlyDictionary Handlers = BuildHandlers(); private static IReadOnlyDictionary BuildHandlers() @@ -164,234 +162,14 @@ public sealed class BattleSession /// . Extracted so unit tests can drive the dispatch without /// standing up real participants. /// - internal IReadOnlyList ComputeFrames( - IBattleParticipant from, MsgEnvelope env) + internal IReadOnlyList ComputeFrames(IBattleParticipant from, MsgEnvelope env) { if (Handlers.TryGetValue(env.Uri, out var handler)) return handler.Handle(BuildContext(from, env)); - // --- legacy switch (shrinking; deleted in the final task) --- - var result = new List(); - var other = ReferenceEquals(from, A) ? B : A; - var phaseFrom = from as IHasHandshakePhase; - - // The dispatch table only covers the Scripted-mode behaviour Phase 1 needs; - // Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches. Handshake-phase - // arms read the SENDER's Phase (per-participant); the session-level Phase - // remains only for the Terminal short-circuit. - switch (env.Uri) - { - case NetworkBattleUri.InitNetwork when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitNetwork: - result.Add(new DispatchRoute(from, BattleFrames.BuildAck(NetworkBattleUri.InitNetwork), true)); - 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. - // - // Critically, do NOT push Matched or BattleStart for Bot mode. The - // architecture spec was right about this: - // 1. The client's MatchingInitBattle (Matching.cs:298) immediately calls - // StartBattleLoad + GotoBattle on the IsAINetwork branch right after - // emitting InitBattle — it does NOT wait for a wire Matched or - // BattleStart envelope. The state-machine trigger is _initNetworkSuccess - // (set when InitNetwork uri is received, i.e., our ack). - // 2. Sending Matched is harmless (gated on status == Connect, which is - // already past by the time the wire round-trip completes). - // 3. Sending BattleStart is ACTIVELY HARMFUL: its handler at - // Matching.cs:417 runs unconditionally and SetNetworkInfo - // (RealTimeNetworkAgent.cs:1553-1564) overwrites OppoBattleStartInfo - // with the wire envelope's oppoInfo. Our oppoInfo comes from - // NoOpBotParticipant.Context placeholders (classId:0, emblemId:0, - // etc.), corrupting the good values the client just set from the - // HTTP /ai__rank_battle/start response — subsequent asset - // loads (LoadOpponentAssets at SBattleLoad.cs:933) then look up - // non-existent assets and silently hang on "Waiting for opponent." - - case NetworkBattleUri.InitBattle - when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle: - // Ack only — NO Matched push. - result.Add(new DispatchRoute(from, BattleFrames.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. The client's AINetworkBattleManager - // populates opponent state from AIBattleStart HTTP data; pushing - // BattleStart here overwrites that state with zeros. - 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(new DispatchRoute(from, BattleFrames.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 - // bot's Context fixture preserves the prod-captured cosmetics that previously - // lived in ScriptedProfiles). - result.Add(new DispatchRoute(from, ScriptedLifecycle.BuildMatched( - from.Context, other.Context, - from.ViewerId, other.ViewerId, - BattleId, ScriptedProfiles.BattleSeed), false)); - phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded; - break; - - case NetworkBattleUri.Loaded when phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded: - { - // Exactly one side goes first. A goes first deterministically: in Scripted that's - // the real player (constructed as A); in PvP that's the first arriver. No Type - // check — the rule is correct in both modes, and Bot/AINetwork never reaches this - // arm (its silent Loaded arm above wins the match). A per-battle coin-flip is a - // follow-up (see plan § Out of scope). - var turnState = ReferenceEquals(from, A) ? 0 : 1; - result.Add(new DispatchRoute(from, ScriptedLifecycle.BuildBattleStart( - from.Context, other.Context, from.ViewerId, turnState), false)); - result.Add(new DispatchRoute(from, ScriptedLifecycle.BuildDeal(), false)); - phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap; - break; - } - - case NetworkBattleUri.Swap when phaseFrom?.Phase == BattleSessionPhase.AwaitingSwap: - { - var hand = ScriptedLifecycle.ComputeHandAfterSwap(BattleFrames.ExtractIdxList(env)); - // SwapResponse is always immediate — it completes the sender's own mulligan UI. - result.Add(new DispatchRoute(from, ScriptedLifecycle.BuildSwapResponse(hand), false)); - _state.PostSwapHands[from] = hand; - phaseFrom!.Phase = BattleSessionPhase.AfterReady; - - // Release Ready to every swapper once all handshake-driving participants have - // swapped. IHasHandshakePhase membership IS the "participates in mulligan" set: - // PvP → {A, B} (both reals) → waits for both - // Scripted → {player, bot} (bot now emits Swap) → waits for both - // Bot/AINet → {real} only (NoOp isn't a phase impl)→ releases on the one Swap - var swappers = new[] { A, B }.Where(p => p is IHasHandshakePhase).ToList(); - if (swappers.All(_state.PostSwapHands.ContainsKey)) - { - foreach (var p in swappers) - { - var opponent = ReferenceEquals(p, A) ? B : A; - var ready = opponent is IHasHandshakePhase && _state.PostSwapHands.TryGetValue(opponent, out var oppoHand) - ? ScriptedLifecycle.BuildReady(_state.PostSwapHands[p], oppoHand) // both hands known - : ScriptedLifecycle.BuildReady(_state.PostSwapHands[p]); // non-interactive opponent - result.Add(new DispatchRoute(p, ready, false)); - } - } - break; - } - - // Regular TurnEnd: continues the game. Scripted forwards to bot for the 3-frame - // burst; PvP broadcasts; Bot stays silent. - case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady: - if (Type == BattleType.Pvp && BothAfterReady()) - { - var turnEndBroadcast = BattleFrames.BuildTurnEndBroadcast(); - var judgeBroadcast = BattleFrames.BuildJudgeBroadcast(); - result.Add(new DispatchRoute(from, turnEndBroadcast, false)); - result.Add(new DispatchRoute(other, turnEndBroadcast, false)); - result.Add(new DispatchRoute(from, judgeBroadcast, false)); - result.Add(new DispatchRoute(other, judgeBroadcast, false)); - } - else if (Type == BattleType.Scripted) - { - result.Add(new DispatchRoute(other, env, false)); - } - // Bot type: no-op (NoOpBot swallows; client handles its own turn end). - break; - - // TurnEndFinal: client signals the player's FINAL turn is over (game-end - // condition met, usually killed opponent's leader). Unified across types: - // forward the envelope to other (matches prod TK2 capture - // battle-traffic_tk2_regular.ndjson:273 — loser-side receives TurnEndFinal - // from server before BattleFinish), then push BattleFinish per-side with - // player-perspective codes (LifeWin to winner, LifeLose to loser). - // ScriptedBotParticipant no longer reacts to TurnEndFinal (only TurnEnd) — - // this dispatch arm owns it. NoOpBotParticipant swallows. Phase → Terminal - // so the RunAsync cascade doesn't synthesize a follow-up BattleFinish. - case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady: - result.Add(new DispatchRoute(other, env, false)); - result.Add(new DispatchRoute(from, BattleFrames.BuildBattleFinish(BattleResult.LifeWin), true)); - result.Add(new DispatchRoute(other, BattleFrames.BuildBattleFinish(BattleResult.LifeLose), true)); - _state.SessionPhase = BattleSessionPhase.Terminal; - break; - - // Retire / Kill: sender concedes (Retire) or the client requested an immediate - // terminate (Kill). Unified across types: push BattleFinish per-side with the - // proper retire codes. Bots swallow their push (no real-opponent state). - case NetworkBattleUri.Retire: - case NetworkBattleUri.Kill: - result.Add(new DispatchRoute(from, BattleFrames.BuildBattleFinish(BattleResult.RetireLose), true)); - result.Add(new DispatchRoute(other, BattleFrames.BuildBattleFinish(BattleResult.RetireWin), true)); - _state.SessionPhase = BattleSessionPhase.Terminal; - break; - - // Frames emitted by the scripted bot (TurnStart / TurnEnd / Judge) — forward - // to the real participant. These match the v1.2 burst's three outbound pushes. - // Pre-migration this arm only handled TurnStart/Judge because the handshake - // TurnEnd arm above (gated on session-level Phase) also caught the bot's TurnEnd. - // Post-migration that arm gates on the sender's per-participant Phase, which the - // bot doesn't have, so the bot's TurnEnd now lands here. - // The `IsRealForwardableFromScripted` guard ensures this arm matches ONLY the - // scripted bot's emissions (sender ViewerId == FakeOpponentViewerId) — without - // it, a TurnStart/TurnEnd/Judge from a real participant in PvP mode would match - // here and `goto default` would skip the PvP forwarder arm below. - case NetworkBattleUri.TurnStart when IsRealForwardableFromScripted(from, env): - case NetworkBattleUri.TurnEnd when IsRealForwardableFromScripted(from, env): - case NetworkBattleUri.Judge when IsRealForwardableFromScripted(from, env): - // Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart, - // TurnEnd, and Judge are intended for the real participant. - result.Add(new DispatchRoute(other, env, false)); - break; - - // Gameplay-frame forwarding (post-AfterReady). Unified across types: - // BothAfterReady() is only true when both participants are RealParticipants - // (ScriptedBot/NoOpBot don't implement IHasHandshakePhase so their Phase is - // always null), so this arm naturally fires for PvP only. Order matters: - // this MUST come after the FakeOpponentViewerId arms so Scripted bot - // emissions don't fall into this forwarder. - case NetworkBattleUri.TurnStart when BothAfterReady(): - case NetworkBattleUri.PlayActions when BothAfterReady(): - case NetworkBattleUri.Echo when BothAfterReady(): - case NetworkBattleUri.TurnEndActions when BothAfterReady(): - case NetworkBattleUri.JudgeResult when BothAfterReady(): - result.Add(new DispatchRoute(other, env, false)); - break; - - default: - _log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}", - BattleId, env.Uri, Phase, from.ViewerId); - break; - } - - return result; + _log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}", + BattleId, env.Uri, Phase, from.ViewerId); + return Array.Empty(); } - // Phase 1: the only "scripted-bot" emissions we need to forward are the three burst - // frames (TurnStart, TurnEnd, Judge) — and TurnEnd is already handled in the switch - // above as a forwardable bot emission. This helper exists so the TurnStart/Judge cases - // above only fire when the source is actually a participant (not malformed inbound). - private static bool IsRealForwardableFromScripted(IBattleParticipant from, MsgEnvelope env) - { - // The bot's emitted frames carry ViewerId == FakeOpponentViewerId. - return from.ViewerId == ScriptedLifecycle.FakeOpponentViewerId; - } - - // Phase 2: PvP gameplay-frame forwarding is gated on BOTH sides having completed - // the handshake (i.e. reached AfterReady). Until then, an early TurnStart/PlayActions - // from one side has no valid recipient. - private bool BothAfterReady() => - (A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady && - (B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady; - }