Files
SVSimServer/SVSim.BattleNode
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
..

SVSim.BattleNode

Socket.IO node-server scaffolding for in-battle traffic. Implements the second of the prod 4-server topology — the realtime channel that handles Matched / BattleStart / Deal / per-action PlayActions / Echo / TurnEnd between the client and a server-side opponent.

v1 scope is "scripted thin sequencer": the server accepts a connection, walks a hand-rolled lifecycle from InitNetwork to mulligan + first turn + opponent TurnStart, then sits at the opponent's-turn screen indefinitely. No real opponent, no battleCode validation, no recovery. v2 work targets each of those.

The library has no dependency on SVSim.EmulatedEntrypoint. It exposes one DI seam (IMatchingBridge) and one ASP.NET Core integration surface (AddBattleNode / UseBattleNode). Pulling the node into a separate process later is one interface and one Kestrel binding.

Architecture

SVSim.BattleNode/
├─ Bridge/          IMatchingBridge — what /do_matching calls to mint a battle id + node URL
├─ Hosting/         ASP.NET Core extensions + the /socket.io/ endpoint handler
├─ Lifecycle/       ScriptedLifecycle — the v1 hand-rolled Matched/BattleStart/Deal/Swap/Ready frames
├─ Protocol/        MsgEnvelope, NetworkBattleUri enum, msgpack ↔ envelope codec
├─ Reliability/     InboundTracker (pubSeq dedup), OutboundSequencer (playSeq archive), Gungnir
├─ Sessions/        BattleSession (per-connection state + WS pump), IBattleSessionStore
└─ Wire/            EIO3 framing, SIO2 framing, NodeCrypto (AES-256-CBC)

Connect handshake (verified end-to-end against the real client)

┌────────┐                                                    ┌────────────┐
│ Client │                                                    │ BattleNode │
└────┬───┘                                                    └──────┬─────┘
     │                                                                │
     │  HTTP POST /arena_two_pick_battle/do_matching                 │ (HTTP host)
     ├──────────────────────────────────────────────────────────────►│
     │  ◄── { matching_state:3004, battle_id, node_server_url,       │
     │        card_master_id, ... }                                  │
     │                                                                │
     │  WS upgrade  ws://<node>/socket.io/                            │
     │     headers: BattleId, viewerId=encryptForNode(uid)            │
     ├──────────────────────────────────────────────────────────────►│ AcceptWebSocketAsync
     │  ◄── EIO3 Open  0{sid,upgrades:[],pingInterval,pingTimeout}    │
     │                                                                │
     │  msg: InitNetwork  (cat=99/general)                            │
     ├──────────────────────────────────────────────────────────────►│
     │  ◄── synchronize: InitNetwork{resultCode:1}                    │
     │                                                                │
     │  MatchingInitBattle: status=Connect; subscribe receiver        │
     │  msg: InitBattle   (cat=2/matching)                            │
     ├──────────────────────────────────────────────────────────────►│
     │  ◄── synchronize: Matched{selfInfo,oppoInfo,selfDeck,bid}      │
     │                                                                │
     │  client loads decks/scene                                      │
     │  msg: Loaded                                                   │
     ├──────────────────────────────────────────────────────────────►│
     │  ◄── synchronize: BattleStart{turnState,battleType,...}        │
     │  ◄── synchronize: Deal{self,oppo}                              │
     │                                                                │
     │  mulligan UI; player chooses cards to swap                     │
     │  msg: Swap{idxList:[...]}                                      │
     ├──────────────────────────────────────────────────────────────►│
     │  ◄── synchronize: Swap{self:[post-mulligan hand]}              │
     │  ◄── synchronize: Ready{self,oppo,idxChangeSeed,spin}          │
     │                                                                │
     │  turn 1: TurnStart, PlayActions, ..., TurnEnd                  │
     ├──────────────────────────────────────────────────────────────►│
     │  ◄── synchronize: TurnStart{spin}    (opponent turn signal)    │
     │                                                                │
     │  sits at "Opponent's turn…" — v1 stopping point                │

Each push from us carries a contiguous playSeq; client-emit pubSeq is echoed back via the Socket.IO ack callback. Gungnir runs a 5s alive heartbeat in parallel reporting scs:ONLINE,ocs:ONLINE.

Wire-format gotchas (discovered during v1 smoke)

These are not in the original protocol docs and tripped us during the smoke walkthrough — leaving them here so the next reader doesn't repeat the diagnosis.

Spec said Actual wire Where it shows up
AdditionalQueryParams on the WS upgrade HTTP request headers, not query string. BestHTTP misnames the API. BattleNodeWebSocketHandler.ReadCredential reads BattleId / viewerId from headers first, query as fallback (for tests).
node_server_url ws://host:port host:port/socket.io/no scheme prefix, path included. BattleNodeOptions.NodeServerUrl default + do_matching response.
card_master_id optional Required when matching_state ∈ {3004,3007,3011} — no Keys.Contains guard client-side. Added to DoMatchingResponseDto with default 1.
resultCode optional on pushes Required = 1 on every scripted synchronize frame; missing means "drop in error handler". ScriptedLifecycle.EnvelopeForPush injects it.
Matched in response to InitNetwork InitBattle. Matched in response to InitNetwork lands before the client's matching handler is subscribed and silently drops. See dispatch in BattleSession.ComputeResponses.
WS binary frames carry raw msgpack EIO3 prefixes binary frames with 0x04 (Message type byte), same as the leading digit on text frames. BattleSession.RunAsync strips on read; EncodeAndSendAsync prepends on send.

