162 Commits

Author SHA1 Message Date
gamer147
db054205b3 feat(battle-node): PvP TurnEnd broadcast + flipped Retire/Kill result
Pvp TurnEnd/TurnEndFinal broadcasts TurnEnd+Judge to BOTH so each
client's JudgeOperation advances. Pvp Retire/Kill pushes BattleFinish
with flipped result (sender=Lose, other=Win). Scripted Retire keeps
Phase 1 behaviour (sender-only Win via BuildBattleFinishNoContest).
2026-06-01 21:51:27 -04:00
gamer147
72dc1887d9 feat(battle-node): PvP gameplay-frame forwarding arms
TurnStart / PlayActions / Echo / TurnEndActions / JudgeResult from a
real participant in Pvp mode forward to the other participant once
BothAfterReady. Scripted's bot-burst case arms (gated on
FakeOpponentViewerId) precede the PvP forwarder so they're unaffected.

The bot-emission TurnStart/TurnEnd/Judge guard was tightened from
`ReferenceEquals(from, A or B)` (always true) to call
IsRealForwardableFromScripted directly in the `when` clause. The prior
shape used `goto default` to drop non-bot senders, which would have
short-circuited the new PvP forwarder for TurnStart in PvP mode.
2026-06-01 21:46:28 -04:00
gamer147
8a97dd0194 test(battle-node): PvP handshake dispatch sees cross-perspective contexts
Tests assert that for Type=Pvp, A's InitBattle gets Matched with A's
ctx as selfInfo and B's ctx as oppoInfo, and symmetrically for B. Same
for Loaded/BattleStart. Swap stays per-sender (each runs their own
mulligan).
2026-06-01 21:41:35 -04:00
gamer147
875a4baa29 refactor(battle-node): move handshake phase reads to per-participant
ComputeFrames now reads (from as IHasHandshakePhase)?.Phase for the
four handshake arms (InitNetwork, InitBattle, Loaded, Swap) and the
TurnEnd gate, transitioning the participant's Phase instead of the
session's. RealParticipant implements IHasHandshakePhase via the new
Phase property; the session-level BattleSession.Phase stays for the
Terminal short-circuit.

Scripted dispatch + wire shape unchanged (single-Real-participant case
collapses to Phase 1 semantics). Test fixture migrates FakeParticipant
to FakeRealParticipant for the side that drives handshake states. The
bot's TurnEnd previously rode the session-level AfterReady arm; with
that arm now gated on the sender's per-participant Phase (which the
bot lacks), TurnEnd joins TurnStart/Judge in the scripted-bot
forwarder arm so the v1.2 burst still reaches the real participant.
2026-06-01 21:33:17 -04:00
gamer147
ac78473a3e feat(battle-node): add RealParticipant.Phase for per-side handshake state
Internal setter; defaults to AwaitingInitNetwork. PvP needs A and B to
progress through the handshake states independently, which the
session-level BattleSession.Phase can't model. Session migration to read
realFrom.Phase is the next task.
2026-06-01 21:25:11 -04:00
gamer147
560feb231a refactor(battle-node): generalise BuildMatched/BuildBattleStart for PvP
Both helpers now take the opponent's MatchContext + an explicit seed
instead of pulling ScriptedProfiles.OpponentMatchedProfile / OpponentBattleStartProfile
internally. ScriptedBotParticipant.Context fixture absorbs the cosmetic
fields previously hardcoded in ScriptedProfiles so Scripted's wire bytes
stay identical - verified by integration tests still green.

