Commit Graph

265 Commits

Author SHA1 Message Date
gamer147
b0b9901c42 feat(inventory): CommitAsync + currency-collision rule
Last post-state per currency wins; non-currency grants collapse to final
count per (type, id). Deltas are verbatim queued, no cascade. SaveChanges
+ DB tx commit happen atomically inside Commit; failure leaves rollback
to DisposeAsync. CS0649 warning on _committed is now resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:02:39 -04:00
gamer147
1ba3f57709 feat(inventory): BackfillCardCosmeticsAsync
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:01:15 -04:00
gamer147
46d8239d5a feat(inventory): TryDebitAsync dispatches currencies + Item
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:00:24 -04:00
gamer147
301da9eeca feat(inventory): TrySpendAsync covers all 4 wallets + freeplay
Crystal/Rupy/RedEther freeplay no-op (returns configured amount,
balance unchanged); SpotPoint always real. Insufficient returns
current balance; success returns post-deduction balance.
SVSimTestFactory gains freeplayEnabled ctor overload that upserts
the Freeplay GameConfigSection row after EnsureSeedData.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:59:26 -04:00
gamer147
a821b7f6b4 feat(inventory): GrantAsync handles Card + cosmetic cascade
Card grants produce a post-state-total entry and run the CardCosmeticReward
cascade (foil twin → id-1 lookup). Cascade additions are skipped when the
viewer already owns the cosmetic; missing-master-row failures are logged
and dropped without failing the parent grant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:54:36 -04:00
gamer147
1f3f81d878 feat(inventory): GrantAsync handles Item branch
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:52:42 -04:00
gamer147
a1cf1d7519 feat(inventory): GrantAsync handles cosmetic branches
Sleeve/Emblem/Skin/Degree/MyPageBG grants are idempotent on the viewer's
owned-collection but always emit a wire entry at the top level (preserves
"+1 sleeve" purchase popup). Unknown ids throw InventoryCatalogException.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:51:46 -04:00
gamer147
3bc38b407b feat(inventory): GrantAsync handles currency branches
Crystal/Rupy/RedEther/SpotCardPoint grants mutate ViewerCurrency in place
and emit post-state-total wire entries. Op log records the post-state for
later currency-collision resolution in CommitAsync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:50:27 -04:00
gamer147
02e86cf16c feat(inventory): BeginAsync loads viewer with canonical graph
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>
2026-05-31 15:46:20 -04:00
gamer147
b181257aaa docs(inventory): XML docs for TrySpend/TryDebit/EffectiveBalance
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:42:41 -04:00
gamer147
220e5699cd feat(inventory): scaffold InventoryService namespace types
Empty interfaces + records for IInventoryService, IInventoryTransaction,
InventoryCommitResult, InventoryLoadConfig, InventoryCatalogException.
Implementation lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:38:51 -04:00
gamer147
fc504af496 feat(tk2): weighted-group reward picking
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>
2026-05-31 13:44:33 -04:00
gamer147
8e017c9d10 feat(check): stub /check/check_time_slip_card_master_hash
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>
2026-05-31 13:29:27 -04:00
gamer147
ac2f31103d fix(arena): match prod get_challenge_info wire shape; stub ranking_history
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>
2026-05-31 13:21:44 -04:00
gamer147
1af56b4ec4 fix(tk2): per-viewer is_join in arena_info + stub /arena/get_challenge_info
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>
2026-05-31 13:13:11 -04:00
gamer147
1e2e18e828 fix(tk2): rewards array uses ReceivedReward shape (reward_detail_id/item_type/is_usable)
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>
2026-05-31 12:56:05 -04:00
gamer147
6381e4da51 fix(tk2): match original SV (5-battle cap, no loss limit)
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>
2026-05-31 12:47:43 -04:00
gamer147
dc19289818 fix(tk2): honor consume_item_type (ticket/crystal/rupy/free) + correct entry ticket id
- ArenaTwoPickConfig: add TicketItemId=1, TicketCost=1, CrystalCost=150, RupyCost=150 scalars
- ArenaTwoPickService.EntryAsync: switch on eARENA_PAY (1/3/4/5); crystal/rupy go through
  ICurrencySpendService.TrySpendAsync; ticket uses item id 1 (challenge ticket, not 80001);
  free entry returns empty reward_list; invalid type throws
