diff --git a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
new file mode 100644
index 0000000..b93f9ac
--- /dev/null
+++ b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+
+namespace SVSim.BattleNode.Protocol.Bodies;
+
+/// Opponent-facing PlayActions frame the node synthesizes from the active player's
+/// send. KnownList reveals the played card's identity (null = token reveal deferred, see
+/// the deterministic-turn slice). OppoTargetList is the renamed targetList
+/// (independent of KnownList — a targeted hand play carries both). Both omitted when null via the
+/// envelope's WhenWritingNull policy.
+public sealed record PlayActionsBroadcastBody(
+ [property: JsonPropertyName("playIdx")] int PlayIdx,
+ [property: JsonPropertyName("type")] int Type,
+ [property: JsonPropertyName("knownList")] IReadOnlyList? KnownList,
+ [property: JsonPropertyName("oppoTargetList")] IReadOnlyList? OppoTargetList) : IMsgBody;
+
+/// One revealed card in a knownList. Vanilla slice fills cardId from the sender's
+/// deck map and leaves spellboost 0 / attachTarget "" (cost/clan/tribe deferred to the card-master
+/// port — the receiver re-derives them from cardId).
+public sealed record KnownCardEntry(
+ [property: JsonPropertyName("idx")] int Idx,
+ [property: JsonPropertyName("cardId")] long CardId,
+ [property: JsonPropertyName("to")] int To,
+ [property: JsonPropertyName("spellboost")] int Spellboost,
+ [property: JsonPropertyName("attachTarget")] string AttachTarget);
+
+/// Renamed targetList entry. isSelf is actor-relative and passes through
+/// verbatim — no perspective flip (bullet-3 audit F2).
+public sealed record OppoTargetEntry(
+ [property: JsonPropertyName("targetIdx")] int TargetIdx,
+ [property: JsonPropertyName("isSelf")] int IsSelf);
diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs
index 81f1b15..6a04b60 100644
--- a/SVSim.BattleNode/Sessions/BattleSession.cs
+++ b/SVSim.BattleNode/Sessions/BattleSession.cs
@@ -49,9 +49,9 @@ public sealed class BattleSession
[NetworkBattleUri.Kill] = retireKill,
[NetworkBattleUri.TurnStart] = new TurnStartHandler(),
[NetworkBattleUri.Judge] = new JudgeHandler(),
- [NetworkBattleUri.PlayActions] = forwardWhenReady,
- [NetworkBattleUri.Echo] = forwardWhenReady,
- [NetworkBattleUri.TurnEndActions] = forwardWhenReady,
+ [NetworkBattleUri.PlayActions] = new PlayActionsHandler(),
+ [NetworkBattleUri.Echo] = new EchoHandler(),
+ [NetworkBattleUri.TurnEndActions] = new TurnEndActionsHandler(),
[NetworkBattleUri.JudgeResult] = forwardWhenReady,
};
}
diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
index 3a53df5..b4873b8 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
@@ -1,11 +1,31 @@
+using SVSim.BattleNode.Sessions;
+
namespace SVSim.BattleNode.Sessions.Dispatch;
-/// Mutable per-session state shared across frame handlers. Today: the session-level
-/// phase (only ever advanced to ) and the mulligan
-/// barrier's post-swap hands. FUTURE (PvP equivalency, NOT this refactor): per-side idx->cardId
-/// maps + reveal-gating state land here.
+/// Mutable per-session state shared across frame handlers. The mulligan barrier's
+/// post-swap hands, plus (PvP-equivalency, vanilla slice) the per-side idx->cardId map used to
+/// synthesize the opponent-facing knownList. FUTURE: a token map (cardIds mined from
+/// orderList add ops, idx>30) + a reveal-gate set land alongside .
internal sealed class BattleSessionState
{
public BattleSessionPhase SessionPhase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
public Dictionary PostSwapHands { get; } = new();
+
+ /// Per-side idx->cardId, seeded lazily from .
+ /// Deck cards only (idx 1..deckCount); tokens (idx>deckCount) are deferred.
+ public Dictionary> IdxToCardId { get; } = new();
+
+ /// The sender's idx->cardId map, seeding it from its on first
+ /// use. BuildPlayerDeck assigns deck idx = position+1, so entry (i+1) -> cardIds[i].
+ public IReadOnlyDictionary GetOrSeedDeckMap(IBattleParticipant side)
+ {
+ if (!IdxToCardId.TryGetValue(side, out var map))
+ {
+ map = new Dictionary();
+ var deck = side.Context.SelfDeckCardIds;
+ for (var i = 0; i < deck.Count; i++) map[i + 1] = deck[i];
+ IdxToCardId[side] = map;
+ }
+ return map;
+ }
}
diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs
new file mode 100644
index 0000000..27dfbb5
--- /dev/null
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs
@@ -0,0 +1,8 @@
+namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
+
+/// Echo is the receiver's per-frame ack; the client has no inbound Echo handler, so the
+/// node consumes it (bullet-2 audit). Relaying would risk an echo->echo storm.
+internal sealed class EchoHandler : IFrameHandler
+{
+ public IReadOnlyList Handle(FrameDispatchContext ctx) => Array.Empty();
+}
diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs
index 4ba8146..563d96b 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs
@@ -1,4 +1,5 @@
using SVSim.BattleNode.Protocol;
+using SVSim.BattleNode.Protocol.Bodies;
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
@@ -6,9 +7,23 @@ internal sealed class JudgeHandler : IFrameHandler
{
public IReadOnlyList Handle(FrameDispatchContext ctx)
{
- // Only a scripted-bot Judge is forwarded. A real player's Judge has no routing rule (drops).
+ // Scripted-bot Judge (test stub): forward verbatim (carries the {spin} shape already).
if (ctx.IsScriptedBot(ctx.From))
return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) };
+
+ // PvP: Judge is the handover gate. The player who sends Judge is the one TAKING OVER the
+ // turn (the client rule is: receive opponent TurnEnd -> SendJudge). Receiving Judge{spin}
+ // fires ControlTurnStartPlayer ("start MY turn"), so the {spin} must REFLECT BACK to the
+ // sender — NOT go to the opponent (that would make the player who just ended their turn
+ // start another one, stalling the loop; confirmed by the 2026-06-03 two-client capture).
+ // The sender then emits TurnStart, which TurnStartHandler relays to the opponent as {spin}.
+ // battleCode is dropped; spin=0 for the deterministic-turn slice.
+ if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
+ {
+ var frame = ctx.Env with { Body = new JudgeBody(Spin: 0) };
+ return new[] { new DispatchRoute(ctx.From, frame, false) };
+ }
+
return Array.Empty();
}
}
diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
new file mode 100644
index 0000000..6d76df4
--- /dev/null
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
@@ -0,0 +1,34 @@
+using SVSim.BattleNode.Protocol;
+using SVSim.BattleNode.Protocol.Bodies;
+
+namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
+
+/// PvP PlayActions translator (vanilla deck-card slice). Synthesizes the opponent-facing
+/// knownList from the sender's idx->cardId map + the orderList move op, renames targetList ->
+/// oppoTargetList, drops orderList, consumes keyAction. Token plays (idx>deck) degrade silently to
+/// {playIdx,type} (no knownList). Scripted/Bot drop (no rule).
+internal sealed class PlayActionsHandler : IFrameHandler
+{
+ public IReadOnlyList Handle(FrameDispatchContext ctx)
+ {
+ if (ctx.Type != BattleType.Pvp || !ctx.BothAfterReady())
+ return Array.Empty();
+
+ var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary();
+ var playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("playIdx"));
+ var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("type"));
+
+ var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
+ var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, entries.GetValueOrDefault("orderList"));
+ var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList"));
+
+ var body = new PlayActionsBroadcastBody(
+ PlayIdx: playIdx,
+ Type: type,
+ KnownList: played is null ? null : new[] { played },
+ OppoTargetList: oppoTargets);
+
+ var frame = ctx.Env with { Body = body };
+ return new[] { new DispatchRoute(ctx.Other, frame, false) };
+ }
+}
diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs
new file mode 100644
index 0000000..96d95c1
--- /dev/null
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs
@@ -0,0 +1,19 @@
+using SVSim.BattleNode.Protocol;
+
+namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
+
+/// PvP TurnEndActions: the sender's orderList is dropped; the opponent receives an
+/// empty body (it only flips _sendEcho + runs the opponent's end-of-turn triggers via the
+/// opponent's own engine). Scripted/Bot drop.
+internal sealed class TurnEndActionsHandler : IFrameHandler
+{
+ public IReadOnlyList Handle(FrameDispatchContext ctx)
+ {
+ if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
+ {
+ var frame = ctx.Env with { Body = new RawBody(new Dictionary()) };
+ return new[] { new DispatchRoute(ctx.Other, frame, false) };
+ }
+ return Array.Empty();
+ }
+}
diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs
index 9f29501..69fb6f0 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs
@@ -1,4 +1,5 @@
using SVSim.BattleNode.Protocol;
+using SVSim.BattleNode.Protocol.Bodies;
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
@@ -16,15 +17,11 @@ internal sealed class TurnEndHandler : IFrameHandler
{
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
{
- var te = BattleFrames.BuildTurnEndBroadcast();
- var jg = BattleFrames.BuildJudgeBroadcast();
- return new[]
- {
- new DispatchRoute(ctx.From, te, false),
- new DispatchRoute(ctx.Other, te, false),
- new DispatchRoute(ctx.From, jg, false),
- new DispatchRoute(ctx.Other, jg, false),
- };
+ // Opponent sees {turnState}; receiving TurnEnd drives ITS SendJudge (handover gate):
+ // the opponent (the turn taker-over) then sends a Judge, which JudgeHandler reflects
+ // back to it to start its turn. battleCode/actionSeq/cemetery are dropped.
+ var te = ctx.Env with { Body = new TurnEndBody(TurnState: 0) };
+ return new[] { new DispatchRoute(ctx.Other, te, false) };
}
if (ctx.Type == BattleType.Scripted)
return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) };
diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs
index ff7e102..348cdcf 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs
@@ -1,4 +1,5 @@
using SVSim.BattleNode.Protocol;
+using SVSim.BattleNode.Protocol.Bodies;
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
@@ -6,10 +7,18 @@ internal sealed class TurnStartHandler : IFrameHandler
{
public IReadOnlyList Handle(FrameDispatchContext ctx)
{
- // Forward the opponent's turn-open to the other side. Union of the two legacy arms:
- // BothAfterReady (PvP / scripted real player) OR a scripted-bot emission (test stub path).
- if (ctx.BothAfterReady() || ctx.IsScriptedBot(ctx.From))
+ // PvP: the active player's TurnStart{orderList} is dropped; the opponent receives {spin}
+ // (spin=0 for the deterministic-turn slice) and self-generates its turn-open.
+ if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
+ {
+ var frame = ctx.Env with { Body = new OpponentTurnStartBody(Spin: 0) };
+ return new[] { new DispatchRoute(ctx.Other, frame, false) };
+ }
+
+ // Scripted-bot emission (test stub path): the bot already emits the {spin} shape — forward.
+ if (ctx.IsScriptedBot(ctx.From))
return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) };
+
return Array.Empty();
}
}
diff --git a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
new file mode 100644
index 0000000..c0e3084
--- /dev/null
+++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
@@ -0,0 +1,74 @@
+using SVSim.BattleNode.Protocol.Bodies;
+
+namespace SVSim.BattleNode.Sessions.Dispatch;
+
+/// Pure transforms from the active player's RawBody sub-structures to the opponent-facing
+/// shapes. No session state, no wire I/O — unit-testable in isolation. RawBody nested values arrive
+/// as Dictionary<string,object?> / List<object?> with numeric leaves boxed
+/// as long/int/double (see MsgEnvelope.FromJson).
+internal static class KnownListBuilder
+{
+ /// The played card's knownList entry, or null when its identity can't be synthesized
+ /// (token idx not in the deck map, or no matching move op). spellboost/attachTarget default to
+ /// 0/"" for the vanilla slice; cost/clan/tribe are deferred (receiver re-derives from cardId).
+ public static KnownCardEntry? BuildPlayedCard(
+ IReadOnlyDictionary deckMap, int playIdx, object? orderList)
+ {
+ if (!deckMap.TryGetValue(playIdx, out var cardId)) return null;
+ var to = ExtractMoveTo(orderList, playIdx);
+ if (to is null) return null;
+ return new KnownCardEntry(Idx: playIdx, CardId: cardId, To: to.Value, Spellboost: 0, AttachTarget: "");
+ }
+
+ /// The to place-state of the FIRST move op whose idx list contains
+ /// (the played card's own move; later add/alter ops are the deferred
+ /// token slice), or null if absent. NOTE: the sender-side to is passed through verbatim —
+ /// for the vanilla slice we assume send-side and recv-side place-state codes match, pending
+ /// recv-capture confirmation.
+ public static int? ExtractMoveTo(object? orderList, int playIdx)
+ {
+ if (orderList is not IEnumerable