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>
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>
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 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>
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>
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>
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>
Three dispatch arms had Type-based branching that was either wrong or
unnecessary. Unified per the audit doc's recommended order, grounded in
verified facts about each participant's PushAsync.
(1) TurnEndFinal — was branched: PvP broadcast TurnEnd+Judge (wrong on a
game-end signal); Scripted pushed BattleFinish(LifeWin). Unified:
- forward the envelope to other (matches prod TK2 capture
battle-traffic_tk2_regular.ndjson:273 — loser receives TurnEndFinal
from server before BattleFinish)
- push BattleFinish(LifeWin) to from (winner)
- push BattleFinish(LifeLose) to other (loser)
- Phase → Terminal
Requires ScriptedBotParticipant.PushAsync to no longer fire its 3-frame
burst on TurnEndFinal (previously it reacted to both TurnEnd and
TurnEndFinal). The dispatch arm now owns TurnEndFinal's response; the
bot reacting too would race with the BattleFinish push. Bot still
fires on regular TurnEnd as before.
(2) Retire / Kill — was branched: PvP pushed Lose=0 (NotFinish) /
Win=1 (NoContest); Scripted pushed BuildBattleFinishNoContest() (Win=1).
Both shipped wrong RESULT_CODE values; the audit doc's outstanding item
documented this. Unified:
- push BattleFinish(RetireLose=106) to from (the retirer)
- push BattleFinish(RetireWin=105) to other (the survivor)
- Phase → Terminal
Added RetireWin=105 / RetireLose=106 to BattleResult enum with the
same player-perspective convention.
(3) PvP gameplay forwarder (TurnStart / PlayActions / Echo /
TurnEndActions / JudgeResult) — had a redundant `Type == BattleType.Pvp`
guard. Verified that BothAfterReady() is naturally only true when both
participants are RealParticipant (ScriptedBot / NoOpBot don't implement
IHasHandshakePhase per RealParticipant.cs:20-23 / Participants/*.cs grep).
Dropped the redundant guard.
Bot type still has its dedicated InitBattle/Loaded/TurnEnd arms above
the unified ones, so Bot-specific behavior is unchanged.
Tests: 177 battle-node tests passing.
- Updated 9 tests to match the unified dispatch (paired BattleFinish
pushes, correct RESULT_CODE values, forwarded TurnEndFinal envelope).
- ScriptedBotParticipantTests.PushAsync_TurnEndFinal_* rewritten to
assert the bot does NOT fire on TurnEndFinal (was asserting it did).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
End-to-end trace of FinishBattleEffect proved my prior direction was
backwards. The path is:
RESULT_CODE → JudgeResultReceive switch (NetworkBattleManagerBase:1439-1459)
→ SettingResultUI_SpecialResultTypeText
→ _finishEffectType = battleResult
→ eventually FinishBattleEffect(:1267-1316):
bool isPlayer = false;
switch (_finishEffectType) {
case WIN: isPlayer = true; break;
case LOSE: isPlayer = false; break;
}
InitiateGameEndSequence(!isPlayer); // NEGATED
→ BattleManagerBase.InitiateGameEndSequence(hasWon):
hasWon=true → WIN screen; hasWon=false → LOSE screen.
So LifeWin=101 (player perspective: "I won by life") → _finishEffectType=LOSE
→ isPlayer=false → hasWon=true → WIN UI. And LifeLose=102 ("I lost") → LOSE UI.
My prior misread treated the inner switch's BATTLE_RESULT_TYPE param as
the final UI render — but that param only feeds the secondary "by retire
/ by disconnect" text, not the primary WIN/LOSE. The real flip happens at
FinishBattleEffect:1315's !isPlayer negation.
User's live repro (bot HP to 0 → LOSS screen) confirmed the inversion.
The prior prod TK2 capture interpretation was also corrected: line 274
`result:102` was a LOSS capture (player lost to the opponent's attack on
line 271), not a win as I claimed earlier.
Changes:
- BattleResult.cs: docstring rewritten with the full FinishBattleEffect
trace. Members reordered (LifeWin first since it's used by Scripted).
- BattleSession.cs:267: Scripted TurnEndFinal arm pushes LifeWin instead
of LifeLose.
- Test updated to assert LifeWin=101 + describe the inversion lesson so
the next reader sees the prior bug context.
177 battle-node tests passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Before: when the player declared their final winning turn (TurnEndFinal),
Scripted mode forwarded it to the bot — which fired a useless 3-frame
TurnStart/TurnEnd/Judge burst as if the game were continuing. No
BattleFinish was ever pushed, so the client's
BattleFinishToOpponentDisConnectChecker (NetworkBattleManagerBase.cs:1640
+ BattleFinishToOpponentDisConnectChecker.cs) parked the player on a
"waiting for opponent" dialog for 128 seconds, eventually falling through
to a synthetic OnDisConnectWin. The user could see "opponent defeated"
animations but couldn't proceed to the post-battle screen.
After: Scripted TurnEndFinal pushes BattleFinish with result=LifeLose=102
to the player (matches the RESULT_CODE the client expects per
NetworkBattleReceiver.cs:963-986; client maps LifeLose → "opponent's life
ran out, PLAYER WIN" UI per NetworkBattleManagerBase.cs:1450-1459). Phase
transitions to Terminal so RunAsync's PvP-disconnect cascade doesn't
synthesize a second BattleFinish on top. No bot burst — the game is
over.
Wire reference: prod TK2 capture battle-traffic_tk2_regular.ndjson:273-274
shows server pushing TurnEndFinal followed immediately by BattleFinish
result:102.
BattleResult enum gets the LifeWin=101 / LifeLose=102 values and a
corrected docstring. The pre-existing Lose=0 / Win=1 / Consistency=2
values stay (Retire/Kill flow ships them today and works as "no contest"
end-of-battle), but their docstring no longer claims they're the WS
shape — they were always the HTTP /finish shape, mislabeled.
TurnEnd (regular, not final) keeps the existing forward-to-bot behavior
in Scripted mode — that's a normal turn boundary, not game end.
PvP TurnEndFinal still broadcasts the same TurnEnd+Judge as regular
TurnEnd; the actual game-end BattleFinish push in PvP rides the loser's
Retire/Kill or the disconnect cascade in RunAsync.
177 battle-node tests passing (was 176; +1 covering the new dispatch arm).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The prior 'hand'-ack fix worked in test but failed in prod because both
the handler and the test used the wrong wire shape. Re-tracing the
client emit path:
RealTimeNetworkAgent.cs:783-786 (msg path):
return MessagePackSerializer.Serialize(
CryptAES.encryptForNode(JsonMapper.ToJson(info))); // ← encrypted
RealTimeNetworkAgent.cs:815-817 (hand path):
return MessagePackSerializer.Serialize(
JsonMapper.ToJson(info)); // ← NOT encrypted
And EmitFrontStockData:717-723 picks "hand" as the SIO event name only
when frontData["StockHandData"] exists; in that branch it passes the
StockHandData list (NOT the dict) to CreatePackEmitHandData. So the
wire body is:
msgpack_string(JsonMapper.ToJson(List<object>))
i.e. a JSON array, unencrypted. EmitMsgUriPack:1456-1458 puts pubSeq at
index 3 of that array (after uri_int / viewerId / udid). The dict's
top-level pubSeq stays client-local for stockEmitMessageMgr.GetSelectData.
Handler now:
- Skips NodeCrypto.DecryptForNode (was throwing FormatException on the
unencrypted bytes — caught and swallowed silently by the existing
outer try/catch, so the bug presented as 'no warning, no ack')
- Parses RootElement.ValueKind:
- Array → arr[3] is the pubSeq
- Object → top-level "pubSeq" (defensive; not used by prod today)
- Falls back to ack arg=0 if neither extraction works (the client's
GetSelectData lookup misses but its OnAck path still fires — same as a
normal cache-miss — so the queue still drains)
Diagnostic [hand-rx] log added (gated by DiagnosticLogging) so we can
see the actual body content per-frame during verification.
Test was also wrong (encrypted dict shape); rewritten to use the real
wire shape (unencrypted JSON array). +1 net new test covering the
dict-shape defensive path.
176 battle-node tests passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Scripted-bot softlock root cause: client-stocked SELECT_SKILL_URI /
SLIDE_OBJECT_URI hand emits (e.g. target selection on unit play / leader
attack) arrive as SIO BinaryEvent("hand", ...) with an ack-id. Our
DispatchSocketIo only had cases for "msg" and "alive" — "hand" fell to
the default Debug-drop with no SIO ack going back. Client's
stockEmitMessageMgr (RealTimeNetworkAgent.cs:1463) blocks subsequent
emits until the previous one is acked, so all follow-up PlayActions /
TurnEndActions / TurnEnd frames were stocked but never transmitted. The
loader hooks at EmitMsg (intent) not the socket layer, which is why
battle-traffic.ndjson shows the frames as sent while the server never
received them. ~10s later the client gives up and aborts the WS.
Wire-shape proof from data_dumps/captures/logs/websocket_output.txt:
line 619: [sio-in] uri=TurnStart pubSeq=17 ackId=16 ... (T3 start)
line 689: [ws-rx-text] preview=451-26["hand", {...}] ← unhandled
line 691: [ws-rx-bin] binLen=58 pendingFrame=hand
(no further [sio-in] entries — server received nothing else)
line 709: [ws-recv-exit] reason=OperationCanceled wsState=Aborted
New HandleHandEventAsync (RealParticipant.cs):
- Fire-and-forget hand frames (no ack-id; TOUCH_URI / SELECT_OBJECT_URI /
TURN_END_READY_URI) are silently swallowed — no queue-blocking risk
- Stocked hand frames decode the binary attachment via the same
msgpack-string + NodeCrypto.Decrypt pipeline as HandleMsgEventAsync,
parse the JSON, extract top-level "pubSeq", and SendSioAckAsync with
that pubSeq as the ack arg (matches what stockEmitMessageMgr.GetSelectData
expects to look up)
- Body shape is {"StockHandData":[uri_int, viewerId, udid, ...params,
pubSeq], "try":0, "pubSeq":N} — NOT a MsgEnvelope (no top-level "uri"),
so we can't reuse HandleMsgEventAsync as-is
- Missing-pubSeq fallback acks with arg=0 (rare path, logged at Warning)
so we never softlock from a malformed body
WireConstants gets the HandEvent = "hand" constant for the dispatch case.
In scripted/Bot mode the ack-only handler is correct (no opponent to
forward touches to). PvP-side forwarding semantics are unverified — see
docs/audits/battle-node-sio-events-2026-06-02.md (outer repo) for the
full event inventory and remaining gaps.
Tests:
- RealParticipantHandEventTests covers the three paths: stocked-with-ack,
fire-and-forget (no ack expected), missing-pubSeq fallback (arg=0). Each
drives a real hand frame through RunAsync via TestWebSocket and asserts
the SIO ack frame shape (43<ackId>[<arg>]) in outbound sends.
- 175 battle-node tests passing (was 172; +3 new). Full suite green.
Diagnostic logs ([sio-in] / [sio-out] / [ws-rx-text] / [ws-rx-bin] /
[ws-recv-exit] / [ws-loop-exit]) are left in place for one verification
cycle. After a live re-run confirms the fix, they should be stripped per
the audit doc's recommended-order step 2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes audit Md11. BattleSession.RunAsync now clears each
RealParticipant.Outbound archive immediately before the TerminateAsync
cascade, releasing the heavy dict the moment the battle ends instead of
waiting for the participant to be GC'd. Bots (NoOp / Scripted) don't
expose an OutboundSequencer, so the 'p is RealParticipant rp' conditional
cast is the natural filter.
Tests: 1 new BattleSessionTerminateCascadeTests — pre-load the archive,
drive RunAsync to completion via TestWebSocket.CompleteIncoming, assert
the archive is empty. Suite: 939 → 948.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous commit 51e9dd2 changed Bot's InitBattle to push Matched and
Loaded to push BattleStart+Deal, on the theory that the architecture
spec's "no Matched in Bot mode" claim was wrong. That theory was based
on misreading Matching.cs:400 (the Matched handler) as a required
state-machine trigger.
End-to-end trace of the AI client flow shows:
1. _initNetworkSuccess (set when the client receives uri=InitNetwork,
i.e., our ack) is the actual trigger — MatchingNetworkConnectChecker
phase 3 sees it and calls MatchingInitBattle.
2. MatchingInitBattle (Matching.cs:298) for IsAINetwork IMMEDIATELY
calls StartBattleLoad + GotoBattle right after emitting InitBattle.
It does NOT wait for any wire envelope.
3. The Matched handler at Matching.cs:400 is gated on
status == Connect and is already past Prepared by the time the
wire round-trip completes — sending Matched is harmless but
unnecessary.
4. The BattleStart handler at Matching.cs:417 runs UNCONDITIONALLY and
SetNetworkInfo at RealTimeNetworkAgent.cs:1562 overwrites
OppoBattleStartInfo with the wire envelope's oppoInfo. Our oppoInfo
comes from NoOpBotParticipant.Context placeholders (classId/emblemId
etc. = 0), corrupting the good values the client set from the HTTP
AIBattleStart response.
The "Waiting for opponent" hang was caused by SBattleLoad.LoadOpponentAssets
trying to fetch emblemId=0, degreeId=0, etc. after BattleStart corrupted
OppoBattleStartInfo. The asset group load silently hangs on missing
assets, no error logged.
Restored the spec's original Bot arms: InitBattle ack-only, Loaded silent,
TurnEnd Judge-to-sender. ai-passive.md updated with the corrected reasoning
and a discovery-history note.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 3 shipped a Bot dispatch table that ack'd InitBattle without
pushing Matched and stayed silent on Loaded, per the architecture spec's
inference that "the client uses AIBattleStart HTTP data instead of
Matched in Bot mode." That inference was wrong.
The client's matching state machine (Matching.ReactionReceiveUri,
Matching.cs:400) gates StartBattleLoad() on the Matched envelope, and
BattleStart at Matching.cs:417 triggers GotoBattle. Without those
envelopes the client never transitions out of MatchingStatus.Connect —
which renders as the "Waiting for opponent" hang on the loading screen.
AIBattleStart HTTP only provides opponent cosmetics, not state-machine
triggers.
Fix: drop the Bot-specific InitBattle ack-only and Loaded silent arms;
let Bot fall through to the existing handshake arms that push Matched
and BattleStart + Deal. Only TurnEnd stays Bot-specific (Judge to
sender, not broadcast — there's no real other side to broadcast to).
Tests updated to match the corrected contract. ai-passive.md doc
amended with a correction note.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three new arms gated on Type == BattleType.Bot, placed before the
existing PvP / Scripted arms:
- InitBattle → ack to sender (no Matched push — client uses AIBattleStart HTTP data)
- Loaded → silent (no BattleStart, no Deal — client short-circuits to GotoBattle)
- TurnEnd / TurnEndFinal → Judge to sender only (not broadcast)
Other URIs in Bot mode fall through existing arms: Swap is Type-agnostic
(per-sender SwapResponse + Ready), Retire/Kill hits the existing
Scripted no-contest BattleFinish(Win), gameplay forwarders are gated on
Pvp so Bot's PlayActions/Echo/etc. fall through default (drop). 8 new
dispatch tests cover the wire contract.
Reference: docs/api-spec/in-battle/ai-passive.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RealParticipant gains _sessionFinished TCS + MarkSessionFinished /
AwaitSessionFinishedAsync. PvP first-arriver's handler awaits the
signal instead of calling self.RunAsync (which the session does
internally on the same instance — double-call would race the WS read).
BattleSession.RunAsync branches on Type: Pvp uses WhenAny + synthesize
BattleFinish(Win) to survivor + WhenAll(drain); Scripted/Bot keep
Phase 1's WhenAll-everything semantics. Disconnect cascade now drives
end-of-battle when a WS drops without a graceful Retire.