The "Waiting for opponent" hang traced to BattleStartControl.IsReady never
flipping true. That's gated by SBattleLoad.LoadOpponentAssets which calls
ResourcesManager.LoadAssetGroupSync with the bot's
{rank, emblemId, degreeId, countryCode} — and our placeholder ids (1/1/1/"NONE")
don't resolve to any asset in the client's resource bundle, so the callback
never fires.
Replaced with the Scripted bot's known-good prod values:
- SleeveId: 704141010
- EmblemId: 400001100
- DegreeId: 120027
- FieldId: 5
- CountryCode: "JPN"
- IsOfficial: 0
These are the same ids ScriptedBotParticipant.Context uses, which we know
load fine because the TK2 Scripted flow has been working end-to-end since
Phase 2.
Reference for the load chain (decompiled client):
BattleUI.WaitForSetUp → m_SBattleLoad.WaitCallBack
→ BattleStartControl.SetUp → CheckAbleToInitialize
→ SBattleLoad.LoadOpponentAssets (SBattleLoad.cs:933)
→ ResourcesManager.LoadAssetGroupSync — hangs on missing assets
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 3 shipped placeholder ai_id values 4001..4008, which the client's
RankMatchAISettingList.GetSettingData() couldn't resolve — the lookup
is .First() against the rm_ai_setting.csv master table and throws
InvalidOperationException ("Sequence contains no matching element")
when the id isn't present. Surfaced on live smoke as a Unity error
during battle load:
Wizard.RankMatchAISettingDataSet.GetSettingData (System.Int32 enemyAiId)
BattleUI+<WaitForSetUp>d__9.MoveNext ()
Replaced with the series-1 enemy_ai_id per class from
data_dumps/client-assets/rm_ai_setting.csv:
1111=Forest, 1121=Sword, 1131=Rune, 1141=Dragon,
1151=Shadow, 1161=Blood, 1171=Haven, 1181=Portal
Practice mode's AI catalog (practice_ai_setting.csv) uses a different
schema keyed by (class_id, difficulty) with no enemy_ai_id field, so
practice ids aren't reusable here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The translation middleware decrypts + msgpack-decodes the request body
into the action's first-parameter type, then re-serializes that DTO to
JSON for the auth handler to read. Phase 3's DoMatchingRequestDto and
RankBattleFinishRequestDto didn't inherit BaseRequest, so viewer_id /
steam_id / steam_session_ticket were dropped during the msgpack → DTO
→ JSON pivot — the auth handler then saw a body with no auth fields
and 401'd every request.
Fixed by making both DTOs extend BaseRequest, mirroring the Phase 2 TK2
DoMatchingRequest pattern.
Also added [FromBody] BaseRequest parameters to the previously body-less
actions (AiStart × 2, ForceFinish, AddClientLog, GetLatestMasterPoint).
The translation middleware explicitly requires at least one parameter
to bind the decrypted msgpack body (see L130-136 of the middleware);
without it the request would throw InvalidOperationException at runtime.
Tests updated to post viewer_id / steam_id / steam_session_ticket
placeholder values in the request body, matching the existing TK2 test
pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AiStartInternal builds the self MatchContext, picks a bot from
IBotRoster, projects to the AiBattleStartResponseDto with camelCase
wire keys (sleeveId, emblemId, ... — see ai-start.md). turnState=0
(player first) is the safe default per the ai-start.md TODO; live
capture would clarify the enum.
No deck → ai_id=-1 fallback (the documented "no AI assigned" sentinel
per AIBattleStartTask.cs:21). 3 new wire-shape tests assert the
camelCase keys land verbatim in the JSON, plus self/oppo info come from
the right sources.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DoMatchingInternal calls IMatchingPairUpService.TryPairAsync, then maps:
- null result → 3002 RETRY (empty node_server_url, no battle_id)
- IsAiFallback → 3011 AI_BATTLE_MATCHING_SUCCEEDED
- IsOwner → 3007 SUCCEEDED_OWNER (cache pickup)
- joiner → 3004 SUCCEEDED
BuildForRankBattleAsync's InvalidOperationException (typically "no deck
for format") surfaces as 3001 ILLEGAL so the client shows the
matchmaking-error dialog rather than retrying.
card_master_id is a placeholder (0) per the per-battle card-master
split deferral. AI-fallback timing is covered by InProcessPairUp unit
tests; controller tests focus on the wire mapping (3002, 3004, 3007).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stands up the controller with all 13 rank-battle URL routes wired via
explicit absolute [HttpPost] attributes (multi-prefix family — can't ride
[Route(\"[controller]\")]). Real DoMatching / AiStart logic arrives in
later tasks; finish + telemetry + force-finish are returnable stubs as
of this task.
DTOs cover the request + response shapes per the spec. Note the
camelCase wire keys on AiBattlePlayerInfo (sleeveId, emblemId, ...) —
the AI battle subsystem uses camelCase, not the project-default
snake_case, per AIBattleStartTask.Parse's literal Keys.Contains lookups.
DoMatchingResponseDto.NodeServerUrl is non-nullable + always-emit (with
[JsonIgnore(Never)]) — matches Phase 2's TK2 fix because the client's
DoMatchingBase parser calls .ToString() without a Keys.Contains guard.
13 routing smoke tests confirm each URL resolves to the controller.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AIBotProfile carries the cosmetic metadata the AI rank-battle start
endpoint composes into oppo_info. BotRoster.Pick is deterministic per
MatchContext so mid-flight retries get the same opponent. ai_id values
4001..4008 are placeholders per the existing ai-start.md TODO — we have
no live capture of the prod catalog.
Future improvement: migrate Roster to a bot-roster.json seed under
SVSim.Bootstrap/Data/seeds/ for editability without rebuilds.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sibling to BuildForTwoPickAsync. Routes through IDeckRepository.GetDeck
to pull the viewer's deck #1 for the requested format (avoiding the
viewer-graph nav-ref auto-load pitfall — DeckCard.Card silently ships
card_id=0 via the default include path). Throws if the viewer has no
deck for the format. Cosmetics fall back to DefaultLoadoutConfig
defaults when unequipped, same shape as TK2.
Used by RankBattleController in a later task to build self-context for
/ai_<fmt>_rank_battle/start and to pair-up under /<fmt>_rank_battle/do_matching.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
InProcessPairUp now consults ModePolicyRegistry per call and reads the
fallback threshold from MatchingConfig via IServiceScopeFactory (singleton
service consuming a scoped IGameConfigService). New behavior for
PvpFirstThenAiFallback modes: when the calling viewer IS the slot's
waiter and Now - WaitingSince >= threshold, the waiter unparks and the
bridge resolves a Bot match. PvpOnly modes (TK2) keep parking forever
(modulo a 5-minute stale-waiter eviction backstop).
TimeProvider is injected so tests can drive time forward with
FakeTimeProvider — 7 new tests cover the four key transitions
(stay-parked / pair-pvp / fall-back / stale-evict) plus per-mode
isolation. Fixture uses [FixtureLifeCycle(InstancePerTestCase)] because
the assembly is Parallelizable(ParallelScope.All).
Program.cs registers ModePolicyRegistry with three rows: TK2 PvpOnly,
rotation/unlimited rank PvpFirstThenAiFallback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pure shape change ahead of Phase 3 AI-fallback wiring — all current
callers pass IsAiFallback: false. TK2 will always emit false (PvpOnly
policy); rank-battle's PvpFirstThenAiFallback branch sets true after
the threshold elapses.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds PolicyKind enum (PvpOnly, PvpFirstThenAiFallback), ModePolicy
record, and ModePolicyRegistry singleton with last-wins dict + PvpOnly
default for unknown modes. Wired into InProcessPairUp in a later task.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds BattleNodeOptions.SoloDefaultsToScripted (default false). When true,
the TK2 do_matching controller treats every solo poll as if ?scripted=1
were passed and returns a Scripted 3004 match immediately — useful for
the live client (which can't append query params) to drive the scripted
bot without needing a second player.
Toggle via "BattleNode:SoloDefaultsToScripted" in appsettings*.json
(Program.cs now binds the BattleNode section over the AddBattleNode
defaults). Turn off to test real PvP with two clients.
Trade-off documented on the option: while on, two simultaneous pollers
each get their own Scripted match instead of pairing, so PvP is
effectively disabled until the flag is flipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors prod's TK2 wire flow: the first arriver (parked, picks up cached
pair on a later poll) gets matching_state 3007 (SUCCEEDED_OWNER); the
second arriver (whose poll triggered the pair) gets 3004 (SUCCEEDED).
Observationally inert in the public matching code path today — the
client's Matching class writes isOwner from the response into a field
that nothing in TK2/ranked reads. Matching_Room (private rooms) DOES
read it but from a separate code path that doesn't consult our response.
We send the split anyway for prod fidelity and to leave room for future
flows (rematch UI, etc.) that might start consuming it.
TryPairAsync now returns PairUpResult(Match, IsOwner) instead of bare
PendingMatch?, so the controller can decide owner vs joiner without
re-deriving it.
Also documents on DoMatchingResponseDto why we omit prod's `room_id`
field (not in the client's DoMatchingDetail model; private-room flows
get their room id from a different API and don't consult this response).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two client-crash bugs in the do_matching response when no partner is
waiting:
1. matching_state was 3001 (RC_BATTLE_MATCHING_ILLEGAL); the client's
Matching.OnFinishedDoMatching switch maps that to an error dialog,
not a retry. The retry state is 3002 (RC_BATTLE_MATCHING_RETRY).
2. node_server_url was omitted entirely. The client's
DoMatchingBase.SettingDoMatchingData reads it via
data["node_server_url"].ToString() with no Keys.Contains guard, so
absence throws KeyNotFoundException out of NetworkManager.Connect
before the matching_state switch is even reached. Prod RETRY
captures send "" while waiting and the real URL only on SUCCEEDED;
match that.
battle_id stays absent; its accessor IS guarded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Solo pollers park (3001 RETRY); two concurrent pollers pair and both
receive 3004 + same BattleId. Cache hits on the first arriver's next
poll. ?scripted=1 retains today's solo Scripted path for dev work.
Response DTO's BattleId/NodeServerUrl become nullable so 3001 omits
them on the wire (WhenWritingNull policy drops them).
ASP.NET's default bool binder rejects "1" as a value, so the scripted
opt-in is bound as string? and parsed permissively (accepts "1" and
"true"/"True"/etc.) rather than relying on built-in bool binding.
Tiny per-mode FCFS slot. First poller parks; second pairs and triggers
bridge.RegisterBattle(p1, p2, Pvp). Match cached for first poller's
next poll (consume-on-read). No MMR, no cross-mode, no timeouts --
the proper queue API is a separate spec; this is the smallest thing
that lets TK2 PvP work end-to-end.
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>
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>
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>
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>
The unit-test suite was spending most of its wall clock writing logs.
NUnit captures stdout per test and embeds it in the trx; with HttpLogging
emitting full request/response per controller call, EF Core SQL at
Information level, and ReferenceDataImporter banners running ~500x
(once per factory construction), the trx grew to 3.2 GB and the NUnit
result-XML serializer OOMed in StringBuilder.ToString() — which the
runner reported as one mysteriously failed test, masking a real
date-dependent failure underneath.
Three sources silenced under environment "Testing":
- appsettings.Testing.json drops Default + Microsoft.AspNetCore +
HttpLoggingMiddleware + EntityFrameworkCore to Warning.
- Program.cs skips app.UseHttpLogging() entirely (avoids the
middleware overhead, not just the log emission).
- ReferenceDataImporter takes optional TextWriters; the test factory
passes TextWriter.Null. Per-importer helpers become instance methods
so they can use the injected writer.
Result on a fresh run with ParallelScope.Fixtures already in place:
- Test duration: 1m46s -> 59s
- Wall clock: 2m23s -> 1m00s
- trx size: 3.2 GB -> 1.7 MB
The previously-masked date-dependent failure (PackControllerFullCatalog
.Info_returns_full_35_pack_catalog_from_production_seed asserting 35
active packs as of 2026-05-23 against a live clock) is now visible and
can be addressed separately.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CommitAsync's inner SaveChangesAsync already flushes the AddClaim
rows + progress.IsPremium mutation alongside the inventory grants
(same scoped DbContext). The trailing _db.SaveChangesAsync was a
no-op in BuyPremium and only meaningful in AddPoints when no level
crossed (no tx opened) — restructured to an else branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removed RewardGrantService, CurrencySpendService, ICurrencySpendService,
ViewerEntitlements, IViewerEntitlements, CardAcquisitionService,
ICardAcquisitionService, CardGrantResult and their tests
(RewardGrantServiceTests, CurrencySpendServiceTests,
CardAcquisitionServiceTests, ViewerEntitlementsTests). Removed four DI
registrations from Program.cs. No caller references any deleted type;
GrantedReward and EffectiveCosmetics were pre-moved to InventoryGrantTypes.cs
in the prior commit. Build clean, 712/712 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Change signature from (Viewer, packId, cardId) to (IInventoryTransaction, packId, cardId).
Drop RewardGrantService from GachaPointService ctor. PackController.ExchangeGachaPoint opens
tx with GachaPointBalances/Received extra includes, passes tx, commits on success.
Update GachaPointServiceTests to use inv.BeginAsync + tx pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace RewardGrantService + ICurrencySpendService + IViewerEntitlements with
IInventoryService. tx.IsFreeplay replaces FakeEntitlements.IsFreeplay; debit
helpers take IInventoryTransaction. ComputePostStateRewardList deleted (replaced
by result.RewardList from CommitAsync). Update 5 test files to new 8-arg ctor.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace RewardGrantService + ICurrencySpendService with IInventoryService tx.
CommitAsync's currency-collision rule replaces the manual Crystal RemoveAll+re-append
scrub in BuyPremiumAsync. AddPointsAsync uses result.Deltas for NewlyClaimed to
preserve per-track visibility (two Rupy grants stay two entries).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace RewardGrantService with IInventoryService tx. Per-reward GrantAsync
calls inside try/catch preserve the NotSupportedException skip; CommitAsync
returns result.RewardList (post-state totals) and accumulated delta list feeds
story_reward_list. Update StoryServiceTests to inject IInventoryService.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace RewardGrantService + HttpContext.RequestServices viewer load with
IInventoryService tx. Single BeginAsync/GrantAsync/CommitAsync wraps all
mission rewards on the win path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace RewardGrantService with IInventoryService tx. EnsureCurrentAsync
still runs before BeginAsync to avoid EF concurrent-context conflicts;
tx.Viewer replaces the manually loaded viewer graph.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace RewardGrantService with IInventoryService tx. GrantAsync returns
post-state totals directly, eliminating the manual ResolvePostStateRewardNum
helper. MissionData loaded via extra include on BeginAsync.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Includes Cards/Sleeves/Emblems/LeaderSkins/Degrees/MyPageBackgrounds/Items
under AsSplitQuery, plus caller-supplied extras via InventoryLoadConfig.
Opens a DB transaction and returns an InventoryTransaction shell. All
mutation methods throw NotImplementedException until subsequent tasks
land them.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the all-rows-granted reward model with per-group weighted
pick. Each ArenaTwoPickReward row now belongs to a RewardGroup with a
Weight; finish/retire groups the WinCount's rows by RewardGroup and
picks exactly one row per group, weighted by Weight (excluding
Weight==0). A RewardNum==0 outcome skips both the grant and the
rewards[] emission. Empty WinCount catalogs emit empty arrays.
Existing seed entries preserve deterministic behavior by living in
single-option groups (each with weight 1). Future seasons can expand
groups to multi-option for true randomized rewards (e.g. 200-280
rupies).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bare BaseTask call fired from DeckDecisionUI.cs:140 (Arena "View Deck"
path) and the TK2 prep screen. Client task has no Parse() override —
just checks result_code, ignores body. Prod (4 captured instances
across traffic_prod_taketwo_selections + traffic_prod_tradeables_capture)
unanimously responds with data: [].
Routing smoke added.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Prod /arena/get_challenge_info capture (Season 26):
- reward_step_info.reward_step_list is a Dict<string,string>
({"5":"5","10":"10","15":"15"}), not the List<int> I'd assumed
- max_reward_step is stringified
The previous stub would have parsed at the client (LitJson tolerates the
shape via indexed iteration), but cleaning to match prod exactly.
Also stubs /arena/get_challenge_ranking_history (new endpoint observed
in the same capture). Prod ships {two_pick: [], sealed: []} with no
history populated — empty lists match. Routing smoke added.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>