diff --git a/SVSim.BattleNode/Sessions/BattleSessionV2.cs b/SVSim.BattleNode/Sessions/BattleSessionV2.cs new file mode 100644 index 0000000..034ba63 --- /dev/null +++ b/SVSim.BattleNode/Sessions/BattleSessionV2.cs @@ -0,0 +1,210 @@ +using Microsoft.Extensions.Logging; +using SVSim.BattleNode.Lifecycle; +using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Protocol.Bodies; + +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 BattleSessionV2 +{ + 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 BattleSessionV2(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. First to complete cancels + // the session via the outer cancellation token. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + var aTask = A.RunAsync(cts.Token); + var bTask = B.RunAsync(cts.Token); + await Task.WhenAny(aTask, bTask); + cts.Cancel(); + 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, "BattleSessionV2 {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; + + // The dispatch table only covers the Scripted-mode behaviour Phase 1 needs; + // Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches. + switch (env.Uri) + { + case NetworkBattleUri.InitNetwork when Phase == BattleSessionPhase.AwaitingInitNetwork: + result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true)); + Phase = BattleSessionPhase.AwaitingInitBattle; + break; + + case NetworkBattleUri.InitBattle when Phase == BattleSessionPhase.AwaitingInitBattle: + // Phase 1: push Matched only to the "real" participant. The session reads + // selfInfo from from.Context; opponent half currently comes from + // ScriptedProfiles inside ScriptedLifecycle.BuildMatched (Phase 2 generalises + // to use other.Context for per-perspective Matched). + result.Add((from, ScriptedLifecycle.BuildMatched(from.Context, from.ViewerId, other.ViewerId, BattleId), false)); + Phase = BattleSessionPhase.AwaitingLoaded; + break; + + case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded: + result.Add((from, ScriptedLifecycle.BuildBattleStart(from.Context, from.ViewerId), false)); + result.Add((from, ScriptedLifecycle.BuildDeal(), false)); + Phase = BattleSessionPhase.AwaitingSwap; + break; + + case NetworkBattleUri.Swap when Phase == BattleSessionPhase.AwaitingSwap: + { + var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env)); + result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false)); + result.Add((from, ScriptedLifecycle.BuildReady(hand), false)); + Phase = BattleSessionPhase.AfterReady; + break; + } + + case NetworkBattleUri.TurnEnd when Phase == BattleSessionPhase.AfterReady: + case NetworkBattleUri.TurnEndFinal when 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. + case NetworkBattleUri.TurnStart 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 + // and Judge are intended for the real participant; TurnEnd handled above. + if (!IsRealForwardableFromScripted(from, env)) goto default; + result.Add((other, env, false)); + break; + + default: + _log.LogDebug("BattleSessionV2 {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(); + } +}