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; namespace SVSim.BattleNode.Sessions; /// /// v2 broker session. Holds two participants and brokers between them. Subscribes /// to each participant's ; on each frame, /// runs to determine the routing (target + frame + noStock /// flag) and dispatches via . /// /// /// Phase 1 wires this for only — the dispatch logic /// preserves v1.2 behaviour. Phase 2 wires Pvp (broadcast Matched/BattleStart per-perspective, /// forward gameplay frames between participants). Phase 3 wires Bot (ack-only). /// public sealed class BattleSession { private readonly ILogger _log; private readonly BattleSessionState _state = new(); public string BattleId { get; } public BattleType Type { get; } public IBattleParticipant A { get; } 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. private static readonly IReadOnlyDictionary Handlers = new Dictionary { [NetworkBattleUri.InitNetwork] = new InitNetworkHandler(), [NetworkBattleUri.InitBattle] = new InitBattleHandler(), [NetworkBattleUri.Loaded] = new LoadedHandler(), }; private FrameDispatchContext BuildContext(IBattleParticipant from, MsgEnvelope env) => new() { A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A, Env = env, Type = Type, BattleId = BattleId, State = _state, }; public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b, ILogger log) { BattleId = battleId; Type = type; A = a; B = b; _log = log; // Subscribe to both participants' emissions. A.FrameEmitted += OnFrameFromA; B.FrameEmitted += OnFrameFromB; } public async Task RunAsync(CancellationToken cancellation) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation); var aTask = A.RunAsync(cts.Token); var bTask = B.RunAsync(cts.Token); if (Type == BattleType.Pvp) { // WhenAny: first WS drop / first graceful close triggers cascade. // ScriptedBotParticipant.RunAsync also returns immediately; that's not used // here (Pvp has two RealParticipants), but we'd still want a synthesized // BattleFinish for the survivor if either side terminates first. var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false); var survivor = first == aTask ? B : A; if (Phase != BattleSessionPhase.Terminal) { // Involuntary drop (no graceful Retire): synthesize BattleFinish(DisconnectWin) // to survivor. DisconnectWin=201 → client renders "opponent disconnected" → // WIN UI; the legacy Win=1 used here previously rendered "no contest". try { await survivor.PushAsync( BattleFrames.BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation) .ConfigureAwait(false); } catch (Exception ex) { _log.LogWarning(ex, "BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)", BattleId); } _state.SessionPhase = BattleSessionPhase.Terminal; } cts.Cancel(); // unblock the survivor's RunAsync read loop try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); } catch { /* swallow cancellation / WS exceptions */ } } else { // Phase 1 semantics for Scripted/Bot: wait for ALL participants. The bot's // RunAsync returns immediately; the session keeps running for the real one. try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); } catch { /* swallow */ } } // Audit Md11 — release per-participant outbound archives at battle-end // (only RealParticipant has one; bots don't archive). Heavy state is // dropped synchronously here so the participant's TerminateAsync doesn't // need to keep the dict alive through its disposal handshake. if (A is RealParticipant rpA) rpA.Outbound.Clear(); if (B is RealParticipant rpB) rpB.Outbound.Clear(); await Task.WhenAll( A.TerminateAsync(BattleFinishReason.NormalFinish), B.TerminateAsync(BattleFinishReason.NormalFinish)) .ConfigureAwait(false); } private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct); private Task OnFrameFromB(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(B, env, ct); private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct) { try { var routes = ComputeFrames(from, env); foreach (var (target, frame, noStock) in routes) { await target.PushAsync(frame, noStock, ct); } } catch (Exception ex) { _log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId); } } /// /// Pure-logic dispatch: given an inbound frame from one participant, return the list /// of (target, frame, noStock) tuples the session should dispatch. Transitions /// . Extracted so unit tests can drive the dispatch without /// standing up real participants. /// 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; } // 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; }