Commit Graph

136 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
486f72f4a0 feat(battle-node): typed PlayActionsBroadcastBody + KnownCardEntry/OppoTargetEntry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:51:02 -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
a533e9d89d feat(battle-node): client-shaped handshake builders for the scripted bot
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:49:38 -04:00
gamer147
633c29b44f feat(battle-node): BuildReady overload carrying the opponent's hand
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>
2026-06-03 10:48:43 -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
c551d7b05e refactor(battle-node): drop dead BattleResult.{Lose,Win,Consistency} members
No dispatch arm has emitted these since the Retire/Kill rewrite to RetireWin=105
/ RetireLose=106. Remove them and the docstring paragraph that explained them.

Test fallout: delete BattleFinishBody_LoseAndConsistency_SerializeAsZeroAndTwo
(its only purpose was locking the dead wire values), and re-point
BattleFinishBody_SerializesResultAndResultCode_AsNumericWireValues at the live
LifeWin=101 so it still guards the JsonNumberEnumConverter numeric-wire behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:17:30 -04:00
gamer147
d76b96b339 test(battle-node): lock server-authored frame shapes against prod captures
Add CaptureConformanceTests: drive one Scripted lifecycle, harvest all ten
server-authored synchronize frames (InitNetwork/Matched/BattleStart/Deal/Swap/
Ready/TurnStart/TurnEnd/Judge/BattleFinish), re-serialize via MsgEnvelope.ToJson,
and diff each against representative prod TK2 capture frames embedded as a
fixture. Comparison is capture-subset-of-ours on body shape (recursive keys +
value category), so missing/miscased/mistyped fields fail but extra envelope
fields we emit don't; pure sequencing keys are excluded.

Because PvP reuses the same ScriptedLifecycle builders for the handshake/mulligan
frames, this transitively locks the PvP handshake shape -- a regression oracle
that outlives the June-2026 server shutdown.

Also replace the stale v1-only README with a pointer to the canonical
docs/battle-node.md hub (outer repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:03: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
9f11896f7b feat(battle-node): polite Socket.IO close on waiting-room timeout
The PvP waiting-room timeout path in BattleNodeWebSocketHandler used to
return immediately after RemovePending, leaving the parked first arriver
to learn about the disconnect via TCP teardown after Kestrel finished
draining the request. BestHTTP / socket.io-client log that as an abrupt
drop rather than a controlled disconnect.

New TryPoliteCloseAsync helper emits an EIO "1" (Close) text frame, then
runs the WebSocket close handshake with NormalClosure. Wrapped in
try/catch + Debug log — teardown races between the server-side close and
client disconnect are routine and not actionable. Uses a fresh 5s CTS so
ctx.RequestAborted being canceled doesn't skip the close.

Wired into both bail-out paths post-AcceptWebSocketAsync that previously
just returned:
- PvP waiting-room timeout / Park-Park race (the main case, per PLAN.md
  L104 (c))
- Unknown BattleType default case (same shape, log message already said
  "closing WS" but didn't actually close — opportunistic fix)

PvpWaitingRoomTimeout integration test tightened: now asserts the polite
"1" text frame arrives before the close handshake, not just that the WS
eventually closes by any means.

172 battle-node tests passing (was 172 before the assertion tightening;
the existing timeout test stayed in.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 14:05:25 -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
10d9f74d05 feat(battle-node): add OutboundSequencer.Clear() for terminate cascade
Audit Md11 (part 2 of 2). Adds an explicit Clear() so BattleSession can
release the archive at battle-end instead of waiting for the participant to
be GC'd. _next is intentionally NOT reset — a post-Clear emit is a bug per
the design, but the seq stream must stay monotonic if it does happen.

Tests cover empty archive after Clear, _next preservation across Clear,
and Clear-on-empty no-op. The BattleSession integration that calls Clear
lands in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:07:00 -04:00