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>
This commit is contained in:
@@ -1,142 +1,21 @@
|
||||
# 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.
|
||||
Socket.IO node-server emulation for in-battle real-time traffic — the second of prod's 4-server
|
||||
topology. Handles `Matched` / `BattleStart` / `Deal` / per-action `PlayActions` / `Echo` /
|
||||
`TurnEnd` between a client and a server-side opponent, for TK2 PvP and AI rank battles.
|
||||
|
||||
**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.
|
||||
## Documentation lives in the outer repo
|
||||
|
||||
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.
|
||||
This project's canonical reference is a single hub doc in the **outer** SVSim repo (this directory
|
||||
is an inner git repo, so the doc isn't tracked alongside the code):
|
||||
|
||||
## Architecture
|
||||
→ **`docs/battle-node.md`** (from the SVSim root) — architecture, the dispatch matrix by battle
|
||||
type, connect handshake + crypto, `BattleFinish` wire-result semantics, SIO/EIO event coverage,
|
||||
reliability (pubSeq/playSeq/Gungnir), wire-format gotchas, where-to-extend, the manual smoke
|
||||
walkthrough, and the consolidated open-items list.
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
Relative path from here: [`../../../docs/battle-node.md`](../../../docs/battle-node.md).
|
||||
|
||||
## Connect handshake (verified end-to-end against the real client)
|
||||
Detailed per-URI wire shapes are in `docs/api-spec/in-battle/`; the hub links into them.
|
||||
|
||||
```
|
||||
┌────────┐ ┌────────────┐
|
||||
│ 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.
|
||||
|
||||
## Related docs
|
||||
|
||||
- `docs/api-spec/in-battle/transport.md` — Socket.IO + AES-for-node wire format, with smoke corrections inline.
|
||||
- `docs/api-spec/in-battle/matching.md` — `do_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.md` — `orderList` 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.
|
||||
Keep `docs/battle-node.md` updated in the same change whenever you alter node behavior.
|
||||
|
||||
Reference in New Issue
Block a user