Commit Graph

668 Commits

Author SHA1 Message Date
gamer147
70b2872589 feat(battle-node): add JudgeBody record for opponent turn-end Judge push
Mirrors OpponentTurnStartBody — JsonPropertyName-cased spin + resultCode
(default 1). First piece of the v1.2 three-frame turn-end burst; nothing
references it yet.
2026-06-01 17:30:00 -04:00
gamer147
5021217134 test(battle-node): wire-shape test + refresh stale comment for v1.1
Final-review follow-ups:
- BuildOpponentTurnStart's doc comment claimed the v1 client sits
  indefinitely — true before the loop closure, false after. Updated
  to describe the pair with BuildOpponentTurnEnd.
- TypedBodyWireShapeTests had no coverage for BuildOpponentTurnEnd;
  added the literal-JSON test so a future JsonPropertyName rename
  on TurnEndBody is caught.
2026-06-01 15:20:48 -04:00
gamer147
ff8e4abea8 test(battle-node): integration test drives two opponent-turn cycles
End-to-end through the real WS pump: after Ready, the test sends two
consecutive TurnEnd msgs and asserts the server pushes
TurnStart+TurnEnd for each. Exercises OutboundSequencer's playSeq
assignment across multiple cycles.
2026-06-01 15:04:21 -04:00
gamer147
decdef29cf test(battle-node): TurnEnd cycle can fire multiple times
Locks the loop invariant: after the first cycle the phase resets to
AfterReady, so the next player TurnEnd matches the same case arm and
produces the same two-frame burst.
2026-06-01 15:01:19 -04:00
gamer147
e30fdb7570 feat(battle-node): scripted opponent turn loop pushes TurnStart + TurnEnd
The TurnEnd/TurnEndFinal case in ComputeResponses now returns two envelopes
back-to-back — opponent TurnStart followed by opponent TurnEnd. Phase enters
OpponentTurn transiently then resets to AfterReady within the same call so
the next player TurnEnd can fire the cycle again. Closes the v1 'stays at
Opponent's turn… forever' stall.
2026-06-01 14:57:49 -04:00
gamer147
96ae090a3a test(battle-node): rewrite TurnEnd dispatch test for two-frame cycle
Replaces the v1.0 single-envelope/OpponentTurn-phase invariant with
the v1.1 two-envelope/AfterReady invariant. Currently failing —
ComputeResponses still does the v1.0 thing. Implementation follows.
2026-06-01 14:53:32 -04:00
gamer147
f24fc7c643 feat(battle-node): BuildOpponentTurnEnd builder for v1.1 turn loop
Pairs with BuildOpponentTurnStart. Wire shape from prod capture
(turnState=0, resultCode=1). Single test locks uri, ViewerId, Cat,
and body shape.
2026-06-01 14:49:52 -04:00
gamer147
d4926e31d6 feat(battle-node): add TurnEndBody record for opponent turn-end push
Mirrors OpponentTurnStartBody — JsonPropertyName-cased turnState +
resultCode (default 1). First piece of the scripted opponent turn-end
loop; nothing references it yet.
2026-06-01 14:46:21 -04:00
gamer147
1904ae4c0c refactor(battle-node): ScriptedLifecycle.InitialHand as ImmutableArray<long>
Audit Md4 cleanup: the prior long[] allowed in-place modification by any
caller with the field reference. ImmutableArray<long> enforces the constant
contract at the type level. ComputeHandAfterSwap uses ToArray() to produce
its mutable working copy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:02:23 -04:00
gamer147
6077844ee8 docs(battle-node): README reflects real drafted deck + cosmetics
Player-side fictions (dummy deck, classId=1) removed; the section now
documents which fields are real (deck, leader, cosmetics) vs still hardcoded
(rank, battlePoint, cardMaster, fieldId, seed) with a pointer to the spec's
§Deferred plumbing for each. "Where to extend" table loses the two done
items and gains a row for wiring future modes via IMatchContextBuilder.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:00:22 -04:00
gamer147
e3cc745a61 test(battle-node): end-to-end drafted deck flows into Matched frame
Seeds a viewer + completed TK2 run, drives the WS handshake to Matched, and
asserts every cardId in selfDeck matches the run's SelectedCardIdsJson. Read
from RawBody (codec's wire-form deserialization) — not from MatchedBody —
since the test client gets the JSON-roundtripped envelope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:51:33 -04:00
gamer147
b0488e3f2e feat(battle-node): BuildBattleStart consumes MatchContext for player half
ClassId/CharaId/CardMasterName/BattleType flow from ctx. PlayerBattleStart
Profile removed; Rank/BattlePoint remain as standalone consts pending real
per-viewer rank tracker. One test updated, one new test added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:49:54 -04:00
gamer147
f589283572 feat(battle-node): BuildMatched consumes MatchContext for player half
selfInfo cosmetics + 30-card selfDeck now read from MatchContext. Opponent
half stays in ScriptedProfiles. DummyCardId / BuildDummyDeck / PlayerMatched
Profile removed. Two new tests lock the deck-idx pairing and cosmetic
flow-through; TypedBodyWireShapeTests + lifecycle tests thread a fixture ctx.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:48:04 -04:00
gamer147
01f9bb722a feat(battle-node): thread MatchContext through bridge to BattleSession
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>
2026-06-01 12:44:42 -04:00
gamer147
a0fdb0f3c5 feat(match-context): add IMatchContextBuilder TK2 implementation
Assembles MatchContext from ArenaTwoPickRun + viewer cosmetics + config.
Per-mode interface — future modes (rank/free/open-room/...) add one method
each. DI scoped registration. Four tests cover happy path, no-run, incomplete
draft, default-loadout fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:40:26 -04:00
gamer147
89b3d23bde feat(viewer-repo): add LoadForMatchContextAsync for battle-node ctx build
Focused AsNoTracking load with Info.SelectedEmblem/SelectedDegree includes
for the new MatchContextBuilder. Single test locks the include graph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:37:44 -04:00
gamer147
0e8f5427c3 feat(battle-node): add MatchContext record for per-mode player snapshot
Public contract between HTTP-side do_matching controllers (assemble) and
SVSim.BattleNode (consume). First piece of the real-drafted-deck wiring;
nothing references it yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:35:43 -04:00
gamer147
ef3d7bb82b refactor(battle-node): WireConstants for SIO event names + crypto RNG battle id 2026-06-01 11:53:01 -04:00
gamer147
133346e3e8 refactor(battle-node): SocketIoFrame throws on namespace; typed JSON construction 2026-06-01 11:48:17 -04:00
gamer147
2588388d9d refactor(battle-node): distinct WS auth status codes + named handler delegate 2026-06-01 11:45:50 -04:00
gamer147
a364f539ad refactor(battle-node): tighten Phase setter to private; document sid opacity 2026-06-01 11:41:47 -04:00
gamer147
677b1f1392 feat(battle-node): BattleResult enum for BattleFinish.result wire codes 2026-06-01 11:41:16 -04:00
gamer147
eaf6d7160b refactor(battle-node): dedupe NodeCrypto AES setup into BuildAes helper 2026-06-01 11:36:48 -04:00
gamer147
34c4ca0237 fix(battle-node): NodeCrypto.GenerateKey masks rand source with & 0xF 2026-06-01 11:35:53 -04:00
gamer147
e4fbb155e4 test(battle-node): pump-level tests for async-Task dispatch, CT, Md5 clip 2026-06-01 11:15:10 -04:00
gamer147
21b7ddf6ae test(battle-node): TestWebSocket mock for pump-level unit tests 2026-06-01 11:13:54 -04:00
gamer147
4dd61343aa fix(battle-node): clip SIO ack arg instead of checked-cast throwing on overflow 2026-06-01 11:13:24 -04:00
gamer147
453865ade2 fix(battle-node): thread session CT through every send instead of None 2026-06-01 11:12:26 -04:00
gamer147
8cce667e02 fix(battle-node): await DispatchSocketIo instead of async-void fire-and-forget 2026-06-01 11:11:58 -04:00
gamer147
0764b8646f feat(battle-node): capture session-scoped CT in BattleSession.RunAsync 2026-06-01 11:11:31 -04:00
gamer147
e4691d616b fix(battle-node): emit envelope keys before body keys in MsgEnvelope.ToJson
Client RealTimeNetworkAgent.SetNetworkInfo iterates the synchronize-data
dict in insertion order. The "uri" key, when recognized as Matched, calls
GameMgr.InitializeSelfInfo which sets _selfDeck = null. Any "selfDeck"
processed before "uri" gets wiped; Matching.StartBattleLoad then crashes
on null.Select(...). Pre-refactor ToJson built a Dictionary envelope-first
then appended body keys, so the bug never surfaced. The typed-body rewrite
inverted the order — restoring envelope-first matches the prod wire.

Regression test BuildMatched_KeyOrder_PutsUriBeforeSelfDeckAndSelfInfo
locks the contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:53:51 -04:00
gamer147
19cc7980d1 test(battle-node): envelope-level wire-shape regression for scripted bodies 2026-06-01 10:40:54 -04:00
gamer147
5ee270eb16 refactor(battle-node): switch MsgEnvelope.Body to IMsgBody, migrate all sites 2026-06-01 10:40:09 -04:00
gamer147
118be92dc5 feat(battle-node): ScriptedProfiles named constants for scripted bodies 2026-06-01 10:35:45 -04:00
gamer147
c7745d8785 feat(battle-node): typed OpponentTurnStart/ResultCodeOnly/BattleFinish/AlivePush bodies 2026-06-01 10:35:18 -04:00
gamer147
97b9b6fe42 feat(battle-node): typed Deal/Swap/Ready bodies + PosIdx 2026-06-01 10:34:44 -04:00
gamer147
78a6fe93fb feat(battle-node): typed BattleStartBody + Self/Oppo info records 2026-06-01 10:34:07 -04:00
gamer147
d9fbb67f0c feat(battle-node): typed MatchedBody + Self/Oppo info records 2026-06-01 10:33:34 -04:00
gamer147
9217de3aa1 feat(battle-node): add IMsgBody marker + RawBody inbound wrapper 2026-06-01 10:32:44 -04:00
gamer147
c279b811ad 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>
2026-06-01 08:57:15 -04:00
gamer147
9e8ebd1b2b fix(battle-node): preserve long type on numeric array elements in FromJson
Root cause for the lingering mulligan failure: the inline conditional
expression in MsgEnvelope.ToObject

    JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(),

unified its branches to the common implicit-convertible type. long→double
is implicit, so both branches collapsed to double and the integer value
silently widened. Inside an array (idxList:[2]), each element came back
as boxed double; OfType<long> in ExtractIdxList then filtered every
entry out, so swapIndices arrived empty and BuildSwapResponse echoed
the unchanged hand — exactly the diff-against-Deal mismatch the client
flagged as "Card swap failed: AbandonCards[2]/DrawCards[]".

Extract a ParseNumber helper that returns object explicitly so each
branch boxes its own runtime type. Also harden ExtractIdxList to accept
any boxed numeric type (long/int/double/decimal/string) so a future
JSON-parser drift can't silently regress this path again.

Two regression tests:
- FromJson_NumericArray_PreservesLongTypeOnEachElement: confirms the
  fix at the JSON-parse layer with a hardcoded "{\"idxList\":[2,3]}".
- Swap_WithIdxListContainingTwo_ProducesHandWithFreshIdxAtPosition1:
  exercises the dispatch end-to-end with a Body holding a real boxed
  long; asserts position 1 of the response hand is the fresh deck idx 4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:40:50 -04:00
gamer147
77fb93f3ea fix(battle-node): real mulligan card replacement + opponent TurnStart push
Two issues caught during v1 smoke at the mulligan / first-turn boundary:

1) BuildSwapResponse ignored the player's idxList and echoed the same
   3-card hand back. The client diffs the new self[] against the Deal
   to compute "drawn cards" — empty diff against the same hand throws
   "Card swap failed: AbandonCards[X]/DrawCards[]". Replace swapped
   idxs with fresh deck idxs (initial hand was 1/2/3, deck has 4..30
   still available). Same hand must flow into Ready since the client
   diffs again there. Move the hand computation into a new helper
   ComputeHandAfterSwap and have ComputeResponses thread it through
   both BuildSwapResponse and BuildReady.

