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) })); + } }