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.
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>
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 + 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>
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>
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>
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>
Sampler is now driven by PackDrawTable: roll DrawTier per slot by
cumulative slot-rate weights, then pick a card within tier by per-card
weights renormalized within the tier. Rate-less Guaranteed-Leader-Card
rows draw uniform over (pool minus owned), falling back to the full
pool when all are owned. Bonus slot fires once at the end of a 10-pack
open when HasBonusSlot is set.
Slot 8 falls back to the general slot's per-card weights for the rolled
tier when slot-8 has only a rarity-level rate quoted (the common shape
on normal packs).
PackController.Open loads the draw table + viewer owned card ids and
passes them to the sampler; the category-based forced-Legendary slot-8
override is gone. ICardFoilLookup replaces ICardPoolProvider for the
foil-twin heuristic.
Drops the test-fixture pack-draw seed overlay so the production seed
flows through the importer tests; controller tests that fabricate their
own card sets now call factory.SeedPackDrawTableAsync(...) to install a
matching stub draw table.
WeightedPick helper handles the cumulative-band roll for both stages.
Five sampler tests + four WeightedPick tests + five importer/repo
tests; full suite is 653/653 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A real /load/index dump emits my_rotation_id as a bare number (0) for
unset MyRotation slots, which 400'd against the string? DTO field
(AllowReadingFromString only covers string->number). FlexibleStringConverter
accepts either form. Also skip empty deck slots (no cards) on import — a
dump carries every slot, mostly empty placeholders the client manages
itself.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends POST /admin/import_viewer to accept owned_cards (snapshot full-replace).
Unknown card_ids are skipped, counted in skipped_card_count on the response, and
logged server-side. Count is clamped to OwnedCardEntry.MaxCopies (3). Also injects
ILogger into AdminController and adds Cards/Items/Decks to the viewer reload graph.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inject ICurrencySpendService and replace the inline crystal/rupee debit
block in BuildDeckController.Buy with TrySpendAsync calls, so freeplay
mode gets the no-deduct path automatically. All 18 BuildDeckController
tests pass unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Route /load/index currency, owned-card list, and cosmetic id-lists through
IViewerEntitlements so freeplay mode inflates all three without touching
the viewer's DB state. Adds LoadControllerFreeplayTests (2 tests).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
practice/deck_list returns the same wire shape as /deck/info (the client parses
both via DeckGroupListData), but only ever sent user decks — so a fresh account
saw no default decks and couldn't start a practice match.
Extract the /deck/info hydration into a shared IDeckListBuilder used by
/deck/info, /deck/my_list, and /practice/deck_list. Practice passes
padEmptySlots:false (deck *select*, not builder) — matches the prod practice
capture, which returns real decks unpadded plus the 8 per-class default decks
and per-class leader-skin settings. Retire the near-duplicate
PracticeDeckListResponse DTO in favor of the shared DeckListResponse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires IGachaPointService.TryExchangeAsync into a controller endpoint.
Loads the viewer with the full cosmetic-grant graph (Cards, Sleeves,
Emblems, Degrees, LeaderSkins, MyPageBackgrounds) plus the gacha-point
balance + received marker, with AsSplitQuery to avoid the cartesian
explosion documented in project_ef_split_query. Returns BadRequest with
the outcome's error code on validation failure, or the post-state
reward_list on success.
Two integration tests: happy-path verifies card grant + balance debit +
received-marker persistence + post-state reward_list shape; insufficient-
balance path returns 400.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PackController.Info's ownedItemsByItemId projection used `i.Item.Id` to
key the dict — EF translates that to the FK column today, but any future
model change that breaks the nav→column mapping would fall back to client
eval and collapse every key to 0 (the default Item constructor's Id),
silently hiding every tutorial pack via item_number=0. EF.Property<int>
reads the shadow FK directly and is robust to nav changes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reject empty / whitespace / explicit-null / over-cap names with 400
instead of NREing on null assignment or storing arbitrarily-long
strings the DB column has no cap on. 24-char limit is a conservative
backstop against direct API abuse; the client UI enforces its own
keyboard limit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>