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>
Bug 1 ("pay to enter again after restart"):
arena_info[0].is_join shipped from the static ArenaSeasonConfig seed,
so /load/index and /mypage/index always emitted false regardless of
viewer state. The client uses is_join to choose between the "Pay to
enter" and "Resume run" dialogs (Wizard/ChallengeEntry.cs:165 + the
ArenaEntryBase._isJoinFunc pivot). Without a per-viewer override every
cold start after a partial run looked like "no run" and the player got
charged again.
LoadController + MyPageController now compute is_join from
ViewerArenaTwoPickRuns presence. MyPageController grew an
IArenaTwoPickRunRepository dep (LoadController already had _db).
Bug 2: /arena/get_challenge_info 404. Stubbed via a new
ArenaController + DTO pair. Returns the season seed's begin/end_time
+ name where available; placeholder zeros for win history. All 6 keys
required by ChallangeHistoryInfoTask.Parse are present (unconditional
JsonData lookups).
Routing smoke added for /arena/get_challenge_info.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /retire and /finish responses carry two reward arrays with DIFFERENT
key schemas:
rewards[] → ReceivedReward(JsonData) parser
{reward_type, reward_detail_id, item_type, reward_count?, is_usable}
reward_list[] → PlayerStaticData.UpdateHaveUserGoodsNumByJsonData
{reward_type, reward_id, reward_num}
We were emitting both with reward_list's schema, so the client threw
KeyNotFoundException on `data["reward_detail_id"]` while parsing each
delta entry — observed live as the retire-screen failure.
- New TwoPickRewardReceivedDto mirrors the existing Achievement/
TotalReceiveCountDto shape.
- FinishResponseDto.Rewards switched from List<RewardEntryDto>
to List<TwoPickRewardReceivedDto>.
- GrantRunRewardsAndDeleteAsync pre-loads ItemEntry.Type for any
Item-typed reward so item_type ships correctly (0 for currencies).
- Existing tests renamed RewardNum→RewardCount on the deltas list.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User clarified: the 0..7 win reward tiers came from Shadowverse Worlds
Beyond (sequel), not the original game we're emulating. Original SV's
Take Two caps at 5 total battles played and has no loss limit (verified
on prod: queueing continues with 2+ losses).
- arena-two-pick-rewards.json: drop 6w + 7w tiers (12 rows remain).
- ArenaTwoPickConfig: remove MaxLosses property.
- ArenaTwoPickService: termination is now battlesPlayed >= maxBattles
(5 from MAX(reward.WinCount)). RecordBattleResult no longer flips
IsSelectCompleted on the 2nd loss.
- ResolveMaxBattleCountAsync empty-catalog default 7 → 5.
- Tests updated for the new counts (16 → 12 rows, max 7 → 5).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MessagePack [Key("...")]-keyed contracts reject unknown fields, so request
DTOs that omit BaseRequest's envelope (viewer_id, steam_id,
steam_session_ticket) fail deserialization on the real msgpack wire path.
Routing smoke + JSON-direct tests didn't catch this because S.T.J. tolerates
extra keys and the routing smoke uses ValidBaseRequestJson, but anything
sent via the actual client encrypted=True path threw
MessagePackSerializationException.
Fix: every Arena*Request now inherits BaseRequest. Also updates the JSON
controller tests + e2e to include the envelope so the [ApiController]
auto-400 validation passes.
Discovered via /arena_colosseum/get_fee_info crash on the in-game arena
screen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements the class-selection and card-pick turns for the Take Two arena draft:
- ChooseClassAsync validates class is offered, locks ClassId, generates first pick set via pool
- ChooseCardAsync appends the two picked cards, advances SelectTurn 1–15, completes draft at turn 15
- 6 new tests covering happy paths and all error codes (class_not_offered, invalid_state, invalid_selection)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>