diff --git a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
index b93f9ac..22fac11 100644
--- a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
+++ b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
@@ -5,13 +5,32 @@ 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.
+/// (independent of KnownList — a targeted hand play carries both). KeyAction forwards a
+/// choice/Discover play's {type,cardId} so the opponent renders the choice-token generation;
+/// the pick (selectCard) is stripped for a hidden (open:0) draw-to-hand choice. All three are
+/// omitted when null via the envelope's WhenWritingNull policy (a vanilla play carries none).
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;
+ [property: JsonPropertyName("oppoTargetList")] IReadOnlyList? OppoTargetList,
+ [property: JsonPropertyName("keyAction")] IReadOnlyList? KeyAction = null) : IMsgBody;
+
+/// Opponent-facing keyAction entry for a choice/Discover play. type/cardId
+/// (the GENERATING card) pass through so the opponent re-derives the candidate pool from that card's
+/// skill; selectCard is stripped (null) for a hidden (open:0) choice — the pick stays secret
+/// until the chosen card is played — and passed through for a visible (open:1) board choice (§6,
+/// provisional pending live confirmation).
+public sealed record KeyActionEntry(
+ [property: JsonPropertyName("type")] int Type,
+ [property: JsonPropertyName("cardId")] long CardId,
+ [property: JsonPropertyName("selectCard")] SelectCardEntry? SelectCard);
+
+/// A visible choice's revealed pick: the chosen cardId(s) and the open flag.
+/// Only emitted for the open:1 pass-through case (open:0 strips the whole selectCard).
+public sealed record SelectCardEntry(
+ [property: JsonPropertyName("cardId")] IReadOnlyList CardId,
+ [property: JsonPropertyName("open")] int Open);
/// 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
diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
index 40723cc..3c0d267 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
@@ -56,4 +56,15 @@ internal sealed class BattleSessionState
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineAddOps(orderList))
RecordToken(isSelf == 1 ? from : other, idx, cardId);
}
+
+ /// Mine + record choice/Discover-token picks ()
+ /// into the correct side's map, by the same isSelf routing as .
+ /// The chosen cardId rides the generating send's keyAction.selectCard (not the orderList add
+ /// op, which carries candidates only); recorded regardless of the choice's open visibility —
+ /// an unplayed idx is never queried, so a stray record is harmless.
+ public void RecordChoicePicksFrom(IBattleParticipant from, IBattleParticipant other, object? orderList, object? keyAction)
+ {
+ foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineChoicePicks(orderList, keyAction))
+ 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 2d1d655..4117e8c 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/EchoHandler.cs
@@ -16,6 +16,9 @@ internal sealed class EchoHandler : IFrameHandler
{
var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault("orderList");
ctx.State.RecordTokensFrom(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.
}
return Array.Empty();
}
diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
index 760089f..53d55a3 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
@@ -3,11 +3,12 @@ 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 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).
+/// 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).
internal sealed class PlayActionsHandler : IFrameHandler
{
public IReadOnlyList Handle(FrameDispatchContext ctx)
@@ -20,12 +21,17 @@ internal sealed class PlayActionsHandler : IFrameHandler
var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("type"));
var orderList = entries.GetValueOrDefault("orderList");
+ var keyAction = entries.GetValueOrDefault("keyAction");
// 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);
+ // Choice/Discover-into-hand: the chosen cardId rides keyAction.selectCard (the orderList's
+ // choiceAdd carries candidates only). Record idx->chosenCardId now so the later play reveals it.
+ ctx.State.RecordChoicePicksFrom(ctx.From, ctx.Other, orderList, keyAction);
+
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList);
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList"));
@@ -34,7 +40,10 @@ internal sealed class PlayActionsHandler : IFrameHandler
PlayIdx: playIdx,
Type: type,
KnownList: played is null ? null : new[] { played },
- OppoTargetList: oppoTargets);
+ OppoTargetList: oppoTargets,
+ // {type,cardId} forwarded so the opponent renders the choice token; selectCard dropped
+ // when open==0 (hidden draw-to-hand pick). Null for a vanilla play (no keyAction).
+ KeyAction: KnownListBuilder.StripKeyActionForOpponent(keyAction));
var frame = ctx.Env with { Body = body };
return new[] { new DispatchRoute(ctx.Other, frame, false) };
diff --git a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
index 646409e..d1dabf3 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
@@ -75,6 +75,94 @@ internal static class KnownListBuilder
}
}
+ /// Mine choice/Discover-token identities: for each isChoice add op (idx, isSelf,
+ /// candidates), resolve its cardId from the keyAction selectCard pick whose cardId is in that
+ /// op's candidate pool. Yields (idx, cardId, isSelf) — same shape as ,
+ /// routed by the same rule. The pick is on
+ /// keyAction.selectCard, NOT the add op (RegisterChoiceAdd strips the concrete cardId,
+ /// NetworkBattleSetupCardEvent.cs:531-543); the candidate-membership join handles the single
+ /// case unambiguously (multi-choice: each chosen cardId matches the one choiceAdd whose candidates
+ /// contain it). type/cardId/open on the keyAction are ignored here — open
+ /// only gates the strip (), not the recording. An add whose
+ /// candidates contain none of the picks is skipped (defensive — no record, no desync); Echo (no
+ /// keyAction) yields nothing, leaving it mining-only via .
+ public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineChoicePicks(object? orderList, object? keyAction)
+ {
+ if (orderList is not IEnumerable