From b6af8bfb7dfe6d53f5dd759c7f275249b2966941 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 3 Jun 2026 23:30:47 -0400 Subject: [PATCH] feat(battle-node): mine generated-token cardIds from orderList add ops KnownListBuilder.MineAddOps extracts (idx,cardId) from isSelf:1 add ops, skipping cross-side gifts and choice tokens. Bullet-3 audit F1. Co-Authored-By: Claude Opus 4.8 --- .../Sessions/Dispatch/KnownListBuilder.cs | 31 +++++++ .../Sessions/KnownListBuilderTests.cs | 91 +++++++++++++++++++ 2 files changed, 122 insertions(+) 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) })); + } }