There's also a JSON parsing pitfall worth knowing about (and that broke the mulligan): the inline conditional el.TryGetInt64(out var l) ? l : el.GetDouble() unifies its branches to the common implicit-convertible type. Since long → double is implicit, the long silently widens to double, and OfType<long> downstream drops every entry. See MsgEnvelope.ParseNumber for the fix — keep number parsing in a separate method so each branch boxes its own runtime type.

v1 scripted opponent — what the client sees

The player half of Matched / BattleStart reads from a MatchContext assembled in SVSim.EmulatedEntrypoint/Services/MatchContextBuilder from the viewer's TK2 run + equipped cosmetics + config — so the mulligan renders the real drafted deck, drafted class/leader, and equipped emblem/degree. The opponent half stays scripted in ScriptedProfiles:

  • Opponent is a fixed silhouette: classId="8", JPN sleeve/emblem/degree, viewer id 999999999.
  • Battle seed is 17548138L in both info blocks (the seed is shared per battle per the spec).
  • Mulligan does real card replacement: any idx in your idxList is swapped for the next unused deck idx (1..3 dealt, so 4..30 are pool).
  • Opponent's turn never actually does anything — we push a single TurnStart{spin:100} after your TurnEnd so the UI transitions to the opponent-turn display, then sit.

A few player-side fields are still hardcoded pending a follow-up slice — Rank, BattlePoint, cardMasterName, fieldId, and the per-battle RNG seed. See the spec's §Deferred plumbing table at docs/superpowers/specs/2026-06-01-battle-node-real-drafted-deck-design.md for what each needs.

Where to extend

You want to Touch
Wire a new mode's do_matching (rank, free, open-room, …) Add one BuildFor<Mode>Async(viewerId, …) method to IMatchContextBuilder reading that mode's deck source; the mode's controller calls IMatchingBridge.RegisterPendingBattle(vid, ctx). No changes to SVSim.BattleNode.
Add a real AI opponent Replace the static dispatch in BattleSession.ComputeResponses (TurnEnd → opponent TurnStart case) with one that drives a decision engine. The OutboundSequencer already assigns playSeq for whatever you push.
Implement recovery IBattleSessionStore already keeps the pending registry. Add a per-battle archive (the OutboundSequencer.Archive already retains every assigned-playSeq push) and bind it to the HTTP /battle/get_recovery_params endpoint.
Validate battleCode Port NetworkConsistency.GetConsistency from the client decompilation. Hook into BattleSession.HandleMsgEventAsync on TurnEnd / Judge.
Type the orderList register actions Spec at docs/api-spec/in-battle/register-actions.md catalogs the eight shapes observed in TK2 captures. Build a discriminated union; replace Dictionary<string, object?> in the Body for the relevant URIs.

Test layout

SVSim.UnitTests/BattleNode/
├─ Bridge/         MatchingBridgeTests           (3 tests — mint id, dedup, format)
├─ Integration/   BattleNodeFlowTests           (end-to-end via WebApplicationFactory)
│                 RawSocketIoTestClient         (test helper)
├─ Lifecycle/     ScriptedLifecycleTests        (11 tests)
├─ Protocol/      MsgEnvelopeTests              (4 tests incl. number-array regression)
│                 MsgPayloadCodecTests          (2 tests — roundtrip + known vector)
├─ Reliability/   GungnirTests / InboundTrackerTests / OutboundSequencerTests
├─ Sessions/      BattleSessionDispatchTests    (8 tests — phase-state machine)
│                 InMemoryBattleSessionStoreTests
└─ Wire/          NodeCryptoTests (with fixed-vector regression)
                  EngineIoFrameTests
                  SocketIoFrameTests (incl. binary attachment + JSON escaping)

Total ~71 BattleNode-scoped tests. The integration test boots the EmulatedEntrypoint host via SVSimTestFactory, mints a battle through IMatchingBridge, opens a TestServer WebSocket, and walks the full handshake through Ready. It exercises every layer.

  • docs/api-spec/in-battle/transport.md — Socket.IO + AES-for-node wire format, with smoke corrections inline.
  • docs/api-spec/in-battle/matching.mddo_matching bridge + client state machine.
  • docs/api-spec/in-battle/server-to-client.md, client-to-server.md — per-uri frame shapes.
  • docs/api-spec/in-battle/register-actions.mdorderList action catalog (for v2).
  • docs/api-spec/in-battle/reliability.md — pubSeq/playSeq stocking + Gungnir.
  • docs/api-spec/in-battle/recovery.md — the reconnect handshake (deferred to v2).
  • docs/operations/battle-node-smoke.md — manual end-to-end checklist.
  • docs/operations/battle-node-smoke-walkthrough.md — annotated walkthrough with per-step diagnostics.
  • docs/superpowers/specs/2026-05-31-battle-node-transport-design.md — v1 design.
  • docs/superpowers/plans/2026-05-31-battle-node-transport.md — v1 implementation plan.