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>
Three coupled correctness fixes to /tutorial/gift_receive's response:
- received_ids / total_receive_count_list / reward_list are now built
from `toClaim` (the gifts THIS call granted), not from `requestedIds`.
Echoing the client's request meant idempotent re-claims re-fired the
"+N received" popup and direct-assigned the same post-state totals
again, breaking the documented idempotency contract.
- is_unreceived_present is now `unclaimedPresents.Count > 0`. The
hardcoded false hid the inbox badge after partial claims even when
present_list still carried unclaimed gifts.
- tutorial_step echoes the persisted (max-preserved) state instead of
a hardcoded 41. A replay against a state>=41 viewer used to surface
41 on the wire and regress the client's tutorial state machine.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same project_ef_nav_include_pitfall as 27ebb51's tutorial pack_open fix
but in the gift path: without .ThenInclude(i => i.Item), the existing
OwnedItemEntry's Item nav defaults to a new ItemEntry() (Id=0), so
RewardGrantService.ApplyAsync's `FirstOrDefault(i => i.Item.Id == detailId)`
misses pre-existing rows. It falls through to add a new entry, and the
(ViewerId, ItemId) unique index added 2026-05-25 throws on SaveChanges →
500 to the client, no tutorial advancement, no currency grant.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The tutorial alias bypassed the currency / type_detail / open-count guards
unconditionally. Combined with the unconditional TutorialState=100 write, any
authenticated viewer could send /tutorial/pack_open with any parent_gacha_id
to draw a pack for free and clobber their state down to 100.
Two gates: parent_gacha_id MUST be 99047 (the legendary starter), and the
viewer's TutorialState MUST be below 100. The state write is also max-preserved
as a belt-and-braces backstop. Mirrors the 31→41 guard in GiftController.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The endpoint used to write the client-supplied step verbatim, so a stale or
replayed request with tutorial_step=0 against any later-stage viewer would
regress the persisted state to 0. NextSceneSwitcher routes step==0 to
AreaSelect section 0, which has no chapter data — the client LINQ-Single()
crashes on next /load/index, bricking the viewer. Math.Max-preserve matches
the 31→41 pattern in GiftController.TutorialGiftReceive.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Without .ThenInclude(i => i.Item), the OwnedItemEntry.Item nav defaults to a
new ItemEntry() with Id=0 (project_ef_nav_include_pitfall), so the
FirstOrDefault(i => i.Item.Id == ticketItemId) lookup never matched. The
ticket was never decremented and reward_list omitted the post-state entry —
on the next /tutorial/pack_info the pack stayed visible and the client
re-clicked into plain /pack/open, which 501s on type_detail=5.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spec at docs/api-spec/endpoints/post-login/download_time-{start,end}.md
already documented both endpoints fully against the decompiled
Wizard/DownloadStartTask.cs and Wizard/DownloadFinishTask.cs — the
controller side was the gap.
The client fires /download_time/start before kicking off an Akamai
asset bundle download and /download_time/end on completion. Both are
pure telemetry from our perspective. When NukeIdentityOnStartup wipes
PlayerPrefs broadly (the pre-narrow loader behaviour), the client
decides it needs to download tutorial assets, calls /download_time/start,
and a 404 there surfaces as an HTTP error popup before the download
proceeds. Stubbing with empty data:{} bodies plus result_code:1 is the
documented minimum-viable response.
Acts as belt-and-suspenders against the narrow IdentityWipe (which
preserves the cache index so downloads shouldn't trigger) ever being
bypassed by a different code path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four targeted fixes that together let /tutorial/pack_info display
the legendary starter at index 0, let /tutorial/pack_open succeed
on it, and let the pack drop out of the shop after.
1. /pack/info now loads viewer.Items into a Dictionary<long,int>
and threads it through ToDto so child_gacha_info.item_number
reflects the viewer's actual owned count of item_id. Previously
defaulted to 0 for every pack, so the legendary pack 99047
reported item_number=0 immediately after the gift granted 1×
ticket id=90001. Verified against the prod tutorial capture.
2. PackRepository.GetActivePacks now orders parent_gacha_id DESC
to match prod's /pack/info wire order (99047, 92001, 80047,
16015...10001). The tutorial pack UI runs with controls locked
and auto-selects index 0 via GachaUI.GetCurrentLegendPackId
(FirstOrDefault on IsLegendPackId), so the legendary starter
needs to be the first legend pack in the list.
3. DbCardPoolProvider.GetPool falls back to all in-rotation cards
when a LegendCardPack's base set has no rows. Pack 99047's
base_pack_id is 90001, a synthetic "Throwback Rotation" category
that doesn't correspond to a real card_set in the prod card
master — its real pool is curated across older rotation sets
(Altersphere through Colosseum). We don't have that membership
map captured yet; the rotation fallback is broader than prod
but produces a valid 8-card draw, which is what the tutorial
needs to advance to step 100. TODO in code points at the
real fix.
4. PackController.Open's tutorial path now consumes the granted
ticket (decrement viewer.Items by packNumber for child.ItemId)
and emits the post-state count in reward_list as
{reward_type:4, reward_id:item_id, reward_num:post_count}.
Without this, the pack stayed at item_number=1 forever, the
shop kept showing it post-tutorial, and the next click hit
/pack/open (not /tutorial/pack_open) which 501s on type_detail=5.
Also: docstring on PackConfigDto.SalesPeriodInfo flags the deferred
wire-fidelity fix (prod emits {"sales_period_time": "<complete_date>"}
for limited windows, [] for evergreens; we always emit {}) and the
retype from Dictionary<string,string?> to a typed
PackSalesPeriodInfoDto. Doesn't affect tutorial flow, deferred for
the pack-system rework.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MyPageTask.Parse (Wizard/MyPageTask.cs:155-163) does
`_userItemDict.Clear();` the moment `user_item_list` is present in
the response body — not when it's non-empty — then re-populates
from the wire. Our /mypage/index was emitting [] by default (the
field initializer on the DTO), which wiped the inventory that
/load/index had just populated.
Downstream consequence: the client's PackChildGachaInfo.CostGoodsCount
reads from _userItemDict, so a wiped dict makes every ticket-cost
pack report CostGoodsCount=0, PackConfig.EnableBuyPack returns false,
and is_hide=1 packs (including the tutorial legendary starter 99047)
disappear from the rotation pack list — even though the gift bundle
just granted the ticket and the DB row exists. The tutorial then
auto-selects whatever non-tutorial pack happens to be at index 0 of
the filtered list, the user can't afford it, and the flow is stuck.
Fix:
- MyPageController.Index now sets UserItemList from viewer.Items
(already loaded by GetViewerByShortUdid's home-screen graph).
- DTO docstring rewritten to call out the presence-sensitive semantics
and the load-bearing path through PackConfig.EnableBuyPack, so the
next developer doesn't get the "empty is fine" hint the old comment
implied.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After /tool/signup the client switches to SID-only headers (no UDID), so
the next request's body can't be decrypted unless the server already
knows the SID's UDID. ShadowverseSessionService now mirrors the client's
Cute/Cryptographer.MakeMd5(viewerId + udid) formula (salt
"r!I@ws8e5i="), and ToolController.Signup prestores the mapping at the
end. Verified against a live signup capture: viewerId=1 +
udid=62747917-93bc-454c-abb4-ef423b3c9317 produces the captured SID
dc4aac79d35fe15dfb6262e0071bb03c.
Note: this only fixes the fresh-signup path. Clients restarting with a
cached viewer_id (which skip /tool/signup entirely) still hit the same
issue — separate follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The client's PlayerStaticData.UpdateHaveUserGoodsNumByJsonData does direct
assignment on each reward_list entry's reward_num, so currency/item totals
must be the new viewer balance — not the gift delta. Fresh accounts were
seeing their cached crystal/rupy balances clobbered down to the gift counts
until the next /load/index. Matches the project_wire_reward_list_post_state
memory and the prod capture (which shows 120 rupy = baseline 20 + gift 100).
Stack [HttpPost("/tutorial/pack_open")] alias on PackController.Open. Detect
isTutorialPath via HttpContext.Request.Path; gate the type_detail rejection,
currency switch, open-count tracking, and currency reward_list entries behind
!isTutorialPath so the starter legendary pack (99047/990047, type_detail=5)
bypasses the purchasable-pack code path. After grant, set MissionData.TutorialState=100
and emit tutorial_step=100 in PackOpenResponse — this is the sole END transition,
per live-traffic capture. Add pack 99047 to test-fixture packs.json.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stacks a second [HttpPost("/tutorial/pack_info")] absolute route on
PackController.Info so the tutorial flow resolves to the same action
and returns the same pack_config_list as /pack/info (no filtering in v1).
Adds PackControllerTests.cs with TutorialPackInfo_returns_same_list_as_pack_info
verifying byte-identical responses from both URLs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements POST /account/update_name — writes Viewer.DisplayName and
returns an empty array per the prod capture. Includes TDD test covering
the persist side-effect.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
POST /tutorial/update echoes tutorial_step back and saves it to
Viewer.MissionData.TutorialState. is_skip=1 is handled server-side
by honoring whatever tutorial_step value the client sends (client
already sends 100 when skipping). Adds TutorialUpdateRequest DTO,
TutorialUpdateResponse DTO, injects SVSimDbContext into
TutorialController, and adds GetViewerTutorialStateAsync helper to
SVSimTestFactory.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Returns an empty data object (result_code=1 from middleware envelope).
Client uses SkipAllNetworkChecks so the response body is never read.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the portal pair (shadowverse-portal.com deck-builder endpoints) as
anonymous routes on the app server. The translation middleware learns a new
[NoWireEncryption] attribute that skips both AES calls but keeps the rest of
the msgpack + base64 + envelope pipeline intact, matching prod's portal wire
profile observed in data_dumps/traffic_prod_deckcode.ndjson.
Storage is a 3-minute IMemoryCache — codes are anonymous-global, 4-char
lowercase alphanumeric (matches the shortest prod sample). Foil bit is
stripped on mint to match prod's normalize-on-encode behaviour.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues caught in a real-client smoke run against the freshly
bootstrapped DB:
1. NRE in ShadowverseTranslationMiddleware for parameterless actions.
Five new actions (Sleeve.Info, LeaderSkin.{Ids,Products},
ItemPurchase.Info, SpotCardExchange.Top) took no parameters, but
the middleware does
`endpointDescriptor.Parameters.FirstOrDefault().ParameterType`
to discover the request DTO — `FirstOrDefault` returns null on a
zero-param action and `.ParameterType` NREs. Tests didn't catch it
because the test client POSTs plain JSON, bypassing this path.
Fix: each action now takes `BaseRequest _` matching the codebase
convention (PuzzleController.Info, BattlePassController.Info, etc.),
plus the middleware throws an actionable
InvalidOperationException pointing at the convention so the next
contributor doesn't repeat the mistake.
2. Leader-skin set sale showed up as "FREE / Claim" with empty
Includes panel after the viewer bought every skin in a series
with no configured bonus items. Root cause: ComputeRewardStatus
emitted status=1 (not_got) when set_sales_status != 0 regardless
of whether rewards.items was empty, and SkinPurchaseInfoTask.
CreateSetSaleInfo flags `is_free=true` on (is_completed &&
not_got). Prod ships status=0 when items is empty even with
set_sales_status==1 — we now mirror that.
504 tests still pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After /tool/signup, the client has a viewer_id but no Steam social row.
The first authenticated request (typically /check/game_start) carries
the Steam ticket; if the SteamId lookup misses but the UDID resolves
to a viewer, attach the Steam social now. Subsequent requests hit the
fast SteamId path. Closes the CheckController.GameStart TODO that was
blocking fresh-client boot.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
POST /tool/signup upserts a Viewer keyed on the resolved request UDID
(via the existing SID->UDID dict). Stashes the viewer on HttpContext so
the translation middleware emits viewer_id/short_udid/udid in
data_headers. Empty data payload -- all signup outputs flow in
data_headers per spec. Idempotent: repeat signups for the same UDID
return the existing viewer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Story emits story_chapter_finish:<main|limited|event>:<story_id>.
Practice emits practice_win:<difficulty>:<enemy_class_id> on win only.
Practice catalog rows use opponent NAMES (e.g. practice_win:elite:arisa)
not numeric class_ids, so captured catalog rows won't match yet. The
infrastructure is in place; bridging numeric→name is a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EnsureCurrentAsync now takes viewerId (was Viewer), so it works with
LoadController's AsNoTracking-loaded detached viewers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three endpoints + 9 integration tests. Captured-data-is-catalog: viewer's
achievement Level starts at MIN(Level) per type from the catalog (not 1),
so the assembler always has a row to render against.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds BattlePassSalesPeriodInfoDto, BattlePassProductDto, BattlePassItemListResponse DTOs,
GetItemListAsync on BattlePassService (one product if not premium + CanPurchase, empty if
already premium or off-season), and the /battle_pass/item_list controller action.
2 new integration tests; all 408 pass.
Also fixes BattlePassRepository.GetActiveSeasonAsync to use client-side
DateTimeOffset filtering (SQLite provider cannot translate DateTimeOffset
comparisons in LINQ WHERE/ORDER BY clauses).
- IndexResponse.BattlePassLevelInfo widened to IReadOnlyDictionary<string,BattlePassLevel>?
so any IReadOnlyDictionary impl (FrozenDictionary, wrapper, etc.) serializes correctly
instead of silently null-ing via a failed as-cast
- LoadController.Index now takes CancellationToken ct and threads it to GetLevelCurveAsync
instead of CancellationToken.None
- BattlePassRepository.ResetLevelCurveCache changed from public to internal; added
InternalsVisibleTo("SVSim.UnitTests") to SVSim.Database.csproj (was absent)
Wire IBattlePassService.GetLevelCurveAsync into LoadController so /load/index
emits the 100-entry battle_pass_level_info dict when levels are seeded.
Also adds BattlePassRepository.ResetLevelCurveCache() to bust the process-level
static cache in tests that seed levels after earlier HTTP calls have primed it
with an empty list, and updates SVSimTestFactory.SeedGlobalsAsync + the stale
Index_surfaces_seeded_globals_after_bootstrap assertion accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>