using System.Net.WebSockets; using Microsoft.Extensions.Logging; using SVSim.BattleNode.Protocol; 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 + /// flag) and dispatches via . /// /// /// Wires both battle modes: Pvp (broadcast Matched/BattleStart per-perspective, forward /// gameplay frames between the two real participants) and Bot (ack-only, NoOp opponent). /// public sealed class BattleSession { private readonly ILogger _log; private readonly BattleSessionState _state = new(); /// Serializes dispatch. Both participants' read loops raise FrameEmitted on their own /// threads, and a dispatch ( + the relay PushAsync calls) mutates /// shared, non-thread-safe state — the dictionaries and each /// participant's OutboundSequencer. This gate funnels both threads through one critical /// section so concurrent frames can't corrupt that state. private readonly SemaphoreSlim _dispatchGate = new(1, 1); /// The per-battle master seed (see ). /// Exposed for logging + future replay persistence. public int MasterSeed => _state.MasterSeed; public string BattleId { get; } public BattleType Type { get; } public IBattleParticipant A { get; } public IBattleParticipant B { get; } public SessionLifecycle Lifecycle => _state.Lifecycle; // 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() { var retireKill = new RetireKillHandler(); var forwardWhenReady = new ForwardWhenBothReadyHandler(); return new Dictionary { [NetworkBattleUri.InitNetwork] = new InitNetworkHandler(), [NetworkBattleUri.InitBattle] = new InitBattleHandler(), [NetworkBattleUri.Loaded] = new LoadedHandler(), [NetworkBattleUri.Swap] = new SwapHandler(), [NetworkBattleUri.TurnEnd] = new TurnEndHandler(), [NetworkBattleUri.TurnEndFinal] = new TurnEndFinalHandler(), [NetworkBattleUri.Retire] = retireKill, [NetworkBattleUri.Kill] = retireKill, [NetworkBattleUri.TurnStart] = new TurnStartHandler(), [NetworkBattleUri.Judge] = new JudgeHandler(), [NetworkBattleUri.PlayActions] = new PlayActionsHandler(), [NetworkBattleUri.Echo] = new EchoHandler(), [NetworkBattleUri.TurnEndActions] = new TurnEndActionsHandler(), [NetworkBattleUri.JudgeResult] = forwardWhenReady, }; } private FrameDispatchContext BuildContext(IBattleParticipant from, MsgEnvelope env) => new() { A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A, Env = env, 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; _log.LogInformation("BattleSession {Bid}: master seed {Seed}", BattleId, _state.MasterSeed); // 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. Pvp has two // RealParticipants; we synthesize a 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 (Lifecycle != SessionLifecycle.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), Stock.Bypass, 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.Lifecycle = SessionLifecycle.Terminal; } cts.Cancel(); // unblock the survivor's RunAsync read loop try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); } catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) { } catch (AggregateException ex) when (ex.Flatten().InnerExceptions.All( e => e is OperationCanceledException or WebSocketException)) { } catch (Exception ex) { _log.LogWarning(ex, "BattleSession {Bid}: unexpected exception from WhenAll (PvP drain)", BattleId); } } else { // Bot mode: the NoOp opponent's RunAsync returns immediately; wait for the real // participant. The session keeps running for the real one. try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); } catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) { } catch (AggregateException ex) when (ex.Flatten().InnerExceptions.All( e => e is OperationCanceledException or WebSocketException)) { } catch (Exception ex) { _log.LogWarning(ex, "BattleSession {Bid}: unexpected exception from WhenAll (Bot drain)", BattleId); } } // Unsubscribe event handlers so the session + state aren't pinned by live delegates. A.FrameEmitted -= OnFrameFromA; B.FrameEmitted -= OnFrameFromB; // Release per-participant outbound archives at battle-end // (only RealParticipant has one; bots don't archive). 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); await A.DisposeAsync().ConfigureAwait(false); await B.DisposeAsync().ConfigureAwait(false); _dispatchGate.Dispose(); } 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) { await _dispatchGate.WaitAsync(ct).ConfigureAwait(false); try { var routes = ComputeFrames(from, env); foreach (var (target, frame, stock) in routes) { await target.PushAsync(frame, stock, ct); } } catch (Exception ex) { _log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId); } finally { _dispatchGate.Release(); } } /// /// Pure-logic dispatch: given an inbound frame from one participant, return the list /// of (target, frame, stock) routes 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)); _log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in lifecycle={Lifecycle} from vid={Vid}", BattleId, env.Uri, Lifecycle, from.ViewerId); return Array.Empty(); } }