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:
@@ -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<string, object?>
|
||||
{
|
||||
["playIdx"] = 3L,
|
||||
["type"] = 30L,
|
||||
["orderList"] = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?> { ["move"] = new Dictionary<string, object?>
|
||||
{ ["idx"] = new List<object?> { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L } },
|
||||
new Dictionary<string, object?> { ["add"] = new Dictionary<string, object?>
|
||||
{ ["idx"] = new List<object?> { 31L }, ["isSelf"] = 1L,
|
||||
["card"] = new Dictionary<string, object?>
|
||||
{ ["candidates"] = new List<object?> { 810041260L, 101041020L } },
|
||||
["isChoice"] = "1" } },
|
||||
new Dictionary<string, object?> { ["move"] = new Dictionary<string, object?>
|
||||
{ ["idx"] = new List<object?> { 31L }, ["isSelf"] = 1L, ["from"] = 50L, ["to"] = 10L } },
|
||||
},
|
||||
["keyAction"] = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = 1L, ["cardId"] = 100_011_010L,
|
||||
["selectCard"] = new Dictionary<string, object?>
|
||||
{ ["cardId"] = new List<object?> { 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<string, object?>
|
||||
{
|
||||
["playIdx"] = 3L,
|
||||
["type"] = 30L,
|
||||
["orderList"] = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?> { ["move"] = new Dictionary<string, object?>
|
||||
{ ["idx"] = new List<object?> { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L } },
|
||||
new Dictionary<string, object?> { ["add"] = new Dictionary<string, object?>
|
||||
{ ["idx"] = new List<object?> { 31L }, ["isSelf"] = 1L,
|
||||
["card"] = new Dictionary<string, object?>
|
||||
{ ["candidates"] = new List<object?> { 810041260L, 101041020L } },
|
||||
["isChoice"] = "1" } },
|
||||
},
|
||||
["keyAction"] = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = 1L, ["cardId"] = 100_011_010L,
|
||||
["selectCard"] = new Dictionary<string, object?>
|
||||
{ ["cardId"] = new List<object?> { 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()
|
||||
{
|
||||
|
||||
@@ -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<string, object?> ChoiceAddOp(long idx, long[] candidates, long isSelf = 1) => new()
|
||||
{
|
||||
["add"] = new Dictionary<string, object?>
|
||||
{
|
||||
["idx"] = new List<object?> { idx },
|
||||
["isSelf"] = isSelf,
|
||||
["card"] = new Dictionary<string, object?>
|
||||
{
|
||||
["candidates"] = candidates.Select(c => (object?)c).ToList(),
|
||||
},
|
||||
["isChoice"] = "1",
|
||||
}
|
||||
};
|
||||
|
||||
// A keyAction entry: { type, cardId (the GENERATING card), selectCard:{ cardId:[chosen...], open } }.
|
||||
private static List<object?> KeyActionChoice(long generatingCardId, long[] chosen, long open) => new()
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = 1L,
|
||||
["cardId"] = generatingCardId,
|
||||
["selectCard"] = new Dictionary<string, object?>
|
||||
{
|
||||
["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<object?> { 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<object?> { 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<object?> { 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<object?> { 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<object?> { 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<object?>
|
||||
{
|
||||
new Dictionary<string, object?> { ["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<object?>()), Is.Null);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user