2) The client doesn't transition to the "Opponent's turn…" display
   on its own after sending TurnEnd — it waits for the server to push
   an opponent TurnStart (per prod TK2 capture line 14). Without it
   the UI just sits on the end-of-turn frame. Add a TurnEnd handler
   that pushes a minimal TurnStart{spin} and transitions to a new
   OpponentTurn phase, which IS the documented v1 stopping point.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:30:44 -04:00
gamer147
e06d97ef6f fix(battle-node): respond to InitBattle/Loaded, not InitNetwork
Pushing Matched in response to InitNetwork lands it before
MatchingInitBattle() finishes wiring up the OnReceivedEvent handler
and setting status=Connect. The client's Matched-case in
ReactionReceiveUri only transitions to StartLoad when status is
Connect at the moment of receipt; otherwise the frame is silently
dropped at the state machine and the matchmaking UI never advances.

The real connect-handshake sequence (per MatchingNetworkConnectChecker
+ Matching.cs):
  1. WS opens.
  2. Client emits InitNetwork (cat=general).
  3. Server replies InitNetwork ack → _initNetworkSuccess = true.
  4. MatchingInitBattle: status=Connect; emit InitBattle; subscribe
     OnReceivedEvent matching handler.
  5. Server replies Matched → status=StartLoad, StartBattleLoad.
  6. Asset load done → client emits Loaded.
  7. Server replies BattleStart + Deal → status=Prepared, GotoBattle.

