using Microsoft.Extensions.Logging; using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol.Bodies; 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; public string BattleId { get; } public BattleType Type { get; } public IBattleParticipant A { get; } public IBattleParticipant B { get; } public BattleSessionPhase Phase { get; private set; } = BattleSessionPhase.AwaitingInitNetwork; 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) { // Run both participants' inbound loops in parallel and wait for them all to // complete. NoOp/Scripted bots return immediately; Real returns when the WS // closes. Using WhenAny here would have killed the session as soon as the // scripted bot's no-op RunAsync resolved. Phase 2's Pvp/Bot cases will need // disconnect propagation; that's wired in their own task. var aTask = A.RunAsync(cancellation); var bTask = B.RunAsync(cancellation); try { await Task.WhenAll(aTask, bTask); } catch { /* swallow cancellation */ } await Task.WhenAll( A.TerminateAsync(BattleFinishReason.NormalFinish), B.TerminateAsync(BattleFinishReason.NormalFinish)); } 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<(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock)> ComputeFrames( IBattleParticipant from, MsgEnvelope env) { var result = new List<(IBattleParticipant, MsgEnvelope, bool)>(); 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((from, BuildAck(NetworkBattleUri.InitNetwork), true)); phaseFrom!.Phase = BattleSessionPhase.AwaitingInitBattle; 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((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: result.Add((from, ScriptedLifecycle.BuildBattleStart( from.Context, other.Context, from.ViewerId), false)); result.Add((from, ScriptedLifecycle.BuildDeal(), false)); phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap; break; case NetworkBattleUri.Swap when phaseFrom?.Phase == BattleSessionPhase.AwaitingSwap: { var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env)); result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false)); result.Add((from, ScriptedLifecycle.BuildReady(hand), false)); phaseFrom!.Phase = BattleSessionPhase.AfterReady; break; } case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady: case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady: // Phase 1: forward the player's TurnEnd to the scripted bot. The bot's // PushAsync fires its three-frame burst via FrameEmitted; each emitted // frame loops back through HandleFrameAsync → ComputeFrames → routes to // the real participant. Net wire effect: same three pushes as v1.2. result.Add((other, env, false)); break; case NetworkBattleUri.Retire: case NetworkBattleUri.Kill: result.Add((from, BuildBattleFinishNoContest(), true)); Phase = 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. case NetworkBattleUri.TurnStart when ReferenceEquals(from, B) || ReferenceEquals(from, A): case NetworkBattleUri.TurnEnd when ReferenceEquals(from, B) || ReferenceEquals(from, A): case NetworkBattleUri.Judge when ReferenceEquals(from, B) || ReferenceEquals(from, A): // Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart, // TurnEnd, and Judge are intended for the real participant. if (!IsRealForwardableFromScripted(from, env)) goto default; result.Add((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; } private MsgEnvelope BuildAck(NetworkBattleUri uri) => new( uri, ViewerId: ScriptedLifecycle.FakeOpponentViewerId, Uuid: WireConstants.ServerUuid, Bid: null, Try: 0, Cat: EmitCategory.General, PubSeq: null, PlaySeq: null, Body: new ResultCodeOnlyBody()); private MsgEnvelope BuildBattleFinishNoContest() => new( NetworkBattleUri.BattleFinish, ViewerId: ScriptedLifecycle.FakeOpponentViewerId, Uuid: WireConstants.ServerUuid, Bid: null, Try: 0, Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new BattleFinishBody(Result: BattleResult.Win)); private static IReadOnlyList ExtractIdxList(MsgEnvelope env) { if (env.Body is not RawBody rawBody) return Array.Empty(); if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string) { var result = new List(); foreach (var item in seq) { switch (item) { case long l: result.Add(l); break; case int i: result.Add(i); break; case double d: result.Add((long)d); break; case decimal m: result.Add((long)m); break; case string s when long.TryParse(s, out var p): result.Add(p); break; } } return result; } return Array.Empty(); } }