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();
+ }
+}