From 5c3835f4fd7a6517b248b1335845d0e76a5a6e55 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 4 Jun 2026 08:53:48 -0400 Subject: [PATCH] feat(battle-node): reveal choice/Discover tokens to opponent Choice/Discover-into-hand fanfares add a candidates-only token to hand; the chosen cardId rides keyAction.selectCard on the generating play, not the orderList add op. Record idx->chosenCardId at generation (candidate-membership join) so the later play reveals the real identity via the existing BuildPlayedCard path; forward {type,cardId} to the opponent and strip selectCard for hidden (open:0) picks (pass through for open:1, provisional). - KnownListBuilder.MineChoicePicks + StripKeyActionForOpponent (pure) - BattleSessionState.RecordChoicePicksFrom (reuses IdxToCardId, no new state) - PlayActionsBroadcastBody.keyAction + KeyActionEntry/SelectCardEntry - PlayActionsHandler wires both; EchoHandler unchanged (picks ride the send) Tests (TDD red->green): 8 KnownListBuilder + 2 dispatch + 2 conformance (shape-locked to tk2_regular L151 generation / L193 reveal). Full suite 976/0. Spec: docs/superpowers/specs/2026-06-04-battle-node-choice-token-reveal-design.md Co-Authored-By: Claude Opus 4.8 --- .../Bodies/PlayActionsBroadcastBody.cs | 25 +++- .../Sessions/Dispatch/BattleSessionState.cs | 11 ++ .../Sessions/Dispatch/Handlers/EchoHandler.cs | 3 + .../Dispatch/Handlers/PlayActionsHandler.cs | 21 ++- .../Sessions/Dispatch/KnownListBuilder.cs | 88 ++++++++++++ .../Integration/CaptureConformanceTests.cs | 120 ++++++++++++++++ .../Sessions/BattleSessionDispatchTests.cs | 98 +++++++++++++ .../Sessions/KnownListBuilderTests.cs | 131 ++++++++++++++++++ 8 files changed, 488 insertions(+), 9 deletions(-) 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 ops) yield break; + + // Flatten every selectCard.cardId pick across all keyAction entries into a membership set. + var picks = new HashSet(); + if (keyAction is IEnumerable kaEntries) + { + foreach (var ka in kaEntries) + { + if (ka is not IDictionary kaDict) continue; + if (!kaDict.TryGetValue("selectCard", out var scRaw) || scRaw is not IDictionary sc) continue; + if (!sc.TryGetValue("cardId", out var idsRaw) || idsRaw is not IEnumerable ids) continue; + foreach (var id in ids) picks.Add(AsLong(id)); + } + } + if (picks.Count == 0) 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.ContainsKey("isChoice")) continue; + if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary card) continue; + if (!card.TryGetValue("candidates", out var candRaw) || candRaw is not IEnumerable candidates) continue; + + // The chosen cardId is the candidate that the active player picked (∈ picks). One per op. + long? chosen = null; + foreach (var c in candidates) + { + var cid = AsLong(c); + if (picks.Contains(cid)) { chosen = cid; break; } + } + if (chosen is null) continue; // no pick in this op's pool — skip (no desync, just no record) + + add.TryGetValue("isSelf", out var isSelfRaw); + var isSelf = (int)AsLong(isSelfRaw); + + if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable idxList) continue; + foreach (var i in idxList) + yield return ((int)AsLong(i), chosen.Value, 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 + /// through when open==1 (visible board choice — provisional reveal-immediately, §6). + /// Non-choice KeyActionTypes are dropped (current behavior) until their own specs. Returns null + /// for absent/empty keyAction or when every entry was dropped (vanilla play unchanged). + public static IReadOnlyList? StripKeyActionForOpponent(object? keyAction) + { + if (keyAction is not IEnumerable entries) return null; + var result = new List(); + foreach (var e in entries) + { + if (e is not IDictionary d) continue; + d.TryGetValue("type", out var typeRaw); + var type = (int)AsLong(typeRaw); + if (type is not (1 or 5)) continue; // only Choice / HaveBeforeSkillChoice handled + + d.TryGetValue("cardId", out var cardIdRaw); + var cardId = AsLong(cardIdRaw); + + SelectCardEntry? selectCard = null; + if (d.TryGetValue("selectCard", out var scRaw) && scRaw is IDictionary sc) + { + sc.TryGetValue("open", out var openRaw); + var open = (int)AsLong(openRaw); + if (open != 0 && sc.TryGetValue("cardId", out var idsRaw) && idsRaw is IEnumerable ids) + selectCard = new SelectCardEntry(ids.Select(AsLong).ToList(), open); + } + result.Add(new KeyActionEntry(type, cardId, selectCard)); + } + return result.Count == 0 ? null : result; + } + /// 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/Integration/CaptureConformanceTests.cs b/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs index 8b18cdf..8473a0a 100644 --- a/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs @@ -352,6 +352,126 @@ public class CaptureConformanceTests Assert.That(ourEntry.GetProperty("cardId").GetInt64(), Is.EqualTo(900811111L)); Assert.That(ourEntry.GetProperty("to").GetInt32(), Is.EqualTo(20)); } + + [Test] + public void SynthesizedChoiceGeneration_matches_prod_recv_keyAction_and_knownList_shape() + { + // Prod recv PlayActions for the generating card play (battle-traffic_tk2_regular.ndjson:151): + // keyAction is {type,cardId} only (selectCard stripped for the hidden open:0 choice); knownList + // reveals the generating DECK card. The choiceAdd lands a hidden token at idx 46 (candidates). + // Subset check covers playIdx/type/keyAction — the parts we own; knownList idx/cardId/to are + // asserted explicitly below (cost/clan/tribe are deferred, re-derived by the receiver from cardId). + const string prodFrame = """ + { "playIdx": 18, "type": 30, + "keyAction": [ { "type": 1, "cardId": 810014030 } ] } + """; + + var deckMap = new Dictionary { [18] = 810014030L }; + var orderList = new List + { + new Dictionary { ["move"] = new Dictionary + { ["idx"] = new List { 18L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L } }, + new Dictionary { ["add"] = new Dictionary + { ["idx"] = new List { 46L }, ["isSelf"] = 1L, + ["card"] = new Dictionary + { ["candidates"] = new List { 810041260L, 101041020L } }, + ["isChoice"] = "1" } }, + }; + var keyActionIn = new List + { + new Dictionary + { + ["type"] = 1L, ["cardId"] = 810014030L, + ["selectCard"] = new Dictionary + { ["cardId"] = new List { 810041260L }, ["open"] = 0L }, + } + }; + + var played = SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.BuildPlayedCard(deckMap, 18, orderList); + var keyActionOut = SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.StripKeyActionForOpponent(keyActionIn); + var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody( + PlayIdx: 18, Type: 30, KnownList: new[] { played! }, OppoTargetList: null, KeyAction: keyActionOut); + var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, + Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body); + + using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env)); + using var prodDoc = JsonDocument.Parse(prodFrame); + var failures = new List(); + CompareSubset(prodDoc.RootElement, ourDoc.RootElement, "PlayActions", isRoot: true, failures); + Assert.That(failures, Is.Empty, string.Join("\n", failures)); + + // The hidden pick must NOT leak: keyAction[0] carries no selectCard. + var ourKa = ourDoc.RootElement.GetProperty("keyAction")[0]; + Assert.That(ourKa.TryGetProperty("selectCard", out _), Is.False, "selectCard must be stripped for open:0"); + Assert.That(ourKa.GetProperty("type").GetInt32(), Is.EqualTo(1)); + Assert.That(ourKa.GetProperty("cardId").GetInt64(), Is.EqualTo(810014030L)); + + // The generating deck card reveals on its own play (idx 18 -> 810014030, to 30). cost/clan/tribe + // are deferred (receiver re-derives from cardId), so only idx/cardId/to are checked — as in the + // sibling SynthesizedKnownList_* tests. + var ourKnown = ourDoc.RootElement.GetProperty("knownList")[0]; + Assert.That(ourKnown.GetProperty("idx").GetInt32(), Is.EqualTo(18)); + Assert.That(ourKnown.GetProperty("cardId").GetInt64(), Is.EqualTo(810014030L)); + Assert.That(ourKnown.GetProperty("to").GetInt32(), Is.EqualTo(30)); + } + + [Test] + public void SynthesizedChoiceReveal_matches_prod_recv_knownList_shape() + { + // Prod recv PlayActions for the chosen token play (battle-traffic_tk2_regular.ndjson:193): + // knownList:[{idx:46, cardId:810041260,...}] — the pick recorded at generation, revealed on play. + const string prodEntry = """ + { "idx": 46, "cardId": 810041260, "to": 20, "cost": 5, "clan": 0, "tribe": "0", "spellboost": 0, "attachTarget": "" } + """; + + // Mine the pick from the generating frame (choiceAdd ∩ selectCard), then build the played entry. + var generatingOrderList = new List + { + new Dictionary { ["add"] = new Dictionary + { ["idx"] = new List { 46L }, ["isSelf"] = 1L, + ["card"] = new Dictionary + { ["candidates"] = new List { 810041260L, 101041020L } }, + ["isChoice"] = "1" } }, + }; + var keyAction = new List + { + new Dictionary + { + ["type"] = 1L, ["cardId"] = 810014030L, + ["selectCard"] = new Dictionary + { ["cardId"] = new List { 810041260L }, ["open"] = 0L }, + } + }; + var map = new Dictionary(); + foreach (var (idx, cardId, _) in SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.MineChoicePicks(generatingOrderList, keyAction)) + map[idx] = cardId; + + var playOrderList = new List + { + new Dictionary { ["move"] = new Dictionary + { ["idx"] = new List { 46L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 20L } }, + }; + var entry = SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.BuildPlayedCard(map, 46, playOrderList); + Assert.That(entry, Is.Not.Null, "the mined choice pick resolves to a knownList entry"); + + var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody( + PlayIdx: 46, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null); + var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, + Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body); + + using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env)); + var ourEntry = ourDoc.RootElement.GetProperty("knownList")[0]; + using var prodDoc = JsonDocument.Parse(prodEntry); + + foreach (var key in new[] { "idx", "cardId", "to" }) + { + Assert.That(ourEntry.TryGetProperty(key, out var ours), Is.True, $"knownList entry missing '{key}'"); + var prodVal = prodDoc.RootElement.GetProperty(key); + Assert.That(ours.ValueKind, Is.EqualTo(prodVal.ValueKind), $"'{key}' type category mismatch"); + } + Assert.That(ourEntry.GetProperty("cardId").GetInt64(), Is.EqualTo(810041260L)); + Assert.That(ourEntry.GetProperty("to").GetInt32(), Is.EqualTo(20)); + } } /// diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 04ec16c..493af41 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -350,6 +350,104 @@ public class BattleSessionDispatchTests Assert.That(pb.KnownList[0].To, Is.EqualTo(20)); } + [Test] + public void Pvp_PlayActions_choice_token_records_pick_and_strips_selectCard() + { + var (s, a, b) = NewPvpSession(); + DriveToAfterReady(s, a); + DriveToAfterReady(s, b); + + // A plays a generating deck card (idx 3) whose fanfare is a hidden draw-to-hand choice: a + // choiceAdd lands a token at idx 31 (candidates only), the move pulls it limbo->hand, and + // keyAction.selectCard names the chosen cardId with open:0 (hidden). + var gen = new Dictionary + { + ["playIdx"] = 3L, + ["type"] = 30L, + ["orderList"] = new List + { + new Dictionary { ["move"] = new Dictionary + { ["idx"] = new List { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L } }, + new Dictionary { ["add"] = new Dictionary + { ["idx"] = new List { 31L }, ["isSelf"] = 1L, + ["card"] = new Dictionary + { ["candidates"] = new List { 810041260L, 101041020L } }, + ["isChoice"] = "1" } }, + new Dictionary { ["move"] = new Dictionary + { ["idx"] = new List { 31L }, ["isSelf"] = 1L, ["from"] = 50L, ["to"] = 10L } }, + }, + ["keyAction"] = new List + { + new Dictionary + { + ["type"] = 1L, ["cardId"] = 100_011_010L, + ["selectCard"] = new Dictionary + { ["cardId"] = new List { 810041260L }, ["open"] = 0L }, + } + }, + }; + var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, gen)); + + Assert.That(routes.Count, Is.EqualTo(1)); + Assert.That(routes[0].Target, Is.SameAs(b)); + var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body; + // The generating deck card reveals from A's deck map (idx 3). + Assert.That(pb.KnownList!.Single().CardId, Is.EqualTo(100_011_010L), "generating deck card revealed"); + // keyAction forwarded as {type,cardId}; selectCard stripped for the hidden choice. + Assert.That(pb.KeyAction, Is.Not.Null); + Assert.That(pb.KeyAction!.Single().Type, Is.EqualTo(1)); + Assert.That(pb.KeyAction.Single().CardId, Is.EqualTo(100_011_010L)); + Assert.That(pb.KeyAction.Single().SelectCard, Is.Null, "the pick stays hidden for open:0"); + } + + [Test] + public void Pvp_PlayActions_reveals_choice_token_when_chosen_card_is_played() + { + var (s, a, b) = NewPvpSession(); + DriveToAfterReady(s, a); + DriveToAfterReady(s, b); + + // Generation frame: records idx 31 -> chosen cardId 810041260 into A's map (from selectCard). + var gen = new Dictionary + { + ["playIdx"] = 3L, + ["type"] = 30L, + ["orderList"] = new List + { + new Dictionary { ["move"] = new Dictionary + { ["idx"] = new List { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L } }, + new Dictionary { ["add"] = new Dictionary + { ["idx"] = new List { 31L }, ["isSelf"] = 1L, + ["card"] = new Dictionary + { ["candidates"] = new List { 810041260L, 101041020L } }, + ["isChoice"] = "1" } }, + }, + ["keyAction"] = new List + { + new Dictionary + { + ["type"] = 1L, ["cardId"] = 100_011_010L, + ["selectCard"] = new Dictionary + { ["cardId"] = new List { 810041260L }, ["open"] = 0L }, + } + }, + }; + s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, gen)); + + // Later A plays the chosen token idx 31 (hand 10 -> field 20). B must see its real identity. + var play = MoveOrderList(idx: 31, from: 10, to: 20); + play["playIdx"] = 31L; + play["type"] = 30L; + var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, play)); + + var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body; + Assert.That(pb.PlayIdx, Is.EqualTo(31)); + Assert.That(pb.KnownList, Is.Not.Null, "the choice pick was recorded at generation"); + Assert.That(pb.KnownList!.Single().Idx, Is.EqualTo(31)); + Assert.That(pb.KnownList[0].CardId, Is.EqualTo(810041260L), "the chosen cardId surfaces on play"); + Assert.That(pb.KnownList[0].To, Is.EqualTo(20)); + } + [Test] public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops() { diff --git a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs index 708fd83..1b3795d 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs @@ -205,4 +205,135 @@ public class KnownListBuilderTests var mined = KnownListBuilder.MineAddOps(orderList).ToList(); Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L, 1), (32, 900811090L, 1) })); } + + // A choice/Discover add op as it arrives in a RawBody: candidates-only (no concrete cardId — + // RegisterChoiceAdd strips it), with isChoice present. Capture battle-traffic_tk2_regular line 152. + private static Dictionary ChoiceAddOp(long idx, long[] candidates, long isSelf = 1) => new() + { + ["add"] = new Dictionary + { + ["idx"] = new List { idx }, + ["isSelf"] = isSelf, + ["card"] = new Dictionary + { + ["candidates"] = candidates.Select(c => (object?)c).ToList(), + }, + ["isChoice"] = "1", + } + }; + + // A keyAction entry: { type, cardId (the GENERATING card), selectCard:{ cardId:[chosen...], open } }. + private static List KeyActionChoice(long generatingCardId, long[] chosen, long open) => new() + { + new Dictionary + { + ["type"] = 1L, + ["cardId"] = generatingCardId, + ["selectCard"] = new Dictionary + { + ["cardId"] = chosen.Select(c => (object?)c).ToList(), + ["open"] = open, + }, + } + }; + + [Test] + public void MineChoicePicks_resolves_idx_to_chosen_cardId_from_selectCard() + { + // The choiceAdd carries only candidates; the pick rides keyAction.selectCard.cardId. The node + // joins them by candidate membership. Capture lines 151/152/193: chosen = candidates[0]. + var orderList = new List { ChoiceAddOp(46, new[] { 810041260L, 101041020L }) }; + var keyAction = KeyActionChoice(generatingCardId: 810014030L, chosen: new[] { 810041260L }, open: 0); + + Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction), + Is.EquivalentTo(new[] { (46, 810041260L, 1) })); + } + + [Test] + public void MineChoicePicks_routes_cross_side_choice_by_isSelf() + { + // A choiceAdd with isSelf:0 (a gifted choice in the opponent's index space) surfaces isSelf:0 + // so the caller routes it into the OTHER side's map (same rule as MineAddOps). + var orderList = new List { ChoiceAddOp(46, new[] { 810041260L, 101041020L }, isSelf: 0) }; + var keyAction = KeyActionChoice(810014030L, new[] { 101041020L }, open: 0); + + Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction), + Is.EquivalentTo(new[] { (46, 101041020L, 0) })); + } + + [Test] + public void MineChoicePicks_yields_nothing_when_no_pick_matches_candidates() + { + var orderList = new List { ChoiceAddOp(46, new[] { 810041260L, 101041020L }) }; + var keyAction = KeyActionChoice(810014030L, new[] { 999999999L }, open: 0); + + Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction), Is.Empty); + } + + [Test] + public void MineChoicePicks_ignores_non_choice_add_ops() + { + // A concrete-token add (cardId, no candidates) is MineAddOps' job — even if its cardId happens + // to equal a selectCard pick, MineChoicePicks only mines isChoice/candidates adds. + var orderList = new List { AddOp(new[] { 31L }, 900111010L) }; + var keyAction = KeyActionChoice(810014030L, new[] { 900111010L }, open: 0); + + Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction), Is.Empty); + } + + [Test] + public void MineChoicePicks_yields_nothing_when_keyAction_absent() + { + // Echo carries orderList but no keyAction; choice mining keys on keyAction, so Echo yields + // nothing here and stays mining-only via MineAddOps (§3.5). + var orderList = new List { ChoiceAddOp(46, new[] { 810041260L, 101041020L }) }; + + Assert.That(KnownListBuilder.MineChoicePicks(orderList, null), Is.Empty); + } + + [Test] + public void StripKeyActionForOpponent_drops_selectCard_when_open_0() + { + // Hidden draw-to-hand choice: opponent gets {type,cardId} only; the pick stays secret. + // Capture line 151: keyAction:[{type:1, cardId:810014030}]. + var keyAction = KeyActionChoice(810014030L, new[] { 810041260L }, open: 0); + var stripped = KnownListBuilder.StripKeyActionForOpponent(keyAction); + + Assert.That(stripped, Is.Not.Null); + Assert.That(stripped!.Count, Is.EqualTo(1)); + Assert.That(stripped[0].Type, Is.EqualTo(1)); + Assert.That(stripped[0].CardId, Is.EqualTo(810014030L)); + Assert.That(stripped[0].SelectCard, Is.Null); + } + + [Test] + public void StripKeyActionForOpponent_passes_selectCard_through_when_open_1() + { + // Visible board choice — provisional reveal-immediately behavior (§6, flagged for the live run). + var keyAction = KeyActionChoice(810014030L, new[] { 810041260L }, open: 1); + var stripped = KnownListBuilder.StripKeyActionForOpponent(keyAction); + + Assert.That(stripped![0].SelectCard, Is.Not.Null); + Assert.That(stripped[0].SelectCard!.CardId, Is.EqualTo(new[] { 810041260L })); + Assert.That(stripped[0].SelectCard.Open, Is.EqualTo(1)); + } + + [Test] + public void StripKeyActionForOpponent_drops_non_choice_types() + { + // Only Choice(1)/HaveBeforeSkillChoice(5) are handled; other KeyActionTypes are dropped + // (current behavior) until their own specs (§6). + var keyAction = new List + { + new Dictionary { ["type"] = 2L, ["cardId"] = 123L }, + }; + Assert.That(KnownListBuilder.StripKeyActionForOpponent(keyAction), Is.Null); + } + + [Test] + public void StripKeyActionForOpponent_returns_null_for_absent_keyAction() + { + Assert.That(KnownListBuilder.StripKeyActionForOpponent(null), Is.Null); + Assert.That(KnownListBuilder.StripKeyActionForOpponent(new List()), Is.Null); + } }