Commit Graph

85 Commits

Author SHA1 Message Date
gamer147
ac78e809cd refactor(battle-node): clear residual scripted-bot prose from comments/docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:52:41 -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
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
8085119439 refactor(battle-node): tidy residue after scripted dispatch-arm removal
Remove the now-unused SVSim.BattleNode.Lifecycle using from
FrameDispatchContext (it was only needed for ScriptedLifecycle inside
the deleted IsScriptedBot helper) and reword the SenderPhase doc comment
so it no longer references the removed dispatch-test scripted-bot stub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:06:25 -04:00
gamer147
ca9ad5db8f refactor(battle-node): remove scripted-bot test-stub arms from dispatch handlers
The IsScriptedBot(ctx.From) forwards in JudgeHandler/TurnStartHandler/TurnEndHandler
and the 'if Type==Scripted' raw-forward only ever fired for ScriptedBotParticipant
emissions; NoOpBot (Bot mode) never emits, so they are dead. Routing is now purely
PvP-vs-Bot. Drops the IsScriptedBot helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:00:57 -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
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
268b864e28 refactor(battle-node): delete legacy ComputeFrames switch; dispatch is now lookup-or-drop 2026-06-03 14:48:33 -04:00
gamer147
503c382646 refactor(battle-node): extract ForwardWhenBothReadyHandler; share handler instances via BuildHandlers 2026-06-03 14:33:26 -04:00
gamer147
db2f711894 refactor(battle-node): extract JudgeHandler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:30:40 -04:00
gamer147
aacd7b56ad refactor(battle-node): extract TurnStartHandler
Unions the two legacy TurnStart arms (IsRealForwardableFromScripted case 11 +
BothAfterReady case 12) into TurnStartHandler. Both arms produce (Other, Env, false)
with no extra guards or state mutations — union is behavior-equivalent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:27:17 -04:00
gamer147
c03fb3c139 refactor(battle-node): extract RetireKillHandler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:24:35 -04:00
gamer147
d35818360f refactor(battle-node): extract TurnEndFinalHandler 2026-06-03 14:21:54 -04:00
gamer147
538099ff4b refactor(battle-node): extract TurnEndHandler 2026-06-03 14:20:25 -04:00
gamer147
477faf3df3 refactor(battle-node): extract SwapHandler (mulligan barrier) 2026-06-03 14:13:26 -04:00
gamer147
3e2931b085 refactor(battle-node): extract LoadedHandler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:10:33 -04:00
gamer147
e5ec8a0de1 refactor(battle-node): extract InitBattleHandler 2026-06-03 14:07:49 -04:00
gamer147
7c36933c06 refactor(battle-node): extract InitNetworkHandler 2026-06-03 14:04:58 -04:00
gamer147
73d2c4e1b8 refactor(battle-node): add frame-handler contract, context, and empty registry shim 2026-06-03 14:03:11 -04:00
gamer147
4f89463f9c refactor(battle-node): extract frame factories into BattleFrames 2026-06-03 13:56:41 -04:00
gamer147
85c43a9a72 refactor(battle-node): move session phase + post-swap hands into BattleSessionState 2026-06-03 13:47:35 -04:00
gamer147
95554cee04 refactor(battle-node): name ComputeFrames routes as DispatchRoute 2026-06-03 13:43:39 -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
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
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
gamer147
0a8a84b2cc refactor(battle-node): unify TurnEndFinal / Retire-Kill / gameplay-forwarder dispatch across types
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>
2026-06-02 18:37:24 -04:00
gamer147
1685b509c3 fix(battle-node): TurnEndFinal pushes LifeWin=101 (player perspective), not LifeLose=102
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>
2026-06-02 18:11:24 -04:00
gamer147
ee23985055 fix(battle-node): push BattleFinish on Scripted TurnEndFinal so the client doesn't park on the disconnect-checker
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>
2026-06-02 17:48:20 -04:00
gamer147
c7e61c6f8d fix(battle-node): hand events are unencrypted JSON arrays, not encrypted dicts
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>
2026-06-02 17:14:13 -04:00
gamer147
8f270a87f0 refactor(battle-node): gate WS diagnostic logging behind config flag
The temporary [sio-in] / [sio-out] / [ws-rx-text] / [ws-rx-bin] /
[ws-recv-exit] / [ws-loop-exit] logs added during the hand-ack
investigation are useful enough to keep around (PvP testing, future WS
debugging) but too chatty to leave on by default. Promote them from
"strip before merge" to a permanent opt-in.

New BattleNodeOptions.DiagnosticLogging (bool, default false). Wired
through BattleNodeWebSocketHandler to RealParticipant via a new optional
ctor parameter (default false — existing test sites pick up the silent
default with no changes). Every Information/Warning log added during the
investigation is now if-gated; non-diagnostic logs (the decode-failure
warnings, the dispatch-drop debug) stay as-is.

Toggle via appsettings*.json:
  "BattleNode": { "DiagnosticLogging": true }

