diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
index 5654059..40723cc 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
@@ -41,4 +41,19 @@ internal sealed class BattleSessionState
GetOrSeedDeckMap(side); // ensure the per-side map exists (deck-seeded)
IdxToCardId[side][idx] = cardId; // overwrite-on-conflict: latest identity wins
}
+
+ /// Mine generated-token identities from a sender's orderListadd ops and
+ /// record each into the correct side's map. isSelf:1 → the sender's own token (); isSelf:0 → a cross-side gift living at that idx in the OPPONENT's index
+ /// space () — isSelf is the sender's perspective tag on
+ /// CardObj.IsPlayer (RegisterToken.cs:22), and a card has a single CardObj.Index, so
+ /// the gifted idx is the same slot in the recipient's own map (the one consulted when the recipient
+ /// later plays it). Shared by PlayActionsHandler and EchoHandler — an Echo's orderList
+ /// carries the same add-op shape (SendCardDataMaker.MakeEchoData), so both mine identically;
+ /// Echo is mined but never relayed.
+ public void RecordTokensFrom(IBattleParticipant from, IBattleParticipant other, object? orderList)
+ {
+ foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineAddOps(orderList))
+ 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 27dfbb5..2d1d655 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs
@@ -1,8 +1,22 @@
+using SVSim.BattleNode.Protocol;
+
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.
+/// 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).
internal sealed class EchoHandler : IFrameHandler
{
- public IReadOnlyList Handle(FrameDispatchContext ctx) => Array.Empty();
+ public IReadOnlyList Handle(FrameDispatchContext ctx)
+ {
+ if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
+ {
+ var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault("orderList");
+ ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList);
+ }
+ return Array.Empty();
+ }
}
diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
index ba9184b..760089f 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
@@ -21,10 +21,10 @@ internal sealed class PlayActionsHandler : IFrameHandler
var orderList = entries.GetValueOrDefault("orderList");
- // Mine generated-token identities from this frame's add ops into the sender's idx->cardId
- // map, so a token played in a LATER frame resolves its cardId (bullet-3 audit F1).
- foreach (var (idx, cardId) in KnownListBuilder.MineAddOps(orderList))
- ctx.State.RecordToken(ctx.From, idx, cardId);
+ // Mine generated-token identities from this frame's add ops into the right side's idx->cardId
+ // map (isSelf:1 → sender; isSelf:0 → opponent, a cross-side gift), so a token played in a LATER
+ // frame resolves its cardId — by whichever side ends up playing it (bullet-3 audit F1).
+ ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList);
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList);
diff --git a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
index 3da25b7..646409e 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
@@ -42,17 +42,19 @@ internal static class KnownListBuilder
return null;
}
- /// Mine generated-token identities from the sender's add ops: yields
- /// (idx, cardId) for every idx in each {add:{idx:[...], isSelf:1, card:{cardId}}}
- /// op. Skips isSelf:0 adds (cross-side gifts — belong in the other side's map, deferred)
- /// and any add whose card has no concrete cardId — choice tokens
- /// (card:{candidates}, RegisterChoiceAdd), copy tokens (card:{baseIdx},
- /// RegisterCopyToken), and private-group adds (string idx) — all deferred and all
- /// caught by the cardId-key / idx-is-list guards. This is the only place a
- /// freshly-generated card's identity exists on the wire (bullet-3 audit F1; producing code
- /// RegisterToken/RegisterActionBase) — the played-card op itself never carries a
- /// cardId.
- public static IEnumerable<(int Idx, long CardId)> MineAddOps(object? orderList)
+ /// Mine generated-token identities from a sender's add ops: yields
+ /// (idx, cardId, isSelf) for every idx in each {add:{idx:[...], isSelf, card:{cardId}}}
+ /// op. isSelf is surfaced verbatim (the sender's perspective tag on CardObj.IsPlayer,
+ /// RegisterToken.cs:22) so the caller can route the identity into the correct side's map —
+ /// isSelf:1 = the sender's own token, isSelf:0 = a cross-side gift living at this idx
+ /// in the OPPONENT's index space (). Skips any add
+ /// whose card has no concrete cardId — choice tokens (card:{candidates},
+ /// RegisterChoiceAdd), copy tokens (card:{baseIdx}, RegisterCopyToken), and
+ /// private-group adds (string idx) — all deferred and all caught by the cardId-key /
+ /// idx-is-list guards. This is the only place a freshly-generated card's identity exists on
+ /// the wire (bullet-3 audit F1; producing code RegisterToken/RegisterActionBase) —
+ /// the played-card op itself never carries a cardId.
+ public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineAddOps(object? orderList)
{
if (orderList is not IEnumerable