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>
Adds GetRewardsByWinCountAsync and GetMaxWinCountAsync (short-circuits
to 0 on empty table). Registers as AddTransient in Program.cs alongside
other global catalog repos. 3 NUnit tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirror of the outer-repo data_dumps/ reorganization (commit e1e595d in
the SVSim outer repo): updates all data_dumps/extract/ → data_dumps/scripts/,
data_dumps/client_master_csv → data_dumps/client-assets, data_dumps/traffic
→ data_dumps/captures/traffic in XML doc-comments and inline comments
across importers, controllers, middlewares, DTOs, and tests. Doc-only;
no logic changes; build green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Prod's /pack/get_gacha_point_rewards offers leader cards from packs
where the leader sits in a non-Legendary tier — UCL pack 16015 has
Kyoka (711531010, Runecraft) and Miyako (711331010, Dragoncraft) as
Gold-tier rows with is_leader=1 in the drawrates. The old filter
(Tier == Legendary && !IsAltArt) excluded them, so the in-game
exchange UI was empty despite the banner advertising leader-card draw
rates.
Fix: filter on (Tier == Legendary || IsLeader) && !IsAltArt. Captures
every legendary plus any leader card regardless of page tier. Verified
against the captured 16015 response in
traffic_prod_all_gacha_exchange.ndjson (28 entries: 26 legendaries +
2 Gold-tier leaders).
Across the seeded data this surfaces 6 additional cards: 3 Bronze-tier
leaders + 3 Gold-tier leaders. The 68 Legendary-tier and 81 Special-
tier leaders were already included.
Renames legendaryCardIds -> exchangeableCardIds for clarity.
Regression test seeds a Gold-tier IsLeader=true card with a Skin row
and asserts the exchange catalog returns it with the Skin reward
entry.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The two wire fields differ for seasonal packs (verified against
traffic_prod_all_gacha_exchange.ndjson — every captured request pairs
odds_gacha_id=16xxx with parent_gacha_id=10xxx). The OLD DTO docstring
assumed they were always equal; today's controller used
ParentGachaId, which lands on the base/family pack id (often a
synthesized disabled stub with no GachaPointConfig) and returns [].
Fix:
- GetGachaPointRewards and ExchangeGachaPoint now consume OddsGachaId.
- Update both DTO docstrings to document the seasonal-pack pattern.
- Regression test seeds (16015 enabled w/ GachaPointConfig, 10015
disabled stub w/o config) and asserts the response uses 16015's
catalog.
Symptom: opening pack 16015 (parent_gacha_id=16015 in /pack/open)
accrued gacha points correctly, but /pack/get_gacha_point_rewards with
{odds_gacha_id:16015, parent_gacha_id:10015} returned an empty list.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extends /pack/open beyond the v1 CRYSTAL_MULTI=2 / DAILY=3 / RUPY_MULTI=7
trio to cover every type_detail whose payment primitive already exists:
1 CRYSTAL -> ICurrencySpendService crystal debit
6 RUPY -> ICurrencySpendService rupee debit
4 TICKET / 5 TICKET_MULTI -> debit child.ItemId from OwnedItemEntry
(ticketsNeeded = cost * packNumber), 400 on
missing/short balance; reward_list gets a
RewardType=4 post-state Item entry to mirror
project_wire_reward_list_post_state
Skin-overload type_details (8/9/13) and free-pack overlays (10/11/12)
stay 501 — they need selection / banner plumbing the current code
doesn't have.
Tutorial alias unchanged: it still consumes the gating ticket post-draw
and stamps tutorial_step=100. The two ticket flows diverge by intent
(tutorial = free server-grant; normal = paid by ticket inventory).
Removed Open_rejects_ticket_type_detail (asserted the old 501 path);
covered by Open_rejects_insufficient_tickets. Updated
NonTutorial_pack_open_does_not_emit_tutorial_step to assert the new
200-on-ticket-success behavior — same invariant under test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>