- Tests: fix ticket id 80001→1 in entry/e2e; add 4 new path tests; update ctor (10th arg)
  across all 4 service test files; fix e2e retire assertion (reward ticket 80001 post-state=1)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:26:57 -04:00
gamer147
668779e8a4 fix(http): inherit BaseRequest on all TK2 + Colosseum request DTOs
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>
2026-05-31 12:06:50 -04:00
gamer147
f8ca4a0ae9 feat(http): stub /arena_colosseum/get_fee_info (is_colosseum_period:false) 2026-05-31 11:58:18 -04:00
gamer147
98fb3c5fcd fix(svc): default MaxBattleCount=7 with warn-log on empty reward catalog 2026-05-31 11:41:57 -04:00
gamer147
2aa0bdefec test(tk2): routing smoke + end-to-end draft→retire
Adds 8 TestCase entries to Authenticated_route_resolves for all
arena_two_pick and arena_two_pick_battle endpoints, and a full
integration test exercising entry → class_choose → 15×card_choose →
retire, verifying seed reward grants (1 ticket + 100 rupies).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 11:37:47 -04:00
gamer147
65e0e0fb09 test(config): update section count from 10 → 11 (ArenaTwoPick) 2026-05-31 11:27:56 -04:00
gamer147
09b8c49743 feat(http): ArenaTwoPickBattleController (do_matching stub + finish)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:27:02 -04:00
gamer147
f272690a31 feat(http): ArenaTwoPickController (6 actions)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:15:53 -04:00
gamer147
e245d5b158 feat(svc): Retire + Finish + RecordBattleResult
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:10:41 -04:00
gamer147
cc40e2d2e8 feat(svc): ChooseClassAsync + ChooseCardAsync (draft state machine)
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>
2026-05-31 11:03:50 -04:00
gamer147
d550f66481 feat(svc): EntryAsync (ticket debit + run insert + candidate classes)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 10:59:05 -04:00
gamer147
ba49852c42 feat(svc): IArenaTwoPickService + response DTOs + GetTopAsync
6 response DTOs, IArenaTwoPickService interface + ArenaTwoPickException,
ArenaTwoPickService skeleton with GetTopAsync implemented and stubs for
Tasks 13-15. 3 NUnit tests for GetTopAsync all pass. DI: AddScoped.
2026-05-31 10:51:41 -04:00
gamer147
a98b60dd36 feat(svc): ArenaTwoPickCardPoolService (rarity-weighted, class+neutral)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 10:46:59 -04:00
gamer147
30a723322c feat(dto): TK2 common DTOs (entry/class/deck/candidate/results/reward)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 10:40:06 -04:00
gamer147
2df18425c4 feat(repo): IArenaTwoPickRunRepository + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 10:37:42 -04:00
gamer147
721cd738d7 feat(repo): IArenaTwoPickRewardRepository + tests
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>
2026-05-31 10:34:12 -04:00
gamer147
5a8ca8853f feat(bootstrap): ArenaTwoPickRewardImporter + tests
Idempotent upsert importer for arena-two-pick-rewards.json; 2 NUnit tests
(16-row load + idempotency). Wired into Program.cs globals pipeline after
ArenaSeasonImporter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 10:30:41 -04:00
gamer147
4f5b4c6a6b feat(bootstrap): add arena-two-pick-rewards seed file + POCO 2026-05-31 10:27:27 -04:00
gamer147
f535642109 feat(config): add ArenaTwoPickConfig section 2026-05-31 10:25:40 -04:00
gamer147
d49b435e53 fix(config): restore pre-existing two_pick_sleeve_id (3000011) 2026-05-31 10:24:55 -04:00
gamer147
6e7f0dc4c9 feat(config): extend ChallengeConfig with TK2 format_info + PoolCardSetIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 10:23:08 -04:00
gamer147
5faa5e2445 feat(db): AddArenaTwoPick migration (rewards + viewer-run tables)
Register ArenaTwoPickRewards and ViewerArenaTwoPickRuns DbSets in SVSimDbContext and generate the AddArenaTwoPick migration with both CreateTable calls, all four jsonb columns on the run table, and the correct indexes (WinCount scalar + unique WinCount/RewardType/RewardId on rewards; unique ViewerId on runs).
2026-05-31 10:20:37 -04:00
gamer147
1dbc5fa831 feat(db): add ViewerArenaTwoPickRun entity + CandidatePair 2026-05-31 10:16:53 -04:00
gamer147
b32583ef48 feat(db): add ArenaTwoPickReward catalog entity 2026-05-31 10:12:16 -04:00
gamer147
50e4989b77 docs(importers): update data_dumps path references for reorg
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>
2026-05-31 01:22:08 -04:00
gamer147
1470406e17 fix(gacha-points): include IsLeader cards regardless of draw tier
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>
2026-05-31 00:21:42 -04:00
gamer147
670e980dc2 data(cosmetics): regenerate CardCosmeticReward seed from refreshed CSVs
1068 -> 1098 rows. Net adds:
  emblems   870 -> 879  (+9)
  sleeves    92 -> 99   (+7)
  skins      74 -> 81   (+7, includes the 5 missing 719xxx LTL leaders)
  degrees    24 -> 31   (+7)
  mypagebg    8 -> 8

