diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
index 3c0d267..6442a0e 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
@@ -67,4 +67,21 @@ internal sealed class BattleSessionState
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineChoicePicks(orderList, keyAction))
RecordToken(isSelf == 1 ? from : other, idx, cardId);
}
+
+ /// Mine + record copy/clone-token identities ()
+ /// into the correct side's map. A copy's source lives at baseIdx in the actor's own index
+ /// space, so the resolution side == the record side, both selected by the same isSelf routing
+ /// as . Passing the LIVE per-side maps (via
+ /// , not snapshots) lets a copy that references a plain/choice token
+ /// added earlier THIS frame resolve — provided this runs AFTER
+ /// / (the handler orders it last).
+ /// Seeding both maps up front matters because a copy-only frame (no concrete/choice add) would never
+ /// have hit yet, leaving the maps unseeded.
+ public void RecordCopyTokensFrom(IBattleParticipant from, IBattleParticipant other, object? orderList)
+ {
+ var selfMap = GetOrSeedDeckMap(from);
+ var otherMap = GetOrSeedDeckMap(other);
+ foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap))
+ RecordToken(isSelf == 1 ? from : other, idx, cardId);
+ }
}
diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs
index 4117e8c..5005a04 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs
@@ -6,8 +6,9 @@ namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
/// node never relays it (bullet-2 audit — relaying would risk an echo->echo storm). It IS mined,
/// though: an Echo's orderList carries the same add-op shape as PlayActions
/// (SendCardDataMaker.MakeEchoData -> MakeCommonSendAndEchoCardData), so it can hold a token's real
-/// identity — notably the receiver's own (isSelf:1) view of a cross-side gift. We mine it into the
-/// right side's idx->cardId map and still return no routes (mining != relaying).
+/// identity — notably the receiver's own (isSelf:1) view of a cross-side gift. We mine it (concrete
+/// tokens and baseIdx copies) into the right side's idx->cardId map and still return no routes
+/// (mining != relaying).
internal sealed class EchoHandler : IFrameHandler
{
public IReadOnlyList Handle(FrameDispatchContext ctx)
@@ -16,6 +17,8 @@ internal sealed class EchoHandler : IFrameHandler
{
var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault("orderList");
ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList);
+ // Copy tokens ride Echo too (same add-op shape); resolve baseIdx against the side's map.
+ ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList);
// No RecordChoicePicksFrom here: choice picks ride keyAction.selectCard on the generating
// SEND, not the receiver's Echo (Echo carries orderList only) — the pick is already
// recorded by PlayActionsHandler. MineChoicePicks(orderList, null) would yield nothing.
diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
index 53d55a3..92eba7e 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
@@ -6,9 +6,10 @@ namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
/// PvP PlayActions translator. Synthesizes the opponent-facing knownList from the sender's
/// idx->cardId map + the orderList move op, renames targetList -> oppoTargetList, drops orderList,
/// and forwards a stripped keyAction for choice/Discover plays ({type,cardId}; selectCard dropped
-/// for a hidden open:0 pick). Token plays resolve their cardId from add ops (concrete tokens) or
-/// keyAction.selectCard (choice picks) mined on earlier frames; an un-generated token idx still
-/// degrades to {playIdx,type} (no knownList). Bot drop (no rule).
+/// for a hidden open:0 pick). Token plays resolve their cardId from add ops (concrete tokens),
+/// keyAction.selectCard (choice picks), or a baseIdx copy resolved against the side's map — all mined
+/// on earlier (or the same) frames; an un-generated token idx still degrades to {playIdx,type}
+/// (no knownList). Bot drop (no rule).
internal sealed class PlayActionsHandler : IFrameHandler
{
public IReadOnlyList Handle(FrameDispatchContext ctx)
@@ -32,6 +33,11 @@ internal sealed class PlayActionsHandler : IFrameHandler
// choiceAdd carries candidates only). Record idx->chosenCardId now so the later play reveals it.
ctx.State.RecordChoicePicksFrom(ctx.From, ctx.Other, orderList, keyAction);
+ // Copy/clone tokens: card:{baseIdx} points at a card in the actor's own index space; resolve it
+ // against that side's map and record copyIdx->cardId so the later play reveals it. Ordered after
+ // the plain/choice mining so a same-frame copy of a just-added token resolves against the live map.
+ ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList);
+
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList);
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList"));
diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
index 493af41..3700801 100644
--- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
+++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
@@ -350,6 +350,177 @@ public class BattleSessionDispatchTests
Assert.That(pb.KnownList[0].To, Is.EqualTo(20));
}
+ [Test]
+ public void Pvp_PlayActions_reveals_copy_token_generated_in_an_earlier_frame()
+ {
+ var (s, a, b) = NewPvpSession();
+ DriveToAfterReady(s, a);
+ DriveToAfterReady(s, b);
+
+ // Frame 1: A plays deck card idx 3; its fanfare ADDS a concrete token idx 31 (cardId 900_111_010)
+ // to A's hand (limbo 50 -> hand 10).
+ var gen = new Dictionary
+ {
+ ["playIdx"] = 3L, ["type"] = 30L,
+ ["orderList"] = new List