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>
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>
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>
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>
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>
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>