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.
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).
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.
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.
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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The decompiled client's DoMatchingBase.SettingCardMasterId calls
jsonData["card_master_id"].ToInt() with no Keys.Contains guard when
matching_state ∈ {3004, 3007, 3011}. Omitting the field crashes the
client with KeyNotFoundException at Cute.NetworkManager+Connect.
Add CardMasterId to DoMatchingResponseDto with a default value of 1
(matching the /load/index response and prod captures). Extend the
controller test to assert the field is present.
Caught during the v1 smoke walk-through; full client log line:
[Error: Unity Log] KeyNotFoundException: The given key was not
present in the dictionary.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
BattlePassRepository._curveCache and MissionCatalogRepository._maxLevelCache
were private-static fields populated lazily on first read from whatever
DbContext happened to be in scope. In production "one DbContext lineage
per process" makes that fine. Under parallel test execution each
SVSimTestFactory owns its own SQLite :memory: DB, so the first reader's
DB (often empty, in tests that don't seed BP) poisoned the cache for
concurrent readers from a seeded DB — assertions like "BP level info
must be present after seeding" failed because the process-static cache
returned an empty list populated by the other test's empty DB.
The first patch attempted a `BypassCacheForTests` static flag, which is
exactly the kind of test-only seam that rots the production code: future
caches get the same flag, repos accumulate hidden knobs, and the
underlying invariant ("a cache populated from arbitrary scope serves
arbitrary scope") goes unaddressed.
Instead, move both caches into the DI-registered IMemoryCache.
AddMemoryCache() registers it as singleton-per-service-provider:
production has one provider → one IMemoryCache → identical caching
semantics to before. Each WebApplicationFactory builds its own
provider → its own IMemoryCache → cache is naturally scoped per fixture,
no cross-test bleed possible.
The ResetLevelCurveCache() method and its three call sites
(SVSimTestFactory.SeedGlobalsAsync, BattlePassServiceTests,
LoadControllerTests) are deleted — a fresh factory owns a fresh empty
cache, no manual invalidation needed.
With this and the previous StoryService fixture-instance fix in place,
ParallelScope.All works: 776/776 in 57s wall clock (down from 59s on
Fixtures, 2m13s pre-parallelism).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
NUnit's default FixtureLifeCycle is SingleInstance — every test in a
class shares one fixture instance, so [SetUp]-initialised fields like
_master / _viewer / _service are reset on every test against the same
object. Under serial execution that's fine; under parallel execution
concurrent SetUps wipe each other's Mock setups and the service code
NREs trying to dereference unconfigured stubs.
Compounding it, NewInMemoryDb was being called with nameof(SetUp) which
is the literal string "SetUp", so every test in the fixture also shared
the same EF InMemory database (the provider keys stores by name).
Two fixes:
- [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] on StoryServiceTests
so each test gets its own instance with its own Mocks.
- Suffix the InMemoryDb name with a Guid so concurrent callers never
share a store.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The full-catalog regression test hardcoded "35 active packs as of
2026-05-23" but the controller filters by DateTime.UtcNow against each
pack's commence/complete dates. When two packs (99047, 80047) crossed
their complete_date of 2026-06-01 01:59:59 UTC, the test started
failing with Expected: 35 / But was: 33 — which had been masked all
along by NUnit's trx serializer OOMing on a different test.
The hardcoded count conflated three things that happened to be equal
on the day the test was written: packs in the seed file, packs active
right now, and 35. The test's real intent (per its class docstring) is
"every pack the importer ingests round-trips through /pack/info";
pinning the clock with TimeProvider would solve today's drift but
re-break the moment someone regenerates the seed or retires a pack.
Expected count now derives from the seed file at test time, filtered
by the same predicate the controller uses (PackRepository
.GetActivePacks: IsEnabled && commence <= now <= complete) via the
shared ImporterBase.ParseWireDateTime parser so any date-string quirk
parses identically on both sides. Spot-check on pack 99047 swapped for
"any pack with non-default pack_category" — same schema-fidelity
coverage (non-zero category survives JSON round trip) without pinning
to an id that rotates.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>