diff --git a/SVSim.BattleNode/Bridge/MatchingBridge.cs b/SVSim.BattleNode/Bridge/MatchingBridge.cs index 0d7ed20..b1940b8 100644 --- a/SVSim.BattleNode/Bridge/MatchingBridge.cs +++ b/SVSim.BattleNode/Bridge/MatchingBridge.cs @@ -2,6 +2,13 @@ using SVSim.BattleNode.Sessions; namespace SVSim.BattleNode.Bridge; +/// +/// In-process implementation of . The HTTP-side +/// do_matching controller calls , which mints a +/// 12-digit decimal battle id, stashes a entry in the +/// , and returns the node URL the client should connect to. +/// The WebSocket handler resolves the same battle id back to the viewer on connect. +/// public sealed class MatchingBridge : IMatchingBridge { private readonly IBattleSessionStore _store; diff --git a/SVSim.BattleNode/Hosting/BattleNodeExtensions.cs b/SVSim.BattleNode/Hosting/BattleNodeExtensions.cs index adb2d78..69d9783 100644 --- a/SVSim.BattleNode/Hosting/BattleNodeExtensions.cs +++ b/SVSim.BattleNode/Hosting/BattleNodeExtensions.cs @@ -1,4 +1,3 @@ -// SVSim.BattleNode/Hosting/BattleNodeExtensions.cs using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using SVSim.BattleNode.Bridge; @@ -6,8 +5,24 @@ using SVSim.BattleNode.Sessions; namespace SVSim.BattleNode.Hosting; +/// +/// Registration + pipeline extensions that turn an arbitrary ASP.NET Core host into a battle +/// node. The library has no dependency on any specific host project — call both methods from +/// wherever you build your . +/// public static class BattleNodeExtensions { + /// + /// Register the battle node's services in DI. All four are singletons because none of them + /// carry per-request state — per-battle state lives on the + /// instance the WebSocket handler constructs on connect. + /// + /// + /// Optional callback to override defaults. The default + /// NodeServerUrl assumes the EmulatedEntrypoint host on + /// http://localhost:5148 and shares the port for the Socket.IO endpoint. Override + /// when the node runs on a different port/host or behind a reverse proxy. + /// public static IServiceCollection AddBattleNode(this IServiceCollection services, Action? configure = null) { var options = new BattleNodeOptions(); @@ -19,6 +34,20 @@ public static class BattleNodeExtensions return services; } + /// + /// Wire up the WebSocket middleware and map the Socket.IO endpoint at /socket.io/. + /// Call this AFTER any HTTP middleware that should still see non-WS requests (auth, + /// routing, controllers) and BEFORE MapControllers(). The endpoint accepts any + /// path under /socket.io; the handler doesn't read the sub-path, so default + /// Socket.IO clients targeting /socket.io/?EIO=3&transport=websocket work + /// without configuration. + /// + /// + /// Steam auth gets a free pass on WS upgrades — see + /// SteamSessionAuthenticationHandler's header-based bypass. The node has its own + /// per-connection auth (encrypted viewerId in the upgrade headers, validated against the + /// matched battle id in ). + /// public static IApplicationBuilder UseBattleNode(this IApplicationBuilder app) { app.UseWebSockets(); diff --git a/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs b/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs index 1aa29dc..25c381e 100644 --- a/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs +++ b/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs @@ -1,4 +1,3 @@ -// SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using SVSim.BattleNode.Sessions; @@ -6,6 +5,28 @@ using SVSim.BattleNode.Wire; namespace SVSim.BattleNode.Hosting; +/// +/// Validates an incoming WebSocket upgrade request, accepts it, and hands off to a fresh +/// . Singleton; no per-request state. +/// +/// +/// The validation chain — cheapest checks first, crypto only after both params are +/// present, WS accept only after the store lookup confirms the credentials match an outstanding +/// pending battle: +/// +/// Reject non-WS requests with 400 (someone hit /socket.io/ via plain HTTP). +/// Read BattleId and encrypted viewerId from request headers, falling back +/// to query string. The real client puts them on headers despite BestHTTP's +/// AdditionalQueryParams API name — see project README §Wire-format gotchas. +/// Decrypt the viewerId with ; reject on +/// parse/decrypt failure. +/// Look up the in the store and verify the decrypted viewer +/// matches the one the registered. +/// AcceptWebSocketAsync, remove the pending entry (it's now an active session), construct +/// , await until the WS +/// closes. +/// +/// public sealed class BattleNodeWebSocketHandler { private readonly IBattleSessionStore _store; @@ -19,6 +40,11 @@ public sealed class BattleNodeWebSocketHandler _log = loggerFactory.CreateLogger(); } + /// + /// Endpoint entry point. Sets to 400 on any validation + /// failure; otherwise upgrades to a WebSocket and awaits + /// until the connection closes. + /// public async Task HandleAsync(HttpContext ctx) { if (!ctx.WebSockets.IsWebSocketRequest) diff --git a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs index 4d03e50..4a2b499 100644 --- a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs +++ b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs @@ -3,15 +3,38 @@ using SVSim.BattleNode.Protocol; namespace SVSim.BattleNode.Lifecycle; /// -/// v1 Path-A scripted opponent. Hand-rolled static frames good enough to land the client on -/// the mulligan screen and let them play turn 1. Templates derived from -/// data_dumps/captures/battle-traffic_tk2_regular.ndjson. +/// v1 hand-rolled scripted opponent. Static frame builders for the five lifecycle uris +/// (Matched / BattleStart / Deal / Swap response / Ready) plus a trivial opponent TurnStart +/// the dispatch pushes after the player's TurnEnd. The values are templated from the TK2 +/// captures at data_dumps/captures/battle-traffic_tk2_regular.ndjson — anything +/// hardcoded here came from a real prod frame. /// +/// +/// "Scripted" means the opponent never reacts to your plays. We push enough to land +/// you on the mulligan screen, run a real mulligan exchange, give you turn 1, transition +/// to "Opponent's turn…" after your TurnEnd, and then sit there indefinitely. This +/// is the documented v1 stopping point. +/// +/// All builders go through , which injects +/// resultCode = 1 into every body. The client's OnReceived drops any +/// synchronize push whose resultCode != Success (absent counts as None=0); leaving +/// it off silently breaks the state machine without surfacing an error. +/// +/// To make this less scripted: see the project README §"Where to extend". +/// public static class ScriptedLifecycle { - /// 30 dummy cardIds — repeats of a stable neutral card. + /// + /// CardId used for all 30 entries in the dummy deck. A stable neutral card that exists in + /// every card-master version we care about, so the client can render it without + /// triggering a card-master-mismatch error. + /// public static readonly long DummyCardId = 100011010; + /// + /// Viewer id we present as the opponent on every scripted push. Out-of-range vs. real + /// viewer ids so it can't collide with a real account in the auth pipeline. + /// public const long FakeOpponentViewerId = 999_999_999L; public static MsgEnvelope BuildMatched(long playerViewerId, long opponentViewerId, string battleId) diff --git a/SVSim.BattleNode/README.md b/SVSim.BattleNode/README.md new file mode 100644 index 0000000..886f7ec --- /dev/null +++ b/SVSim.BattleNode/README.md @@ -0,0 +1,137 @@ +# 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:///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` 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 + +Hardcoded in `ScriptedLifecycle`: + +- **Your deck** is 30 copies of `cardId = 100011010` (a neutral card stable across card-master versions). Your actual TK2 draft is ignored. +- **Your leader** is `classId="1"`, `charaId="1"` regardless of class you drafted. +- **Opponent** is a fixed silhouette: `classId="8"`, KOR 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. + +## Where to extend + +| You want to | Touch | +|---|---| +| Use the real drafted deck | `ScriptedLifecycle.BuildMatched` (`selfDeck`) + the `IMatchingBridge` to plumb the viewer's TK2 run state through | +| Show the real drafted class | `ScriptedLifecycle.BuildBattleStart` (`selfInfo.classId/charaId`) — read from the same source | +| 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` 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. diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 4e0c736..043afda 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -178,6 +178,18 @@ public sealed class BattleSession } } + /// + /// Pure-logic lifecycle state machine: given an inbound and the + /// current , return the envelopes the session should push back AND + /// transition . Extracted as an internal method so unit tests can drive + /// the state machine without standing up a real WebSocket. + /// + /// + /// Ordered list of (envelope, no-stock) tuples. NoStock = true means the push is a + /// control frame (ack / BattleFinish) and bypasses 's + /// playSeq assignment + Resume archive. NoStock = false means the push is part of + /// the ordered stream and gets a fresh playSeq. + /// internal IReadOnlyList<(MsgEnvelope Envelope, bool NoStock)> ComputeResponses(MsgEnvelope env) { var result = new List<(MsgEnvelope Envelope, bool NoStock)>();