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>
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>
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>
Replaces the Phase-2 log-and-return stub with a real session
construction. P2 is always null for Bot (bridge contract), so no
WaitingRoom flow needed — single real WS, Phase-1 WhenAll-everything
RunAsync semantics work because NoOp.RunAsync completes immediately.
Integration test follows in the next task.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pvp arrivers Pair-or-Park: second arriver constructs the session;
first arriver awaits self.AwaitSessionFinishedAsync (never calls
self.RunAsync directly because the session does). Park-race retries
Pair once. Bot type still stubbed for Phase 3. Scripted path unchanged.
Viewer-id validation extended to accept either P1 or P2 (PvP sessions
have both).
Per-BattleId slot keyed dict. Pair returns the first arriver to the
second; ParkAsync awaits a TCS and returns the second arriver. Timeout
defaults to BattleNodeOptions.WaitingRoomTimeout (60s); evict on timeout
keeps the dict clean. Singleton in DI; consumed by the handler in the
next task.
Old single-WS BattleSession + its dispatch/pump/ClipAckArg tests are
obsolete after the Task 9 handler cutover. ClipAckArg overflow + boundary
coverage moved into RealParticipantTests. BattleSessionV2 renamed back
to BattleSession; the V2 suffix was a placeholder during the parallel
-build refactor.
Production WS path now constructs RealParticipant + ScriptedBotParticipant
and hands them to BattleSessionV2 instead of the old single-WS
BattleSession. Wire behaviour preserved end-to-end (BattleNodeFlowTests
still pass).
Also fixes a RunAsync bug uncovered by the cutover: WhenAny would
terminate the session as soon as the scripted bot's no-op RunAsync
resolved, killing the live WS read loop before any traffic arrived.
Phase 1 semantics are simpler — wait for ALL participants. Phase 2's
Pvp disconnect propagation will revisit this.
IMatchingBridge.RegisterPendingBattle now takes a MatchContext; PendingBattle
carries it; BattleSession stores it. ArenaTwoPickBattleController builds ctx
from IMatchContextBuilder. ScriptedLifecycle still uses ScriptedProfiles for
the player half — Tasks 5/6 migrate the lifecycle.
Existing tests updated: MatchingBridgeTests, BattleNodeFlowTests,
InMemoryBattleSessionStoreTests, BattleSessionDispatchTests, BattleSession
PumpTests, ArenaTwoPickBattleControllerTests (which now seeds a TK2 run +
adds a no-active-run 400 case).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Previous fix used Context.WebSockets.IsWebSocketRequest, but that
requires UseWebSockets() to have already run — and UseBattleNode
(which calls UseWebSockets) is registered AFTER UseAuthentication
in Program.cs, so the WS feature isn't installed when auth runs.
Switch to reading the raw Upgrade header, which works regardless
of middleware order.
Also split the WS handler's "Unknown battle/viewer pair" warning
into two distinct cases so we can tell unknown-BattleId from
viewer-id-mismatch (which lets us see whether the bridge stored
the right viewer or the client is encrypting a different id).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues caught in the real-client smoke:
1) BestHTTP's SocketOptions.AdditionalQueryParams puts BattleId and
viewerId on HTTP request HEADERS for WebSocket-only transport
(NOT on the URL query string as the in-battle/transport.md spec
says). Real clients therefore send them as headers; our handler
was reading from query and rejecting every connect with "Unknown
battle/viewer pair: <bid>/<garbage>". Fix: header-first, query-
fallback (so the integration test still works against TestServer).
2) The Steam auth handler was running on every WS upgrade and
throwing NotSupportedException on Request.Body.Seek (Kestrel's
HttpRequestStream doesn't support Seek, and a WS upgrade is GET
with Content-Length: 0 anyway). It flooded logs and added no
value — the battle node has its own per-connection credentials.
Skip auth when IsWebSocketRequest is true.
Spec correction for in-battle/transport.md to follow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>