diff --git a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
index d1dabf3..6d2cfe0 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
@@ -130,6 +130,47 @@ internal static class KnownListBuilder
}
}
+ /// Mine copy/clone-token identities: for each copy add op
+ /// ({idx:[...], isSelf, card:{baseIdx, isPremium}} ), resolve its cardId from the appropriate
+ /// side's idx->cardId map. The copied card lives at baseIdx in the actor's OWN index space —
+ /// RegisterCopyToken is emitted only for !IsReferenceOpponenCard
+ /// (NetworkBattleManagerBase.cs:1106 ); a cross-side copy sends a concrete cardId via a
+ /// plain RegisterToken instead (handled by ). Yields
+ /// (idx, cardId, isSelf) — same shape as , routed by the same
+ /// rule: isSelf:1 resolves+records into the
+ /// sender's map ( ), isSelf:0 into the opponent's
+ /// ( ). Skips an add with a concrete cardId (→ MineAddOps), one with
+ /// candidates (→ MineChoicePicks), a string baseIdx (private-group copy,
+ /// RegisterCopyToken.cs:19-22 ), and a baseIdx absent from the chosen map (unknown source
+ /// → degrade, no desync). isPremium (IsFoil) is cosmetic and ignored.
+ public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineCopyTokens(
+ object? orderList,
+ IReadOnlyDictionary selfMap,
+ IReadOnlyDictionary otherMap)
+ {
+ 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;
+
+ if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary card) continue;
+ if (card.ContainsKey("cardId")) continue; // concrete token → MineAddOps
+ if (!card.TryGetValue("baseIdx", out var baseRaw)) continue; // not a copy (candidates → MineChoicePicks)
+ if (baseRaw is string) continue; // private-group copy → string baseIdx, skip
+ var baseIdx = (int)AsLong(baseRaw);
+
+ add.TryGetValue("isSelf", out var isSelfRaw);
+ var isSelf = (int)AsLong(isSelfRaw);
+ var map = isSelf == 1 ? selfMap : otherMap;
+ if (!map.TryGetValue(baseIdx, out var cardId)) continue; // unknown source → degrade
+
+ if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable idxList) continue;
+ foreach (var i in idxList)
+ yield return ((int)AsLong(i), cardId, isSelf);
+ }
+ }
+
/// Map an inbound keyAction (the active player's send) to the opponent-facing list:
/// for each Choice(1)/HaveBeforeSkillChoice(5) entry, keep {type,cardId} and drop
/// selectCard when its open==0 (hidden draw-to-hand pick stays secret), pass it
diff --git a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs
index 1b3795d..cdbac0a 100644
--- a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs
+++ b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs
@@ -336,4 +336,90 @@ public class KnownListBuilderTests
Assert.That(KnownListBuilder.StripKeyActionForOpponent(null), Is.Null);
Assert.That(KnownListBuilder.StripKeyActionForOpponent(new List()), Is.Null);
}
+
+ // A copy add op as it arrives in a RawBody: { "add": { "idx":[..], "isSelf":n, "card":{ "baseIdx":m, "isPremium":0 } } }
+ private static Dictionary CopyOp(long[] idxs, long baseIdx, long isSelf = 1) => new()
+ {
+ ["add"] = new Dictionary
+ {
+ ["idx"] = idxs.Select(i => (object?)i).ToList(),
+ ["isSelf"] = isSelf,
+ ["card"] = new Dictionary { ["baseIdx"] = baseIdx, ["isPremium"] = 0L },
+ }
+ };
+
+ [Test]
+ public void MineCopyTokens_resolves_baseIdx_against_selfMap_for_isSelf_1()
+ {
+ var orderList = new List { CopyOp(new[] { 31L }, baseIdx: 5L, isSelf: 1) };
+ var selfMap = new Dictionary { [5] = 100_011_010L };
+ var otherMap = new Dictionary();
+ var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList();
+ Assert.That(mined, Is.EquivalentTo(new[] { (31, 100_011_010L, 1) }));
+ }
+
+ [Test]
+ public void MineCopyTokens_resolves_baseIdx_against_otherMap_for_isSelf_0()
+ {
+ // Cross-side copy shape (battle-traffic_tk2_regular.ndjson:196 is an isSelf:0 Echo, baseIdx 21):
+ // the source lives in the OPPONENT's index space, so resolve against otherMap and record there.
+ var orderList = new List { CopyOp(new[] { 49L }, baseIdx: 21L, isSelf: 0) };
+ var selfMap = new Dictionary();
+ var otherMap = new Dictionary { [21] = 900_841_330L };
+ var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList();
+ Assert.That(mined, Is.EquivalentTo(new[] { (49, 900_841_330L, 0) }));
+ }
+
+ [Test]
+ public void MineCopyTokens_skips_copy_when_baseIdx_absent_from_map()
+ {
+ // Unknown source (e.g. a card the node never recorded) → no record, no desync, the play degrades.
+ var orderList = new List { CopyOp(new[] { 31L }, baseIdx: 99L, isSelf: 1) };
+ Assert.That(
+ KnownListBuilder.MineCopyTokens(orderList, new Dictionary(), new Dictionary()),
+ Is.Empty);
+ }
+
+ [Test]
+ public void MineCopyTokens_ignores_concrete_and_choice_adds()
+ {
+ // A concrete-cardId add is MineAddOps' job; a candidates add is MineChoicePicks' — both skipped here.
+ var orderList = new List
+ {
+ new Dictionary { ["add"] = new Dictionary
+ { ["idx"] = new List { 31L }, ["isSelf"] = 1L,
+ ["card"] = new Dictionary { ["cardId"] = 900_111_010L } } },
+ new Dictionary { ["add"] = new Dictionary
+ { ["idx"] = new List { 32L }, ["isSelf"] = 1L,
+ ["card"] = new Dictionary { ["candidates"] = new List { 1L, 2L } },
+ ["isChoice"] = "1" } },
+ };
+ var map = new Dictionary { [1] = 5L };
+ Assert.That(KnownListBuilder.MineCopyTokens(orderList, map, map), Is.Empty);
+ }
+
+ [Test]
+ public void MineCopyTokens_skips_string_baseIdx_private_group()
+ {
+ // PrivateGroupIndexMsg != "" makes baseIdx a STRING (RegisterCopyToken.cs:19-22) — the hidden
+ // private-card path; skipped just like private-group idx in MineAddOps.
+ var orderList = new List
+ {
+ new Dictionary { ["add"] = new Dictionary
+ { ["idx"] = new List { 31L }, ["isSelf"] = 1L,
+ ["card"] = new Dictionary { ["baseIdx"] = "g1", ["isPremium"] = 0L } } },
+ };
+ Assert.That(
+ KnownListBuilder.MineCopyTokens(orderList, new Dictionary(), new Dictionary()),
+ Is.Empty);
+ }
+
+ [Test]
+ public void MineCopyTokens_yields_for_every_idx_in_a_multi_idx_copy_op()
+ {
+ var orderList = new List { CopyOp(new[] { 31L, 32L }, baseIdx: 5L, isSelf: 1) };
+ var selfMap = new Dictionary { [5] = 700L };
+ var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, new Dictionary()).ToList();
+ Assert.That(mined, Is.EquivalentTo(new[] { (31, 700L, 1), (32, 700L, 1) }));
+ }
}