diff --git a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
index c0e3084..3da25b7 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
@@ -42,6 +42,37 @@ 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)
+ {
+ if (orderList is not IEnumerable ops) yield break;
+ foreach (var op in ops)
+ {
+ if (op is not IDictionary opDict) continue;
+ if (!opDict.TryGetValue("add", out var addRaw) || addRaw is not IDictionary add) continue;
+
+ add.TryGetValue("isSelf", out var isSelfRaw);
+ if (AsLong(isSelfRaw) != 1) continue; // own tokens only; cross-side gifts deferred
+
+ if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary card) continue;
+ if (!card.TryGetValue("cardId", out var cardIdRaw)) continue; // candidates/isChoice → no identity yet
+ var cardId = AsLong(cardIdRaw);
+
+ if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable idxList) continue;
+ foreach (var i in idxList)
+ yield return ((int)AsLong(i), cardId);
+ }
+ }
+
/// Rename targetList -> oppoTargetList ; isSelf is actor-relative
/// and passes through unchanged (F2). Null for a missing/empty list.
public static IReadOnlyList? RenameTargets(object? targetList)
diff --git a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs
index d983c6a..8f08a83 100644
--- a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs
+++ b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs
@@ -111,4 +111,95 @@ public class KnownListBuilderTests
Assert.That(KnownListBuilder.RenameTargets(null), Is.Null);
Assert.That(KnownListBuilder.RenameTargets(new List()), Is.Null);
}
+
+ // An add op as it arrives in a RawBody: { "add": { "idx": [..], "isSelf": n, "card": { "cardId": n } } }
+ private static Dictionary AddOp(long[] idxs, long cardId, long isSelf = 1) => new()
+ {
+ ["add"] = new Dictionary
+ {
+ ["idx"] = idxs.Select(i => (object?)i).ToList(),
+ ["isSelf"] = isSelf,
+ ["card"] = new Dictionary { ["cardId"] = cardId },
+ }
+ };
+
+ [Test]
+ public void MineAddOps_yields_idx_to_cardId_for_every_idx_in_an_add_op()
+ {
+ var orderList = new List { AddOp(new[] { 31L, 32L }, 900111010L) };
+ var mined = KnownListBuilder.MineAddOps(orderList).ToList();
+
+ Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L), (32, 900111010L) }));
+ }
+
+ [Test]
+ public void MineAddOps_skips_add_ops_for_the_opponent_isSelf_0()
+ {
+ // A card given to the opponent (isSelf:0) belongs in the other side's map — deferred.
+ var orderList = new List { AddOp(new[] { 31L }, 900111010L, isSelf: 0) };
+ Assert.That(KnownListBuilder.MineAddOps(orderList), Is.Empty);
+ }
+
+ [Test]
+ public void MineAddOps_skips_choice_adds_with_no_concrete_cardId()
+ {
+ // { "add": { "idx":[46], "card": { "candidates":[...] }, "isChoice":"1" } } — identity undetermined.
+ var orderList = new List
+ {
+ new Dictionary
+ {
+ ["add"] = new Dictionary
+ {
+ ["idx"] = new List { 46L },
+ ["isSelf"] = 1L,
+ ["card"] = new Dictionary
+ {
+ ["candidates"] = new List { 810041260L, 101041020L },
+ },
+ ["isChoice"] = "1",
+ }
+ }
+ };
+ Assert.That(KnownListBuilder.MineAddOps(orderList), Is.Empty);
+ }
+
+ [Test]
+ public void MineAddOps_skips_copy_token_adds_with_baseIdx_and_no_cardId()
+ {
+ // RegisterCopyToken.MakeCardData → { "baseIdx": N, "isPremium": 0 } — no cardId, deferred.
+ var orderList = new List
+ {
+ new Dictionary
+ {
+ ["add"] = new Dictionary
+ {
+ ["idx"] = new List { 33L },
+ ["isSelf"] = 1L,
+ ["card"] = new Dictionary { ["baseIdx"] = 12L, ["isPremium"] = 0L },
+ }
+ }
+ };
+ Assert.That(KnownListBuilder.MineAddOps(orderList), Is.Empty);
+ }
+
+ [Test]
+ public void MineAddOps_ignores_non_add_ops_and_null()
+ {
+ Assert.That(KnownListBuilder.MineAddOps(OrderListMove(3, 10, 20)), Is.Empty);
+ Assert.That(KnownListBuilder.MineAddOps(null), Is.Empty);
+ }
+
+ [Test]
+ public void MineAddOps_yields_from_multiple_add_ops_in_one_orderList()
+ {
+ var orderList = new List
+ {
+ new Dictionary { ["move"] = new Dictionary
+ { ["idx"] = new List { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L } },
+ AddOp(new[] { 31L }, 900111010L),
+ AddOp(new[] { 32L }, 900811090L),
+ };
+ var mined = KnownListBuilder.MineAddOps(orderList).ToList();
+ Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L), (32, 900811090L) }));
+ }
}