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:
@@ -5,13 +5,32 @@ namespace SVSim.BattleNode.Protocol.Bodies;
|
|||||||
/// <summary>Opponent-facing PlayActions frame the node synthesizes from the active player's
|
/// <summary>Opponent-facing PlayActions frame the node synthesizes from the active player's
|
||||||
/// send. <c>KnownList</c> reveals the played card's identity (null = token reveal deferred, see
|
/// send. <c>KnownList</c> reveals the played card's identity (null = token reveal deferred, see
|
||||||
/// the deterministic-turn slice). <c>OppoTargetList</c> is the renamed <c>targetList</c>
|
/// the deterministic-turn slice). <c>OppoTargetList</c> is the renamed <c>targetList</c>
|
||||||
/// (independent of KnownList — a targeted hand play carries both). Both omitted when null via the
|
/// (independent of KnownList — a targeted hand play carries both). <c>KeyAction</c> forwards a
|
||||||
/// envelope's WhenWritingNull policy.</summary>
|
/// choice/Discover play's <c>{type,cardId}</c> so the opponent renders the choice-token generation;
|
||||||
|
/// the pick (<c>selectCard</c>) 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).</summary>
|
||||||
public sealed record PlayActionsBroadcastBody(
|
public sealed record PlayActionsBroadcastBody(
|
||||||
[property: JsonPropertyName("playIdx")] int PlayIdx,
|
[property: JsonPropertyName("playIdx")] int PlayIdx,
|
||||||
[property: JsonPropertyName("type")] int Type,
|
[property: JsonPropertyName("type")] int Type,
|
||||||
[property: JsonPropertyName("knownList")] IReadOnlyList<KnownCardEntry>? KnownList,
|
[property: JsonPropertyName("knownList")] IReadOnlyList<KnownCardEntry>? KnownList,
|
||||||
[property: JsonPropertyName("oppoTargetList")] IReadOnlyList<OppoTargetEntry>? OppoTargetList) : IMsgBody;
|
[property: JsonPropertyName("oppoTargetList")] IReadOnlyList<OppoTargetEntry>? OppoTargetList,
|
||||||
|
[property: JsonPropertyName("keyAction")] IReadOnlyList<KeyActionEntry>? KeyAction = null) : IMsgBody;
|
||||||
|
|
||||||
|
/// <summary>Opponent-facing keyAction entry for a choice/Discover play. <c>type</c>/<c>cardId</c>
|
||||||
|
/// (the GENERATING card) pass through so the opponent re-derives the candidate pool from that card's
|
||||||
|
/// skill; <c>selectCard</c> 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).</summary>
|
||||||
|
public sealed record KeyActionEntry(
|
||||||
|
[property: JsonPropertyName("type")] int Type,
|
||||||
|
[property: JsonPropertyName("cardId")] long CardId,
|
||||||
|
[property: JsonPropertyName("selectCard")] SelectCardEntry? SelectCard);
|
||||||
|
|
||||||
|
/// <summary>A visible choice's revealed pick: the chosen <c>cardId</c>(s) and the <c>open</c> flag.
|
||||||
|
/// Only emitted for the open:1 pass-through case (open:0 strips the whole <c>selectCard</c>).</summary>
|
||||||
|
public sealed record SelectCardEntry(
|
||||||
|
[property: JsonPropertyName("cardId")] IReadOnlyList<long> CardId,
|
||||||
|
[property: JsonPropertyName("open")] int Open);
|
||||||
|
|
||||||
/// <summary>One revealed card in a <c>knownList</c>. Vanilla slice fills cardId from the sender's
|
/// <summary>One revealed card in a <c>knownList</c>. Vanilla slice fills cardId from the sender's
|
||||||
/// deck map and leaves spellboost 0 / attachTarget "" (cost/clan/tribe deferred to the card-master
|
/// deck map and leaves spellboost 0 / attachTarget "" (cost/clan/tribe deferred to the card-master
|
||||||
|
|||||||
@@ -56,4 +56,15 @@ internal sealed class BattleSessionState
|
|||||||
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineAddOps(orderList))
|
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineAddOps(orderList))
|
||||||
RecordToken(isSelf == 1 ? from : other, idx, cardId);
|
RecordToken(isSelf == 1 ? from : other, idx, cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Mine + record choice/Discover-token picks (<see cref="KnownListBuilder.MineChoicePicks"/>)
|
||||||
|
/// into the correct side's map, by the same <c>isSelf</c> routing as <see cref="RecordTokensFrom"/>.
|
||||||
|
/// The chosen cardId rides the generating send's <c>keyAction.selectCard</c> (not the orderList add
|
||||||
|
/// op, which carries candidates only); recorded regardless of the choice's <c>open</c> visibility —
|
||||||
|
/// an unplayed idx is never queried, so a stray record is harmless.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ internal sealed class EchoHandler : IFrameHandler
|
|||||||
{
|
{
|
||||||
var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault("orderList");
|
var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault("orderList");
|
||||||
ctx.State.RecordTokensFrom(ctx.From, ctx.Other, 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<DispatchRoute>();
|
return Array.Empty<DispatchRoute>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ using SVSim.BattleNode.Protocol.Bodies;
|
|||||||
|
|
||||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
/// <summary>PvP PlayActions translator (vanilla deck-card slice). Synthesizes the opponent-facing
|
/// <summary>PvP PlayActions translator. Synthesizes the opponent-facing knownList from the sender's
|
||||||
/// knownList from the sender's idx->cardId map + the orderList move op, renames targetList ->
|
/// idx->cardId map + the orderList move op, renames targetList -> oppoTargetList, drops orderList,
|
||||||
/// oppoTargetList, drops orderList, consumes keyAction.
|
/// and forwards a stripped keyAction for choice/Discover plays ({type,cardId}; selectCard dropped
|
||||||
/// Token plays resolve their cardId from add ops mined on earlier frames; an un-generated token
|
/// for a hidden open:0 pick). Token plays resolve their cardId from add ops (concrete tokens) or
|
||||||
/// idx still degrades to {playIdx,type} (no knownList). Bot drop (no rule).</summary>
|
/// keyAction.selectCard (choice picks) mined on earlier frames; an un-generated token idx still
|
||||||
|
/// degrades to {playIdx,type} (no knownList). Bot drop (no rule).</summary>
|
||||||
internal sealed class PlayActionsHandler : IFrameHandler
|
internal sealed class PlayActionsHandler : IFrameHandler
|
||||||
{
|
{
|
||||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
@@ -20,12 +21,17 @@ internal sealed class PlayActionsHandler : IFrameHandler
|
|||||||
var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("type"));
|
var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("type"));
|
||||||
|
|
||||||
var orderList = entries.GetValueOrDefault("orderList");
|
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
|
// 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
|
// 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).
|
// frame resolves its cardId — by whichever side ends up playing it (bullet-3 audit F1).
|
||||||
ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList);
|
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 deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
|
||||||
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList);
|
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList);
|
||||||
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList"));
|
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList"));
|
||||||
@@ -34,7 +40,10 @@ internal sealed class PlayActionsHandler : IFrameHandler
|
|||||||
PlayIdx: playIdx,
|
PlayIdx: playIdx,
|
||||||
Type: type,
|
Type: type,
|
||||||
KnownList: played is null ? null : new[] { played },
|
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 };
|
var frame = ctx.Env with { Body = body };
|
||||||
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
||||||
|
|||||||
@@ -75,6 +75,94 @@ internal static class KnownListBuilder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Mine choice/Discover-token identities: for each <c>isChoice</c> add op (idx, isSelf,
|
||||||
|
/// candidates), resolve its cardId from the keyAction <c>selectCard</c> pick whose cardId is in that
|
||||||
|
/// op's candidate pool. Yields <c>(idx, cardId, isSelf)</c> — same shape as <see cref="MineAddOps"/>,
|
||||||
|
/// routed by the same <see cref="BattleSessionState.RecordTokensFrom"/> rule. The pick is on
|
||||||
|
/// keyAction.selectCard, NOT the add op (RegisterChoiceAdd strips the concrete cardId,
|
||||||
|
/// <c>NetworkBattleSetupCardEvent.cs:531-543</c>); the candidate-membership join handles the single
|
||||||
|
/// case unambiguously (multi-choice: each chosen cardId matches the one choiceAdd whose candidates
|
||||||
|
/// contain it). <c>type</c>/<c>cardId</c>/<c>open</c> on the keyAction are ignored here — <c>open</c>
|
||||||
|
/// only gates the strip (<see cref="StripKeyActionForOpponent"/>), 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 <see cref="MineAddOps"/>.</summary>
|
||||||
|
public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineChoicePicks(object? orderList, object? keyAction)
|
||||||
|
{
|
||||||
|
if (orderList is not IEnumerable<object?> ops) yield break;
|
||||||
|
|
||||||
|
// Flatten every selectCard.cardId pick across all keyAction entries into a membership set.
|
||||||
|
var picks = new HashSet<long>();
|
||||||
|
if (keyAction is IEnumerable<object?> kaEntries)
|
||||||
|
{
|
||||||
|
foreach (var ka in kaEntries)
|
||||||
|
{
|
||||||
|
if (ka is not IDictionary<string, object?> kaDict) continue;
|
||||||
|
if (!kaDict.TryGetValue("selectCard", out var scRaw) || scRaw is not IDictionary<string, object?> sc) continue;
|
||||||
|
if (!sc.TryGetValue("cardId", out var idsRaw) || idsRaw is not IEnumerable<object?> 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<string, object?> opDict) continue;
|
||||||
|
if (!opDict.TryGetValue("add", out var addRaw) || addRaw is not IDictionary<string, object?> add) continue;
|
||||||
|
if (!add.ContainsKey("isChoice")) continue;
|
||||||
|
if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue;
|
||||||
|
if (!card.TryGetValue("candidates", out var candRaw) || candRaw is not IEnumerable<object?> 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<object?> idxList) continue;
|
||||||
|
foreach (var i in idxList)
|
||||||
|
yield return ((int)AsLong(i), chosen.Value, isSelf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map an inbound keyAction (the active player's send) to the opponent-facing list:
|
||||||
|
/// for each Choice(1)/HaveBeforeSkillChoice(5) entry, keep <c>{type,cardId}</c> and drop
|
||||||
|
/// <c>selectCard</c> when its <c>open==0</c> (hidden draw-to-hand pick stays secret), pass it
|
||||||
|
/// through when <c>open==1</c> (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).</summary>
|
||||||
|
public static IReadOnlyList<KeyActionEntry>? StripKeyActionForOpponent(object? keyAction)
|
||||||
|
{
|
||||||
|
if (keyAction is not IEnumerable<object?> entries) return null;
|
||||||
|
var result = new List<KeyActionEntry>();
|
||||||
|
foreach (var e in entries)
|
||||||
|
{
|
||||||
|
if (e is not IDictionary<string, object?> 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<string, object?> sc)
|
||||||
|
{
|
||||||
|
sc.TryGetValue("open", out var openRaw);
|
||||||
|
var open = (int)AsLong(openRaw);
|
||||||
|
if (open != 0 && sc.TryGetValue("cardId", out var idsRaw) && idsRaw is IEnumerable<object?> ids)
|
||||||
|
selectCard = new SelectCardEntry(ids.Select(AsLong).ToList(), open);
|
||||||
|
}
|
||||||
|
result.Add(new KeyActionEntry(type, cardId, selectCard));
|
||||||
|
}
|
||||||
|
return result.Count == 0 ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Rename <c>targetList</c> -> <c>oppoTargetList</c>; <c>isSelf</c> is actor-relative
|
/// <summary>Rename <c>targetList</c> -> <c>oppoTargetList</c>; <c>isSelf</c> is actor-relative
|
||||||
/// and passes through unchanged (F2). Null for a missing/empty list.</summary>
|
/// and passes through unchanged (F2). Null for a missing/empty list.</summary>
|
||||||
public static IReadOnlyList<OppoTargetEntry>? RenameTargets(object? targetList)
|
public static IReadOnlyList<OppoTargetEntry>? RenameTargets(object? targetList)
|
||||||
|
|||||||
@@ -352,6 +352,126 @@ public class CaptureConformanceTests
|
|||||||
Assert.That(ourEntry.GetProperty("cardId").GetInt64(), Is.EqualTo(900811111L));
|
Assert.That(ourEntry.GetProperty("cardId").GetInt64(), Is.EqualTo(900811111L));
|
||||||
Assert.That(ourEntry.GetProperty("to").GetInt32(), Is.EqualTo(20));
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -350,6 +350,104 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(pb.KnownList[0].To, Is.EqualTo(20));
|
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]
|
[Test]
|
||||||
public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops()
|
public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -205,4 +205,135 @@ public class KnownListBuilderTests
|
|||||||
var mined = KnownListBuilder.MineAddOps(orderList).ToList();
|
var mined = KnownListBuilder.MineAddOps(orderList).ToList();
|
||||||
Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L, 1), (32, 900811090L, 1) }));
|
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