Commit Graph

129 Commits

Author SHA1 Message Date
gamer147
24180d5b4b refactor(battle-node): de-magic wire flags and scattered constants
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>
2026-06-04 20:46:09 -04:00
gamer147
3f5d97cb2f feat(battle-node): derive Matched.seed + Ready.idxChangeSeed from master seed
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>
2026-06-04 18:20:51 -04:00
gamer147
6f7fcfe28e feat(battle-node): per-battle master seed + node-side deck shuffle
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>
2026-06-04 18:14:14 -04:00
gamer147
11c98bf67b feat(battle-node): BattleSeeds — stable per-battle seed derivation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:13:06 -04:00
gamer147
75f3d8ea5b revert(battle-node): remove real-spin logic (CountHiddenDraws + per-frame spin)
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>
2026-06-04 16:07:08 -04:00
gamer147
617714ebea feat(battle-node): emit real spin per-frame on forwarded PlayActions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:13:47 -04:00
gamer147
63cb3248b4 feat(battle-node): CountHiddenDraws — hidden shared-RNG draw tally for real spin
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:13:47 -04:00
gamer147
7bd2c0f2d7 test(battle-node): lock relayed uList shape vs prod recv capture (line 75)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:20:03 -04:00
gamer147
a0aa58cfbe feat(battle-node): relay uList on PvP PlayActions
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>
2026-06-04 11:18:20 -04:00
gamer147
c0309061fa feat(battle-node): UnapprovedCardEntry + RelayUList pure transform
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>
2026-06-04 11:17:10 -04:00
gamer147
61080adace test(battle-node): lock copy-op parse vs prod capture line 196
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:12:20 -04:00
gamer147
b6edfbcf15 feat(battle-node): reveal copy tokens on play via baseIdx resolution
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>
2026-06-04 10:11:34 -04:00
gamer147
f9c7e6124b feat(battle-node): resolve copy-token cardIds from baseIdx (pure)
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>
2026-06-04 10:09:36 -04:00
gamer147
5c3835f4fd 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>
2026-06-04 08:53:48 -04:00
gamer147
62251482e4 feat(battle-node): cross-side gift + Echo-frame token mining
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>
2026-06-04 07:59:46 -04:00
gamer147
155ccf0a48 test(battle-node): lock token-reveal knownList shape vs prod capture line 96
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:41:59 -04:00
gamer147
d8b5ef950d feat(battle-node): reveal generated tokens on play via remembered identity
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>
2026-06-03 23:36:44 -04:00
gamer147
b6af8bfb7d feat(battle-node): mine generated-token cardIds from orderList add ops
KnownListBuilder.MineAddOps extracts (idx,cardId) from isSelf:1 add ops,
skipping cross-side gifts and choice tokens. Bullet-3 audit F1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:30:47 -04:00
gamer147
4b38a9d3e0 test(battle-node): rename ScriptedBotCtx test helper to FakeOpponentCtx
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>
2026-06-03 20:57:58 -04:00
gamer147
ba18790156 refactor(battle-node): rename ScriptedLifecycle->ServerBattleFrames, ScriptedProfiles->BattleFrameDefaults
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>
2026-06-03 20:36:32 -04:00
gamer147
e9493e24c4 refactor(battle-node): drop BattleType.Scripted and the scripted-only builders
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>
2026-06-03 20:27:57 -04:00
gamer147
b0e3783757 refactor(battle-node): drop dead MatchingResolver options param; fix stray BOM
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:23:57 -04:00
gamer147
f21ab7a38c refactor(battle-node): remove ScriptedBotParticipant and dev-affordance wiring
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>
2026-06-03 20:15:48 -04:00
gamer147
963adbbd1b test(battle-node): delete scripted participant + scripted-only builder tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:55:00 -04:00
gamer147
3fe378d801 test(battle-node): drop scripted dispatch tests; retarget generic fixture to PvP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:49:49 -04:00
gamer147
3ccd986e65 test(battle-node): drop scripted smoke test; retarget deck-plumbing test to PvP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:42:20 -04:00
gamer147
3feb535072 test(battle-node): drop dead ViewerId const + refresh stale coverage doc
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>
2026-06-03 19:37:29 -04:00
gamer147
a916afe924 test(battle-node): drive the conformance oracle via two-client PvP
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>
2026-06-03 19:33:58 -04:00
gamer147
e98bd10dbe fix(battle-node): reflect PvP Judge back to its sender (turn handover)
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>
2026-06-03 18:45:17 -04:00
gamer147
c360d639f2 refactor(battle-node): address final-review minor notes (comments + test backfill)
- 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>
2026-06-03 18:26:07 -04:00
gamer147
bca94648f7 test(battle-node): update PvpHandshakeAndGameplay to deterministic-turn translator contract
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>
2026-06-03 18:21:01 -04:00
gamer147
f0026972cb test(battle-node): ground synthesized knownList shape against prod recv capture
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:13:42 -04:00
gamer147
f9c671c089 feat(battle-node): TurnEndActionsHandler emits empty body to opponent in PvP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:11:41 -04:00
gamer147
58994a53c9 feat(battle-node): JudgeHandler emits {spin:0} to opponent in PvP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:09:44 -04:00
gamer147
3c8a00c928 feat(battle-node): TurnEndHandler emits {turnState:0} to opponent only in PvP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:07:44 -04:00
gamer147
6e85a6b2db feat(battle-node): TurnStartHandler emits {spin:0} to opponent in PvP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:05:15 -04:00
gamer147
6b580c622d feat(battle-node): EchoHandler consumes Echo instead of relaying
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:03:19 -04:00
gamer147
506d286529 feat(battle-node): PlayActionsHandler synthesizes knownList (vanilla deck-card slice)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:59:54 -04:00
gamer147
030d3b8057 feat(battle-node): KnownListBuilder pure transforms (knownList synth, target rename)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:56:12 -04:00
gamer147
b295fd8f09 feat(battle-node): per-side idx->cardId map on BattleSessionState
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:53:32 -04:00
gamer147
afe2984075 test(battle-node): drive PvP flow handshakes through the mulligan barrier
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>
2026-06-03 10:58:33 -04:00
gamer147
feb387d3d5 test(battle-node): real scripted bot drives handshake through the mulligan barrier
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:53:51 -04:00
gamer147
2d31037648 fix(battle-node): type-agnostic mulligan barrier withholds Ready until both swap
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>
2026-06-03 10:52:33 -04:00
gamer147
8052ed60ec refactor(battle-node): scripted bot drives the handshake as a real participant
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>
2026-06-03 10:51:08 -04:00
gamer147
a533e9d89d feat(battle-node): client-shaped handshake builders for the scripted bot
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:49:38 -04:00
gamer147
633c29b44f feat(battle-node): BuildReady overload carrying the opponent's hand
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>
2026-06-03 10:48:43 -04:00
gamer147
ae11fe0957 fix(battle-node): assign turnState per side instead of hardcoding 0
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>
2026-06-03 10:47:56 -04:00
gamer147
c551d7b05e refactor(battle-node): drop dead BattleResult.{Lose,Win,Consistency} members
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>
2026-06-02 21:17:30 -04:00
gamer147
d76b96b339 test(battle-node): lock server-authored frame shapes against prod captures
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>
2026-06-02 20:03:56 -04:00
gamer147
a198174ede fix(battle-node): involuntary-drop survivor gets DisconnectWin, not Win=NoContest
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>
2026-06-02 19:26:16 -04:00