Add AwaitingInitBattle phase, gate Matched on InitBattle receipt, and
gate BattleStart+Deal on Loaded receipt. Update dispatch and
integration tests to walk the new sequence; InitBattle's wire cat is
Matching(2), not Battle(1).

Caught during v1 smoke walkthrough — battle-traffic.ndjson showed the
client receiving Matched/BattleStart at sub-millisecond gaps after
InitNetwork ack, but never advancing past matchmaking.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 02:08:04 -04:00
gamer147
0b859f1c8e fix(check): merge anonymous resignup viewer into Steam-linked viewer
GameStart already detects the Steam-vs-UDID mismatch produced by
wipe-and-resignup; it now also reclaims the orphan. New
ViewerRepository.MergeAnonymousViewerInto transfers the fresh UDID
from V_new onto V_old in one save (freeing the unique-index slot),
then deletes V_new in a second save. Partial-failure mode is a
benign null-UDID viewer; two rows never contend for the same UDID.
Side benefit: future GetViewerByUdid lookups now short-circuit to
V_old without going through the Steam handler.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:59:47 -04:00
gamer147
01b0c64a63 fix(battle-node): inject resultCode=1 into every scripted synchronize push
The client's OnReceived routing drops any synchronize push whose
resultCode != Success(1) — and absent counts as 0(None), which is
also dropped. Our InitNetwork ack and BattleFinish already included
resultCode=1, but the five lifecycle bodies (Matched, BattleStart,
Deal, Swap response, Ready) didn't, so the client silently dropped
every one of them.

