diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
index b4873b8..5654059 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
@@ -4,15 +4,17 @@ namespace SVSim.BattleNode.Sessions.Dispatch;
/// 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 .
+/// synthesize the opponent-facing knownList. Generated tokens (cardIds mined from
+/// orderList add ops) are recorded into the SAME
+/// map via ; a reveal-gate set is still future.
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.
+ /// Holds deck cards (idx 1..deckCount, seeded) and generated tokens (idx>deckCount, recorded
+ /// from add ops via ).
public Dictionary> IdxToCardId { get; } = new();
/// The sender's idx->cardId map, seeding it from its on first
@@ -28,4 +30,15 @@ internal sealed class BattleSessionState
}
return map;
}
+
+ /// Record a generated token's identity into the side's idx->cardId map (the same map
+ /// that holds deck cards). Mined from the sender's orderListadd ops by
+ /// ; surfaced later by BuildPlayedCard when the
+ /// token is the played card. Deck idxs (1..deckCount) and token idxs (>deckCount) don't
+ /// collide — the client allocates token idxs after the deck.
+ public void RecordToken(IBattleParticipant side, int idx, long cardId)
+ {
+ GetOrSeedDeckMap(side); // ensure the per-side map exists (deck-seeded)
+ IdxToCardId[side][idx] = cardId; // overwrite-on-conflict: latest identity wins
+ }
}
diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
index f591f30..ba9184b 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
@@ -5,8 +5,9 @@ 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). Bot drop (no rule).
+/// oppoTargetList, drops orderList, consumes keyAction.
+/// Token plays resolve their cardId from add ops mined on earlier 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)
@@ -18,8 +19,15 @@ internal sealed class PlayActionsHandler : IFrameHandler
var playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("playIdx"));
var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("type"));
+ 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);
+
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
- var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, entries.GetValueOrDefault("orderList"));
+ var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList);
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList"));
var body = new PlayActionsBroadcastBody(
diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
index d9409da..a9e1ab8 100644
--- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
+++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs
@@ -250,7 +250,7 @@ public class BattleSessionDispatchTests
}
[Test]
- public void Pvp_PlayActions_token_idx_degrades_to_no_knownList()
+ public void Pvp_PlayActions_ungenerated_token_idx_degrades_to_no_knownList()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
@@ -268,6 +268,49 @@ public class BattleSessionDispatchTests
Assert.That(pb.KnownList, Is.Null);
}
+ [Test]
+ public void Pvp_PlayActions_reveals_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 (a spell, hand 10 -> cemetery 30) whose fanfare ADDS
+ // token idx 31 (cardId 900111010) to A's hand (limbo 50 -> hand 10).
+ var gen = new Dictionary
+ {
+ ["playIdx"] = 3L,
+ ["type"] = 30L,
+ ["orderList"] = new List