Behavior-preserving; full solution builds, 1013 tests green.
ClassId is the one genuinely-closed set of the three flagged stringly fields, so it
becomes a CardClass enum (1..8). Wire stays "1".."8": producer casts
(CardClass)run.ClassId, ServerBattleFrames renders via CardClassWire.ToWireValue().
RankBattleController's AI-start path drops a fragile int.TryParse(...)?:-1 for (int)cast.
CharaId (free-form leader/skin id, e.g. "5000123") and CountryCode (open-ended account
data) stay string with proper XML docs; CountryCodes.Korea/Japan name the captured values.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Behavior-preserving; 231 BattleNode tests green.
FrameDispatchContext.BothAfterReady() -> BothSidesAfterReady() (7 call sites). The
4 inline `SenderPhase == AfterReady` checks in TurnEndHandler/TurnEndFinalHandler now
read a new SenderIsAfterReady property. Both carry cross-referencing docs so the
Bot-arm (sender-only) vs PvP-arm (both-sides) distinction is explicit at the type.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Behavior-preserving; 271 BattleNode/Matching/Services tests green, full solution builds.
"BattleType" meant two things: the Sessions.BattleType enum (Pvp/Bot) and an int
"mode id" field. Renamed the int field on MatchContext AND the BattleStartBody wire
DTO to BattleModeId (wire key stays "battleType" via JsonPropertyName), so BattleType
now means only the enum project-wide.
New Bridge/BattleModes.cs (TakeTwo = 11) replaces every 11 literal — both prod
MatchContextBuilder sites and the test fixtures/assertions. The arbitrary-passthrough
42 and bot 0 stay literal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Behavior-preserving; 231 BattleNode tests green.
One enum conflated two axes. Split:
- HandshakePhase (per participant): AwaitingInitNetwork..AfterReady. On
IHasHandshakePhase.Phase, FrameDispatchContext.SenderPhase, the handler gates.
- SessionLifecycle (per battle): Active | Terminal. On the renamed
BattleSessionState.Lifecycle (was SessionPhase, defaulting to a handshake value)
and BattleSession.Lifecycle (was Phase). Reads are only != Terminal, so the
Active default is behavior-identical.
OpponentTurn was dead (never assigned) -> dropped. BattleSessionPhase deleted; the
two axes can no longer be cross-assigned.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Behavior-preserving; 231 BattleNode tests green.
- MinedToken record struct replaces the transpose-prone (int Idx, long CardId,
CardOwner IsSelf) tuple returned by KnownListBuilder.Mine*. Positional deconstruct
keeps the Record*From call sites unchanged.
- enum Stock { Normal, Bypass } replaces the negative `bool noStock` on
IBattleParticipant.PushAsync and DispatchRoute, threaded through both participants,
BattleSession, and all handler construction sites.
- enum KeyActionType mirrors the client's SendKeyActionDataManager.KeyActionType;
the StripKeyActionForOpponent guard compares named values, KeyActionEntry.Type is
the enum (wire-identical via JsonNumberEnumConverter).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MineAddOps/MineChoicePicks/MineCopyTokens return types and all
extraction casts changed from int to CardOwner. The 4 routing
comparisons in BattleSessionState now read isSelf == CardOwner.Self
instead of isSelf == 1.
No wire or behavioral change — CardOwner was already in use on the
wire-facing side (OppoTargetEntry, UnapprovedCardEntry); this extends
it to the internal mining path so the bare-int transpose risk is gone.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
RegisterPending → TryRegisterPending (TryAdd instead of indexer) so
battle-id collisions return false instead of silently evicting a live
battle. MatchingBridge retries with fresh IDs on collision (max 5).
Before registering, EvictStaleForViewer removes any stale pending
battle the viewer left behind, enforcing the one-pending-per-viewer
invariant that was previously comment-asserted.
Store tests switched to per-test local stores to fix a race under
the assembly-wide ParallelScope.All.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
#3: SocketIoFrame.Parse now range-checks the packet type char (was
unchecked cast — any char outside 0-6 produced an undefined enum
value) and uses int.TryParse for ack-id (was int.Parse — a >10-digit
ack-id threw OverflowException, tearing down the WS mid-game). Both
now throw ArgumentException consistently. The read loop in
RealParticipant wraps both EIO and SIO parse calls with try-catch so
a malformed frame is logged and skipped instead of killing the battle.
#4: MatchedSelfInfo/MatchedOppoInfo OppoId and Seed narrowed from
long to int. The client reads both with Convert.ToInt32 inside a
swallowing try/catch — any value > int.MaxValue silently dropped the
Matched event, preventing the battle from starting. Seed was already
int-range (BattleSeeds.Stable returns int); OppoId (viewer ID) is
~847M in captures, well under int.MaxValue. The narrowing cast now
happens explicitly in ServerBattleFrames.BuildMatched at the wire
boundary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bot roster pick was hashing (UserName, ClassId) — same player always
faced the same bot class. Now hashes battleId so different matches get
different opponents while retries of the same pending battle stay
consistent.
AI start response hardcoded Seed=0 for both sides, so the client's
deck shuffle/mulligan/draw RNG was deterministic every match. The
BattleNode's per-battle MasterSeed (Random.Shared) was never sent to
bot-mode clients because InitBattleHandler skips the Matched frame.
Now populates Seed with Random.Shared.Next() on the HTTP response.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
In PvP a BattleSession subscribes to both participants' FrameEmitted, and each
RealParticipant raises it from its own WebSocket read loop -- two threads. The
dispatch path (ComputeFrames + the relay PushAsync calls) mutates shared,
non-thread-safe state: the BattleSessionState dictionaries (deck maps, post-swap
hands, idx->cardId reveal map). Concurrent frames from both players could corrupt
those dictionaries (InvalidOperationException / torn playSeq / wrong card identity).
Add a per-session SemaphoreSlim _dispatchGate around the whole HandleFrameAsync so
both read loops funnel through one critical section. ComputeFrames stays lock-free
(the direct-call test seam is single-threaded).
Analysis during the fix showed each OutboundSequencer is single-writer-per-instance
in steady state (A's loop only writes B's Outbound and vice-versa), so the live race
is the shared BattleSessionState, which the gate fully serializes.
TDD: BattleSessionDispatchConcurrencyTests drives both participants to AfterReady,
then fires TurnStart from both at once; the target PushAsync records peak in-flight
dispatches. Red (MaxConcurrent=2) before the gate, green (1) after.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
BuildForRankBattleAsync projected deck.Cards.Select(c => c.Card.Id),
discarding Count. DeckCard is count-based (one row per unique card +
a Count), so a 3-copy card shipped to the node as a single in-battle
card -- matched decks showed 1 of each card instead of the real count.
Expand each row by its Count so SelfDeckCardIds carries one entry per
physical card. TwoPick path is unaffected (flat per-pick list).
Add a regression test seeding 3+2+1 copies (failed Expected 6/was 3).
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>