docs(battle-node): project README + docstrings on hosting/lifecycle

Add a per-project README in SVSim.BattleNode/ that covers:
- Architecture (the six concern folders)
- The connect-handshake sequence verified end-to-end at smoke
- A wire-format-gotchas table for the spec divergences caught during
  v1 (headers vs query for credentials, schemeless node URL with
  /socket.io/ path, required card_master_id, required resultCode=1,
  Matched in response to InitBattle not InitNetwork, EIO3 0x04 prefix
  on binary frames, FromJson conditional-expression number-boxing)
- What the v1 scripted opponent does and what is hardcoded
- A "where to extend" table for v2 work
- The full test layout and cross-references to specs/plans

Fill in XML docs on the public surface that previously had none:
- BattleNodeExtensions.AddBattleNode / UseBattleNode (DI + middleware
  wiring, including the pipeline-order note that auth runs before
  UseWebSockets)
- BattleNodeWebSocketHandler class + HandleAsync (the validation chain)
- BattleSession.ComputeResponses (the lifecycle state machine, with
  the NoStock flag's meaning)
- ScriptedLifecycle class (v1 scope, resultCode injection rule,
  pointer to the "where to extend" section)
- MatchingBridge class (mint-id + register flow)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-01 08:57:15 -04:00
parent 9e8ebd1b2b
commit c279b811ad
6 changed files with 240 additions and 6 deletions

View File

@@ -2,6 +2,13 @@ using SVSim.BattleNode.Sessions;
namespace SVSim.BattleNode.Bridge;
/// <summary>
/// In-process implementation of <see cref="IMatchingBridge"/>. The HTTP-side
/// <c>do_matching</c> controller calls <see cref="RegisterPendingBattle"/>, which mints a
/// 12-digit decimal battle id, stashes a <see cref="PendingBattle"/> entry in the
/// <see cref="IBattleSessionStore"/>, and returns the node URL the client should connect to.
/// The WebSocket handler resolves the same battle id back to the viewer on connect.
/// </summary>
public sealed class MatchingBridge : IMatchingBridge
{
private readonly IBattleSessionStore _store;

View File

@@ -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;
/// <summary>
/// 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 <see cref="WebApplication"/>.
/// </summary>
public static class BattleNodeExtensions
{
/// <summary>
/// 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 <see cref="BattleSession"/>
/// instance the WebSocket handler constructs on connect.
/// </summary>
/// <param name="configure">
/// Optional callback to override <see cref="BattleNodeOptions"/> defaults. The default
/// <c>NodeServerUrl</c> assumes the EmulatedEntrypoint host on
/// <c>http://localhost:5148</c> and shares the port for the Socket.IO endpoint. Override
/// when the node runs on a different port/host or behind a reverse proxy.
/// </param>
public static IServiceCollection AddBattleNode(this IServiceCollection services, Action<BattleNodeOptions>? configure = null)
{
var options = new BattleNodeOptions();
@@ -19,6 +34,20 @@ public static class BattleNodeExtensions
return services;
}
/// <summary>
/// Wire up the WebSocket middleware and map the Socket.IO endpoint at <c>/socket.io/</c>.
/// Call this AFTER any HTTP middleware that should still see non-WS requests (auth,
/// routing, controllers) and BEFORE <c>MapControllers()</c>. The endpoint accepts any
/// path under <c>/socket.io</c>; the handler doesn't read the sub-path, so default
/// Socket.IO clients targeting <c>/socket.io/?EIO=3&amp;transport=websocket</c> work
/// without configuration.
/// </summary>
/// <remarks>
/// Steam auth gets a free pass on WS upgrades — see
/// <c>SteamSessionAuthenticationHandler</c>'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 <see cref="BattleNodeWebSocketHandler.HandleAsync"/>).
/// </remarks>
public static IApplicationBuilder UseBattleNode(this IApplicationBuilder app)
{
app.UseWebSockets();

View File

@@ -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;
/// <summary>
/// Validates an incoming WebSocket upgrade request, accepts it, and hands off to a fresh
/// <see cref="BattleSession"/>. Singleton; no per-request state.
/// </summary>
/// <remarks>
/// <para>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:</para>
/// <list type="number">
/// <item>Reject non-WS requests with 400 (someone hit <c>/socket.io/</c> via plain HTTP).</item>
/// <item>Read <c>BattleId</c> and encrypted <c>viewerId</c> from request headers, falling back
/// to query string. The real client puts them on headers despite BestHTTP's
/// <c>AdditionalQueryParams</c> API name — see project README §Wire-format gotchas.</item>
/// <item>Decrypt the viewerId with <see cref="NodeCrypto.DecryptForNode"/>; reject on
/// parse/decrypt failure.</item>
/// <item>Look up the <see cref="PendingBattle"/> in the store and verify the decrypted viewer
/// matches the one the <see cref="Bridge.IMatchingBridge"/> registered.</item>
/// <item>AcceptWebSocketAsync, remove the pending entry (it's now an active session), construct
/// <see cref="BattleSession"/>, await <see cref="BattleSession.RunAsync"/> until the WS
/// closes.</item>
/// </list>
/// </remarks>
public sealed class BattleNodeWebSocketHandler
{
private readonly IBattleSessionStore _store;
@@ -19,6 +40,11 @@ public sealed class BattleNodeWebSocketHandler
_log = loggerFactory.CreateLogger<BattleNodeWebSocketHandler>();
}
/// <summary>
/// Endpoint entry point. Sets <see cref="HttpContext.Response"/> to 400 on any validation
/// failure; otherwise upgrades to a WebSocket and awaits
/// <see cref="BattleSession.RunAsync"/> until the connection closes.
/// </summary>
public async Task HandleAsync(HttpContext ctx)
{
if (!ctx.WebSockets.IsWebSocketRequest)

View File

@@ -3,15 +3,38 @@ using SVSim.BattleNode.Protocol;
namespace SVSim.BattleNode.Lifecycle;
/// <summary>
/// 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 <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c> — anything
/// hardcoded here came from a real prod frame.
/// </summary>
/// <remarks>
/// <para>"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 <c>TurnEnd</c>, and then sit there indefinitely. This
/// is the documented v1 stopping point.</para>
/// <para>
/// All builders go through <see cref="EnvelopeForPush"/>, which injects
/// <c>resultCode = 1</c> into every body. The client's <c>OnReceived</c> drops any
/// synchronize push whose <c>resultCode != Success</c> (absent counts as None=0); leaving
/// it off silently breaks the state machine without surfacing an error.
/// </para>
/// <para>To make this less scripted: see the project README §"Where to extend".</para>
/// </remarks>
public static class ScriptedLifecycle
{
/// <summary>30 dummy cardIds — repeats of a stable neutral card.</summary>
/// <summary>
/// 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.
/// </summary>
public static readonly long DummyCardId = 100011010;
/// <summary>
/// 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.
/// </summary>
public const long FakeOpponentViewerId = 999_999_999L;
public static MsgEnvelope BuildMatched(long playerViewerId, long opponentViewerId, string battleId)

137
SVSim.BattleNode/README.md Normal file
View File

@@ -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://<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
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<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.

View File

@@ -178,6 +178,18 @@ public sealed class BattleSession
}
}
/// <summary>
/// Pure-logic lifecycle state machine: given an inbound <see cref="MsgEnvelope"/> and the
/// current <see cref="Phase"/>, return the envelopes the session should push back AND
/// transition <see cref="Phase"/>. Extracted as an internal method so unit tests can drive
/// the state machine without standing up a real WebSocket.
/// </summary>
/// <returns>
/// Ordered list of (envelope, no-stock) tuples. <c>NoStock = true</c> means the push is a
/// control frame (ack / BattleFinish) and bypasses <see cref="OutboundSequencer"/>'s
/// playSeq assignment + Resume archive. <c>NoStock = false</c> means the push is part of
/// the ordered stream and gets a fresh playSeq.
/// </returns>
internal IReadOnlyList<(MsgEnvelope Envelope, bool NoStock)> ComputeResponses(MsgEnvelope env)
{
var result = new List<(MsgEnvelope Envelope, bool NoStock)>();