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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-04 08:53:48 -04:00
parent 62251482e4
commit 5c3835f4fd
8 changed files with 488 additions and 9 deletions

View File

@@ -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<int, long> { [18] = 810014030L };
var orderList = new List<object?>
{
new Dictionary<string, object?> { ["move"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 18L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L } },
new Dictionary<string, object?> { ["add"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 46L }, ["isSelf"] = 1L,
["card"] = new Dictionary<string, object?>
{ ["candidates"] = new List<object?> { 810041260L, 101041020L } },
["isChoice"] = "1" } },
};
var keyActionIn = new List<object?>
{
new Dictionary<string, object?>
{
["type"] = 1L, ["cardId"] = 810014030L,
["selectCard"] = new Dictionary<string, object?>
{ ["cardId"] = new List<object?> { 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<string>();
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<object?>
{
new Dictionary<string, object?> { ["add"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 46L }, ["isSelf"] = 1L,
["card"] = new Dictionary<string, object?>
{ ["candidates"] = new List<object?> { 810041260L, 101041020L } },
["isChoice"] = "1" } },
};
var keyAction = new List<object?>
{
new Dictionary<string, object?>
{
["type"] = 1L, ["cardId"] = 810014030L,
["selectCard"] = new Dictionary<string, object?>
{ ["cardId"] = new List<object?> { 810041260L }, ["open"] = 0L },
}
};
var map = new Dictionary<int, long>();
foreach (var (idx, cardId, _) in SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.MineChoicePicks(generatingOrderList, keyAction))
map[idx] = cardId;
var playOrderList = new List<object?>
{
new Dictionary<string, object?> { ["move"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 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));
}
}
/// <summary>