[Timestamp] byte[] doesn't work under SQLite (the test backend) — EF
expects the DB to populate it on insert, but SQLite has no equivalent
of Postgres's xmin. The WHERE Status = Unclaimed filter plus
IInventoryService's viewer-level concurrency is the practical defense;
RowVersion was only a backstop. Regenerated the migration without the
RowVersion column.
Wire reward_type on the gift endpoint uses a gift-specific scheme that
diverges from UserGoodsType for currencies: wire 1 = Crystal (enum=2),
wire 9 = Rupy (enum=9), wire 4 = Item (enum=4). A naked cast resolves
wire 1 to UserGoodsType.RedEther and silently grants the wrong wallet
— restored the explicit WireRewardTypeToUserGoodsType map from the old
tutorial controller.
Retrofits existing GiftControllerTests to call SeedTutorialPresentsAsync
on the new helper (RegisterViewer doesn't auto-seed; only the prod
signup path does). All 7 existing tests pass.
Mirrors banners pattern: clear-and-rewrite from per-table JSON seed.
Ships one entry pointing at parent_gacha_id 80032 to match the
2026-06-03 prod capture.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Walk-down behavior: each call emits the highest-priority unfired
active dialog; subsequent calls walk to the next-priority entry.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Singleton keyed by ShortUdid; lock on per-viewer set to avoid
cross-viewer contention. Process lifetime — restart re-fires.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Window is [begin, end) — exclusive upper bound. Ordered priority-DESC
then Id-ASC so the controller can break on the first match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step 7 of multi-instancing migration. Residual SVSim.UnitTests that touch
engine code directly are wrapped in TestBattleScope. EngineSessionGate is
deleted along with the _engineOwned bookkeeping in BattleSession; engine
setup is unconditional now that per-battle state is isolated on the ambient.
Gate-specific fallback branches in BattleSession.ShadowIngest are simplified.
Suite fully green (SVSim.UnitTests, SVSim.BattleEngine.Tests).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Source the played card's opponent-facing knownList[].cardId off the shadow engine
(SessionBattleEngine.PlayedCardId -> BattleCardBase.CardId), engine-first with the
wire-mined idx->cardId map as the fallback. PROVEN engine-resolved (each backed by a
HeadlessConductorTests PlayedCardId_* test): deck cards and receive-path substituted/
revealed tokens (engine seats the wire id at the wire idx).
PARTIAL retirement: the wire-mining bookkeeping (MineAddOps/MineChoicePicks/MineCopyTokens
+ Record*From) is KEPT as the load-bearing fallback. The choice/Discover, copy/clone and
cross-side (isSelf:0) token cases are NOT proven to resolve at a wire idx headless — the
autonomous token_draw path seats a chosen token at engine Index 0 (would collide with the
leader), and copy/cross-side aren't cheaply fixturable. Deleting their mining on faith
would silently corrupt opponent reveals, so it stays behind a TODO(M-HC-4f) gate.
- SessionBattleEngine.PlayedCardId: new accessor mirroring PlayedCardClan/Tribe.
- BuildPlayedCard: signature deckMap->explicit cardId; null on cardId==0 (no engine id AND
no mined/deck-map fallback).
- PlayActionsHandler: cardId = engine.PlayedCardId(seat, idx, fallback: mapped) ; mining retained.
- Tests: PlayedCardId_* (deck/substituted/degrade pass; choice-gap [Explicit] documents the
Index-0 finding). KnownListBuilder + CaptureConformance call-sites updated to new signature.
Full BattleNode suite 263/263 green; HeadlessConductorTests 27/27; drift clean; no Engine edits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prod always emits clan (int ClanType) + tribe (comma-joined int TribeType
string, "0" for none) on every knownList entry (battle-traffic_tk2_regular
.ndjson). Source both off the resolved engine (SessionBattleEngine.PlayedCardClan/
PlayedCardTribe -> BattleCardBase.Clan/Tribe), so skill-applied clan/tribe
changes ride the wire rather than the static card-master value. Thread through
KnownListBuilder.BuildPlayedCard + PlayActionsHandler; add clan/tribe to the
KnownCardEntry DTO (always present, non-null). Node-side only; no engine edits,
drift clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A targeted hand-play (PLAY_HAND_SELECT, opcode 31) and a choice play (PLAY_HAND +
keyAction type Choice) both resolve headless through the recovery receive conductor
with NO new shim/view fills — the 4a/4b view seeds (DetailPanelControl, _inPlayFrameEffect,
_playerInfoPair, HeadlessConductorVfxMgr) already cover the target/choice surface, because
the recovery path resolves targets/choices from the wire frame without the interactive
select UI, and the damage/token VFX execute through the existing top-level InstantVfx path.
Fixtures (cards.json, full skill mechanics):
- single-target: 100414020 (cost-1 Dragoncraft spell, when_play damage=2 to a selected
enemy unit). Asserts the enemy 1/4 (101411060) drops to life 2 — exact magnitude, survives.
- choice: 127011010 (cost-1 Neutral choice follower, choose 1 of 2 tokens to add to hand).
Asserts the chosen token (B) lands in hand and the un-chosen token (A) does not — decisive
about WHICH branch resolved. Wire keyAction shape cross-checked against a real capture of
this exact card (battle_test/rng/battle-traffic_cl1.ndjson); the receiver consumes a flat
selectCard list (ConvertToListInt).
Drivers: NodeNativeBattleHarness.TargetedPlayBody (reuses the {targetIdx,vid,selectSkillIndex}
target shape proven by AttackBody) + ChoicePlayBody. Zero Engine/*.cs edits (drift clean).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drive ATTACK frames through the headless receive conductor and assert on engine
board state (node-native harness). Two cases: follower -> enemy leader (leader
life drops by atk, attacker spent) and a lethal follower-vs-follower trade (both
removed). ATTACK opcode confirmed = 10 (NetworkBattleDefine.PlayActionType).
Headless view-untangle (no Engine logic edits; drift clean):
- IBattlePlayerView.AttackSelectControl -> non-null HeadlessAttackSelectControl
(no-op RegisterAttackPair/ResetCardAfterAttack); IsCardTranslatable left to base.
- IBattleCardView.CardInfo -> backing card via BuildInfo (so IsCardTranslatable
reads authentic IsClass); class/null view ctors now chain : base(buildInfo).
- IBattleCardView._inPlayFrameEffect -> non-null no-op control.
- Seed Certification.viewer_id headless so the IsRecovery target parse
(vid != UserViewerID) does not throw inside SavedataManager and silently drop
the parsed targetList.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The headless engine accumulates spell-charge for real on the receive path
(each spell play runs the played card's own AddSpellChargeCount) and resolves
the discounted cost by construction, so the wire-derived spellboost-count
bookkeeping is redundant. Engine-source the knownList spellboost COUNT too
(prod-faithful) via a new SessionBattleEngine.PlayedCardSpellboost, using the
same persist-post-play zone search as PlayedCardCost (SpellChargeCount survives
PlayCard; only ctor/ReturnCard zero it).
- Delete IdxToSpellboost/SpellboostMap/GetSpellboostMap/RecordSpellboostFrom
(BattleSessionState) and MineAlterSpellboosts (KnownListBuilder); token/choice/
copy identity maps are untouched.
- BuildPlayedCard takes an engine-sourced spellboost int (drops spellboostMap).
- Seed BattleLogManager fusion lists headless (the per-frame filter cleanup
NREs on null EnemyFusionCard when a fanfare card registers a CalledCreateFilter)
so real spell-charge grantor plays resolve.
- Add committed real-charge regression tests (no SeedHandCardSpellboostCost seam):
one grantor play accumulates +1 on the reducer -> cost 5->4, count 1, persisting
post-play; handler emits cost 4 + spellboost 1 engine-sourced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The opponent-facing PlayActions knownList now carries the engine-RESOLVED
play-time cost (KnownCardEntry.cost), sourced from the headless shadow engine's
PlayedCost on the just-resolved card. This closes the spellboost cost-desync BY
CONSTRUCTION: the engine already knows the true discounted cost (spellboost +
board modifiers folded in), so no bookkeeping is needed.
- DTO: add non-nullable cost to KnownCardEntry (prod emits cost 45/45).
- SessionBattleEngine.PlayedCardCost(seat, idx, fallback): finds the resolved
card by engine Index across in-play/cemetery/hand zones and returns PlayedCost
(captured by PlayCard at resolution == discounted Cost), degrading to fallback
when the engine is not owned/ready.
- PlayActionsHandler sources the played card's cost from ctx.Engine (ShadowIngest
already resolved the play before the handler runs). Spellboost-map plumbing
stays for now; Task 6 (M-HC-3b) retires it.
- Validation: engine-read test (charge-seeded reducer 101314020: base 5, cost
5/1/0 at charge 0/4/5) + handler-emit test asserting knownList[0].cost == 1
(discounted, not base 5) with non-vacuity. Board-dependent (when_evolve_other)
case deferred to M-HC-4 (evolve not yet headless); cost is read off the resolved
engine so board modifiers are captured by construction once their ops resolve.
- Harness: promote alt vanilla follower id (101211120) to AltVanillaFollowerId.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drive a node-native battle to seat B's turn, then ingest an opponent
PlayActions reveal frame (knownList[{idx,cardId,to:Field}], isPlayerSeat:false)
matching battle_test_cl2's wire shape. The engine's ReplaceReceivedCard.ReplaceCard
-> CreateActualCard -> CreateBattleCardWithGameObject path resolves headless and
seats the substituted card on seat B's board with the wire cardId. No Engine/ logic
edits and no new view shims were needed — the card-creation view surface is fully
covered by the BackGround/icon-anim/play-queue/hand stubs from Tasks 2/3.
Adds InPlayCardId(seat, boardPos) accessor (SessionBattleEngine + harness) to read a
seated in-play follower's true identity, leader-excluded like BoardCount.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 2's WireMulliganPhase already installed the full mulligan delegate
set (Swap/Ready, not just Deal) via MulliganEventSetting, and the
mulligan + turn-draw mutations flow through VfxMgr.RegisterSequentialVfx
— which HeadlessConductorVfxMgr runs for InstantVfx. So Swap/Ready/
TurnStart/TurnEnd resolve headless with ZERO new shim/seed/view fills.
Adds the M-HC-1 milestone assertions: a mulligan-swap test (post-swap
hand holds deck idx 1,2,4 — idx-3 swapped for the next unused idx) and a
two-turn test (Deal->Swap->Ready->TurnStart/TurnEnd x2) asserting the
engine's deterministic node-native progression on both seats
(hand/deck/PP/turn/leader-life) at each boundary. Frame shapes mirror the
captured battle_test_cl1 receive stream (self/oppo pos-idx lists, spin).
Harness/node: +DeckCount/Turn board-state pass-throughs (test reads).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The engine's receive CONDUCTOR fuses each authoritative mutation behind a view
call: the play mutation is an InstantVfx registered to VfxMgr, and the deal hand
is seated by MulliganPhaseBase.StartDeal wired to OperateReceive.OnReceiveDeal.
Headless, the shared VfxMgr no-op'd registration (correct for the direct
ActionProcessor path the M2-M12 oracles use) and OnReceiveDeal was never wired,
so the receive path resolved nothing.
Untangle (Candidate B, zero Engine logic edits):
- InstantVfx.Run() opt-in executor (authored shim).
- HeadlessConductorVfxMgr : VfxMgr runs registered InstantVfx; wired only via the
node's SessionContentsCreator.CreateVfxMgr (verified the receive mgr's VfxMgr
comes from there — BattleManagerBase.cs:768). M2-M12 use HeadlessContentsCreator,
so they're isolated by construction.
- WireMulliganPhase: construct NetworkMulliganPhase + MulliganEventSetting() to
install OnReceiveDeal -> StartDeal (the node never pumps the phase machine).
View no-op surface (the 7 from the probe, minus 1 not hit; +1 emergent):
- Deal wiring (NetworkMulliganPhase) [node seed]
- MulliganInfoControl._partsPlayer/_partsOpponent._exchangeMark/_keepZone/_abandonZone [node seed: prefab + SeedMulliganInfoControl]
- Data.BattleRecoveryInfo (IsMulliganEnd=false) [EngineGlobalInit seed]
- IBattlePlayerView.PlayQueueView -> HeadlessPlayQueueViewStub [_IfaceImpl.g.cs, both getters]
- DetailMgr.DetailPanelControl/SubDetailPanelControl [node seed]
- BattleCardIconAnimations.collection (emergent: UpdateInPlayBattleCardIconLabel) -> HeadlessIconAnimations empty SkillCollectionBase [_IfaceImpl.g.cs]
- BattleMenuBtn (probe item 7): NOT hit on the vanilla path; not seeded.
Oracle (HeadlessConductorTests): node Deal seats 3-card hand; a vanilla
hand-card Play leaves hand (-1), adds board (+1), drops PP by cost.
Regression: 24/24 BattleEngine.Tests oracles (M2-M12) green; 241/241
SVSim.UnitTests BattleNode green. The 2 SessionEngine capture-replay shadow
tests are marked Ignore (superseded): they passed VACUOUSLY when the receive
path resolved nothing; with resolution live they hit the documented
capture-replay draw-misalignment artifact. Node-native battles are the oracle.
Drift: no drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The node hardcoded knownList.spellboost=0 on every played card. Prod sends
the true accumulated count, which the client reads straight into the card's
cost model; with 0 the opponent computes the card at full price and silently
rejects the play in OperateReceiveChecker.IsPlayCard (PP-over -> ConductError
-> NullOperationCollection -> no render/echo), desyncing the board.
Mine spellboost-count changes from the sender''s orderList alter ops
(MineAlterSpellboosts: a/s/h ops), accumulate per-side idx->count in
BattleSessionState (RecordSpellboostFrom), and surface the current count on
the played card via BuildPlayedCard. Recorded from the authoritative
PlayActions only (never the Echo) and folded in AFTER the played card is
built, since a card''s cost is fixed as it leaves hand and a play that grants
spellboost targets the rest of the hand.
Also adds a [sio-in-body] full-body inbound log to RealParticipant to capture
both clients'' re-simulated responses for PvP RNG verification.
Co-Authored-By: Claude Opus 4.8 <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; 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>