Generated by build-card-cosmetic-rewards.py from the per-type CSVs
refreshed in the previous outer-repo commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 00:02:28 -04:00
gamer147
61ae086332 fix(gacha-points): look up by odds_gacha_id, not parent_gacha_id
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>
2026-05-30 23:30:18 -04:00
gamer147
9c9d0fc41f feat(packs): accept all currently-supported currencies on /pack/open
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>
2026-05-30 23:17:11 -04:00
gamer147
d9d29fbfea Merge progression-import-export: progression import/export + pack system rewrite
Two unrelated feature sets landed on the same dev branch this session:

1. Progression import/export (7 prior commits): owned cards + items +
   decks + tolerant numeric my_rotation_id parsing + literal-client-JSON
   wire-shape coverage.

2. Pack system rewrite (7 new commits): full-fidelity per-pack draw
   tables seeded from the 279 archived drawrates CSVs, replacing the
   pack->CardSet pool assumption. New EF entities, importer, sampler,
   IsEnabled admin gate on PackConfig, statistical sampler test,
   PackRateConfig marked Obsolete.

Tests: 648/648 green.
Bootstrap end-to-end: 279 PackDrawConfigs / 1973 SlotRates /
90800 CardWeights / 35 enabled + 244 disabled stubs in Packs.
2026-05-30 22:51:23 -04:00
gamer147
d66d1d8c6e test(packs): statistical sampler + mark PackRateConfig [Obsolete]
200k-slot statistical test asserts observed tier rates within +/- 0.5pp
of the seeded SV Classic shape (Bronze=76.5 / Silver=16 / Gold=6 /
Legendary=1.5 on general slots). Marked [Category("Slow")].

PackRateConfig is marked [Obsolete] — no longer consulted by
PackOpenService. Internal callers (GameConfigService / DbContext config
seeding / its own tests) still reference it; they'll go when v1
stabilizes and PackRateConfig is fully retired.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:46:12 -04:00
gamer147
517f855112 feat(packs): wire PackDrawTableImporter; retire ICardPoolProvider
Bootstrap Program.cs now calls PackDrawTableImporter after PackImporter.
Delete DbCardPoolProvider, ICardPoolProvider, and the DbCardPoolProvider
tests — the new IPackDrawTableRepository covers what GachaPointService
needed (legendary-tier card_ids per pack) and PackOpenService takes the
draw table directly.

GachaPointService now resolves the legendary catalog from
PackDrawTable.CardWeights filtered by Tier==Legendary, instead of
ICardPoolProvider.GetPool then a rarity filter. Same end set, no DB pool
walk.

Test fallout: tests that fabricate custom card sets for gacha-point
tests now call factory.SeedPackDrawTableFromSetAsync(packId, setId)
to install a matching legendary-tier stub. Full suite: 647/647 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:45:02 -04:00
gamer147
1c386b5ed0 feat(packs): rewrite PackOpenService against per-pack draw table
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>
2026-05-30 22:26:45 -04:00