Six distinct fixes accumulated over live-test iterations against four bids
(654473755566, 806245601092, 283192092460, 131549100204, 799755786270) — together
they take the shadow engine from "throws on the first non-mulligan play" to
"survives a full PvP battle, only weird-edge-case Unity touches still left to whack".
1. Engine StableRandom seed aligned with clients' Matched.seed
(BattleSession.EnsureEngineSetup, NodeNativeBattleHarness.Create). Clients seed
_stableRandom with BattleSeeds.Stable(masterSeed) (the value the node ships in
Matched.seed); we were passing the RAW masterSeed to engine.Setup, so every
StableRandom call diverged from call #1 onward — every turn-1+ draw picked a
different deck position than the clients. Verified Stable(1184631275)=1543475792
matches the wire on bid 654473755566.
2. SeedDeck advances cardTotalNum to deck.Count+1 + pins BattleStartDeckCardList.
Mirrors SBattleLoad.InitPlayer's tail (SBattleLoad.cs:1292). Without it,
skill-generated tokens auto-assigned Index 0,1,... and COLLIDED with deck-loaded
indices 1..40 — silent until something addressed the deck card with the
colliding Index (Hoverboarder at deck idx 1 + a token at engine Index 1 made
GetBattleCardIdx's SingleOrDefault throw on bid 806245601092).
3. BattleCardView.GameObject lazily non-null in the shim (ViewUiTouchStubs.cs).
The IsRecovery card-create delegate (NetworkBattleManagerBase.cs:379) passes
null cardGameObject; Skill_metamorphose.cs:147 in the in-play branch then NRE'd
on `metamorphosedCard.BattleCardView.GameObject.transform.rotation = identity`,
a purely cosmetic touch with no game-state implication. Bid 283192092460:
Petrification on a board follower.
4. TranslateChoiceKeyAction unwraps wrapped selectCard on shadow ingest
(SessionBattleEngine.cs, sibling to TranslateTargetOwners). Live sender-send
wires Choice plays as selectCard:{cardId:[...], open:0}; engine's
ConvertToListInt does `value as List<object>` — a Dict casts to null and
foreach NREs. The receiver's swallow-all catch (NetworkBattleReceiver.cs:1255)
logs to Debug.LogError + LocalLog — both shimmed/no-op'd headlessly — and
returns false, but Receive calls ReceivedMessage with checkBreakData:false so
the false isn't propagated. The play continues with choiceIdList=[], the chosen
branch never resolves, the played card stays in hand; a later targeted play
(A's bounce on B's "board" idx 20) then can't find the target → NRE on null in
ActionProcessor.PlayCard:407. Bid 131549100204: B's Resonance + A's bounce.
Opponent-relay path is unaffected — node strips selectCard from broadcasts.
5. HeadlessHandViewStub overrides HandUnfocus/HandFocus/FocusRearrangeHandHand
to return NullVfx. CreateHandControl returns null in headless; the base
methods unconditionally deref `_handControl.SetHandState(...)`. A follower
with a when_spell_play Heal trigger fired on its leader for amount 0 — even
a 0-heal drives ApplyHealing → CreatePullHandInVfx → HandUnfocus → NRE.
Bid 799755786270: two consecutive spell plays both crashed this stack.
Added InternalsVisibleTo("SVSim.BattleEngine.Tests") so the shim-level
regression tests can pin the no-op contracts directly.
Plus the previous-session fixes carried in this same uncommitted state
(see docs/superpowers/plans/2026-06-07-shadow-engine-desync-handoff.md):
- doesPlayerGoFirst:true + mgr.IsFirst:true (turn-1 draw count correct
per seat)
- RecoveryOperationCollection.PlayHandCardOperation routes all type:30
through PlaySkillSelectHandCardOperation (skips the two-phase user-select
guard that aborts targeted spells in recovery)
- ShadowFeed + ToRawBody: server-generated typed bodies (DealBody, etc.)
converted to RawBody before engine.Receive (`env.Body as RawBody`
returned null for typed bodies)
- Ready idxChangeSeed seeds A's XorShift via the receiver; B's seed is
injected via SeedOppoIdxChange (BattleSeeds.IdxChange + viewerId)
- ReadySpin defaulted to 0 (was 243) — non-zero double-cranks the shadow
which ingests BOTH sides' Ready frames on one stream
Test counts: SVSim.UnitTests 1054/1054, SVSim.BattleEngine.Tests 34/34.
Open: known-residual Unity touches are individual whack-a-mole now (per-card
skill edge cases), not the structural divergences fixed here.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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; 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>
#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>
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>
Both now derive per-battle from the master seed via BattleSeeds; only
animation/UI constants (ReadySpin, rank/battlePoint placeholders) remain in
BattleFrameDefaults.
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>
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>
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>
Phase 2 absorbed the scripted opponent cosmetics + class/chara fixture
into ScriptedBotParticipant.Context; the two profile fields have been
unreferenced since (kept one phase as documentation tie-back, per PLAN.md
L104 (d)). The Context comments now describe the values directly with
frame[N] provenance instead of pointing at the deleted fields. Also
removes the now-unused SVSim.BattleNode.Protocol.Bodies import from
ScriptedProfiles.cs.
948 tests passing (unchanged).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both helpers now take the opponent's MatchContext + an explicit seed
instead of pulling ScriptedProfiles.OpponentMatchedProfile / OpponentBattleStartProfile
internally. ScriptedBotParticipant.Context fixture absorbs the cosmetic
fields previously hardcoded in ScriptedProfiles so Scripted's wire bytes
stay identical - verified by integration tests still green.
Phase 2 prep: PvP arms will call the same helpers with the real opponent
participant's Context.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the third frame of the burst. Wire shape from prod (spin + resultCode).
OpponentJudgeSpin const next to OpponentTurnStartSpin for consistency.
Single test locks uri, ViewerId, Cat, and body shape.
Final-review follow-ups:
- BuildOpponentTurnStart's doc comment claimed the v1 client sits
indefinitely — true before the loop closure, false after. Updated
to describe the pair with BuildOpponentTurnEnd.
- TypedBodyWireShapeTests had no coverage for BuildOpponentTurnEnd;
added the literal-JSON test so a future JsonPropertyName rename
on TurnEndBody is caught.
Audit Md4 cleanup: the prior long[] allowed in-place modification by any
caller with the field reference. ImmutableArray<long> enforces the constant
contract at the type level. ComputeHandAfterSwap uses ToArray() to produce
its mutable working copy.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ClassId/CharaId/CardMasterName/BattleType flow from ctx. PlayerBattleStart
Profile removed; Rank/BattlePoint remain as standalone consts pending real
per-viewer rank tracker. One test updated, one new test added.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
selfInfo cosmetics + 30-card selfDeck now read from MatchContext. Opponent
half stays in ScriptedProfiles. DummyCardId / BuildDummyDeck / PlayerMatched
Profile removed. Two new tests lock the deck-idx pairing and cosmetic
flow-through; TypedBodyWireShapeTests + lifecycle tests thread a fixture ctx.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a per-project README in SVSim.BattleNode/ that covers:
- Architecture (the six concern folders)
- The connect-handshake sequence verified end-to-end at smoke
- A wire-format-gotchas table for the spec divergences caught during
v1 (headers vs query for credentials, schemeless node URL with
/socket.io/ path, required card_master_id, required resultCode=1,
Matched in response to InitBattle not InitNetwork, EIO3 0x04 prefix
on binary frames, FromJson conditional-expression number-boxing)
- What the v1 scripted opponent does and what is hardcoded
- A "where to extend" table for v2 work
- The full test layout and cross-references to specs/plans
Fill in XML docs on the public surface that previously had none:
- BattleNodeExtensions.AddBattleNode / UseBattleNode (DI + middleware
wiring, including the pipeline-order note that auth runs before
UseWebSockets)
- BattleNodeWebSocketHandler class + HandleAsync (the validation chain)
- BattleSession.ComputeResponses (the lifecycle state machine, with
the NoStock flag's meaning)
- ScriptedLifecycle class (v1 scope, resultCode injection rule,
pointer to the "where to extend" section)
- MatchingBridge class (mint-id + register flow)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues caught during v1 smoke at the mulligan / first-turn boundary:
1) BuildSwapResponse ignored the player's idxList and echoed the same
3-card hand back. The client diffs the new self[] against the Deal
to compute "drawn cards" — empty diff against the same hand throws
"Card swap failed: AbandonCards[X]/DrawCards[]". Replace swapped
idxs with fresh deck idxs (initial hand was 1/2/3, deck has 4..30
still available). Same hand must flow into Ready since the client
diffs again there. Move the hand computation into a new helper
ComputeHandAfterSwap and have ComputeResponses thread it through
both BuildSwapResponse and BuildReady.
2) The client doesn't transition to the "Opponent's turn…" display
on its own after sending TurnEnd — it waits for the server to push
an opponent TurnStart (per prod TK2 capture line 14). Without it
the UI just sits on the end-of-turn frame. Add a TurnEnd handler
that pushes a minimal TurnStart{spin} and transitions to a new
OpponentTurn phase, which IS the documented v1 stopping point.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The client's OnReceived routing drops any synchronize push whose
resultCode != Success(1) — and absent counts as 0(None), which is
also dropped. Our InitNetwork ack and BattleFinish already included
resultCode=1, but the five lifecycle bodies (Matched, BattleStart,
Deal, Swap response, Ready) didn't, so the client silently dropped
every one of them.
Symptom: battle-traffic.ndjson capture showed the client receiving
InitNetwork/Matched/BattleStart, but the UI stayed at the matchmaking
screen until timeout — Matched/BattleStart were dropped at the
routing layer before they ever reached the state machine. Move the
resultCode injection into the shared EnvelopeForPush helper so every
scripted push gets it.
Caught during v1 smoke walkthrough.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>