Phase 2 prep: PvP arms will call the same helpers with the real opponent
participant's Context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 21:16:11 -04:00
gamer147
2d7cee38d3 refactor(battle-node): drop old BattleSession; rename V2 -> BattleSession
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.
2026-06-01 20:10:14 -04:00
gamer147
bbc3a47f7a test(battle-node): BattleSessionV2 dispatch covers Scripted-mode routing
Mirrors v1.2's BattleSessionDispatchTests but asserts on (target, frame,
noStock) routing tuples returned by ComputeFrames. Covers InitNetwork
ack, InitBattle/Loaded/Swap server-synthesized broadcasts (to the real
participant only in Scripted mode), TurnEnd forwarding to the scripted
bot, scripted-bot-emitted frames routing back to the real participant,
Retire/Kill BattleFinish path, and out-of-order frame drops.
2026-06-01 20:03:06 -04:00
gamer147
d665f88067 refactor(battle-node): unify IMatchingBridge.RegisterBattle signature
Single RegisterBattle(p1, p2?, type) with contract validation throws on
invalid combinations (Pvp requires both; Bot requires p2==null; Scripted
accepts either). PendingBattle carries Type + P1 + nullable P2. Handler
+ controller adapt; v1.2 behaviour preserved because Scripted is the
only type used today (Phase 2 adds Pvp, Phase 3 adds Bot).
2026-06-01 20:00:52 -04:00
gamer147
acd0997cfb feat(battle-node): add RealParticipant wrapping WS + sequencers
Lifts the WS read loop, SIO encode/decode, per-WS OutboundSequencer +
InboundTracker, and SIO ack out of BattleSession into a participant.
PushAsync(noStock=false) assigns playSeq via the sequencer; noStock=true
bypasses it. FrameEmitted fires on each deduplicated inbound envelope.
The existing BattleSession keeps its own copy of the WS code for now;
Task 9 cuts the handler over to use BattleSessionV2 + RealParticipant
and Task 10 deletes the old BattleSession + duplicate code.
2026-06-01 19:57:45 -04:00
gamer147
fcdcc5d590 feat(battle-node): add ScriptedBotParticipant wrapping v1.2 burst
PushAsync(TurnEnd|TurnEndFinal) fires FrameEmitted three times:
OpponentTurnStart + OpponentTurnEnd + OpponentJudge. Behaviour-identical
to the v1.2 case arm in BattleSession.ComputeResponses; just repackaged
as a participant. Other URIs are swallowed. Used by Phase 1 to preserve
v1.2 behaviour under the new abstraction; replaces the case-arm logic
in BattleSession in Task 7.
2026-06-01 19:56:01 -04:00
gamer147
553a79c795 feat(battle-node): add NoOpBotParticipant
Silent participant for the Phase 3 Bot type. PushAsync swallows;
FrameEmitted never fires; RunAsync completes immediately. ViewerId is
the existing FakeOpponentViewerId const for consistency with scripted
lifecycle builders. Three tests lock the no-op contract.
2026-06-01 19:55:00 -04:00
gamer147
479548fa56 test(battle-node): integration test expects three frames per cycle
End-to-end exercises the v1.2 burst: each TurnEnd from the client now
produces TurnStart + TurnEnd + Judge through the real WS pump.
2026-06-01 17:42:44 -04:00
gamer147
136149ed6b test(battle-node): wire-shape test for BuildOpponentJudge
Mirrors BuildOpponentTurnEnd_SerializesTurnStateAndResultCode. Guards
JudgeBody's JsonPropertyName keys against rename-induced wire breakage
(per feedback_wire_shape_tests pattern).
2026-06-01 17:40:33 -04:00
gamer147
007513e55c test(battle-node): TurnEnd dispatch tests expect three-frame burst (TDD red)
Both single-cycle and consecutive-cycles tests now assert the v1.2
three-frame burst (TurnStart + TurnEnd + Judge). Currently failing —
ComputeResponses still pushes only two frames. Implementation follows.
2026-06-01 17:34:59 -04:00
gamer147
8a5b8b747d feat(battle-node): BuildOpponentJudge builder for v1.2 turn-end Judge
Adds the third frame of the burst. Wire shape from prod (spin + resultCode).
OpponentJudgeSpin const next to OpponentTurnStartSpin for consistency.
Single test locks uri, ViewerId, Cat, and body shape.
2026-06-01 17:32:22 -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
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
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
133346e3e8 refactor(battle-node): SocketIoFrame throws on namespace; typed JSON construction 2026-06-01 11:48:17 -04:00
gamer147
677b1f1392 feat(battle-node): BattleResult enum for BattleFinish.result wire codes 2026-06-01 11:41:16 -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
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
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
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
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
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
gamer147
905fdc780a test(battle-node): end-to-end flow test through Ready via WebApplicationFactory
Boots SVSimTestFactory (in-memory SQLite + reference-data CSV import),
mints a battle via IMatchingBridge, opens a raw Socket.IO v2 client
against the in-process TestServer, drives InitNetwork → Loaded → Swap,
and asserts the right scripted frames come back in order.

Verifies the full transport stack end-to-end: EIO3+SIO2 framing,
encryptForNode codec, MsgPayloadCodec roundtrip, InboundTracker
pubSeq dedup + ack echo, OutboundSequencer playSeq assignment, and
ScriptedLifecycle's Path-A frame builders.

Note: RawSocketIoTestClient.DisposeAsync skips the graceful CloseAsync
handshake — TestServer's in-process WebSocket implementation can hang
on it. Abrupt Dispose is fine: the server's ReceiveAsync throws
WebSocketException, BattleSession.RunAsync returns, and the handler
completes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:37:31 -04:00
gamer147
f19da481c3 fix(battle-node): MatchingBridge avoids Math.Abs(int.MinValue) overflow
Cast GetHashCode() result to long before Math.Abs to prevent OverflowException
on the ~1-in-4B case where GetHashCode returns int.MinValue. Adds a regression
test pinning the 12-digit decimal format end-to-end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:33:35 -04:00
gamer147
d3c4b3083e feat(battle-node): IMatchingBridge + MatchingBridge mint battle id + node url 2026-05-31 22:31:04 -04:00
gamer147
680630050b fix(battle-node): BattleSession crash safety, fresh-key per push, phase guards
- Wrap HandleMsgEventAsync / HandleAliveEventAsync bodies in try/catch(Exception)
  logging at Error, eliminating async-void unobserved-exception crash risk (Issue 1).
- Replace deterministic seq-based key generator with RandomNumberGenerator.GetInt32
  so each EncodeAndSendAsync call uses a fresh random key (Issue 2).
- Add `when Phase == …` guards to InitNetwork / Loaded / Swap cases in
  ComputeResponses; add default arm that logs+drops out-of-order URIs (Issue 3).
- Widen SendSioAckAsync arg from int to long; drop (int) cast at call site;
  boundary cast to int is now checked() for defensive overflow detection (Issue 4).
- Update RunAsync doc comment (was stale Task-13 placeholder) (Issue 5).
- Add Kill and out-of-order-Swap-before-Loaded tests (Issue 6).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:28:13 -04:00
gamer147
f6aee5b0f8 feat(battle-node): BattleSession routes lifecycle URIs through ScriptedLifecycle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:21:55 -04:00
gamer147
30b457c9a0 fix(battle-node): assert Bid is in envelope (not Body) on BuildMatched
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:18:12 -04:00
gamer147
0fd4f5f9f7 feat(battle-node): ScriptedLifecycle frame builders (Path-A static opponent) 2026-05-31 22:15:44 -04:00