Quality pass from the 2026-06-04 BattleNode review (audit in the outer
repo). All changes are behavior-preserving — identical wire bytes,
verified by the full 1008-test suite staying green.
- Name scattered magic numbers: crypto key/IV lengths, outbound-sequencer
base, WS receive buffer / EIO ping / SID length, polite-close timeout,
upgrade-credential keys, battle-id digit math, deterministic-turn spin.
- resultCode = 1 -> (int)ReceiveNodeResultCode.Success across body records.
- Pong "3" -> EngineIoPacketType.Pong; remove dead NoOpBotParticipant.Touch
(replace with #pragma warning disable CS0067).
- Wire-flag enums, serialized as numbers via JsonNumberEnumConverter:
turnState -> TurnState{First,Second}, isSelf -> CardOwner{Opponent,Self},
open -> ChoiceVisibility{Hidden,Open}.
- isOfficial / isInvoke -> bool / bool? via new NumericBoolJsonConverter
(reads/writes 0/1; TDD'd). Scoped to the BattleNode wire boundary only;
MatchContext and the HTTP/AI-start path stay int (AI-start uses -1 as a
sentinel, so it is not boolean).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
InitBattle now emits Stable(master) as the shared effect seed and the master-
shuffled deck as selfDeck; Swap emits each recipient's per-side IdxChange seed.
BattleSession exposes + logs the master seed per battle for future replay.
Updated lifecycle/dispatch/integration tests (deck assertions now permutation-
based since selfDeck is shuffled).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GetOrSeedDeckMap now seeds from a Fisher-Yates shuffle of the deck keyed by the
per-battle MasterSeed, so the reveal map and the wire selfDeck share one
shuffled order. Updated the existing build-order test to the shuffle semantics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two-sided capture (data_dumps/captures/battle_test/rng, 2026-06-04) showed the
receiver already reproduces uList-relayed deck fetches (Hoverboard) and turn
draws on its own shared stream, so the emitted spin=1 double-cranked and desynced
the clients by 1. Residual spin is ~0 for the current card pool. Reverts 63cb324
and 617714e; back to the prior correct spin:0 behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Forwards the sender's deck-sourced summons/fetches to the opponent
(closes the spin-independent slice of direct-to-field summons). uList
coexists with the synthesized knownList in the same frame.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verbatim uList relay shape + transform (deck-sourced summons/fetches),
mirroring RenameTargets. Not yet wired into the handler.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PlayActionsHandler + EchoHandler now call RecordCopyTokensFrom (ordered
after plain/choice mining) to resolve a copy add's baseIdx against the
side's live idx->cardId map and record copyIdx->cardId. A copy played in a
later (or same) frame synthesizes a knownList instead of degrading.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KnownListBuilder.MineCopyTokens resolves a copy add's baseIdx against the
actor's own idx->cardId map (self/other by isSelf), yielding (idx,cardId,
isSelf). Skips concrete/choice adds, string (private-group) baseIdx, and
unknown sources (degrade). Third token-reveal slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Close the two generated-token gaps that desynced PvP live test #3 (the
Forestcraft Fairy), both sourced from the 2026-06-03 decomp-validation table.
- MineAddOps now returns (idx, cardId, isSelf) and no longer drops isSelf:0.
isSelf is the sender's perspective tag on CardObj.IsPlayer (RegisterToken.cs:22)
and a card has one CardObj.Index, so an isSelf:0 add is the opponent's card.
- New shared BattleSessionState.RecordTokensFrom routes isSelf:1 -> sender,
isSelf:0 -> opponent (the gift lives in the recipient's map, consulted when
they play it). PlayActionsHandler delegates to it.
- EchoHandler now mines via the same helper but still returns no routes. An
Echo's orderList carries the same add-op shape as a send (MakeEchoData ->
MakeCommonSendAndEchoCardData), so MineAddOps applies verbatim; mining != relaying.
Choice/copy/private-group adds stay skipped (no concrete cardId). Full solution
963/963 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PlayActionsHandler mines add ops into BattleSessionState.RecordToken each
frame; a token played in a later frame now synthesizes a knownList from the
remembered cardId instead of degrading. Bullet-3 audit F1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure private-helper rename in the two lifecycle test fixtures for lexical
hygiene — matches the kept ServerBattleFrames.FakeOpponentViewerId. The
fixture is a fake opponent MatchContext, never a "scripted bot". No behavior
change; both fixtures green (20/20).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure rename. These hold the shared server-authored frame builders used by every
battle mode's handshake/mulligan dispatch — the 'Scripted' name was a historical
accident that hid the PvP/Bot crossover. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removes the Scripted enum value, the bot's client-shaped emissions (BuildClient*),
the canned opponent turn (BuildOpponent*), and OpponentTurnStartSpin. The shared
server-frame builders (Matched/BattleStart/Deal/Swap/Ready + ComputeHandAfterSwap)
and OpponentJudgeSpin (Bot mode) stay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deletes the scripted opponent and every entry point that created a
BattleType.Scripted session (the ?scripted=1 query opt-in, the
SoloDefaultsToScripted toggle, the resolver short-circuit, the WS handler case,
the bridge validation arm). Real two-client PvP and the Bot matchmaking-timeout
fallback are untouched. ResolveAsync drops its scriptedOptIn parameter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up cleanup to the two-client PvP conformance drive. The class-level
ViewerId const is no longer referenced (both remaining `ViewerId:` sites are
the MsgEnvelope named ctor arg, passing `vid`/literal 1), and the Coverage
doc-comment still described "a single Scripted session" — refresh it to the
two-client PvP reality. No behavior change; tests 2/2 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The golden-match oracle harvested all ten server-authored frames from a single
Scripted client. Re-point it at a two-client PvP session (same shared builders
for handshake/mulligan, real turn-cycle frames for TurnStart/TurnEnd/Judge) so
the oracle survives removal of the scripted bot. Category-based shape check is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live two-client run (data_dumps/captures/battle_test) exposed a turn-handover
stall: ending a turn on client A made BOTH clients show A's turn again; the
opponent never got a turn. Root cause: JudgeHandler routed the {spin:0} Judge to
ctx.Other. The client rule is 'receive opponent TurnEnd -> SendJudge', so the
PASSIVE player (the one taking over the turn) is the Judge sender, and 'receive
Judge -> ControlTurnStartPlayer' starts the RECEIVER's turn. Routing to ctx.Other
delivered the Judge to the player who had just ended their turn, restarting it in
a closed loop while the taker-over sat on 'Opponent's Turn'.
Fix: the PvP Judge {spin} reflects back to ctx.From (the sender / turn taker-over),
matching the Bot arm's existing 'Judge to sender only' handover. The sender then
emits TurnStart, which relays to the opponent as {spin}. Updated the dispatch unit
test and the PvpHandshakeAndGameplay integration test to the real handover order
(passive sends Judge -> receives it back -> sends TurnStart -> opponent sees it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- PlayActionsHandler doc: drop the phantom 'with a debug log' (handlers are
stateless singletons with no logger); say token plays degrade silently.
- KnownListBuilder.ExtractMoveTo doc: note first-match-wins semantics and the
send-side==recv-side 'to' assumption pending recv-capture confirmation.
- KnownListBuilderTests: add multi-move first-match coverage and the
in-deck-but-no-matching-move null branch for BuildPlayedCard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The end-to-end PvP gameplay test asserted the pre-translator relay contract
(A's TurnEnd broadcasts TurnEnd+Judge to both sides). Tasks 7/8 replaced that
with the per-URI translator: the active player ends its turn by sending TurnEnd
then Judge, the opponent receives the translated {turnState:0}/{spin:0} frames,
and the sender receives nothing. Rewrote the gameplay section to drive and
assert the new contract. PlayActions remains delivered to the opponent (Uri
preserved, body now synthesized by PlayActionsHandler).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The three PvP BattleNodeFlowTests drove each client's handshake to Ready
independently; the new barrier withholds Ready until both sides swap, so the
single-client helper timed out. Split DriveHandshakeAsync into DriveThroughSwapAsync
(stops at SwapResponse) + DrivePvpHandshakeAsync (drives both, then drains the
barrier-released Ready for each). Scripted/Bot single-client paths are unaffected
(non-IHasHandshakePhase opponent releases Ready immediately).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ready was sent per-side immediately carrying the placeholder opponent hand, so
one client cleared mulligan before the other. The barrier now releases Ready to
every IHasHandshakePhase participant only once all have swapped, each carrying
the opponent's real post-mulligan hand. No Type check — NoOp (Bot/AINetwork)
isn't a phase impl, so that mode still releases immediately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements IHasHandshakePhase and emits client-shaped InitNetwork/InitBattle/
Loaded/Swap (reacting to the session's pushes) instead of being a passive
TurnEnd-only fixture the session narrates around. This is what lets the
type-agnostic mulligan barrier (next task) work in Scripted mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds BuildReady(selfHand, oppoHand) for the mulligan barrier; the single-arg
overload keeps the InitialHand placeholder for non-interactive opponents.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both PvP clients received turnState:0 ('both go first'). BuildBattleStart
now takes turnState; the Loaded arm assigns 0 to A, 1 to B — no Type check,
correct in Scripted (real player = A = first) and PvP (first arriver first).
Updated three existing BuildBattleStart callers in the test suite to pass
turnState:0 (the param is now required).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No dispatch arm has emitted these since the Retire/Kill rewrite to RetireWin=105
/ RetireLose=106. Remove them and the docstring paragraph that explained them.
Test fallout: delete BattleFinishBody_LoseAndConsistency_SerializeAsZeroAndTwo
(its only purpose was locking the dead wire values), and re-point
BattleFinishBody_SerializesResultAndResultCode_AsNumericWireValues at the live
LifeWin=101 so it still guards the JsonNumberEnumConverter numeric-wire behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add CaptureConformanceTests: drive one Scripted lifecycle, harvest all ten
server-authored synchronize frames (InitNetwork/Matched/BattleStart/Deal/Swap/
Ready/TurnStart/TurnEnd/Judge/BattleFinish), re-serialize via MsgEnvelope.ToJson,
and diff each against representative prod TK2 capture frames embedded as a
fixture. Comparison is capture-subset-of-ours on body shape (recursive keys +
value category), so missing/miscased/mistyped fields fail but extra envelope
fields we emit don't; pure sequencing keys are excluded.
Because PvP reuses the same ScriptedLifecycle builders for the handshake/mulligan
frames, this transitively locks the PvP handshake shape -- a regression oracle
that outlives the June-2026 server shutdown.
Also replace the stale v1-only README with a pointer to the canonical
docs/battle-node.md hub (outer repo).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code-review follow-up to the dispatch unification (0a8a84b).
1. The RunAsync drop cascade synthesized BattleFinish(Win=1), which the client
renders as RESULT_CODE.NoContest ("battle ended in no contest") instead of a
win. Add DisconnectWin=201 (already in the client enum, routes to WIN UI) and
ship it for involuntary opponent drops. Update PvpMidGameDisconnect_FullCascade.
2. Remove BuildBattleFinishNoContest() — dead since the Retire/Kill arm moved to
RetireWin/RetireLose.
3. Correct the BattleResult docstring: Lose/Win/Consistency are no longer emitted
by any dispatch arm; they survive only as serialization-test constants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>