Or live via the singleton:
  factory.Services.GetRequiredService<BattleNodeOptions>().DiagnosticLogging = true

175 battle-node tests still passing — existing tests use the constructor
default and emit nothing, so no test changes were required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:49:17 -04:00
gamer147
9fc1d055d8 fix(battle-node): ack 'hand' SIO events to unblock client emit queue
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>
2026-06-02 16:41:40 -04:00
gamer147
a6b9a942ab chore(battle-node): delete dead ScriptedProfiles.Opponent{Matched,BattleStart}Profile fields
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>
2026-06-02 13:58:32 -04:00
gamer147
5c4e427fab feat(battle-node): clear RealParticipant outbound archive on session terminate
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>
2026-06-02 13:10:15 -04:00
gamer147
898b872edd fix(rank-battle): route ai-start through the queue-time MatchContext
Live-smoke bug 2026-06-02: queued Bloodcraft (deck #5), wire showed
classId=2 (Swordcraft) for self_info on the /ai_unlimited_rank_battle/start
response — client rendered the wrong leader.

Two layers of the same bug:

1. MatchContextBuilder.BuildForRankBattleAsync hardcoded deckNo=1 instead
   of taking it from the do_matching request — verified against
   data_dumps/captures/traffic.ndjson L17 where deck_no=5 was on the wire.
   Signature changes to (viewerId, format, deckNo); DoMatchingInternal
   passes req.DeckNo.

2. AiStartInternal rebuilt MatchContext from scratch — but the /ai_*/start
   request body is BaseRequest only, no deck_no on the wire. The fix uses
   the MatchContext the bridge already stored at do_matching resolution time
   (in the Bot PendingBattle), so deck/cosmetic data is consistent end-to-end.
   New IBattleSessionStore.TryFindPendingForViewer(viewerId) finds the
   viewer's pending battle for lookup. The store entry persists across
   ai_start (idempotent reads are fine — the WS handler removes on connect).
   No-pending sentinel: ai_id=-1 surfaces the "no AI assigned" error in the
   client.

Tests: 936 → 939 passing.
- MatchContextBuilderTests.BuildForRankBattle_uses_the_caller_supplied_deck_number
  seeds deck #1 (class 1) and deck #5 (class 6) and asserts the deckNo
  argument picks the right one.
- RankBattleControllerTests.AiStart_self_info_class_matches_queued_deck_number
  is the end-to-end regression: register Bot battle with deck #5, hit
  /ai_unlimited_rank_battle/start, assert self_info.classId == 6.
- RankBattleControllerTests.AiStart_without_pending_battle_returns_neg1_sentinel
  locks the defensive ai_id=-1 path.
- Existing AiStart_* tests bypass do_matching, so adapted to call a new
  RegisterBotBattleAsync helper that mirrors what InProcessPairUp does on
  AI-fallback resolution.

SeedDeckAsync gains an optional classId so test cases can differentiate
decks by class (was always picking Classes.First()).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 12:28:42 -04:00
gamer147
8aead62116 fix(battle-node): revert Bot Matched/BattleStart push (corrupts OppoBattleStartInfo)
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>
2026-06-02 11:23:13 -04:00
gamer147
51e9dd2094 fix(battle-node): Bot mode must push Matched + BattleStart (client state-machine triggers)
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>
2026-06-02 09:56:22 -04:00
gamer147
a4685a9188 feat(battle-node): Bot dispatch arms in ComputeFrames
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>
2026-06-02 01:27:08 -04:00
gamer147
ca5a1e926d feat(battle-node): RealParticipant session-finished signal + Pvp cascade
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.
2026-06-01 21:58:47 -04:00
gamer147
db054205b3 feat(battle-node): PvP TurnEnd broadcast + flipped Retire/Kill result
Pvp TurnEnd/TurnEndFinal broadcasts TurnEnd+Judge to BOTH so each
client's JudgeOperation advances. Pvp Retire/Kill pushes BattleFinish
with flipped result (sender=Lose, other=Win). Scripted Retire keeps
Phase 1 behaviour (sender-only Win via BuildBattleFinishNoContest).
2026-06-01 21:51:27 -04:00
gamer147
72dc1887d9 feat(battle-node): PvP gameplay-frame forwarding arms
TurnStart / PlayActions / Echo / TurnEndActions / JudgeResult from a
real participant in Pvp mode forward to the other participant once
BothAfterReady. Scripted's bot-burst case arms (gated on
FakeOpponentViewerId) precede the PvP forwarder so they're unaffected.

The bot-emission TurnStart/TurnEnd/Judge guard was tightened from
`ReferenceEquals(from, A or B)` (always true) to call
IsRealForwardableFromScripted directly in the `when` clause. The prior
shape used `goto default` to drop non-bot senders, which would have
short-circuited the new PvP forwarder for TurnStart in PvP mode.
2026-06-01 21:46:28 -04:00