Symptom: battle-traffic.ndjson capture showed the client receiving
InitNetwork/Matched/BattleStart, but the UI stayed at the matchmaking
screen until timeout — Matched/BattleStart were dropped at the
routing layer before they ever reached the state machine. Move the
resultCode injection into the shared EnvelopeForPush helper so every
scripted push gets it.

Caught during v1 smoke walkthrough.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:55:35 -04:00
gamer147
e7dac31d52 fix(check): emit rewrite_viewer_id when UDID and Steam viewers disagree
Wipe-and-resignup left the client stuck with the blank V_new's id in
Certification.ViewerId. /tool/signup is anonymous, so it can't see the
Steam ticket and creates a fresh anonymous viewer keyed on the new UDID;
the Steam handler on the next request resolves to V_old and serves its
data, but no normal-response hook overwrites Certification.ViewerId.
GameStart now compares the UDID-keyed viewer to the auth-resolved one
and emits rewrite_viewer_id when they differ, which Cute/GameStartCheckTask
writes back into Certification.ViewerId.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:49:56 -04:00
gamer147
cc32223d7d fix(battle-node): strip/prepend EIO3 type byte on binary WS frames
Engine.IO v3 frames over WebSocket prepend the packet-type byte (0x04
for Message) to BINARY frames, the binary analog of the leading digit
on text frames. The real client honors this and our session was
treating the entire binary frame as the Socket.IO attachment payload —
the msgpack decoder saw 0x04 as a positive fixint and failed
deserialization on every inbound msg event.

Symmetric fix: strip 0x04 from inbound binary frames in
BattleSession.RunAsync, prepend 0x04 to outbound binary frames in
EncodeAndSendAsync. RawSocketIoTestClient gets the same on both
directions so the integration test still exercises the same wire
shape as a real client.

Caught during v1 smoke walkthrough, after the WS upgrade started
succeeding (101 Switching Protocols).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:48:52 -04:00
gamer147
ccc9b41473 fix(battle-node): header-based WS detection in auth; split unknown-bid vs mismatch logs
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>
2026-06-01 01:17:42 -04:00
gamer147
1252f7bd35 fix(battle-node): read WS credentials from headers; skip Steam auth on WS upgrades
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>
2026-06-01 01:12:21 -04:00
gamer147
5525dbee24 fix(battle-node): node_server_url matches prod wire format (no scheme, with path)
Prod do_matching captures (data_dumps/captures/traffic_prod_tk2_*) send
the node URL as host:port/socket.io/ with no scheme prefix —
e.g. "node06.shadowverse.jp:13560/socket.io/". BestHTTP's SocketManager
expects this exact shape; the leading ws:// we were sending plus the
missing /socket.io/ path was preventing the client from completing the
post-do_matching connect (eventually times out with "connection timed
out").

Update BattleNodeOptions default, Program.cs override, and both
controller and bridge tests to use "localhost:5148/socket.io/".

Discovered during v1 smoke walkthrough.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:06:40 -04:00