diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs
index 81f1b15..e6d209b 100644
--- a/SVSim.BattleNode/Sessions/BattleSession.cs
+++ b/SVSim.BattleNode/Sessions/BattleSession.cs
@@ -49,7 +49,7 @@ public sealed class BattleSession
[NetworkBattleUri.Kill] = retireKill,
[NetworkBattleUri.TurnStart] = new TurnStartHandler(),
[NetworkBattleUri.Judge] = new JudgeHandler(),
- [NetworkBattleUri.PlayActions] = forwardWhenReady,
+ [NetworkBattleUri.PlayActions] = new PlayActionsHandler(),
[NetworkBattleUri.Echo] = forwardWhenReady,
[NetworkBattleUri.TurnEndActions] = forwardWhenReady,
[NetworkBattleUri.JudgeResult] = forwardWhenReady,
diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
new file mode 100644
index 0000000..9f22097
--- /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 to
+/// {playIdx,type} with a debug log. 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.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
index 31f417b..537eb82 100644
--- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
+++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
@@ -398,17 +398,83 @@ public class BattleSessionDispatchTests
}
[Test]
- public void Pvp_PlayActions_from_A_in_BothAfterReady_forwards_to_B()
+ public void Pvp_PlayActions_synthesizes_knownList_from_sender_deck()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
- var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions));
+ var body = MoveOrderList(idx: 3, from: 10, to: 20);
+ body["playIdx"] = 3L;
+ body["type"] = 30L;
+
+ var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes[0].Target, Is.SameAs(b));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.PlayActions));
+ var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
+ Assert.That(pb.PlayIdx, Is.EqualTo(3));
+ Assert.That(pb.Type, Is.EqualTo(30));
+ Assert.That(pb.KnownList!.Count, Is.EqualTo(1));
+ Assert.That(pb.KnownList[0].Idx, Is.EqualTo(3));
+ Assert.That(pb.KnownList[0].CardId, Is.EqualTo(100_011_010L)); // PlayerACtx deck cardId
+ Assert.That(pb.KnownList[0].To, Is.EqualTo(20));
+ Assert.That(pb.OppoTargetList, Is.Null);
+ }
+
+ [Test]
+ public void Pvp_PlayActions_renames_targetList_to_oppoTargetList()
+ {
+ var (s, a, b) = NewPvpSession();
+ DriveToAfterReady(s, a);
+ DriveToAfterReady(s, b);
+
+ var body = MoveOrderList(idx: 3, from: 10, to: 20);
+ body["playIdx"] = 3L;
+ body["type"] = 31L;
+ body["targetList"] = new List