Adds StoryDeckView projection, IBuildDeckRepository.GetStoryDecksByClass interface method,
and BuildDeckRepository implementation that loads StoryDeckEntry rows for a class, fetches
matching BuildDeckProductEntry card lists, and expands each card by Number into a flat
CardIdArray. TDD: 2 tests in StoryDeckRepositoryTests (expand + empty-class).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verified against Wizard.Title/UserNameInput.cs:30 in the 2026-05-23
decompile:
IsFinished = !string.IsNullOrEmpty(PlayerStaticData.UserName);
Any non-empty seeded value — including the prior " - " placeholder
this method was passing — sets IsFinished=true on the first frame and
silently skips both the input dialog and the /tutorial/update_action #1
+ /account/update_name calls that travel with it. The in-source comment
described the opposite behavior; empty string is what actually triggers
the dialog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
BuildDefaultViewer hardcoded TutorialState=1 — correct for fresh anonymous
signups (RegisterAnonymousViewer) but wrong for AdminController.ImportViewer
and Steam-social signups, which both go through RegisterViewer and expect a
prod-replica viewer that boots to the home screen. Add an initialTutorialState
parameter (default 1 preserves RegisterAnonymousViewer behavior); RegisterViewer
passes 100.
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>
Three corrections to BuildDefaultViewer + RegisterAnonymousViewer
verified against data_dumps/traffic_prod_tutorial.ndjson:
- TutorialState now 1 (TUTORIAL_STEP0), not 0 (PRE_TUTORIAL_STEP).
Wizard.Title.NextSceneSwitcher routes step==1 to the Prologue scene;
any other non-{31,41,100} step routes to AreaSelect at section 0,
which has no chapter data and crashes the client with a LINQ
Single() "Sequence contains no matching element" from
AreaSelectUI.SelectChapter. Prod's first /check/game_start returns
now_tutorial_step="1"; step 0 is a pre-existence state we never
want to expose on the wire.
- DisplayName " - " (literal space-dash-space), not "Player".
Wizard.Title.UserNameInput.Start short-circuits with
IsFinished=true on !string.IsNullOrEmpty(PlayerStaticData.UserName),
silently skipping the name dialog AND the tutorial sub-step it
drives (/tutorial/update_action #1 + /account/update_name). Prod
uses " - " as the unset placeholder.
- viewer.Info.SelectedEmblem/SelectedDegree assigned from the default
emblem/degree the loadout grants. Without this, /load/index emits
selected_emblem_id=0 and selected_degree_id=0 for a fresh viewer
that owns those cosmetics — prod sends the real granted IDs.
Also surfaces the actual Postgres constraint name in the unique-
violation re-raise (ExtractConstraintName), instead of always
saying "UDID" — the original message was misleading whenever the
real constraint was on owned-collection rows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously RegisterAnonymousViewer auto-completed the tutorial, which
prevented the client from ever entering the tutorial flow. SeedViewerAsync
gains a tutorialState parameter (default 100) so existing tests keep
their pre-completed-tutorial assumption.
Implements ICardInventoryRepository.SetProtected — loads only the
owned-cards collection (no decks/currency), guards against the EF
owned-nav Card.Id==0 default-init quirk, and accepts Count=0 rows
(destruct→re-protect round-trip). Covered by 4 new NUnit tests
(flip, unset, zero-count-row, unknown-card error). Full suite: 533/533.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(1) RegisterAnonymousViewer now catches the unique-violation
race (SQLSTATE 23505 on Postgres / code 19 on SQLite) and
re-reads by UDID, returning the existing row instead of
surfacing 500 to the second concurrent /tool/signup caller.
New repo test exercises the back-to-back register path.
(2) Add unique index on SocialAccountConnection (AccountType,
AccountId). The auth handler's find-or-link path claimed
this index existed as the dedup backstop; the claim was
accurate as design intent but the schema was missing. Now
matched. Comment in handler updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Review polish on the prior commit (30874c6):
- BuildDefaultViewer extract dropped the "filter out Id=0 placeholders
and dedupe" comment from the leader-skin grant block — restored.
- LinkSteamToViewer test now calls link twice and asserts count stays
at 1, exercising the alreadyLinked short-circuit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extracts the default-loadout body into a private BuildDefaultViewer
helper shared by the existing Steam-import path and a new
RegisterAnonymousViewer for /tool/signup. LinkSteamToViewer is the
seam SteamSessionAuthenticationHandler will call on first-Steam-touch
of a UDID-keyed viewer.
Co-Authored-By: Claude Opus 4.7 <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>
GetOrCreateProgressAsync now persists the new row itself and catches
DbUpdateException on unique-constraint violations — concurrent /info
calls no longer throw 500s. BattlePassService no longer calls
SaveChangesAsync after the get-or-create. FormatWireDate uses a named
JstOffset constant instead of an inline TimeSpan.FromHours(9).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Add ValueGeneratedOnAdd to ViewerBattlePassProgress.Id and
ViewerBattlePassClaims.Id so Postgres generates IDENTITY values at
runtime. Regenerate AddBattlePass migration in-place to include the
IdentityByDefaultColumn annotations. Add IBattlePassRepository /
BattlePassRepository (season lookup + level-curve cache) and
IViewerBattlePassRepository / ViewerBattlePassRepository
(get-or-create progress, claim reads/writes).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>