Compare commits

...

100 Commits

Author SHA1 Message Date
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
gamer147
0169ec57b4 feat(packs): split TryGetFoilTwin into ICardFoilLookup
Extracts the foil-twin lookup from ICardPoolProvider into a dedicated
ICardFoilLookup service. PackOpenService takes the lookup as a
parameter; the legacy DbCardPoolProvider stays registered until T12
removes it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:53:59 -04:00
gamer147
3c36124fa7 feat(packs): PackDrawTable aggregate + IPackDrawTableRepository
Aggregate (Config + SlotRates + CardWeights) and a single-pack getter
loaded as one unit per /pack/open. PackOpenService consumes the
aggregate; tests use the production seed (fixture overlay) to validate
shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:53:33 -04:00
gamer147
f7407fe382 feat(packs): PackImporter stubs pass + IsEnabled gate in active-packs
PackImporter now runs a second pass over pack-stubs.json, inserting
PackConfigEntry placeholders for any pack_id NOT already present from
the live-capture packs.json pass. Synthesized stubs default
IsEnabled=false; live-capture rows default IsEnabled=true.

PackRepository.GetActivePacks filters by IsEnabled in addition to the
date window, so synthesized stubs stay hidden until an operator opts
them in (UPDATE Packs SET IsEnabled=true WHERE Id=...).

Bundles Task 6 + Task 11 because adding pack-stubs.json to the
test-fixture set surfaces an extra row in PackControllerFullCatalogTests'
35-pack count assertion; the filter is what makes the test resilient.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:52:27 -04:00
gamer147
72c8fe627b feat(packs): PackDrawTableImporter with fixture tests
Idempotent upsert keyed on pack_id; slot rates and card weights are
wiped per pack and reinserted. String slot/tier in the seed translate
to enum at import time.

Tests:
- Imports_config_slot_rates_and_card_weights
- Is_idempotent_on_rerun

Fixtures live under SVSim.Bootstrap/Data/test-fixtures/seeds/ and overlay
the production seeds via the existing csproj rule (test-fixture file
beats production file at same Link path).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:45:06 -04:00
gamer147
f9f5b0dfa4 feat(packs): add PackDraw seed DTOs and IsEnabled on PackSeed
Three new seed DTOs (PackDrawConfigSeed, PackDrawSlotRateSeed,
PackDrawCardWeightSeed) — slot and tier carried as strings, importer
translates to enum.

PackSeed gains is_enabled (defaults true so existing seeds remain
enabled).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:43:27 -04:00
gamer147
8e98180951 feat(packs): add pack-draw and pack-stub seed files
Generated by data_dumps/extract/extract-pack-draw-rates.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:42:20 -04:00
gamer147
b78d7d6cbe feat(packs): add PackDraw* tables and IsEnabled column
Three new EF entities and a migration:
- PackDrawConfigEntry (per-pack: animation rate, has-bonus flag, special-kind label)
- PackDrawSlotRateEntry (pack/slot/tier -> rate, unique index)
- PackDrawCardWeightEntry (per-card-rate facts incl rate-less rows)

DrawSlot {General, Eighth, Bonus} and DrawTier {Bronze, Silver, Gold, Legendary, Special}.
Special collapses leader_card and limited_time_leader (verified mutually exclusive per pack).

IsEnabled column on PackConfigEntry — admin gate for synthesized stubs, distinct from
the wire-mirror IsHide.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:40:50 -04:00
gamer147
f754ef1ad3 fix(import): tolerate numeric my_rotation_id; skip empty deck slots
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>
2026-05-29 21:03:10 -04:00
gamer147
06108e4b6f test(import): literal-client-JSON wire-shape coverage for new fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:57:54 -04:00
gamer147
2e96001654 docs(import): update DefaultSleeveId comment after removing deck cloning 2026-05-29 18:54:00 -04:00
gamer147
4965851238 feat(import): import decks; remove obsolete default-deck cloning
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:42:07 -04:00
gamer147
d7e5557d61 feat(import): import consumable item inventory 2026-05-29 18:33:11 -04:00
gamer147
71b3c3e19f feat(import): import owned card collection with unknown-card skip
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>
2026-05-29 18:22:44 -04:00
gamer147
ed5be80f08 Merge freeplay-mode: global freeplay toggle + centralized spend/entitlement primitives 2026-05-29 16:40:46 -04:00
gamer147
9b2696fac5 test(freeplay): assert DB-untouched invariant on freeplay pack open
Crystal-pack open under freeplay with 0 balance: verifies the request
succeeds (HTTP 200) and Currency.Crystals is unchanged in the DB afterward.
2026-05-29 14:42:10 -04:00
gamer147
302bf17c31 feat(cosmetics): route ownership checks + shop owned-flags through entitlements (freeplay)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:36:50 -04:00
gamer147
d68a85bbc5 refactor(battlepass): route premium-buy crystal spend through CurrencySpendService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:23:50 -04:00
gamer147
ee407befb5 refactor(spotcard): centralize spot-point spend via CurrencySpendService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:20:32 -04:00
gamer147
5c6b703276 refactor(itempurchase): route currency spend (not items) through CurrencySpendService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:18:57 -04:00
gamer147
fb257a544f refactor(leaderskin): route currency spend through CurrencySpendService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:17:01 -04:00
gamer147
1f58461326 refactor(sleeve): route currency spend through CurrencySpendService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:14:14 -04:00
gamer147
2e021c8b9e refactor(builddeck): route currency spend through CurrencySpendService
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>
2026-05-29 14:12:43 -04:00
gamer147
163299504a refactor(pack): route currency spend through CurrencySpendService (freeplay) 2026-05-29 14:10:50 -04:00
gamer147
a3a49077b5 refactor(load): drop now-dead card/collection repo deps after entitlements move
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:07:30 -04:00
gamer147
092176ea1a feat(load): project currency/cards/cosmetics through entitlements (freeplay)
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>
2026-05-29 14:03:35 -04:00
gamer147
d560f9ade4 chore(di): register entitlements + spend services; add test freeplay helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:55:46 -04:00
gamer147
0052307686 feat(services): CurrencySpendService (central debit primitive, freeplay-aware) 2026-05-29 13:49:36 -04:00
gamer147
b7ee0cdcf8 test(entitlements): cover EffectiveOwnedCards/Cosmetics; document include preconditions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:47:22 -04:00
gamer147
3bf9ad1c42 test(config): include Freeplay in exhaustive ConfigSection seed-count assertion 2026-05-29 13:41:52 -04:00
gamer147
91c539fb8d feat(services): ViewerEntitlements (freeplay-aware ownership/balance authority)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:40:24 -04:00
gamer147
be19c0ad8d feat(repo): cosmetic catalog id enumerations on ICollectionRepository 2026-05-29 13:29:19 -04:00
gamer147
b4f6992918 feat(services): declare entitlements + currency-spend primitives
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:27:22 -04:00
gamer147
7b5edb7c65 feat(config): add Freeplay config section (default off)
Adds FreeplayConfig [ConfigSection("Freeplay")] with Enabled=false,
CurrencyAmount=99999, CardCopies=3, and ShippedDefaults(). Covered by
FreeplayConfigTests verifying the tier-chain resolves shipped defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:25:47 -04:00
gamer147
76aad36e84 Merge practice-default-decks: serve default/trial/leader-skin lists on practice/deck_list 2026-05-29 12:12:38 -04:00
gamer147
2d675aa35d feat(practice): serve default/trial/leader-skin lists on practice/deck_list
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>
2026-05-29 12:01:36 -04:00
gamer147
1e53748ae3 Merge story-build-trial-decks: serve build/trial/default deck lists on get_deck_list 2026-05-29 11:22:05 -04:00
gamer147
6f9976ebad style(story): blank line before StartAsync 2026-05-29 11:11:40 -04:00
gamer147
bd2eaa9e97 refactor(deck): re-type /deck/info trial_deck_list to List<TrialDeck>?
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:54:53 -04:00
gamer147
363213ccf7 test(story): literal-JSON wire-shape guard for get_deck_list deck lists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:52:06 -04:00
gamer147
66dc0cc657 feat(story): populate build/trial/default deck lists on get_deck_list
Wire IBuildDeckRepository into StoryService; GetDeckListAsync now looks
up the chapter's CharaId, fetches class-specific prebuilt/trial decks via
GetStoryDecksByClass, and loads all DefaultDecks for default_deck_list.
Class guard (1-8) leaves build/trial empty for non-class chapters, matching
prod behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:47:20 -04:00
gamer147
6a507553d1 feat(dto): TrialDeck + fleshed BuildDeck + trial/default on GetDeckListResponse 2026-05-29 10:38:39 -04:00
gamer147
68d783192d feat(repo): GetStoryDecksByClass joins story-deck presentation to product card lists
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>
2026-05-29 10:36:14 -04:00
gamer147
e792e8d79d feat(bootstrap): StoryDeckImporter + seed model, wired after BuildDeck
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:32:12 -04:00
gamer147
e0da7f09ca feat(db): AddStoryDeck migration (DDL)
Creates StoryDecks table with all required columns (Id PK, DeckNo, Kind,
ClassId, DeckName, SleeveId, LeaderSkinId, IsRecommend, OrderNum, EntryNo,
DeckFormat nullable, DateCreated, DateUpdated). Pure DDL — no InsertData/HasData.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:28:50 -04:00
gamer147
75a2fca8bb feat(db): StoryDeckEntry presentation table + StoryDeckKind enum
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:25:03 -04:00
gamer147
405f49c490 feat(seeds): story-deck presentation seed (53 build + 59 trial)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:23:11 -04:00
gamer147
7292c44082 fix(pack): include all pack legendaries in gacha-point catalog + correct class_id 2026-05-29 08:36:37 -04:00
gamer147
a8bbc39bfd fix(pack): emit one gacha-point entry per emblem cosmetic + clean stale docstring 2026-05-29 00:39:50 -04:00
gamer147
168e347a82 feat(pack): wire real gacha-point balance into /pack/info (skip ticket-only packs) 2026-05-29 00:16:08 -04:00
gamer147
739f629996 feat(pack): accrue gacha points on /pack/open (skip tutorial) 2026-05-29 00:07:28 -04:00
gamer147
b47ec3b64d feat(pack): /pack/exchange_gacha_point endpoint
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>
2026-05-28 23:57:39 -04:00
gamer147
9e7b7eed27 feat(pack): /pack/get_gacha_point_rewards endpoint 2026-05-28 23:49:08 -04:00
gamer147
1eaf0d0bc4 refactor(pack): collapse null-forgive in gacha-point exchange balance guard 2026-05-28 23:42:32 -04:00
gamer147
e1f5b9b6c3 feat(pack): gacha-point exchange (debit + grant)
Implements GachaPointService.TryExchangeAsync: validates pack
exchangeability, balance >= threshold, card in catalog, not already
received; debits balance, marks received, grants the card through
RewardGrantService (cascade handles cosmetics). Re-adds the
RewardGrantService injection that was removed in the Task 3 fix-up
(matches the "inject when you call" convention).

Card grant produces the wire-shape reward_list directly via the
cosmetic cascade — the catalog's reward_list remains the display-only
shape for /pack/get_gacha_point_rewards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:37:00 -04:00
gamer147
c7fb56f95f test(pack): cover additive accrual on existing gacha-point balance
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:27:56 -04:00
gamer147
723a97e3af feat(pack): gacha-point accrual with per-child override 2026-05-28 23:23:33 -04:00
gamer147
e41ceff0be refactor(pack): clarify gacha-point leader detection and drop unused grants injection 2026-05-28 23:20:33 -04:00
gamer147
66c0b1c951 feat(pack): gacha-point catalog read (legendaries + leader cards) 2026-05-28 23:11:48 -04:00
gamer147
ef1af8259e feat(pack): gacha-point endpoint DTOs 2026-05-28 22:56:33 -04:00
gamer147
96f1d73e35 docs: fix memory-reference name in gacha-point model docstrings 2026-05-28 22:54:46 -04:00
gamer147
21adc68e28 feat(db): add gacha-point balance + received tables 2026-05-28 22:50:22 -04:00
gamer147
261ce67cee fix(story): tutorial section is_finished derives from viewer tutorial_step
Section 0 (prologue) has no chapter rows server-side — the prologue is
hardcoded client-side in Wizard/Prologue.cs — so the chapter-completion
rollup always emitted is_finished=false. The client uses that flag to
derive IsTutorialReplay; with it false, AreaSelectUI.OnTouchChapterListTutorial
blocks every chapter switch and the default focus (last visible chapter)
becomes the only confirmable one, matching the reported "all 3 greyed out,
only the 3rd playable" symptom on replay.

Override sectionFinished for id=0 with viewer.MissionData.TutorialState >= 100,
matching prod traffic_prod_626_story.ndjson btn_story_tutorial.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:01:50 -04:00
199 changed files with 866936 additions and 1055 deletions

View File

@@ -1016,6 +1016,36 @@ card_id,type,cosmetic_id,quantity
718841020,7,718841020,1
718841020,8,120018,1
718841020,10,3918,1
719141010,6,719141011,1
719141010,7,400004404,1
719141010,7,719141010,1
719141010,8,303501,1
719141010,10,4201,1
719141020,6,719141020,1
719141020,7,400004603,1
719141020,8,303702,1
719141020,10,4401,1
719241010,6,719241011,1
719241010,7,719241010,1
719241010,8,122001,1
719241010,10,4002,1
719241020,6,719241020,1
719241020,7,400004604,1
719241020,8,303703,1
719241020,10,4402,1
719341010,6,719341015,1
719341010,7,400004602,1
719341010,8,303701,1
719341010,10,4413,1
719441010,6,719441010,1
719441010,7,719441015,1
719441010,7,719441016,1
719441010,8,122002,1
719441010,10,4004,1
719641010,6,719641010,1
719641010,7,400004605,1
719641010,8,303704,1
719641010,10,4406,1
720541010,6,720541010,1
720541010,7,720541010,1
721141010,6,721141011,1
1 card_id type cosmetic_id quantity
1016 718841020 7 718841020 1
1017 718841020 8 120018 1
1018 718841020 10 3918 1
1019 719141010 6 719141011 1
1020 719141010 7 400004404 1
1021 719141010 7 719141010 1
1022 719141010 8 303501 1
1023 719141010 10 4201 1
1024 719141020 6 719141020 1
1025 719141020 7 400004603 1
1026 719141020 8 303702 1
1027 719141020 10 4401 1
1028 719241010 6 719241011 1
1029 719241010 7 719241010 1
1030 719241010 8 122001 1
1031 719241010 10 4002 1
1032 719241020 6 719241020 1
1033 719241020 7 400004604 1
1034 719241020 8 303703 1
1035 719241020 10 4402 1
1036 719341010 6 719341015 1
1037 719341010 7 400004602 1
1038 719341010 8 303701 1
1039 719341010 10 4413 1
1040 719441010 6 719441010 1
1041 719441010 7 719441015 1
1042 719441010 7 719441016 1
1043 719441010 8 122002 1
1044 719441010 10 4004 1
1045 719641010 6 719641010 1
1046 719641010 7 400004605 1
1047 719641010 8 303704 1
1048 719641010 10 4406 1
1049 720541010 6 720541010 1
1050 720541010 7 720541010 1
1051 721141010 6 721141011 1

View File

@@ -0,0 +1,14 @@
[
{ "win_count": 0, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
{ "win_count": 0, "reward_type": 9, "reward_id": 0, "reward_num": 100 },
{ "win_count": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
{ "win_count": 1, "reward_type": 9, "reward_id": 0, "reward_num": 300 },
{ "win_count": 2, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
{ "win_count": 2, "reward_type": 9, "reward_id": 0, "reward_num": 500 },
{ "win_count": 3, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
{ "win_count": 3, "reward_type": 9, "reward_id": 0, "reward_num": 700 },
{ "win_count": 4, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
{ "win_count": 4, "reward_type": 9, "reward_id": 0, "reward_num": 850 },
{ "win_count": 5, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
{ "win_count": 5, "reward_type": 9, "reward_id": 0, "reward_num": 1000 }
]

View File

@@ -1,4 +1,13 @@
{
"use_two_pick_premium_card": false,
"two_pick_sleeve_id": 3000011
"two_pick_sleeve_id": 3000011,
"last_card_pack_set_id": 10015,
"card_pool_name": "Throwback Rotation",
"card_pool_url": "",
"announce_id": "",
"start_time": "",
"end_time": "",
"two_pick_type": 0,
"strategy_pick_num": 0,
"pool_card_set_ids": [10000, 10011, 10012, 10013, 10014, 10015]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
[
{
"parent_gacha_id": 10001,
"base_pack_id": 10001,
"gacha_type": 1,
"pack_category": 0,
"poster_type": 0,
"commence_date": "2016-06-17 00:00:00",
"complete_date": "2026-06-30 23:59:59",
"sleeve_id": 0,
"special_sleeve_id": 0,
"override_draw_effect_pack_id": 0,
"override_ui_effect_pack_id": 0,
"gacha_detail": "STUB CLC",
"is_hide": false,
"is_new": false,
"is_pre_release": false,
"open_count_limit": 0,
"sales_period_time": null,
"gacha_point": null,
"child_gachas": [
{ "gacha_id": 100011, "type_detail": 2, "cost": 200, "card_count": 1 },
{ "gacha_id": 100012, "type_detail": 2, "cost": 1800, "card_count": 10 }
],
"banners": [],
"is_enabled": false
},
{
"parent_gacha_id": 95001,
"base_pack_id": 95001,
"gacha_type": 1,
"pack_category": 2,
"poster_type": 0,
"commence_date": "2016-06-17 00:00:00",
"complete_date": "2026-06-30 23:59:59",
"sleeve_id": 0,
"special_sleeve_id": 0,
"override_draw_effect_pack_id": 0,
"override_ui_effect_pack_id": 0,
"gacha_detail": "7th Anniv stub",
"is_hide": false,
"is_new": false,
"is_pre_release": false,
"open_count_limit": 0,
"sales_period_time": null,
"gacha_point": null,
"child_gachas": [
{ "gacha_id": 950011, "type_detail": 2, "cost": 200, "card_count": 1 },
{ "gacha_id": 950012, "type_detail": 2, "cost": 1800, "card_count": 10 }
],
"banners": [],
"is_enabled": false
}
]

View File

@@ -53,7 +53,7 @@ public class AchievementCatalogImporter
{
Console.WriteLine($"[AchievementCatalogImporter] WARN: {unmappedTypes.Count} types " +
$"with no event_type: [{string.Join(", ", unmappedTypes.OrderBy(x => x))}] — " +
"add to ACHIEVEMENT_EVENT_MAP in data_dumps/extract/extract-achievements.py");
"add to ACHIEVEMENT_EVENT_MAP in data_dumps/scripts/extract-achievements.py");
}
return created + updated;
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of <see cref="ArenaTwoPickReward"/> rows from
/// <c>arena-two-pick-rewards.json</c>. Key = (WinCount, RewardType, RewardId).
/// </summary>
public class ArenaTwoPickRewardImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var path = Path.Combine(seedDir, "arena-two-pick-rewards.json");
if (!File.Exists(path))
{
Console.WriteLine($"[ArenaTwoPickRewardImporter] missing {path}; skipping.");
return 0;
}
var seeds = SeedLoader.LoadList<ArenaTwoPickRewardSeed>(path);
var existing = await context.ArenaTwoPickRewards
.ToDictionaryAsync(r => (r.WinCount, r.RewardType, r.RewardId));
int upserted = 0;
foreach (var s in seeds)
{
if (existing.TryGetValue((s.WinCount, s.RewardType, s.RewardId), out var row))
{
row.RewardNum = s.RewardNum;
}
else
{
context.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
{
WinCount = s.WinCount,
RewardType = s.RewardType,
RewardId = s.RewardId,
RewardNum = s.RewardNum,
});
}
upserted++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[ArenaTwoPickRewardImporter] upserted={upserted}");
return upserted;
}
}

View File

@@ -54,7 +54,7 @@ public class BattlePassMonthlyMissionImporter
{
Console.WriteLine($"[BattlePassMonthlyMissionImporter] WARN: {unmapped.Count} rows " +
$"with no event_type: [{string.Join(", ", unmapped)}] — add name to " +
"BP_MONTHLY_EVENT_MAP in data_dumps/extract/extract-bp-monthly-missions.py");
"BP_MONTHLY_EVENT_MAP in data_dumps/scripts/extract-bp-monthly-missions.py");
}
return created + updated;
}

View File

@@ -2,7 +2,7 @@ namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Tiny shared helper for content importers. Capture parsing has moved out of the bootstrap
/// project entirely (extractors under <c>data_dumps/extract/</c> emit per-table seed JSON);
/// project entirely (extractors under <c>data_dumps/scripts/</c> emit per-table seed JSON);
/// only the wire-date normaliser stays here because several seed-driven importers still need
/// to canonicalise prod-shaped timestamp strings.
/// </summary>

View File

@@ -8,7 +8,7 @@ namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of the item catalog from <c>seeds/items.json</c>. Source is the client's
/// <c>item_master.csv</c> + <c>itemtext.json</c> (extracted via
/// <c>data_dumps/extract/extract-items.py</c>). Rows missing from the seed are LEFT INTACT.
/// <c>data_dumps/scripts/extract-items.py</c>). Rows missing from the seed are LEFT INTACT.
/// </summary>
public class ItemImporter
{

View File

@@ -8,7 +8,7 @@ namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of the item-purchase catalog from <c>seeds/item-purchase.json</c>.
/// Source is the wire <c>/item_purchase/info</c> response, extracted via
/// <c>data_dumps/extract/extract-item-purchase.py</c>. Rows missing from the seed are LEFT INTACT.
/// <c>data_dumps/scripts/extract-item-purchase.py</c>. Rows missing from the seed are LEFT INTACT.
/// </summary>
public class ItemPurchaseImporter
{

View File

@@ -9,7 +9,7 @@ namespace SVSim.Bootstrap.Importers;
/// Idempotent upsert of the leader-skin-shop catalog from <c>seeds/leader-skin-shop.json</c>.
/// Mirror of <see cref="SleeveShopImporter"/>. Source is the wire
/// <c>/leader_skin/products</c> response, extracted via
/// <c>data_dumps/extract/extract-leader-skin-shop.py</c>. Rows missing from the seed are LEFT INTACT.
/// <c>data_dumps/scripts/extract-leader-skin-shop.py</c>. Rows missing from the seed are LEFT INTACT.
/// </summary>
public class LeaderSkinShopImporter
{

View File

@@ -50,7 +50,7 @@ public class MissionCatalogImporter
{
Console.WriteLine($"[MissionCatalogImporter] WARN: {unmapped.Count} mission_ids with " +
$"no event_type: [{string.Join(", ", unmapped)}] — add to MISSION_EVENT_MAP " +
"in data_dumps/extract/extract-missions.py and re-run the extractor");
"in data_dumps/scripts/extract-missions.py and re-run the extractor");
}
return created + updated;
}

View File

@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of the per-pack draw table from
/// <c>seeds/pack-draw-config.json</c>, <c>pack-draw-slot-rates.json</c>, and
/// <c>pack-draw-card-weights.json</c>. Replaces wholesale per pack (config keyed on
/// pack_id; slot rates / card weights wiped and reinserted) — the upstream data is
/// post-shutdown closed, so we do not preserve hand-edits on these tables.
/// </summary>
public class PackDrawTableImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var configs = SeedLoader.LoadList<PackDrawConfigSeed>(Path.Combine(seedDir, "pack-draw-config.json"));
var slotRates = SeedLoader.LoadList<PackDrawSlotRateSeed>(Path.Combine(seedDir, "pack-draw-slot-rates.json"));
var cardWeights = SeedLoader.LoadList<PackDrawCardWeightSeed>(Path.Combine(seedDir, "pack-draw-card-weights.json"));
if (configs.Count == 0)
{
Console.WriteLine("[PackDrawTableImporter] No seed rows; skipping.");
return 0;
}
var seedPackIds = configs.Select(c => c.PackId).ToHashSet();
// Full-replace strategy: wipe rows for any pack in the seed, then reinsert.
await context.PackDrawCardWeights
.Where(w => seedPackIds.Contains(w.PackId))
.ExecuteDeleteAsync();
await context.PackDrawSlotRates
.Where(s => seedPackIds.Contains(s.PackId))
.ExecuteDeleteAsync();
var existingConfigs = await context.PackDrawConfigs
.Where(c => seedPackIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);
foreach (var s in configs)
{
var row = existingConfigs.TryGetValue(s.PackId, out var ex)
? ex : new PackDrawConfigEntry { Id = s.PackId };
row.AnimationRatePct = s.AnimationRatePct;
row.HasBonusSlot = s.HasBonusSlot;
row.SpecialKind = s.SpecialKind;
row.ShortCode = s.ShortCode;
if (ex is null) context.PackDrawConfigs.Add(row);
}
foreach (var s in slotRates)
{
context.PackDrawSlotRates.Add(new PackDrawSlotRateEntry
{
PackId = s.PackId,
Slot = ParseSlot(s.Slot),
Tier = ParseTier(s.Tier),
RatePct = s.RatePct,
});
}
foreach (var s in cardWeights)
{
context.PackDrawCardWeights.Add(new PackDrawCardWeightEntry
{
PackId = s.PackId,
Slot = ParseSlot(s.Slot),
Tier = ParseTier(s.Tier),
CardId = s.CardId,
RatePct = s.RatePct,
IsLeader = s.IsLeader,
IsAltArt = s.IsAltArt,
});
}
await context.SaveChangesAsync();
Console.WriteLine($"[PackDrawTableImporter] {configs.Count} configs / {slotRates.Count} slot rates / {cardWeights.Count} card weights");
return configs.Count;
}
private static DrawSlot ParseSlot(string s) => s switch
{
"general" => DrawSlot.General,
"eighth" => DrawSlot.Eighth,
"bonus" => DrawSlot.Bonus,
_ => throw new InvalidDataException($"PackDrawTableImporter: unknown slot \"{s}\""),
};
private static DrawTier ParseTier(string s) => s switch
{
"bronze" => DrawTier.Bronze,
"silver" => DrawTier.Silver,
"gold" => DrawTier.Gold,
"legendary" => DrawTier.Legendary,
"special" => DrawTier.Special,
_ => throw new InvalidDataException($"PackDrawTableImporter: unknown tier \"{s}\""),
};
}

View File

@@ -61,6 +61,7 @@ public class PackImporter
ExchangeablePoint = s.GachaPoint.ExchangeablePoint,
IncreaseGachaPoint = s.GachaPoint.IncreaseGachaPoint,
};
pack.IsEnabled = s.IsEnabled;
// Owned collections -- clear and rehydrate (matches the previous wholesale-replace semantics).
pack.ChildGachas.Clear();
@@ -101,7 +102,75 @@ public class PackImporter
}
await context.SaveChangesAsync();
Console.WriteLine($"[PackImporter] +{created}/~{updated}");
return created + updated;
Console.WriteLine($"[PackImporter] capture: +{created}/~{updated}");
// Second pass: synthesized stubs from pack-stubs.json. Skip any pack_id that already
// exists from the live-capture pass (capture wins on conflict).
var stubs = SeedLoader.LoadList<PackSeed>(Path.Combine(seedDir, "pack-stubs.json"));
int stubsAdded = 0;
foreach (var s in stubs)
{
if (s.ParentGachaId == 0) continue;
if (existing.ContainsKey(s.ParentGachaId)) continue;
var pack = new PackConfigEntry
{
Id = s.ParentGachaId,
BasePackId = s.BasePackId,
GachaType = s.GachaType,
PackCategory = (PackCategory)s.PackCategory,
PosterType = s.PosterType,
CommenceDate = ParseWireDateTime(s.CommenceDate),
CompleteDate = ParseWireDateTime(s.CompleteDate),
SleeveId = s.SleeveId,
SpecialSleeveId = s.SpecialSleeveId,
OverrideDrawEffectPackId = s.OverrideDrawEffectPackId,
OverrideUiEffectPackId = s.OverrideUiEffectPackId,
GachaDetail = s.GachaDetail,
IsHide = s.IsHide,
IsNew = s.IsNew,
IsPreRelease = s.IsPreRelease,
OpenCountLimit = s.OpenCountLimit,
SalesPeriodTime = string.IsNullOrEmpty(s.SalesPeriodTime) ? null : ParseWireDateTime(s.SalesPeriodTime),
GachaPointConfig = s.GachaPoint is null ? null : new PackGachaPointConfig
{
ExchangeablePoint = s.GachaPoint.ExchangeablePoint,
IncreaseGachaPoint = s.GachaPoint.IncreaseGachaPoint,
},
IsEnabled = s.IsEnabled,
};
foreach (var c in s.ChildGachas)
{
pack.ChildGachas.Add(new PackChildGachaEntry
{
GachaId = c.GachaId,
TypeDetail = c.TypeDetail,
Cost = c.Cost,
CardCount = c.CardCount,
ItemId = c.ItemId,
IsDailySingle = c.IsDailySingle,
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint,
PurchaseLimitCount = c.PurchaseLimitCount,
FreeGachaCampaignId = c.FreeGachaCampaignId,
CampaignName = c.CampaignName,
});
}
foreach (var b in s.Banners)
{
pack.Banners.Add(new PackBannerEntry
{
BannerName = b.BannerName,
DialogTitle = b.DialogTitle,
});
}
context.Packs.Add(pack);
existing[s.ParentGachaId] = pack;
stubsAdded++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[PackImporter] stubs: +{stubsAdded}");
return created + updated + stubsAdded;
}
}

View File

@@ -42,6 +42,15 @@ public class RotationConfigImporter
{
c.UseTwoPickPremiumCard = cc.UseTwoPickPremiumCard;
c.TwoPickSleeveId = cc.TwoPickSleeveId;
c.LastCardPackSetId = cc.LastCardPackSetId;
c.CardPoolName = cc.CardPoolName;
c.CardPoolUrl = cc.CardPoolUrl;
c.AnnounceId = cc.AnnounceId;
c.StartTime = cc.StartTime;
c.EndTime = cc.EndTime;
c.TwoPickType = cc.TwoPickType;
c.StrategyPickNum = cc.StrategyPickNum;
c.PoolCardSetIds = cc.PoolCardSetIds ?? new List<int>();
});
touched++;
}

View File

@@ -5,7 +5,7 @@ namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Reads a JSON seed file under <c>SVSim.Bootstrap/Data/seeds/</c>. Replaces ImporterBase.LoadCapture.
/// Files are produced by extractors in <c>data_dumps/extract/</c>; the bootstrap project does not
/// Files are produced by extractors in <c>data_dumps/scripts/</c>; the bootstrap project does not
/// transform wire formats. Missing files are non-fatal (returns empty/null) — caller decides.
/// </summary>
public static class SeedLoader

View File

@@ -8,7 +8,7 @@ namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of the sleeve-shop catalog from <c>seeds/sleeve-shop.json</c>.
/// Source is the wire <c>/sleeve/info</c> response, extracted via
/// <c>data_dumps/extract/extract-sleeve-shop.py</c>. Mirror of the BuildDeck importer pattern.
/// <c>data_dumps/scripts/extract-sleeve-shop.py</c>. Mirror of the BuildDeck importer pattern.
/// Rows missing from the seed are LEFT INTACT (so manual test fixtures survive re-runs).
/// </summary>
public class SleeveShopImporter

View File

@@ -8,7 +8,7 @@ namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of the spot card exchange catalog from <c>seeds/spot-card-exchange.json</c>.
/// Source is the wire <c>/spot_card_exchange/top</c> response, extracted via
/// <c>data_dumps/extract/extract-spot-card-exchange.py</c>. Rows missing from the seed are
/// <c>data_dumps/scripts/extract-spot-card-exchange.py</c>. Rows missing from the seed are
/// LEFT INTACT.
/// </summary>
public class SpotCardExchangeImporter

View File

@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of story-deck presentation rows from <c>seeds/story-decks.json</c>.
/// Card lists are NOT imported here — they belong to BuildDeckProductEntry (deck_no == product_id),
/// so this importer should run AFTER BuildDeckImporter.ImportPackageAsync. Rows missing from the
/// seed are left intact.
/// </summary>
public class StoryDeckImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var seed = SeedLoader.LoadList<StoryDeckSeed>(Path.Combine(seedDir, "story-decks.json"));
if (seed.Count == 0)
{
Console.WriteLine("[StoryDeckImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.StoryDecks.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.DeckNo == 0) continue;
var entry = existing.TryGetValue(s.DeckNo, out var ex) ? ex : new StoryDeckEntry { DeckNo = s.DeckNo };
entry.Kind = string.Equals(s.Kind, "trial", StringComparison.OrdinalIgnoreCase)
? StoryDeckKind.Trial : StoryDeckKind.Build;
entry.ClassId = s.ClassId;
entry.DeckName = s.DeckName;
entry.SleeveId = s.SleeveId;
entry.LeaderSkinId = s.LeaderSkinId;
entry.IsRecommend = s.IsRecommend;
entry.OrderNum = s.OrderNum;
entry.EntryNo = s.EntryNo;
entry.DeckFormat = s.DeckFormat;
if (ex is null) { context.StoryDecks.Add(entry); existing[s.DeckNo] = entry; created++; }
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[StoryDeckImporter] +{created}/~{updated}");
return created + updated;
}
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public class ArenaTwoPickRewardSeed
{
[JsonPropertyName("win_count")] public int WinCount { get; set; }
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
[JsonPropertyName("reward_id")] public long RewardId { get; set; }
[JsonPropertyName("reward_num")] public int RewardNum { get; set; }
}

View File

@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class PackDrawCardWeightSeed
{
[JsonPropertyName("pack_id")] public int PackId { get; set; }
[JsonPropertyName("slot")] public string Slot { get; set; } = "general";
[JsonPropertyName("tier")] public string Tier { get; set; } = "bronze";
[JsonPropertyName("card_id")] public long CardId { get; set; }
[JsonPropertyName("rate_pct")] public double? RatePct { get; set; }
[JsonPropertyName("is_leader")] public bool IsLeader { get; set; }
[JsonPropertyName("is_alt_art")] public bool IsAltArt { get; set; }
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class PackDrawConfigSeed
{
[JsonPropertyName("pack_id")] public int PackId { get; set; }
[JsonPropertyName("short_code")] public string? ShortCode { get; set; }
[JsonPropertyName("animation_rate_pct")] public double AnimationRatePct { get; set; }
[JsonPropertyName("has_bonus_slot")] public bool HasBonusSlot { get; set; }
[JsonPropertyName("special_kind")] public string? SpecialKind { get; set; }
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class PackDrawSlotRateSeed
{
[JsonPropertyName("pack_id")] public int PackId { get; set; }
[JsonPropertyName("slot")] public string Slot { get; set; } = "general";
[JsonPropertyName("tier")] public string Tier { get; set; } = "bronze";
[JsonPropertyName("rate_pct")] public double RatePct { get; set; }
}

View File

@@ -24,6 +24,7 @@ public sealed class PackSeed
[JsonPropertyName("gacha_point")] public PackGachaPointSeed? GachaPoint { get; set; }
[JsonPropertyName("child_gachas")] public List<PackChildGachaSeed> ChildGachas { get; set; } = new();
[JsonPropertyName("banners")] public List<PackBannerSeed> Banners { get; set; } = new();
[JsonPropertyName("is_enabled")] public bool IsEnabled { get; set; } = true;
}
public sealed class PackGachaPointSeed

View File

@@ -21,6 +21,16 @@ public sealed class ChallengeConfigSeed
{
[JsonPropertyName("use_two_pick_premium_card")] public bool UseTwoPickPremiumCard { get; set; }
[JsonPropertyName("two_pick_sleeve_id")] public long TwoPickSleeveId { get; set; }
[JsonPropertyName("last_card_pack_set_id")] public int LastCardPackSetId { get; set; }
[JsonPropertyName("card_pool_name")] public string CardPoolName { get; set; } = "";
[JsonPropertyName("card_pool_url")] public string CardPoolUrl { get; set; } = "";
[JsonPropertyName("announce_id")] public string AnnounceId { get; set; } = "";
[JsonPropertyName("start_time")] public string StartTime { get; set; } = "";
[JsonPropertyName("end_time")] public string EndTime { get; set; } = "";
[JsonPropertyName("two_pick_type")] public int TwoPickType { get; set; } = 0;
[JsonPropertyName("strategy_pick_num")] public int StrategyPickNum { get; set; } = 0;
[JsonPropertyName("pool_card_set_ids")] public List<int> PoolCardSetIds { get; set; } = new();
}
/// <summary>

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class StoryDeckSeed
{
[JsonPropertyName("deck_no")] public int DeckNo { get; set; }
[JsonPropertyName("kind")] public string Kind { get; set; } = "build";
[JsonPropertyName("class_id")] public int ClassId { get; set; }
[JsonPropertyName("deck_name")] public string DeckName { get; set; } = "";
[JsonPropertyName("sleeve_id")] public int SleeveId { get; set; }
[JsonPropertyName("leader_skin_id")] public int LeaderSkinId { get; set; }
[JsonPropertyName("is_recommend")] public int IsRecommend { get; set; }
[JsonPropertyName("order_num")] public int OrderNum { get; set; }
[JsonPropertyName("entry_no")] public int EntryNo { get; set; }
[JsonPropertyName("deck_format")] public int? DeckFormat { get; set; }
}

View File

@@ -76,7 +76,7 @@ public static class Program
if (!opts.SkipGlobals)
{
// Per-domain seed pipeline. Each importer reads a per-table JSON seed file under
// SVSim.Bootstrap/Data/seeds/ produced by an extractor in data_dumps/extract/.
// SVSim.Bootstrap/Data/seeds/ produced by an extractor in data_dumps/scripts/.
//
// RotationConfigImporter writes the Rotation GameConfig section that RotationFlagUpdater
// reads; CardImporter ran earlier in the !SkipCards block so CardSets are populated.
@@ -84,6 +84,7 @@ public static class Program
await new MyRotationImporter().ImportAsync(context, opts.SeedDir);
await new AvatarAbilityImporter().ImportAsync(context, opts.SeedDir);
await new ArenaSeasonImporter().ImportAsync(context, opts.SeedDir);
await new ArenaTwoPickRewardImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassSeasonImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassRewardImporter().ImportAsync(context, opts.SeedDir);
@@ -116,6 +117,7 @@ public static class Program
await new DefaultDeckImporter().ImportAsync(context, opts.SeedDir);
await new PackImporter().ImportAsync(context, opts.SeedDir);
await new PackDrawTableImporter().ImportAsync(context, opts.SeedDir);
// BuildDeck pipeline: series CSV → catalog JSON → package CSV. Catalog must run after
// series CSV (FK on products → series) and before package CSV (so the catalog-side
@@ -124,6 +126,7 @@ public static class Program
await buildDeck.ImportSeriesAsync(context, opts.ReferenceDataDir);
await buildDeck.ImportCatalogAsync(context, opts.SeedDir);
await buildDeck.ImportPackageAsync(context, opts.ReferenceDataDir);
await new StoryDeckImporter().ImportAsync(context, opts.SeedDir);
}
else
{
@@ -227,7 +230,7 @@ public static class Program
" --story-data-dir <dir> Override story data directory (default: shipped Data/story)\n" +
" --skip-story Skip story import (worlds/sections/chapters/sbs)\n" +
"\n" +
"Capture-derived seeds are produced by extractors under data_dumps/extract/* and\n" +
"Capture-derived seeds are produced by extractors under data_dumps/scripts/* and\n" +
"checked into SVSim.Bootstrap/Data/seeds/. The bootstrap project never parses wire\n" +
"captures directly — refresh seeds by re-running the relevant extractor.\n" +
"\n" +

View File

@@ -0,0 +1,8 @@
namespace SVSim.Database.Enums;
public enum DrawSlot
{
General = 0,
Eighth = 1,
Bonus = 2,
}

View File

@@ -0,0 +1,16 @@
namespace SVSim.Database.Enums;
/// <summary>
/// Per-draw page tier the slot rolls into. Distinct from card-master <see cref="Rarity"/>:
/// for the four base values they line up, but <c>Special</c> covers the per-pack
/// "Leader Card" / "Limited-Time Leader" tiers — its cards are typically Rarity.Legendary
/// with the IsLeader printing flag set.
/// </summary>
public enum DrawTier
{
Bronze = 0,
Silver = 1,
Gold = 2,
Legendary = 3,
Special = 4,
}

View File

@@ -0,0 +1,11 @@
namespace SVSim.Database.Enums;
/// <summary>
/// Which story deck-select group a prebuilt deck belongs to. Build = the named story decks
/// (build_deck_list); Trial = archetype trial decks (trial_deck_list). Stored as int.
/// </summary>
public enum StoryDeckKind
{
Build = 0,
Trial = 1,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddGachaPointExchange : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ViewerGachaPointBalance",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PackId = table.Column<int>(type: "integer", nullable: false),
Points = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerGachaPointBalance", x => new { x.ViewerId, x.Id });
table.ForeignKey(
name: "FK_ViewerGachaPointBalance_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ViewerGachaPointReceived",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PackId = table.Column<int>(type: "integer", nullable: false),
CardId = table.Column<long>(type: "bigint", nullable: false),
ReceivedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerGachaPointReceived", x => new { x.ViewerId, x.Id });
table.ForeignKey(
name: "FK_ViewerGachaPointReceived_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ViewerGachaPointBalance_ViewerId_PackId",
table: "ViewerGachaPointBalance",
columns: new[] { "ViewerId", "PackId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ViewerGachaPointReceived_ViewerId_PackId_CardId",
table: "ViewerGachaPointReceived",
columns: new[] { "ViewerId", "PackId", "CardId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ViewerGachaPointBalance");
migrationBuilder.DropTable(
name: "ViewerGachaPointReceived");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddStoryDeck : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "StoryDecks",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
DeckNo = table.Column<int>(type: "integer", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
ClassId = table.Column<int>(type: "integer", nullable: false),
DeckName = table.Column<string>(type: "text", nullable: false),
SleeveId = table.Column<int>(type: "integer", nullable: false),
LeaderSkinId = table.Column<int>(type: "integer", nullable: false),
IsRecommend = table.Column<int>(type: "integer", nullable: false),
OrderNum = table.Column<int>(type: "integer", nullable: false),
EntryNo = table.Column<int>(type: "integer", nullable: false),
DeckFormat = table.Column<int>(type: "integer", nullable: true),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_StoryDecks", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StoryDecks");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddPackDrawTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsEnabled",
table: "Packs",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "PackDrawCardWeights",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PackId = table.Column<int>(type: "integer", nullable: false),
Slot = table.Column<int>(type: "integer", nullable: false),
Tier = table.Column<int>(type: "integer", nullable: false),
CardId = table.Column<long>(type: "bigint", nullable: false),
RatePct = table.Column<double>(type: "double precision", nullable: true),
IsLeader = table.Column<bool>(type: "boolean", nullable: false),
IsAltArt = table.Column<bool>(type: "boolean", nullable: false),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PackDrawCardWeights", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PackDrawConfigs",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
AnimationRatePct = table.Column<double>(type: "double precision", nullable: false),
HasBonusSlot = table.Column<bool>(type: "boolean", nullable: false),
SpecialKind = table.Column<string>(type: "text", nullable: true),
ShortCode = table.Column<string>(type: "text", nullable: true),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PackDrawConfigs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PackDrawSlotRates",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PackId = table.Column<int>(type: "integer", nullable: false),
Slot = table.Column<int>(type: "integer", nullable: false),
Tier = table.Column<int>(type: "integer", nullable: false),
RatePct = table.Column<double>(type: "double precision", nullable: false),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PackDrawSlotRates", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_PackDrawCardWeights_PackId_Slot_Tier",
table: "PackDrawCardWeights",
columns: new[] { "PackId", "Slot", "Tier" });
migrationBuilder.CreateIndex(
name: "IX_PackDrawSlotRates_PackId_Slot_Tier",
table: "PackDrawSlotRates",
columns: new[] { "PackId", "Slot", "Tier" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PackDrawCardWeights");
migrationBuilder.DropTable(
name: "PackDrawConfigs");
migrationBuilder.DropTable(
name: "PackDrawSlotRates");
migrationBuilder.DropColumn(
name: "IsEnabled",
table: "Packs");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddArenaTwoPick : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ArenaTwoPickRewards",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
WinCount = table.Column<int>(type: "integer", nullable: false),
RewardType = table.Column<int>(type: "integer", nullable: false),
RewardId = table.Column<long>(type: "bigint", nullable: false),
RewardNum = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ArenaTwoPickRewards", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ViewerArenaTwoPickRuns",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ViewerId = table.Column<long>(type: "bigint", nullable: false),
EntryId = table.Column<long>(type: "bigint", nullable: false),
RewardScheduleId = table.Column<int>(type: "integer", nullable: false),
ChallengeId = table.Column<int>(type: "integer", nullable: false),
MaxBattleCount = table.Column<int>(type: "integer", nullable: false),
ClassId = table.Column<int>(type: "integer", nullable: false),
LeaderSkinId = table.Column<long>(type: "bigint", nullable: false),
CandidateClassIdsJson = table.Column<string>(type: "jsonb", nullable: false),
SelectTurn = table.Column<int>(type: "integer", nullable: false),
IsSelectCompleted = table.Column<bool>(type: "boolean", nullable: false),
SelectedCardIdsJson = table.Column<string>(type: "jsonb", nullable: false),
PendingPickSetsJson = table.Column<string>(type: "jsonb", nullable: false),
NextCandidateId = table.Column<long>(type: "bigint", nullable: false),
ResultListJson = table.Column<string>(type: "jsonb", nullable: false),
WinCount = table.Column<int>(type: "integer", nullable: false),
LossCount = table.Column<int>(type: "integer", nullable: false),
IsRetire = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerArenaTwoPickRuns", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ArenaTwoPickRewards_WinCount",
table: "ArenaTwoPickRewards",
column: "WinCount");
migrationBuilder.CreateIndex(
name: "IX_ArenaTwoPickRewards_WinCount_RewardType_RewardId",
table: "ArenaTwoPickRewards",
columns: new[] { "WinCount", "RewardType", "RewardId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ViewerArenaTwoPickRuns_ViewerId",
table: "ViewerArenaTwoPickRuns",
column: "ViewerId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ArenaTwoPickRewards");
migrationBuilder.DropTable(
name: "ViewerArenaTwoPickRuns");
}
}
}

View File

@@ -450,6 +450,36 @@ namespace SVSim.Database.Migrations
b.ToTable("ArenaSeasons");
});
modelBuilder.Entity("SVSim.Database.Models.ArenaTwoPickReward", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("RewardId")
.HasColumnType("bigint");
b.Property<int>("RewardNum")
.HasColumnType("integer");
b.Property<int>("RewardType")
.HasColumnType("integer");
b.Property<int>("WinCount")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WinCount");
b.HasIndex("WinCount", "RewardType", "RewardId")
.IsUnique();
b.ToTable("ArenaTwoPickRewards");
});
modelBuilder.Entity("SVSim.Database.Models.AvatarAbilityEntry", b =>
{
b.Property<int>("Id")
@@ -1500,6 +1530,9 @@ namespace SVSim.Database.Migrations
b.Property<int>("GachaType")
.HasColumnType("integer");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsHide")
.HasColumnType("boolean");
@@ -1538,6 +1571,110 @@ namespace SVSim.Database.Migrations
b.ToTable("Packs");
});
modelBuilder.Entity("SVSim.Database.Models.PackDrawCardWeightEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("CardId")
.HasColumnType("bigint");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsAltArt")
.HasColumnType("boolean");
b.Property<bool>("IsLeader")
.HasColumnType("boolean");
b.Property<int>("PackId")
.HasColumnType("integer");
b.Property<double?>("RatePct")
.HasColumnType("double precision");
b.Property<int>("Slot")
.HasColumnType("integer");
b.Property<int>("Tier")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("PackId", "Slot", "Tier");
b.ToTable("PackDrawCardWeights");
});
modelBuilder.Entity("SVSim.Database.Models.PackDrawConfigEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<double>("AnimationRatePct")
.HasColumnType("double precision");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<bool>("HasBonusSlot")
.HasColumnType("boolean");
b.Property<string>("ShortCode")
.HasColumnType("text");
b.Property<string>("SpecialKind")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("PackDrawConfigs");
});
modelBuilder.Entity("SVSim.Database.Models.PackDrawSlotRateEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<int>("PackId")
.HasColumnType("integer");
b.Property<double>("RatePct")
.HasColumnType("double precision");
b.Property<int>("Slot")
.HasColumnType("integer");
b.Property<int>("Tier")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("PackId", "Slot", "Tier")
.IsUnique();
b.ToTable("PackDrawSlotRates");
});
modelBuilder.Entity("SVSim.Database.Models.PaymentItemEntry", b =>
{
b.Property<int>("Id")
@@ -2240,6 +2377,53 @@ namespace SVSim.Database.Migrations
b.ToTable("SpotCardExchangeCatalog");
});
modelBuilder.Entity("SVSim.Database.Models.StoryDeckEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<int>("ClassId")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<int?>("DeckFormat")
.HasColumnType("integer");
b.Property<string>("DeckName")
.IsRequired()
.HasColumnType("text");
b.Property<int>("DeckNo")
.HasColumnType("integer");
b.Property<int>("EntryNo")
.HasColumnType("integer");
b.Property<int>("IsRecommend")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<int>("LeaderSkinId")
.HasColumnType("integer");
b.Property<int>("OrderNum")
.HasColumnType("integer");
b.Property<int>("SleeveId")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("StoryDecks");
});
modelBuilder.Entity("SVSim.Database.Models.UnlimitedRestrictionEntry", b =>
{
b.Property<long>("Id")
@@ -2328,6 +2512,83 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerAchievements");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerArenaTwoPickRun", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("CandidateClassIdsJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<int>("ChallengeId")
.HasColumnType("integer");
b.Property<int>("ClassId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("EntryId")
.HasColumnType("bigint");
b.Property<bool>("IsRetire")
.HasColumnType("boolean");
b.Property<bool>("IsSelectCompleted")
.HasColumnType("boolean");
b.Property<long>("LeaderSkinId")
.HasColumnType("bigint");
b.Property<int>("LossCount")
.HasColumnType("integer");
b.Property<int>("MaxBattleCount")
.HasColumnType("integer");
b.Property<long>("NextCandidateId")
.HasColumnType("bigint");
b.Property<string>("PendingPickSetsJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("ResultListJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<int>("RewardScheduleId")
.HasColumnType("integer");
b.Property<int>("SelectTurn")
.HasColumnType("integer");
b.Property<string>("SelectedCardIdsJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.Property<int>("WinCount")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ViewerId")
.IsUnique();
b.ToTable("ViewerArenaTwoPickRuns");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassClaimEntry", b =>
{
b.Property<long>("Id")
@@ -3476,6 +3737,65 @@ namespace SVSim.Database.Migrations
.HasForeignKey("ViewerId");
});
b.OwnsMany("SVSim.Database.Models.ViewerGachaPointBalance", "GachaPointBalances", b1 =>
{
b1.Property<long>("ViewerId")
.HasColumnType("bigint");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("PackId")
.HasColumnType("integer");
b1.Property<int>("Points")
.HasColumnType("integer");
b1.HasKey("ViewerId", "Id");
b1.HasIndex("ViewerId", "PackId")
.IsUnique();
b1.ToTable("ViewerGachaPointBalance");
b1.WithOwner()
.HasForeignKey("ViewerId");
});
b.OwnsMany("SVSim.Database.Models.ViewerGachaPointReceived", "GachaPointReceived", b1 =>
{
b1.Property<long>("ViewerId")
.HasColumnType("bigint");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<long>("CardId")
.HasColumnType("bigint");
b1.Property<int>("PackId")
.HasColumnType("integer");
b1.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b1.HasKey("ViewerId", "Id");
b1.HasIndex("ViewerId", "PackId", "CardId")
.IsUnique();
b1.ToTable("ViewerGachaPointReceived");
b1.WithOwner()
.HasForeignKey("ViewerId");
});
b.OwnsOne("SVSim.Database.Models.ViewerInfo", "Info", b1 =>
{
b1.Property<long>("ViewerId")
@@ -3593,6 +3913,10 @@ namespace SVSim.Database.Migrations
b.Navigation("Currency")
.IsRequired();
b.Navigation("GachaPointBalances");
b.Navigation("GachaPointReceived");
b.Navigation("Info")
.IsRequired();

View File

@@ -0,0 +1,29 @@
// SVSim.Database/Models/ArenaTwoPickReward.cs
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
/// <summary>
/// One row of the Take Two run-end reward table. Multiple rows per <see cref="WinCount"/>
/// (e.g. 1 ticket + N rupies = 2 rows). Seeded by <c>ArenaTwoPickRewardImporter</c> from
/// <c>SVSim.Bootstrap/Data/seeds/arena-two-pick-rewards.json</c>.
/// </summary>
[Index(nameof(WinCount))]
[Index(nameof(WinCount), nameof(RewardType), nameof(RewardId), IsUnique = true)]
public class ArenaTwoPickReward
{
public long Id { get; set; }
/// <summary>0..MaxWins. Run ends at LossCount==2 or WinCount==MAX(WinCount).</summary>
public int WinCount { get; set; }
/// <summary><see cref="UserGoodsType"/> on the wire (e.g. Item=4, Rupy=9).</summary>
public int RewardType { get; set; }
/// <summary>Item id for Item; 0 for currencies.</summary>
public long RewardId { get; set; }
/// <summary>Count (e.g. ticket quantity or rupy amount).</summary>
public int RewardNum { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace SVSim.Database.Models;
/// <summary>
/// One of the 2 pick-sets offered to the player on the current draft turn. Persisted as
/// part of <see cref="ViewerArenaTwoPickRun.PendingPickSetsJson"/>. <see cref="Id"/> is the
/// monotonic counter the client sends back as <c>selected_id</c> on /card_choose.
/// </summary>
public class CandidatePair
{
public long Id { get; set; }
public int Turn { get; set; }
public int SetNum { get; set; }
public long CardId1 { get; set; }
public long CardId2 { get; set; }
public bool IsSelected { get; set; }
}

View File

@@ -0,0 +1,37 @@
namespace SVSim.Database.Models.Config;
/// <summary>
/// Take Two run mechanics: rarity weights, class/neutral mix, per-battle XP, season ids,
/// allowed-class allow-list. The pool's set scoping lives on <see cref="ChallengeConfig"/>;
/// this section is purely mechanics + the reward-schedule/challenge ids stamped on each run.
/// </summary>
[ConfigSection("ArenaTwoPick")]
public class ArenaTwoPickConfig
{
public int RewardScheduleId { get; set; } = 1;
public int ChallengeId { get; set; } = 1;
public int ClassXpPerBattle { get; set; } = 100;
public int SpotPointsPerBattle { get; set; } = 10;
public double LegendaryRate { get; set; } = 0.06;
public double GoldRate { get; set; } = 0.17;
public double SilverRate { get; set; } = 0.33;
public double BronzeRate { get; set; } = 0.44;
public double NeutralMixRate { get; set; } = 0.25;
/// <summary>TK2 entry ticket — item id 1 (challenge ticket). Distinct from the run-end
/// REWARD ticket id (80001, throwback pack ticket).</summary>
public int TicketItemId { get; set; } = 1;
public int TicketCost { get; set; } = 1;
public int CrystalCost { get; set; } = 150;
public int RupyCost { get; set; } = 150;
public List<int> AllowedClassIds { get; set; } = new();
public static ArenaTwoPickConfig ShippedDefaults() => new()
{
AllowedClassIds = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8 },
};
}

View File

@@ -6,5 +6,19 @@ public class ChallengeConfig
public bool UseTwoPickPremiumCard { get; set; }
public long TwoPickSleeveId { get; set; }
// Wire-mirrored fields from format_info (ChallengeData.cs parser)
public int LastCardPackSetId { get; set; }
public string CardPoolName { get; set; } = "";
public string CardPoolUrl { get; set; } = "";
public string AnnounceId { get; set; } = "";
public string StartTime { get; set; } = "";
public string EndTime { get; set; } = "";
public int TwoPickType { get; set; } = 0;
public int StrategyPickNum { get; set; } = 0;
// Server-internal: which sets the TK2 pool draws from. Empty falls back to
// RotationConfig.RotationCardSetIds at runtime in the card-pool service.
public List<int> PoolCardSetIds { get; set; } = new();
public static ChallengeConfig ShippedDefaults() => new();
}

View File

@@ -0,0 +1,17 @@
namespace SVSim.Database.Models.Config;
/// <summary>
/// Global "freeplay" toggle. When <see cref="Enabled"/>, every viewer is treated (in logic,
/// never in the DB) as owning all cards (<see cref="CardCopies"/> each), all cosmetics, and
/// <see cref="CurrencyAmount"/> of Crystal/Rupee/Red-Ether. See
/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md.
/// </summary>
[ConfigSection("Freeplay")]
public class FreeplayConfig
{
public bool Enabled { get; set; } = false;
public ulong CurrencyAmount { get; set; } = 99999;
public int CardCopies { get; set; } = 3;
public static FreeplayConfig ShippedDefaults() => new();
}

View File

@@ -6,6 +6,7 @@ namespace SVSim.Database.Models.Config;
/// <see cref="ShippedDefaults"/>, not in the initialiser — see PerSlot docstring.
/// </summary>
[ConfigSection("PackRates")]
[Obsolete("PackRateConfig is no longer consulted by PackOpenService — per-pack rates come from PackDrawTable. Retire once v1 stabilizes.")]
public class PackRateConfig
{
/// <summary>

View File

@@ -13,7 +13,7 @@ public class ResourceConfig
/// <c>PlayerPrefs["RES_VER"]</c> and uses it as the version path component for asset
/// manifest lookups: <c>https://&lt;cdn&gt;/dl/Manifest/&lt;RES_VER&gt;/&lt;lang&gt;/&lt;Platform&gt;/</c>.
/// <para>
/// Default value is the prod-captured version from <c>data_dumps/traffic_prod_tutorial.ndjson</c>
/// Default value is the prod-captured version from <c>data_dumps/captures/traffic_prod_tutorial.ndjson</c>
/// (2026-05-28) — i.e., a path Akamai actually serves. When this rotates (or Akamai sunsets
/// ahead of June 2026), update via DB <c>GameConfigs</c> row, appsettings.json, or this
/// shipped default; no code change needed.

View File

@@ -4,7 +4,7 @@ namespace SVSim.Database.Models;
/// <summary>
/// Item master row. Mirrors the client's <c>item_master.csv</c> + <c>itemtext.json</c>
/// (under <c>data_dumps/client_master_csv/</c>): <see cref="Type"/> matches the client-side
/// (under <c>data_dumps/client-assets/</c>): <see cref="Type"/> matches the client-side
/// item_type enum (1 = challenge ticket, 2 = card-pack ticket, 3 = premium orb,
/// 4 = colosseum ticket, 5 = orb piece, 6 = skin/event ticket, 7 = other);
/// <see cref="ThumbnailPath"/> is the client-resolved sprite key.

View File

@@ -33,6 +33,13 @@ public class PackConfigEntry : BaseEntity<int>
public int OpenCountLimit { get; set; }
/// <summary>
/// Server admin gate. True for live-capture-derived rows; false for synthesized stubs
/// (operator opt-in per pack). Filtered in PackRepository.GetActivePacks; distinct from
/// the wire-mirror IsHide.
/// </summary>
public bool IsEnabled { get; set; } = true;
public PackGachaPointConfig? GachaPointConfig { get; set; }
public List<PackBannerEntry> Banners { get; set; } = new();

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations.Schema;
using SVSim.Database.Common;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
/// <summary>
/// Per-card-rate fact: which card prints in which (pack, slot, tier) at what rate.
/// RatePct is nullable for rate-less "Guaranteed Leader Card" rows (sampler uses
/// "uniform over (pool minus owned)" in that case).
/// </summary>
public class PackDrawCardWeightEntry : BaseEntity<long>
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public override long Id { get; set; }
public int PackId { get; set; }
public DrawSlot Slot { get; set; }
public DrawTier Tier { get; set; }
public long CardId { get; set; }
public double? RatePct { get; set; }
public bool IsLeader { get; set; }
public bool IsAltArt { get; set; }
}

View File

@@ -0,0 +1,16 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One row per pack covered by drawrates data. PK is the pack id (matches PackConfigEntry.Id
/// for live-capture rows; standalone for archive-only packs). Weak relationship — PackDraw rows
/// exist for all archived packs even when no PackConfigEntry is enabled.
/// </summary>
public class PackDrawConfigEntry : BaseEntity<int>
{
public double AnimationRatePct { get; set; }
public bool HasBonusSlot { get; set; }
public string? SpecialKind { get; set; }
public string? ShortCode { get; set; }
}

View File

@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations.Schema;
using SVSim.Database.Common;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
/// <summary>
/// Per (pack, slot, tier) rate. Natural key (PackId, Slot, Tier) is enforced via unique index.
/// Id is auto-generated — override BaseEntity's [DatabaseGenerated(None)] default.
/// </summary>
public class PackDrawSlotRateEntry : BaseEntity<long>
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public override long Id { get; set; }
public int PackId { get; set; }
public DrawSlot Slot { get; set; }
public DrawTier Tier { get; set; }
public double RatePct { get; set; }
}

View File

@@ -0,0 +1,28 @@
using SVSim.Database.Common;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
/// <summary>
/// Presentation metadata for a story-mode prebuilt/trial deck, as surfaced under
/// main_story/get_deck_list's build_deck_list / trial_deck_list. PK (DeckNo) equals the deck's
/// wire deck_no, which also equals BuildDeckProductEntry.Id — the 40-card list is read from that
/// product (single source of truth), NOT stored here. Sourced from
/// data_dumps/captures/traffic_prod_trial_decks.ndjson via seeds/story-decks.json.
/// </summary>
public class StoryDeckEntry : BaseEntity<int>
{
public int DeckNo { get => Id; set => Id = value; } // == BuildDeckProductEntry.Id
public StoryDeckKind Kind { get; set; }
public int ClassId { get; set; }
public string DeckName { get; set; } = string.Empty;
public int SleeveId { get; set; }
public int LeaderSkinId { get; set; }
public int IsRecommend { get; set; }
public int OrderNum { get; set; }
public int EntryNo { get; set; }
/// <summary>Trial decks carry a deck_format on the wire; build decks do not (null).</summary>
public int? DeckFormat { get; set; }
}

View File

@@ -65,6 +65,10 @@ public class Viewer : BaseEntity<long>
public List<ViewerPackOpenCount> PackOpenCounts { get; set; } = new List<ViewerPackOpenCount>();
public List<ViewerGachaPointBalance> GachaPointBalances { get; set; } = new List<ViewerGachaPointBalance>();
public List<ViewerGachaPointReceived> GachaPointReceived { get; set; } = new List<ViewerGachaPointReceived>();
public List<ViewerBuildDeckProductPurchase> BuildDeckPurchases { get; set; } = new List<ViewerBuildDeckProductPurchase>();
public List<ViewerMission> Missions { get; set; } = new List<ViewerMission>();

View File

@@ -0,0 +1,64 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// One active Take Two run per viewer. Standalone (not a Viewer owned collection) to avoid
/// the EF nav-include pitfalls in project_ef_nav_include_pitfall and to keep /load/index cheap.
/// Row is deleted on /retire and /finish completion. Unique index on ViewerId enforces
/// "one active run per viewer".
/// <para>
/// Lists are stored as jsonb strings (<c>{Field}Json</c>) per the project's inline-JSON column
/// pattern (see DefaultDeckEntry.CardIdArray). Repos own (de)serialization.
/// </para>
/// </summary>
[Index(nameof(ViewerId), IsUnique = true)]
public class ViewerArenaTwoPickRun
{
public long Id { get; set; }
public long ViewerId { get; set; }
/// <summary>Wire <c>entry_info.id</c> / <c>two_pick_entry_id</c>. Set to <see cref="Id"/> on insert.</summary>
public long EntryId { get; set; }
public int RewardScheduleId { get; set; }
public int ChallengeId { get; set; }
/// <summary>MAX(reward.WinCount) at creation time. Stamped on the row so mid-run reward-table edits don't change the cap.</summary>
public int MaxBattleCount { get; set; }
/// <summary>0 until /class_choose.</summary>
public int ClassId { get; set; }
/// <summary>0 until first battle; set to class default on /class_choose.</summary>
public long LeaderSkinId { get; set; }
[Column(TypeName = "jsonb")]
public string CandidateClassIdsJson { get; set; } = "[]";
/// <summary>1..15.</summary>
public int SelectTurn { get; set; }
public bool IsSelectCompleted { get; set; }
[Column(TypeName = "jsonb")]
public string SelectedCardIdsJson { get; set; } = "[]";
[Column(TypeName = "jsonb")]
public string PendingPickSetsJson { get; set; } = "[]";
/// <summary>Monotonic counter for CandidatePair.Id; advances by 2 each draft turn.</summary>
public long NextCandidateId { get; set; } = 1;
[Column(TypeName = "jsonb")]
public string ResultListJson { get; set; } = "[]";
public int WinCount { get; set; }
public int LossCount { get; set; }
public bool IsRetire { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// Per-viewer, per-pack gacha-point balance. Owned collection on <see cref="Viewer"/>.
/// <c>PackId</c> = parent_gacha_id. <c>Points</c> accumulates one per pack opened (or
/// <c>PackChildGachaEntry.OverrideIncreaseGachaPoint</c> when set on the child) and is
/// decremented by <see cref="PackGachaPointConfig.ExchangeablePoint"/> per exchange.
/// Unique index on (ViewerId, PackId) per project_owned_collection_unique_index.
/// </summary>
[Owned]
public class ViewerGachaPointBalance
{
public int PackId { get; set; }
public int Points { get; set; }
}

View File

@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// Marker row recording that a viewer has already redeemed <c>CardId</c> from <c>PackId</c>'s
/// gacha-point exchange. Drives the per-entry <c>is_received</c> flag in
/// <c>/pack/get_gacha_point_rewards</c>. Owned collection on <see cref="Viewer"/>.
/// Unique index on (ViewerId, PackId, CardId) per project_owned_collection_unique_index.
/// </summary>
[Owned]
public class ViewerGachaPointReceived
{
public int PackId { get; set; }
public long CardId { get; set; }
public DateTime ReceivedAt { get; set; }
}

View File

@@ -64,4 +64,38 @@ public class BuildDeckRepository : IBuildDeckRepository
await _db.SaveChangesAsync();
return row.PurchaseCount;
}
public async Task<List<StoryDeckView>> GetStoryDecksByClass(int classId)
{
var decks = await _db.StoryDecks.Where(d => d.ClassId == classId).ToListAsync();
if (decks.Count == 0) return new();
var ids = decks.Select(d => d.DeckNo).ToList();
var products = await _db.BuildDeckProducts
.Where(p => ids.Contains(p.Id))
.Include(p => p.Cards)
.AsSplitQuery()
.ToListAsync();
// Expand each product's owned card rows by Number into a flat card_id list (spots included —
// validated against the prod capture, 112/112 match).
var cardsById = products.ToDictionary(
p => p.Id,
p => p.Cards.SelectMany(c => Enumerable.Repeat(c.CardId, c.Number)).ToList());
return decks.Select(d => new StoryDeckView
{
DeckNo = d.DeckNo,
Kind = d.Kind,
ClassId = d.ClassId,
DeckName = d.DeckName,
SleeveId = d.SleeveId,
LeaderSkinId = d.LeaderSkinId,
IsRecommend = d.IsRecommend,
OrderNum = d.OrderNum,
EntryNo = d.EntryNo,
DeckFormat = d.DeckFormat,
CardIdArray = cardsById.TryGetValue(d.DeckNo, out var cards) ? cards : new(),
}).ToList();
}
}

View File

@@ -26,4 +26,11 @@ public interface IBuildDeckRepository
/// Returns the new total.
/// </summary>
Task<int> IncrementPurchaseCount(long viewerId, int productId);
/// <summary>
/// Story deck-select decks for a class: StoryDeckEntry presentation rows joined to the matching
/// BuildDeckProductEntry card lists (deck_no == product_id), expanded to a flat card_id array.
/// Returns build and trial decks together; the caller splits by Kind.
/// </summary>
Task<List<StoryDeckView>> GetStoryDecksByClass(int classId);
}

View File

@@ -0,0 +1,22 @@
using SVSim.Database.Enums;
namespace SVSim.Database.Repositories.BuildDeck;
/// <summary>
/// A story-select deck ready for the wire: presentation metadata from StoryDeckEntry plus the
/// 40-card list expanded from the matching BuildDeckProductEntry. Plain projection, not an entity.
/// </summary>
public sealed class StoryDeckView
{
public int DeckNo { get; init; }
public StoryDeckKind Kind { get; init; }
public int ClassId { get; init; }
public string DeckName { get; init; } = string.Empty;
public int SleeveId { get; init; }
public int LeaderSkinId { get; init; }
public int IsRecommend { get; init; }
public int OrderNum { get; init; }
public int EntryNo { get; init; }
public int? DeckFormat { get; init; }
public List<long> CardIdArray { get; init; } = new();
}

View File

@@ -16,4 +16,16 @@ public class CollectionRepository : ICollectionRepository
{
return await _dbContext.Set<LeaderSkinEntry>().AsNoTracking().Include(skin => skin.Class).ToListAsync();
}
public Task<List<int>> GetAllSleeveIds() =>
_dbContext.Set<SleeveEntry>().AsNoTracking().Select(s => s.Id).ToListAsync();
public Task<List<int>> GetAllEmblemIds() =>
_dbContext.Set<EmblemEntry>().AsNoTracking().Select(e => e.Id).ToListAsync();
public Task<List<int>> GetAllDegreeIds() =>
_dbContext.Set<DegreeEntry>().AsNoTracking().Select(d => d.Id).ToListAsync();
public Task<List<int>> GetAllMyPageBackgroundIds() =>
_dbContext.Set<MyPageBackgroundEntry>().AsNoTracking().Select(m => m.Id).ToListAsync();
}

View File

@@ -5,4 +5,8 @@ namespace SVSim.Database.Repositories.Collectibles;
public interface ICollectionRepository
{
Task<List<LeaderSkinEntry>> GetLeaderSkins();
Task<List<int>> GetAllSleeveIds();
Task<List<int>> GetAllEmblemIds();
Task<List<int>> GetAllDegreeIds();
Task<List<int>> GetAllMyPageBackgroundIds();
}

View File

@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Globals;
public class ArenaTwoPickRewardRepository : IArenaTwoPickRewardRepository
{
private readonly SVSimDbContext _db;
public ArenaTwoPickRewardRepository(SVSimDbContext db) => _db = db;
public async Task<List<ArenaTwoPickReward>> GetRewardsByWinCountAsync(int winCount) =>
await _db.ArenaTwoPickRewards
.Where(r => r.WinCount == winCount)
.ToListAsync();
public async Task<int> GetMaxWinCountAsync()
{
if (!await _db.ArenaTwoPickRewards.AnyAsync()) return 0;
return await _db.ArenaTwoPickRewards.MaxAsync(r => r.WinCount);
}
}

View File

@@ -0,0 +1,9 @@
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Globals;
public interface IArenaTwoPickRewardRepository
{
Task<List<ArenaTwoPickReward>> GetRewardsByWinCountAsync(int winCount);
Task<int> GetMaxWinCountAsync();
}

View File

@@ -12,11 +12,11 @@ public class PackRepository : IPackRepository
await _db.Packs
.Include(p => p.ChildGachas)
.Include(p => p.Banners)
.Where(p => p.CommenceDate <= now && p.CompleteDate >= now)
.Where(p => p.IsEnabled && p.CommenceDate <= now && p.CompleteDate >= now)
// parent_gacha_id DESC matches the prod /pack/info wire order. The tutorial pack
// UI runs with controls locked and auto-selects the FIRST entry in
// pack_config_list, so the legendary starter pack (99047) MUST be index 0 for the
// tutorial to progress. Verified against data_dumps/traffic_prod_tutorial.ndjson —
// tutorial to progress. Verified against data_dumps/captures/traffic_prod_tutorial.ndjson —
// prod emits [99047, 92001, 80047, 16015..16011, 10032..10001].
.OrderByDescending(p => p.Id)
.ToListAsync();

View File

@@ -0,0 +1,7 @@
namespace SVSim.Database.Repositories.PackDrawTables;
public interface IPackDrawTableRepository
{
/// <summary>Returns the draw table for <paramref name="packId"/>, or null if not seeded.</summary>
Task<PackDrawTable?> GetAsync(int packId);
}

View File

@@ -0,0 +1,14 @@
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.PackDrawTables;
/// <summary>
/// All draw data for a single pack: per-pack config + slot rates + per-card weights.
/// Loaded as one unit by <see cref="IPackDrawTableRepository.GetAsync"/>.
/// </summary>
public sealed class PackDrawTable
{
public required PackDrawConfigEntry Config { get; init; }
public required IReadOnlyList<PackDrawSlotRateEntry> SlotRates { get; init; }
public required IReadOnlyList<PackDrawCardWeightEntry> CardWeights { get; init; }
}

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Repositories.PackDrawTables;
public class PackDrawTableRepository : IPackDrawTableRepository
{
private readonly SVSimDbContext _db;
public PackDrawTableRepository(SVSimDbContext db) { _db = db; }
public async Task<PackDrawTable?> GetAsync(int packId)
{
var config = await _db.PackDrawConfigs.FirstOrDefaultAsync(c => c.Id == packId);
if (config is null) return null;
var slotRates = await _db.PackDrawSlotRates
.Where(s => s.PackId == packId)
.ToListAsync();
var cardWeights = await _db.PackDrawCardWeights
.Where(w => w.PackId == packId)
.ToListAsync();
return new PackDrawTable
{
Config = config,
SlotRates = slotRates,
CardWeights = cardWeights,
};
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Viewer;
public class ArenaTwoPickRunRepository : IArenaTwoPickRunRepository
{
private readonly SVSimDbContext _db;
public ArenaTwoPickRunRepository(SVSimDbContext db) => _db = db;
public Task<ViewerArenaTwoPickRun?> GetByViewerIdAsync(long viewerId) =>
_db.ViewerArenaTwoPickRuns.FirstOrDefaultAsync(r => r.ViewerId == viewerId);
public async Task UpsertAsync(ViewerArenaTwoPickRun run)
{
run.UpdatedAt = DateTime.UtcNow;
if (run.Id == 0)
{
run.CreatedAt = DateTime.UtcNow;
_db.ViewerArenaTwoPickRuns.Add(run);
}
else
{
_db.ViewerArenaTwoPickRuns.Update(run);
}
await _db.SaveChangesAsync();
}
public async Task DeleteAsync(long viewerId)
{
var row = await _db.ViewerArenaTwoPickRuns.FirstOrDefaultAsync(r => r.ViewerId == viewerId);
if (row is null) return;
_db.ViewerArenaTwoPickRuns.Remove(row);
await _db.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,10 @@
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Viewer;
public interface IArenaTwoPickRunRepository
{
Task<ViewerArenaTwoPickRun?> GetByViewerIdAsync(long viewerId);
Task UpsertAsync(ViewerArenaTwoPickRun run);
Task DeleteAsync(long viewerId);
}

View File

@@ -68,8 +68,12 @@ public class SVSimDbContext : DbContext
public DbSet<SpecialDeckFormatEntry> SpecialDeckFormats => Set<SpecialDeckFormatEntry>();
public DbSet<PaymentItemEntry> PaymentItems => Set<PaymentItemEntry>();
public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>();
public DbSet<PackDrawConfigEntry> PackDrawConfigs => Set<PackDrawConfigEntry>();
public DbSet<PackDrawSlotRateEntry> PackDrawSlotRates => Set<PackDrawSlotRateEntry>();
public DbSet<PackDrawCardWeightEntry> PackDrawCardWeights => Set<PackDrawCardWeightEntry>();
public DbSet<BuildDeckSeriesEntry> BuildDeckSeries => Set<BuildDeckSeriesEntry>();
public DbSet<BuildDeckProductEntry> BuildDeckProducts => Set<BuildDeckProductEntry>();
public DbSet<StoryDeckEntry> StoryDecks => Set<StoryDeckEntry>();
public DbSet<SleeveShopSeriesEntry> SleeveShopSeries => Set<SleeveShopSeriesEntry>();
public DbSet<SleeveShopProductEntry> SleeveShopProducts => Set<SleeveShopProductEntry>();
public DbSet<ItemPurchaseCatalogEntry> ItemPurchaseCatalog => Set<ItemPurchaseCatalogEntry>();
@@ -97,6 +101,9 @@ public class SVSimDbContext : DbContext
public DbSet<ViewerClaimedTutorialGift> ViewerClaimedTutorialGifts => Set<ViewerClaimedTutorialGift>();
public DbSet<ArenaTwoPickReward> ArenaTwoPickRewards { get; set; } = null!;
public DbSet<ViewerArenaTwoPickRun> ViewerArenaTwoPickRuns { get; set; } = null!;
#endregion
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
@@ -145,6 +152,15 @@ public class SVSimDbContext : DbContext
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.ChildGachas);
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.Banners);
modelBuilder.Entity<PackDrawSlotRateEntry>(e =>
{
e.HasIndex(x => new { x.PackId, x.Slot, x.Tier }).IsUnique();
});
modelBuilder.Entity<PackDrawCardWeightEntry>(e =>
{
e.HasIndex(x => new { x.PackId, x.Slot, x.Tier });
});
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
// OwnedCardEntry and OwnedItemEntry use composite PK (ViewerId, Id) where Id is auto-
@@ -167,6 +183,16 @@ public class SVSimDbContext : DbContext
b.HasIndex("ViewerId", "ProductId").IsUnique();
});
modelBuilder.Entity<Viewer>().OwnsMany(v => v.GachaPointBalances, b =>
{
b.HasIndex("ViewerId", "PackId").IsUnique();
});
modelBuilder.Entity<Viewer>().OwnsMany(v => v.GachaPointReceived, b =>
{
b.HasIndex("ViewerId", "PackId", "CardId").IsUnique();
});
// A given social account links to exactly one viewer — two viewers cannot share the same
// Steam (or Facebook, etc.) account. This is the dedup backstop the auth handler's find-
// or-link path (SteamSessionAuthenticationHandler) relies on: two concurrent first-Steam-

View File

@@ -0,0 +1,51 @@
using SVSim.Database.Models;
namespace SVSim.Database.Services;
public class CurrencySpendService : ICurrencySpendService
{
private readonly IViewerEntitlements _entitlements;
public CurrencySpendService(IViewerEntitlements entitlements) => _entitlements = entitlements;
public Task<SpendResult> TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default)
{
if (cost < 0) cost = 0;
// Freeplay bypass applies only to the three main currencies; SpotPoint always real.
if (_entitlements.IsFreeplay && currency != SpendCurrency.SpotPoint)
{
return Task.FromResult(new SpendResult(
SpendOutcome.Success, _entitlements.EffectiveBalance(viewer, currency)));
}
ulong current = GetBalance(viewer, currency);
if (current < (ulong)cost)
return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current));
ulong post = current - (ulong)cost;
SetBalance(viewer, currency, post);
return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post));
}
private static ulong GetBalance(Viewer v, SpendCurrency c) => c switch
{
SpendCurrency.Crystal => v.Currency.Crystals,
SpendCurrency.Rupee => v.Currency.Rupees,
SpendCurrency.RedEther => v.Currency.RedEther,
SpendCurrency.SpotPoint => v.Currency.SpotPoints,
_ => throw new ArgumentOutOfRangeException(nameof(c)),
};
private static void SetBalance(Viewer v, SpendCurrency c, ulong value)
{
switch (c)
{
case SpendCurrency.Crystal: v.Currency.Crystals = value; break;
case SpendCurrency.Rupee: v.Currency.Rupees = value; break;
case SpendCurrency.RedEther: v.Currency.RedEther = value; break;
case SpendCurrency.SpotPoint: v.Currency.SpotPoints = value; break;
default: throw new ArgumentOutOfRangeException(nameof(c));
}
}
}

View File

@@ -0,0 +1,14 @@
using SVSim.Database.Models;
namespace SVSim.Database.Services;
/// <summary>
/// Centralized debit primitive — the symmetric twin of <c>RewardGrantService.ApplyAsync</c>.
/// Encapsulates the affordability-check + deduction + post-state-total pattern that was inlined
/// across the shop/pack controllers. Does NOT call <c>SaveChangesAsync</c>; the caller saves.
/// Freeplay (for Crystal/Rupee/RedEther) makes spends always succeed without deducting.
/// </summary>
public interface ICurrencySpendService
{
Task<SpendResult> TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default);
}

View File

@@ -0,0 +1,54 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Database.Services;
/// <summary>
/// The single read/ownership authority for what a viewer is *treated as* owning. Knows the
/// Freeplay flag; all freeplay read-side behavior lives here. See
/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md.
/// </summary>
/// <remarks>
/// <b>Include precondition:</b> methods that inspect the viewer's collections require the
/// viewer to have been loaded with <c>.Include(v =&gt; v.Cards).ThenInclude(c =&gt; c.Card)</c>
/// and the cosmetic collections
/// (<c>Sleeves</c>, <c>Emblems</c>, <c>Degrees</c>, <c>LeaderSkins</c>, <c>MyPageBackgrounds</c>)
/// included. Without those includes the EF owned-collection nav refs are null or zero-filled
/// (see the EF owned-collection nav-include pitfall in MEMORY.md).
/// </remarks>
public interface IViewerEntitlements
{
/// <summary>True when the global Freeplay config section is enabled.</summary>
bool IsFreeplay { get; }
/// <summary>
/// The balance the viewer is treated as having: the configured freeplay amount for
/// Crystal/Rupee/RedEther when freeplay is on, otherwise (and always for SpotPoint) the real
/// <c>viewer.Currency</c> field.
/// </summary>
long EffectiveBalance(Viewer viewer, SpendCurrency currency);
bool OwnsCard(Viewer viewer, long cardId);
/// <summary><paramref name="type"/> uses <see cref="CosmeticType"/> (Skin == leader skin).</summary>
bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id);
/// <summary>The full owned-card projection for /load/index's user_card_list.</summary>
Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default);
/// <summary>The cosmetic id-lists + leader-skin catalog/owned-set for /load/index.</summary>
Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default);
}
/// <summary>
/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns"
/// (all of them in freeplay). Leader skins are always the full catalog with a per-skin owned flag;
/// <see cref="OwnedLeaderSkinIds"/> is every skin id in freeplay.
/// </summary>
public sealed record EffectiveCosmetics(
IReadOnlyList<int> SleeveIds,
IReadOnlyList<int> EmblemIds,
IReadOnlyList<int> DegreeIds,
IReadOnlyList<int> MyPageBackgroundIds,
IReadOnlyList<LeaderSkinEntry> AllLeaderSkins,
IReadOnlySet<int> OwnedLeaderSkinIds);

View File

@@ -0,0 +1,16 @@
namespace SVSim.Database.Services;
/// <summary>The scalar wallet currencies the central debit primitive understands.</summary>
public enum SpendCurrency { Crystal, Rupee, RedEther, SpotPoint }
public enum SpendOutcome { Success, Insufficient }
/// <summary>
/// Result of a <see cref="ICurrencySpendService.TrySpendAsync"/> call. <see cref="PostStateTotal"/>
/// is the balance the client should show after the spend — the real post-deduction balance, or the
/// freeplay effective balance when the spend was a freeplay no-op.
/// </summary>
public sealed record SpendResult(SpendOutcome Outcome, long PostStateTotal)
{
public bool Success => Outcome == SpendOutcome.Success;
}

View File

@@ -0,0 +1,107 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Card;
using SVSim.Database.Repositories.Collectibles;
namespace SVSim.Database.Services;
public class ViewerEntitlements : IViewerEntitlements
{
private readonly IGameConfigService _config;
private readonly ICardRepository _cards;
private readonly ICollectionRepository _collection;
public ViewerEntitlements(IGameConfigService config, ICardRepository cards, ICollectionRepository collection)
{
_config = config;
_cards = cards;
_collection = collection;
}
private FreeplayConfig Cfg => _config.Get<FreeplayConfig>();
public bool IsFreeplay => Cfg.Enabled;
public long EffectiveBalance(Viewer viewer, SpendCurrency currency)
{
var cfg = Cfg;
if (cfg.Enabled && currency != SpendCurrency.SpotPoint)
return checked((long)cfg.CurrencyAmount);
return currency switch
{
SpendCurrency.Crystal => (long)viewer.Currency.Crystals,
SpendCurrency.Rupee => (long)viewer.Currency.Rupees,
SpendCurrency.RedEther => (long)viewer.Currency.RedEther,
SpendCurrency.SpotPoint => (long)viewer.Currency.SpotPoints,
_ => throw new ArgumentOutOfRangeException(nameof(currency)),
};
}
public bool OwnsCard(Viewer viewer, long cardId)
=> Cfg.Enabled || viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0);
public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id)
{
if (Cfg.Enabled) return true;
return type switch
{
CosmeticType.Sleeve => viewer.Sleeves.Any(s => s.Id == id),
CosmeticType.Emblem => viewer.Emblems.Any(e => e.Id == id),
CosmeticType.Degree => viewer.Degrees.Any(d => d.Id == id),
CosmeticType.Skin => viewer.LeaderSkins.Any(s => s.Id == id),
CosmeticType.MyPageBG => viewer.MyPageBackgrounds.Any(m => m.Id == id),
_ => false,
};
}
public async Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default)
{
var defaults = await _cards.GetDefaultCards();
var defaultIds = defaults.Select(c => c.Id).ToHashSet();
var cfg = Cfg;
if (cfg.Enabled)
{
var all = await _cards.GetAll(onlyCollectible: true);
return all
.Select(c => new OwnedCardEntry
{
Card = c,
Count = cfg.CardCopies,
IsProtected = defaultIds.Contains(c.Id),
})
.ToList();
}
var owned = viewer.Cards.Where(c => c.Count > 0 && !defaultIds.Contains(c.Card.Id));
return owned
.Concat(defaults.Select(bc => new OwnedCardEntry { Card = bc, Count = 3, IsProtected = true }))
.ToList();
}
public async Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default)
{
var allSkins = await _collection.GetLeaderSkins();
if (Cfg.Enabled)
{
return new EffectiveCosmetics(
await _collection.GetAllSleeveIds(),
await _collection.GetAllEmblemIds(),
await _collection.GetAllDegreeIds(),
await _collection.GetAllMyPageBackgroundIds(),
allSkins,
allSkins.Select(s => s.Id).ToHashSet());
}
return new EffectiveCosmetics(
viewer.Sleeves.Select(s => s.Id).ToList(),
viewer.Emblems.Select(e => e.Id).ToList(),
viewer.Degrees.Select(d => d.Id).ToList(),
viewer.MyPageBackgrounds.Select(m => m.Id).ToList(),
allSkins,
viewer.LeaderSkins.Select(s => s.Id).ToHashSet());
}
}

View File

@@ -1,11 +1,12 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin;
@@ -20,11 +21,14 @@ public class AdminController : SVSimController
{
private readonly IViewerRepository _viewerRepository;
private readonly SVSimDbContext _dbContext;
private readonly ILogger<AdminController> _logger;
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext)
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext,
ILogger<AdminController> logger)
{
_viewerRepository = viewerRepository;
_dbContext = dbContext;
_logger = logger;
}
/// <summary>
@@ -81,6 +85,9 @@ public class AdminController : SVSimController
.Include(v => v.Degrees)
.Include(v => v.LeaderSkins)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Decks).ThenInclude(d => d.Cards)
.FirstAsync(v => v.Id == viewerId);
if (request.DisplayName is not null) viewer.DisplayName = request.DisplayName;
@@ -124,25 +131,145 @@ public class AdminController : SVSimController
}
}
// Clone the 8 starter decks into the viewer when freshly created — workaround for a
// client-side NRE in the deck-edit menu (DeckListUI.IsVisibleCreateNewButton at
// decompile Wizard/DeckListUI.cs:316 unconditionally reads `_deckGroup.DeckFormat`, but
// _deckGroup is null when GetCustomDeckGroup() finds no matching CustomDeck group in
// DeckGroupDataBase — which is exactly what happens for a fresh viewer). Prod players
// acquire decks via tutorial; we shortcut by seeding the 8 defaults at import time.
// See docs/audits/deck-edit-empty-decklist-nre-2026-05-23.md for the full background.
if (wasCreated)
// Accumulates distinct card_ids referenced by the import (owned list + deck lists)
// that aren't in our card master. Surfaced in the response and logged after save.
var skippedCardIds = new HashSet<long>();
if (request.OwnedCards is not null)
{
await CloneDefaultDecksToViewerAsync(viewer);
var wanted = request.OwnedCards
.GroupBy(c => c.CardId)
.Select(g => g.First())
.ToList();
var ids = wanted.Select(c => c.CardId).ToList();
var cardMaster = await _dbContext.Cards
.Where(c => ids.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);
viewer.Cards.Clear();
foreach (var c in wanted)
{
if (!cardMaster.TryGetValue(c.CardId, out var card))
{
skippedCardIds.Add(c.CardId);
continue;
}
viewer.Cards.Add(new OwnedCardEntry
{
Card = card,
Count = Math.Clamp(c.Count, 1, OwnedCardEntry.MaxCopies),
IsProtected = c.IsProtected,
});
}
}
if (request.Items is not null)
{
var wanted = request.Items
.GroupBy(i => i.ItemId)
.Select(g => g.First())
.ToList();
var ids = wanted.Select(i => i.ItemId).ToList();
var itemMaster = await _dbContext.Items
.Where(i => ids.Contains(i.Id))
.ToDictionaryAsync(i => i.Id);
viewer.Items.Clear();
foreach (var i in wanted)
{
if (!itemMaster.TryGetValue(i.ItemId, out var item)) continue; // unknown master id
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = i.Count, Viewer = viewer });
}
}
if (request.Decks is not null)
{
var allDeckCardIds = request.Decks
.Where(d => d.CardIdArray is not null)
.SelectMany(d => d.CardIdArray!)
.Distinct()
.ToList();
var deckCardMaster = await _dbContext.Cards
.Where(c => allDeckCardIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);
var classes = await _dbContext.Classes.Include(c => c.LeaderSkins).ToDictionaryAsync(c => c.Id);
var sleeves = await _dbContext.Sleeves.ToDictionaryAsync(s => (long)s.Id);
var leaderSkins = await _dbContext.LeaderSkins.ToDictionaryAsync(s => s.Id);
var defaultSleeve = await _dbContext.Sleeves.FindAsync((int)DefaultSleeveId);
var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking()
.Select(s => (int?)s.Id)
.OrderByDescending(id => id)
.FirstOrDefaultAsync())?.ToString();
_dbContext.RemoveRange(viewer.Decks);
viewer.Decks.Clear();
foreach (var d in request.Decks)
{
// A /load/index dump carries every deck slot, most of them empty placeholders
// (no cards). Skip them: the client manages empty slots itself (it's why the old
// default-deck cloning was removed), and importing empty MyRotation slots would
// otherwise persist decks with a bogus rotation id.
if ((d.CardIdArray?.Count ?? 0) == 0) continue;
Format format;
try { format = FormatExtensions.FromApi(d.DeckFormat); }
catch (ArgumentOutOfRangeException) { continue; } // skip unsupported wire format
if (!classes.TryGetValue(d.ClassId, out var classEntry)) continue;
SleeveEntry? sleeve = null;
if (d.SleeveId.HasValue) sleeves.TryGetValue(d.SleeveId.Value, out sleeve);
sleeve ??= defaultSleeve;
LeaderSkinEntry? leaderSkin = null;
if (d.LeaderSkinId.HasValue) leaderSkins.TryGetValue(d.LeaderSkinId.Value, out leaderSkin);
leaderSkin ??= classEntry.DefaultLeaderSkin ?? classEntry.LeaderSkins.FirstOrDefault();
if (sleeve is null || leaderSkin is null) continue;
var cards = (d.CardIdArray ?? new List<long>())
.GroupBy(id => id)
.Where(g =>
{
if (deckCardMaster.ContainsKey(g.Key)) return true;
skippedCardIds.Add(g.Key);
return false;
})
.Select(g => new DeckCard { Card = deckCardMaster[g.Key], Count = g.Count() })
.ToList();
viewer.Decks.Add(new ShadowverseDeckEntry
{
Name = d.DeckName ?? $"Deck {d.DeckNo}",
Number = d.DeckNo,
Format = format,
Class = classEntry,
Sleeve = sleeve,
LeaderSkin = leaderSkin,
RandomLeaderSkin = (d.IsRandomLeaderSkin ?? 0) != 0,
Cards = cards,
MyRotationId = format == Format.MyRotation ? (d.MyRotationId ?? latestMyRotationId) : null,
});
}
}
await _dbContext.SaveChangesAsync();
if (skippedCardIds.Count > 0)
{
_logger.LogWarning(
"ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown " +
"card_id(s) not present in the card master. Sample: [{Sample}]",
request.SteamId, viewer.Id, skippedCardIds.Count,
string.Join(", ", skippedCardIds.Take(20)));
}
return new ImportViewerResponse
{
ViewerId = viewer.Id,
ShortUdid = viewer.ShortUdid,
WasCreated = wasCreated
WasCreated = wasCreated,
SkippedCardCount = skippedCardIds.Count,
};
}
@@ -162,81 +289,8 @@ public class AdminController : SVSimController
}
/// <summary>
/// Default sleeve id used for cloned starter decks. Matches prod's wire shape — every
/// default_deck_list entry on /deck/info has <c>sleeve_id: 3000011</c>.
/// Fallback sleeve id used when an imported deck has no resolvable <c>sleeve_id</c>.
/// 3000011 is prod's default deck sleeve.
/// </summary>
private const long DefaultSleeveId = 3000011L;
/// <summary>
/// Formats we clone the starter decks into. Each format the player can open the deck-edit
/// menu for needs at least one CustomDeck group in <c>Data.DeckGroupDataBase</c>, otherwise
/// the client NREs on <c>_deckGroup.DeckFormat</c> in DeckListUI.IsVisibleCreateNewButton.
/// Rotation / Unlimited / MyRotation are the always-active base formats; PreRotation /
/// Crossover / Avatar are seasonal and gated by UI state — leave them empty for now (see
/// docs/audits/deck-edit-empty-decklist-nre-2026-05-23.md follow-ups).
/// </summary>
private static readonly Format[] SeededDeckFormats = { Format.Rotation, Format.Unlimited, Format.MyRotation };
/// <summary>
/// Materialize the 8 default decks into the viewer's deck collection, once per seeded format.
/// The tracked <paramref name="viewer"/> instance gets new ShadowverseDeckEntry rows added to
/// its Decks navigation; EF picks them up on the caller's SaveChangesAsync.
/// </summary>
private async Task CloneDefaultDecksToViewerAsync(Viewer viewer)
{
var defaultDecks = await _dbContext.DefaultDecks.AsNoTracking().OrderBy(d => d.Id).ToListAsync();
if (defaultDecks.Count == 0) return;
// Resolve nav-property entities once. Classes need LeaderSkins included for the
// DefaultLeaderSkin nav lookup. Cards are fetched in one bulk query keyed by id.
var classes = await _dbContext.Classes.Include(c => c.LeaderSkins).ToDictionaryAsync(c => c.Id);
var defaultSleeve = await _dbContext.Sleeves.FindAsync((int)DefaultSleeveId);
var allCardIds = defaultDecks
.SelectMany(d => JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions.Instance) ?? new List<long>())
.Distinct()
.ToList();
var cards = await _dbContext.Cards.Where(c => allCardIds.Contains(c.Id)).ToDictionaryAsync(c => c.Id);
// Seeded MyRotation placeholder decks need a real rotation_id, otherwise the client's
// DeckData.GetMyRotationClassName NREs on `info.LastPackText` when the user clicks one
// (info is null because Data.MyRotationAllInfo.Get(null) returns null). Pick the highest
// rotation id available — it includes the most recent pack and therefore covers every
// class (including class_id=8 Nemesis, which requires last_pack >= 10007).
var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking()
.Select(s => (int?)s.Id)
.OrderByDescending(id => id)
.FirstOrDefaultAsync())?.ToString();
foreach (var format in SeededDeckFormats)
{
int slot = 1;
foreach (var d in defaultDecks)
{
if (!classes.TryGetValue(d.ClassId, out var classEntry)) continue;
var leaderSkin = classEntry.DefaultLeaderSkin ?? classEntry.LeaderSkins.FirstOrDefault();
if (leaderSkin is null || defaultSleeve is null) continue;
var cardIdArray = JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions.Instance) ?? new List<long>();
var deckCards = cardIdArray
.GroupBy(id => id)
.Where(g => cards.ContainsKey(g.Key))
.Select(g => new DeckCard { Card = cards[g.Key], Count = g.Count() })
.ToList();
viewer.Decks.Add(new ShadowverseDeckEntry
{
Name = d.DeckName,
Number = slot++,
Format = format,
Class = classEntry,
Sleeve = defaultSleeve,
LeaderSkin = leaderSkin,
RandomLeaderSkin = false,
Cards = deckCards,
MyRotationId = format == Format.MyRotation ? latestMyRotationId : null,
});
}
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaColosseum;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaColosseum;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// Stub controller for the Colosseum arena family. Currently only emits a "no Colosseum
/// period" /get_fee_info response so the home/arena screen doesn't 404. The full Colosseum
/// flow (top, entry, register_deck, event_info, retire, finish, class_choose, card_choose,
/// matchmaking) is deferred — see Wizard/ColosseumEntryInfoTask.cs for the parser surface.
/// </summary>
[Route("arena_colosseum")]
public class ArenaColosseumController : SVSimController
{
[HttpPost("get_fee_info")]
public IActionResult GetFeeInfo([FromBody] GetFeeInfoRequest req)
{
if (!TryGetViewerId(out _)) return Unauthorized();
return Ok(new GetFeeInfoResponseDto());
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Repositories.Globals;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Arena;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Arena;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// Generic /arena/* family — primarily challenge-history info read by the TK2 entry screen's
/// detail button. TODO: lifetime TK2 stats tracking; today we emit a stub.
/// </summary>
[Route("arena")]
public class ArenaController : SVSimController
{
private readonly IGlobalsRepository _globalsRepository;
public ArenaController(IGlobalsRepository globalsRepository)
{
_globalsRepository = globalsRepository;
}
[HttpPost("get_challenge_info")]
public async Task<IActionResult> GetChallengeInfo([FromBody] GetChallengeInfoRequest req)
{
if (!TryGetViewerId(out _)) return Unauthorized();
var season = await _globalsRepository.GetCurrentArenaSeason();
// Best-effort: pull begin/end_time + name from the season seed when present; otherwise
// emit deterministic stub values. All 6 ChallangeHistoryInfoTask.Parse fields must be
// present — the parser accesses them unconditionally.
var beginTime = "2026-05-01 02:00:00";
var endTime = "2026-06-01 01:59:59";
var name = "Take Two";
if (season is not null && !string.IsNullOrEmpty(season.FormatInfo) && season.FormatInfo != "{}")
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(season.FormatInfo);
if (doc.RootElement.TryGetProperty("start_time", out var st)) beginTime = st.GetString() ?? beginTime;
if (doc.RootElement.TryGetProperty("end_time", out var et)) endTime = et.GetString() ?? endTime;
if (doc.RootElement.TryGetProperty("card_pool_name", out var cp)) name = cp.GetString() ?? name;
}
catch { /* fall back to defaults */ }
}
// Default Challenge Master reward steps from prod capture: 3 milestones at 5/10/15 wins.
var rewardSteps = new Dictionary<string, string>
{
["5"] = "5",
["10"] = "10",
["15"] = "15",
};
return Ok(new GetChallengeInfoResponseDto
{
ChallengeName = name,
BeginTime = beginTime,
EndTime = endTime,
TwoPickAllWinCount = 0,
RewardStepInfo = new RewardStepInfoDto
{
MaxRewardStep = 15,
RewardStepList = rewardSteps,
},
});
}
[HttpPost("get_challenge_ranking_history")]
public IActionResult GetChallengeRankingHistory([FromBody] GetChallengeInfoRequest req)
{
if (!TryGetViewerId(out _)) return Unauthorized();
// Prod returns {two_pick: [], sealed: []}. Stub matches.
return Ok(new GetChallengeRankingHistoryResponseDto());
}
}

View File

@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
[Route("arena_two_pick_battle")]
public class ArenaTwoPickBattleController : SVSimController
{
private readonly IArenaTwoPickService _svc;
public ArenaTwoPickBattleController(IArenaTwoPickService svc) => _svc = svc;
[HttpPost("do_matching")]
public IActionResult DoMatching([FromBody] DoMatchingRequest req)
{
if (!TryGetViewerId(out _)) return Unauthorized();
return Ok(new DoMatchingResponseDto());
}
[HttpPost("finish")]
public async Task<IActionResult> Finish([FromBody] BattleFinishRequest req)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
try
{
var result = await _svc.RecordBattleResultAsync(vid, req.BattleResult == 1);
return Ok(new BattleFinishResponseDto
{
BattleResult = result.BattleResult,
GetClassExperience = result.GetClassExperience,
ClassExperience = result.ClassExperience,
ClassLevel = result.ClassLevel,
SpotPointInfo = new SpotPointInfoDto
{
BeforeSpotPoint = result.BeforeSpotPoint,
AddSpotPoint = result.AddSpotPoint,
AfterSpotPoint = result.AfterSpotPoint,
},
});
}
catch (ArenaTwoPickException ex)
{
return BadRequest(new { error_code = ex.ErrorCode });
}
}
}

View File

@@ -0,0 +1,60 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
[Route("arena_two_pick")]
public class ArenaTwoPickController : SVSimController
{
private readonly IArenaTwoPickService _svc;
public ArenaTwoPickController(IArenaTwoPickService svc) => _svc = svc;
[HttpPost("top")]
public async Task<IActionResult> Top([FromBody] TopRequest _)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
return Ok(await _svc.GetTopAsync(vid));
}
[HttpPost("entry")]
public async Task<IActionResult> Entry([FromBody] EntryRequest req)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
return await GuardAsync(() => _svc.EntryAsync(vid, req.ConsumeItemType));
}
[HttpPost("class_choose")]
public async Task<IActionResult> ClassChoose([FromBody] ClassChooseRequest req)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
return await GuardAsync(() => _svc.ChooseClassAsync(vid, req.ClassId));
}
[HttpPost("card_choose")]
public async Task<IActionResult> CardChoose([FromBody] CardChooseRequest req)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
return await GuardAsync(() => _svc.ChooseCardAsync(vid, req.SelectedId));
}
[HttpPost("retire")]
public async Task<IActionResult> Retire([FromBody] RetireRequest _)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
return await GuardAsync(() => _svc.RetireAsync(vid));
}
[HttpPost("finish")]
public async Task<IActionResult> Finish([FromBody] FinishRequest _)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
return await GuardAsync(() => _svc.FinishAsync(vid));
}
private async Task<IActionResult> GuardAsync<T>(Func<Task<T>> action)
{
try { return Ok(await action()); }
catch (ArenaTwoPickException ex) { return BadRequest(new { error_code = ex.ErrorCode }); }
}
}

View File

@@ -22,15 +22,18 @@ public class BuildDeckController : SVSimController
private readonly IBuildDeckRepository _repo;
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly ICurrencySpendService _spend;
public BuildDeckController(
IBuildDeckRepository repo,
SVSimDbContext db,
RewardGrantService rewards)
RewardGrantService rewards,
ICurrencySpendService spend)
{
_repo = repo;
_db = db;
_rewards = rewards;
_spend = spend;
}
/// <summary>
@@ -200,19 +203,15 @@ public class BuildDeckController : SVSimController
// Debit + post-state currency entry
if (request.SalesType == 1)
{
ulong cost = (ulong)priceCrystal!.Value;
if (viewer.Currency.Crystals < cost)
return BadRequest(new { error = "insufficient_crystals" });
viewer.Currency.Crystals -= cost;
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals });
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, priceCrystal!.Value);
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal });
}
else if (request.SalesType == 2)
{
ulong cost = (ulong)priceRupy!.Value;
if (viewer.Currency.Rupees < cost)
return BadRequest(new { error = "insufficient_rupees" });
viewer.Currency.Rupees -= cost;
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees });
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, priceRupy!.Value);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal });
}
// sales_type == 0 (free): no debit, no currency entry

View File

@@ -5,6 +5,7 @@ using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Check;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -67,4 +68,19 @@ public class CheckController : SVSimController
KorAuthorityId = 0
};
}
/// <summary>
/// Card-master rotation-period integrity probe. Wire path is
/// <c>check/check_time_slip_card_master_hash</c> but the client task is
/// <c>CheckTimeSlipRotationPeriodTask</c> — a pure <c>BaseTask</c> with no
/// <c>Parse()</c> override (Wizard/CheckTimeSlipRotationPeriodTask.cs). Fired from
/// <c>DeckDecisionUI.cs:140</c> (Arena "View Deck" path) and the TK2 prep screen.
/// Prod responds with <c>data: []</c> in every observed capture across
/// traffic_prod_taketwo_selections.ndjson + traffic_prod_tradeables_capture.ndjson.
/// </summary>
[HttpPost("check_time_slip_card_master_hash")]
public IActionResult CheckTimeSlipCardMasterHash([FromBody] CheckTimeSlipCardMasterHashRequest req)
{
return Ok(Array.Empty<object>());
}
}

View File

@@ -48,7 +48,7 @@ public class DeckBuilderController : ControllerBase
Clan = req.Clan.ToString(),
SubClan = req.SubClan ?? 0,
// Standard decks emit int 0; my-rotation decks emit the rotation id as a string.
// Mixed wire typing matches prod (data_dumps/traffic_prod_deckcode.ndjson).
// Mixed wire typing matches prod (data_dumps/captures/traffic_prod_deckcode.ndjson).
RotationId = (object?)req.RotationId ?? 0,
// Strip the foil flag (ones digit) — matches prod's normalize-on-encode behaviour
// observed in the traffic dump (e.g. 703441011 → 703441010).

View File

@@ -14,48 +14,21 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
public class DeckController : SVSimController
{
private readonly IDeckRepository _deckRepository;
private readonly IGlobalsRepository _globalsRepository;
private readonly SVSimDbContext _dbContext;
private readonly DeckOptions _deckOptions;
private readonly IDeckListBuilder _deckListBuilder;
private static readonly System.Text.Json.JsonSerializerOptions JsonbReadOptions = new()
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower,
NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
};
public DeckController(IDeckRepository deckRepository, IGlobalsRepository globalsRepository, SVSimDbContext dbContext, IOptions<DeckOptions> deckOptions)
public DeckController(IDeckRepository deckRepository, SVSimDbContext dbContext, IDeckListBuilder deckListBuilder)
{
_deckRepository = deckRepository;
_globalsRepository = globalsRepository;
_dbContext = dbContext;
_deckOptions = deckOptions.Value;
}
/// <summary>
/// Pads a viewer's real deck list with empty-slot placeholders up to <see cref="DeckOptions.MaxDeckSlots"/>.
/// Required because the client's <c>DeckUI.DeckViewData.CreateDeckViewList</c> only renders
/// a "New Deck" tile when the response contains an entry whose <c>card_id_array</c> is empty —
/// without padding, the player cannot create additional decks once any exist.
/// </summary>
private List<UserDeck> PadEmptySlots(List<UserDeck> realDecks)
{
var taken = realDecks.Select(d => d.DeckNumber).ToHashSet();
var result = new List<UserDeck>(realDecks);
for (int slot = 1; slot <= _deckOptions.MaxDeckSlots; slot++)
{
if (!taken.Contains(slot))
{
result.Add(UserDeck.CreateEmptySlot(slot));
}
}
return result;
_deckListBuilder = deckListBuilder;
}
// Request deck_format fields arrive as wire ints (MessagePack-CSharp doesn't honor STJ
@@ -68,93 +41,15 @@ public class DeckController : SVSimController
public async Task<ActionResult<DeckListResponse>> Info(DeckInfoRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat));
// Deck builder screen: pad empty "New Deck" slots so the player can create more decks.
return await _deckListBuilder.BuildAsync(viewerId, AsFormat(request.DeckFormat), padEmptySlots: true);
}
[HttpPost("my_list")]
public async Task<ActionResult<DeckListResponse>> MyList(DeckFormatRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat));
}
/// <summary>
/// Shared hydration for <c>/deck/info</c> and <c>/deck/my_list</c> — both endpoints return the
/// same <see cref="DeckListResponse"/> DTO and the client's DeckInfoTask.Parse / DeckMyListTask.Parse
/// are identical (both call <c>DeckGroupListData(jsonData, format)</c>).
///
/// Wire shape swaps based on the request format. When the client asks for All-format
/// (<c>deck_format=0</c>), prod emits per-format keys (<c>user_deck_rotation</c>, etc.);
/// for a specific format request, prod emits a single <c>user_deck_list</c>. The client's
/// <c>DeckListUtility.ParseDeckInfoResponceData</c> branches on these two shapes, so the
/// controller mirrors it exactly.
/// </summary>
private async Task<DeckListResponse> BuildDeckListResponseAsync(long viewerId, Format requestFormat)
{
var defaultDecks = await _globalsRepository.GetDefaultDecks();
// user_leader_skin_setting_list is PER-VIEWER (the wire `user_` prefix is honest, despite
// the misleading docstring on DefaultLeaderSkinSetting). Source it from the viewer's
// ViewerClassData rows, matching how /load/index's user_class_list reads them. The global
// DefaultLeaderSkinSettings table is now used only as initial seed values for fresh
// viewers (ViewerRepository.RegisterViewer); the per-class current skin is on
// viewer.Classes[i].LeaderSkin and gets mutated by /leader_skin/update.
var viewerClasses = await _dbContext.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Classes)
.Select(c => new { c.Class.Id, LeaderSkinId = c.LeaderSkin.Id })
.ToListAsync();
var response = new DeckListResponse
{
DefaultDeckList = defaultDecks.ToDictionary(
d => d.Id.ToString(),
d => new DefaultDeck
{
DeckNo = d.DeckNo,
ClassId = d.ClassId,
SleeveId = d.SleeveId,
LeaderSkinId = d.LeaderSkinId,
DeckName = d.DeckName,
CardIdArray = System.Text.Json.JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions) ?? new(),
// TODO(deck-stub): wire from real per-deck state once user maintenance / availability tracking lands.
// Prod emits is_complete_deck=1, is_available_deck=1, maintenance_card_ids=[] for the 8 starter decks.
IsCompleteDeck = 1,
IsAvailableDeck = 1,
MaintenanceCardIds = new(),
}),
UserLeaderSkinSettingList = viewerClasses.ToDictionary(
vc => vc.Id.ToString(),
vc => new UserLeaderSkinSetting
{
ClassId = vc.Id,
IsRandomLeaderSkin = 0, // random-skin mode (per-class shuffle pool) not yet persisted
LeaderSkinId = vc.LeaderSkinId,
}),
MaintenanceCardList = new(), // sourced from same place as /load/index when wired
};
if (requestFormat == Format.All)
{
// Prod's All-format response emits these three per-format lists (each [] for fresh viewers).
// The PreRotation / Crossover / Avatar siblings exist in client code but prod omits them
// for our profile; we mirror that omission and leave the nullable DTO fields unset.
var formats = new[] { Format.Rotation, Format.Unlimited, Format.MyRotation };
var byFormat = await _deckRepository.GetDecksByFormats(viewerId, formats);
response.UserDeckRotation = PadEmptySlots(byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList());
response.UserDeckUnlimited = PadEmptySlots(byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList());
response.UserDeckMyRotation = PadEmptySlots(byFormat[Format.MyRotation].Select(d => new UserDeck(d)).ToList());
// trial_deck_list is prod-emitted on /deck/info (All format) but omitted on /deck/my_list
// (specific format). Empty array in the 2026-05-23 prod capture.
response.TrialDeckList = new();
}
else
{
var decks = await _deckRepository.GetDecks(viewerId, requestFormat);
response.UserDeckList = PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList());
}
return response;
return await _deckListBuilder.BuildAsync(viewerId, AsFormat(request.DeckFormat), padEmptySlots: true);
}
[HttpPost("get_empty_deck_number")]
@@ -201,7 +96,7 @@ public class DeckController : SVSimController
var decks = await _deckRepository.GetDecks(viewerId, format);
return new DeckUpdateResponse
{
UserDeckList = PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList())
UserDeckList = _deckListBuilder.PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList())
};
}

View File

@@ -23,12 +23,14 @@ public class ItemPurchaseController : SVSimController
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly TimeProvider _time;
private readonly ICurrencySpendService _spend;
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend)
{
_db = db;
_rewards = rewards;
_time = time;
_spend = spend;
}
[HttpPost("info")]
@@ -117,7 +119,7 @@ public class ItemPurchaseController : SVSimController
var rewardList = new List<RewardListEntry>();
// Debit the require side. RewardGrantService is grant-only, so handle this inline.
var debit = TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
var debit = await TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState);
@@ -160,29 +162,29 @@ public class ItemPurchaseController : SVSimController
/// from the viewer, returning a post-state-aware <see cref="RewardListEntry"/> the client
/// uses to refresh its cached count. Returns an error string on insufficient balance.
/// </summary>
private static (RewardListEntry? PostState, string? Error) TryDebit(
private async Task<(RewardListEntry? PostState, string? Error)> TryDebit(
Viewer viewer, UserGoodsType type, long detailId, int num)
{
switch (type)
{
case UserGoodsType.RedEther:
if (viewer.Currency.RedEther < (ulong)num)
return (null, "insufficient_red_ether");
viewer.Currency.RedEther -= (ulong)num;
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)viewer.Currency.RedEther }, null);
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.RedEther, num);
if (!r.Success) return (null, "insufficient_red_ether");
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
case UserGoodsType.Crystal:
if (viewer.Currency.Crystals < (ulong)num)
return (null, "insufficient_crystals");
viewer.Currency.Crystals -= (ulong)num;
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null);
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, num);
if (!r.Success) return (null, "insufficient_crystals");
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
case UserGoodsType.Rupy:
if (viewer.Currency.Rupees < (ulong)num)
return (null, "insufficient_rupees");
viewer.Currency.Rupees -= (ulong)num;
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null);
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, num);
if (!r.Success) return (null, "insufficient_rupees");
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
case UserGoodsType.Item:
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
if (owned is null || owned.Count < num)

View File

@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
@@ -30,12 +31,18 @@ public class LeaderSkinController : SVSimController
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly TimeProvider _time;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
private readonly ICollectionRepository _collection;
public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection)
{
_db = db;
_rewards = rewards;
_time = time;
_spend = spend;
_entitlements = entitlements;
_collection = collection;
}
[HttpPost("set")]
@@ -62,7 +69,7 @@ public class LeaderSkinController : SVSimController
var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId);
if (skin is null) return BadRequest(new { error = "unknown_skin" });
if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" });
if (viewer.LeaderSkins.All(s => s.Id != skin.Id))
if (!_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, skin.Id))
return BadRequest(new { error = "skin_not_owned" });
classData.LeaderSkin = skin;
@@ -81,6 +88,12 @@ public class LeaderSkinController : SVSimController
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
if (_entitlements.IsFreeplay)
{
var all = (await _collection.GetLeaderSkins()).Select(s => s.Id).OrderBy(id => id).ToList();
return new LeaderSkinIdsResponse { UserLeaderSkinIds = all };
}
var ids = await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
@@ -95,10 +108,12 @@ public class LeaderSkinController : SVSimController
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var ownedSkinIds = (await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
.ToListAsync()).ToHashSet();
var ownedSkinIds = _entitlements.IsFreeplay
? (await _collection.GetLeaderSkins()).Select(s => s.Id).ToHashSet()
: (await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
.ToListAsync()).ToHashSet();
var claimedSeries = (await _db.ViewerLeaderSkinSetClaims
.Where(c => c.ViewerId == viewerId)
@@ -171,11 +186,11 @@ public class LeaderSkinController : SVSimController
var viewer = await LoadViewerGraphAsync(viewerId);
// Already-purchased = viewer owns the leader_skin this product grants.
if (viewer.LeaderSkins.Any(s => s.Id == product.LeaderSkinId))
if (_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, product.LeaderSkinId))
return BadRequest(new { error = "already_purchased" });
var rewardList = new List<RewardListEntry>();
var debit = DebitProductPrice(viewer, product, request.SalesType);
var debit = await DebitProductPrice(viewer, product, request.SalesType);
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState);
@@ -205,8 +220,11 @@ public class LeaderSkinController : SVSimController
var viewer = await LoadViewerGraphAsync(viewerId);
if (_entitlements.IsFreeplay)
return BadRequest(new { error = "already_purchased" });
var rewardList = new List<RewardListEntry>();
var debit = DebitSetPrice(viewer, series, request.SalesType);
var debit = await DebitSetPrice(viewer, series, request.SalesType);
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState);
@@ -332,52 +350,58 @@ public class LeaderSkinController : SVSimController
return false;
}
private (RewardListEntry? PostState, string? Error) DebitProductPrice(
private async Task<(RewardListEntry? PostState, string? Error)> DebitProductPrice(
Viewer viewer, LeaderSkinShopProductEntry product, int salesType)
{
return salesType switch
switch (salesType)
{
0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0 => (null, null),
0 => (null, "price_not_available_for_currency"),
1 => product.SinglePriceCrystal is null
? (null, "price_not_available_for_currency")
: DebitCrystal(viewer, product.SinglePriceCrystal.Value),
2 => product.SinglePriceRupy is null
? (null, "price_not_available_for_currency")
: DebitRupy(viewer, product.SinglePriceRupy.Value),
_ => (null, "invalid_sales_type"),
};
case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0:
return (null, null);
case 0:
return (null, "price_not_available_for_currency");
case 1:
if (product.SinglePriceCrystal is null) return (null, "price_not_available_for_currency");
return await DebitCrystal(viewer, product.SinglePriceCrystal.Value);
case 2:
if (product.SinglePriceRupy is null) return (null, "price_not_available_for_currency");
return await DebitRupy(viewer, product.SinglePriceRupy.Value);
default:
return (null, "invalid_sales_type");
}
}
private (RewardListEntry? PostState, string? Error) DebitSetPrice(
private async Task<(RewardListEntry? PostState, string? Error)> DebitSetPrice(
Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType)
{
return salesType switch
switch (salesType)
{
0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0 => (null, null),
0 => (null, "price_not_available_for_currency"),
1 => series.SetPriceCrystal is null
? (null, "price_not_available_for_currency")
: DebitCrystal(viewer, series.SetPriceCrystal.Value),
2 => series.SetPriceRupy is null
? (null, "price_not_available_for_currency")
: DebitRupy(viewer, series.SetPriceRupy.Value),
_ => (null, "invalid_sales_type"),
};
case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0:
return (null, null);
case 0:
return (null, "price_not_available_for_currency");
case 1:
if (series.SetPriceCrystal is null) return (null, "price_not_available_for_currency");
return await DebitCrystal(viewer, series.SetPriceCrystal.Value);
case 2:
if (series.SetPriceRupy is null) return (null, "price_not_available_for_currency");
return await DebitRupy(viewer, series.SetPriceRupy.Value);
default:
return (null, "invalid_sales_type");
}
}
private static (RewardListEntry?, string?) DebitCrystal(Viewer viewer, int amount)
private async Task<(RewardListEntry?, string?)> DebitCrystal(Viewer viewer, int amount)
{
if (viewer.Currency.Crystals < (ulong)amount) return (null, "insufficient_crystals");
viewer.Currency.Crystals -= (ulong)amount;
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null);
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, amount);
if (!r.Success) return (null, "insufficient_crystals");
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
private static (RewardListEntry?, string?) DebitRupy(Viewer viewer, int amount)
private async Task<(RewardListEntry?, string?)> DebitRupy(Viewer viewer, int amount)
{
if (viewer.Currency.Rupees < (ulong)amount) return (null, "insufficient_rupees");
viewer.Currency.Rupees -= (ulong)amount;
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null);
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, amount);
if (!r.Success) return (null, "insufficient_rupees");
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
private async Task ApplyRewardsAsync<T>(

View File

@@ -1,13 +1,12 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using PreReleaseInfoEntity = SVSim.Database.Models.PreReleaseInfo;
using PreReleaseInfoDto = SVSim.EmulatedEntrypoint.Models.Dtos.PreReleaseInfo;
using SVSim.Database.Repositories.Card;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
@@ -42,30 +41,27 @@ public class LoadController : SVSimController
};
private readonly IViewerRepository _viewerRepository;
private readonly ICardRepository _cardRepository;
private readonly ICollectionRepository _collectionRepository;
private readonly IGlobalsRepository _globalsRepository;
private readonly ICardAcquisitionService _acquisition;
private readonly IGameConfigService _config;
private readonly IBattlePassService _battlePass;
private readonly IViewerMissionStateService _missionState;
private readonly SVSimDbContext _db;
private readonly IViewerEntitlements _entitlements;
public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository,
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository,
public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
ICardAcquisitionService acquisition, IGameConfigService config,
IBattlePassService battlePass, IViewerMissionStateService missionState,
SVSimDbContext db)
SVSimDbContext db, IViewerEntitlements entitlements)
{
_viewerRepository = viewerRepository;
_cardRepository = cardRepository;
_collectionRepository = collectionRepository;
_globalsRepository = globalsRepository;
_acquisition = acquisition;
_config = config;
_battlePass = battlePass;
_missionState = missionState;
_db = db;
_entitlements = entitlements;
}
[HttpPost("index")]
@@ -127,20 +123,11 @@ public class LoadController : SVSimController
// * card_set_id=90000 (engine tokens, char_type=4): never collectible
// Both naturally fall out of "ownership-only" since the viewer can't own them;
// re-confirm the filter if we later move to Option B and start iterating card-sets.
var defaultCards = await _cardRepository.GetDefaultCards();
var defaultCardIds = defaultCards.Select(c => c.Id).ToHashSet();
var ownedCollectibles = viewer.Cards
.Where(c => c.Count > 0 && !defaultCardIds.Contains(c.Card.Id));
var allCardsAsOwned = ownedCollectibles
.Concat(defaultCards.Select(bc => new OwnedCardEntry
{
Card = bc,
Count = 3,
IsProtected = true
}))
.ToList();
// Owned-card projection (incl. the freeplay "all cards" path) lives in the entitlements
// service so both modes share one definition.
var allCardsAsOwned = await _entitlements.EffectiveOwnedCardsAsync(viewer, ct);
List<LeaderSkinEntry> allLeaderSkins = await _collectionRepository.GetLeaderSkins();
var cosmetics = await _entitlements.EffectiveCosmeticsAsync(viewer, ct);
var classExpCurve = await _globalsRepository.GetClassExpCurve();
List<ClassExp> classExps = new();
@@ -179,7 +166,13 @@ public class LoadController : SVSimController
{
UserTutorial = new UserTutorial { TutorialStep = viewer.MissionData.TutorialState },
UserInfo = new UserInfo(deviceType, viewer),
UserCurrency = new UserCurrency(viewer),
UserCurrency = new UserCurrency(viewer)
{
Crystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal),
TotalCrystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal),
Rupees = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee),
RedEther = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.RedEther),
},
UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
SpotPoint = checked((int)viewer.Currency.SpotPoints),
UserRotationDecks = new UserFormatDeckInfo
@@ -199,13 +192,13 @@ public class LoadController : SVSimController
},
UserCards = allCardsAsOwned.Select(card => new UserCard(card)).ToList(),
UserClasses = viewer.Classes.Select(vc => new UserClass(vc)).ToList(),
Sleeves = viewer.Sleeves.Select(s => new SleeveIdentifier { SleeveId = s.Id }).ToList(),
UserEmblems = viewer.Emblems.Select(e => new EmblemIdentifier { EmblemId = e.Id }).ToList(),
UserDegrees = viewer.Degrees.Select(d => new DegreeIdentifier { DegreeId = d.Id }).ToList(),
LeaderSkins = allLeaderSkins
.Select(skin => new UserLeaderSkin(skin, viewer.LeaderSkins.Any(vs => vs.Id == skin.Id)))
Sleeves = cosmetics.SleeveIds.Select(id => new SleeveIdentifier { SleeveId = id }).ToList(),
UserEmblems = cosmetics.EmblemIds.Select(id => new EmblemIdentifier { EmblemId = id }).ToList(),
UserDegrees = cosmetics.DegreeIds.Select(id => new DegreeIdentifier { DegreeId = id }).ToList(),
LeaderSkins = cosmetics.AllLeaderSkins
.Select(skin => new UserLeaderSkin(skin, cosmetics.OwnedLeaderSkinIds.Contains(skin.Id)))
.ToList(),
MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id.ToString()).ToList(),
MyPageBackgrounds = cosmetics.MyPageBackgroundIds.Select(id => id.ToString()).ToList(),
LootBoxRegulations = new LootBoxRegulations(),
GatheringInfo = new GatheringInfo(),
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
@@ -252,7 +245,7 @@ public class LoadController : SVSimController
UseChallengePickTwoPremiumCard = challenge.UseTwoPickPremiumCard ? 1 : 0,
ChallengePickTwoCardSleeve = (int)challenge.TwoPickSleeveId,
},
ArenaInfos = await BuildArenaInfosAsync(),
ArenaInfos = await BuildArenaInfosAsync(viewer.Id),
RotationSets = rotationSets,
UserConfig = new UserConfig(),
OpenBattlefieldIds = (await _globalsRepository.GetBattlefields(true))
@@ -271,7 +264,7 @@ public class LoadController : SVSimController
/// field is omitted on the wire, which the client's <c>Keys.Contains("arena_info")</c> guard
/// (LoadDetail.cs:261) handles cleanly.
/// </summary>
private async Task<List<ArenaInfo>?> BuildArenaInfosAsync()
private async Task<List<ArenaInfo>?> BuildArenaInfosAsync(long viewerId)
{
var season = await _globalsRepository.GetCurrentArenaSeason();
if (season is null) return null;
@@ -282,6 +275,15 @@ public class LoadController : SVSimController
format = JsonSerializer.Deserialize<ArenaFormatInfo>(season.FormatInfo, JsonbReadOptions.Instance);
}
// is_join must reflect the viewer's actual TK2 state — true if they have an
// active ViewerArenaTwoPickRun row. The client uses this to decide between the
// "Pay to enter" and "Resume run" dialogs (Wizard/ChallengeEntry.cs:165 + ArenaEntryBase).
// Without a per-viewer override here, every cold start after a partial run shows
// "Pay to enter" — losing the in-progress draft from the player's perspective.
bool hasActiveRun = await _db.ViewerArenaTwoPickRuns
.AsNoTracking()
.AnyAsync(r => r.ViewerId == viewerId);
return new List<ArenaInfo>
{
new ArenaInfo
@@ -291,7 +293,7 @@ public class LoadController : SVSimController
Cost = season.Cost,
RupeeCost = season.RupyCost,
TicketCost = season.TicketCost,
IsJoin = season.IsJoin,
IsJoin = hasActiveRun,
FormatInfo = format,
}
};

View File

@@ -23,12 +23,15 @@ public class MyPageController : SVSimController
private readonly IViewerRepository _viewerRepository;
private readonly IGlobalsRepository _globalsRepository;
private readonly IGameConfigService _config;
private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns;
public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, IGameConfigService config)
public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns)
{
_viewerRepository = viewerRepository;
_globalsRepository = globalsRepository;
_config = config;
_arenaTwoPickRuns = arenaTwoPickRuns;
}
[HttpPost("index")]
@@ -69,7 +72,7 @@ public class MyPageController : SVSimController
LastAnnounceId = 0, // TODO(mypage-stub): globals announcement metadata
LastAnnounceUpdateTime = string.Empty, // TODO(mypage-stub): globals announcement metadata
FeatureMaintenanceList = new(), // TODO(mypage-stub): FeatureMaintenanceEntry rows
ArenaInfo = await BuildArenaInfosAsync(),
ArenaInfo = await BuildArenaInfosAsync(viewer.Id),
IsArenaChallengePeriod = false, // TODO(mypage-stub): globals/ArenaSeason flag
IsAvailableColosseumFreeEntry = false, // TODO(mypage-stub): viewer + globals free-entry quota
ColosseumInfo = BuildColosseumInfo(colosseum),
@@ -155,9 +158,16 @@ public class MyPageController : SVSimController
/// _twoPickData.ChallengeData which is only built when arena_info[0].format_info is present.
/// So we always populate format_info from the same ArenaSeason.FormatInfo jsonb /load/index uses.
/// </summary>
private async Task<List<ArenaInfo>> BuildArenaInfosAsync()
private async Task<List<ArenaInfo>> BuildArenaInfosAsync(long viewerId)
{
var season = await _globalsRepository.GetCurrentArenaSeason();
// is_join MUST reflect the viewer's actual TK2 state — true iff they have an
// active ViewerArenaTwoPickRun row. The client uses this to choose between the
// "Pay to enter" and "Resume run" dialogs (Wizard/ChallengeEntry.cs:165 + ArenaEntryBase).
// See LoadController.BuildArenaInfosAsync for the matching /load/index path.
bool hasActiveRun = (await _arenaTwoPickRuns.GetByViewerIdAsync(viewerId)) is not null;
if (season is null)
{
return new List<ArenaInfo>
@@ -169,7 +179,7 @@ public class MyPageController : SVSimController
Cost = 0,
RupeeCost = 0,
TicketCost = 0,
IsJoin = false,
IsJoin = hasActiveRun,
},
};
}
@@ -189,7 +199,7 @@ public class MyPageController : SVSimController
Cost = season.Cost,
RupeeCost = season.RupyCost,
TicketCost = season.TicketCost,
IsJoin = season.IsJoin,
IsJoin = hasActiveRun,
FormatInfo = format,
}
};

View File

@@ -5,10 +5,12 @@ using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Pack;
using SVSim.Database.Repositories.PackDrawTables;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -24,25 +26,37 @@ public class PackController : SVSimController
private readonly IPackRepository _packs;
private readonly PackOpenService _opener;
private readonly ICardPoolProvider _pools;
private readonly IPackDrawTableRepository _drawTables;
private readonly ICardFoilLookup _foils;
private readonly IRandom _rng;
private readonly SVSimDbContext _db;
private readonly ICardAcquisitionService _acquisition;
private readonly IGachaPointService _gachaPoint;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
public PackController(
IPackRepository packs,
PackOpenService opener,
ICardPoolProvider pools,
IPackDrawTableRepository drawTables,
ICardFoilLookup foils,
IRandom rng,
SVSimDbContext db,
ICardAcquisitionService acquisition)
ICardAcquisitionService acquisition,
IGachaPointService gachaPoint,
ICurrencySpendService spend,
IViewerEntitlements entitlements)
{
_packs = packs;
_opener = opener;
_pools = pools;
_drawTables = drawTables;
_foils = foils;
_rng = rng;
_db = db;
_acquisition = acquisition;
_gachaPoint = gachaPoint;
_spend = spend;
_entitlements = entitlements;
}
[HttpPost("info")]
@@ -77,18 +91,48 @@ public class PackController : SVSimController
.Select(i => new { ItemId = (long)EF.Property<int>(i, "ItemId"), i.Count })
.ToDictionaryAsync(x => x.ItemId, x => x.Count);
var gachaPointBalancesByPackId = await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.GachaPointBalances)
.Select(b => new { b.PackId, b.Points })
.ToDictionaryAsync(x => x.PackId, x => x.Points);
return new PackInfoResponse
{
PackConfigList = packs.Select(p => ToDto(p, openCounts, ownedItemsByItemId)).ToList(),
PackConfigList = packs
.Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId))
.ToList(),
};
}
private static PackConfigDto ToDto(
PackConfigEntry p,
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
IReadOnlyDictionary<long, int> ownedItemsByItemId)
IReadOnlyDictionary<long, int> ownedItemsByItemId,
IReadOnlyDictionary<int, int> gachaPointBalancesByPackId)
{
int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0;
// Ticket-only pack: every child is TICKET (4) or TICKET_MULTI (5). These are
// gifted-currency packs (tutorial starter, throwback) that don't participate in
// gacha-point accrual or exchange, even if GachaPointConfig is set in seed.
bool isTicketOnly = p.ChildGachas.All(c => c.TypeDetail == 4 || c.TypeDetail == 5);
PackGachaPointDto? gachaPointDto = null;
if (p.GachaPointConfig is not null && !isTicketOnly)
{
int balance = gachaPointBalancesByPackId.TryGetValue(p.Id, out var b) ? b : 0;
int threshold = p.GachaPointConfig.ExchangeablePoint;
gachaPointDto = new PackGachaPointDto
{
PackId = p.BasePackId.ToString(CultureInfo.InvariantCulture),
GachaPoint = balance,
IncreaseGachaPoint = p.GachaPointConfig.IncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
ExchangeableGachaPoint = threshold,
IsExchangeableGachaPoint = balance >= threshold,
};
}
return new PackConfigDto
{
ParentGachaId = p.Id,
@@ -130,14 +174,7 @@ public class PackController : SVSimController
OpenCountLimit = p.OpenCountLimit,
IsHide = p.IsHide ? 1 : 0,
PackCategory = (int)p.PackCategory,
GachaPoint = p.GachaPointConfig is null ? null : new PackGachaPointDto
{
PackId = p.BasePackId.ToString(CultureInfo.InvariantCulture),
GachaPoint = 0,
IncreaseGachaPoint = p.GachaPointConfig.IncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
ExchangeableGachaPoint = p.GachaPointConfig.ExchangeablePoint,
IsExchangeableGachaPoint = false,
},
GachaPoint = gachaPointDto,
IsPreRelease = p.IsPreRelease,
ExistsPurchaseReward = false,
IsNew = p.IsNew,
@@ -146,6 +183,57 @@ public class PackController : SVSimController
};
}
[HttpPost("get_gacha_point_rewards")]
public async Task<ActionResult<GetGachaPointRewardsResponse>> GetGachaPointRewards(
GetGachaPointRewardsRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// odds_gacha_id is the active seasonal pack id (the one with GachaPointConfig +
// balance). parent_gacha_id is the base_pack_id of the family — not the lookup key.
// See GetGachaPointRewardsRequest docstring; verified against
// traffic_prod_all_gacha_exchange.ndjson.
var rewards = await _gachaPoint.GetRewardsAsync(request.OddsGachaId, viewerId);
return new GetGachaPointRewardsResponse
{
GachaPointRewards = rewards.ToList(),
};
}
[HttpPost("exchange_gacha_point")]
public async Task<ActionResult<ExchangeGachaPointResponse>> ExchangeGachaPoint(
ExchangeGachaPointRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// Load the viewer with the collections the service mutates (balances, received marker,
// cards, cosmetics). AsSplitQuery per project_ef_split_query memory.
var viewer = await _db.Viewers
.Include(v => v.GachaPointBalances)
.Include(v => v.GachaPointReceived)
.Include(v => v.Cards)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.LeaderSkins)
.Include(v => v.MyPageBackgrounds)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// Use odds_gacha_id (the seasonal pack id) — that's where the balance / received marker
// live. Mirrors the GetGachaPointRewards fix.
var outcome = await _gachaPoint.TryExchangeAsync(viewer, request.OddsGachaId, request.CardId);
if (!outcome.Success) return BadRequest(new { error = outcome.Error });
await _db.SaveChangesAsync();
return new ExchangeGachaPointResponse
{
RewardList = outcome.RewardList.ToList(),
};
}
[HttpPost("open")]
[HttpPost("/tutorial/pack_open")]
public async Task<ActionResult<PackOpenResponse>> Open(PackOpenRequest request)
@@ -189,15 +277,19 @@ public class PackController : SVSimController
// when buying a RUPY_MULTI (type_detail=7) child. The gacha_id alone disambiguates the
// child; gacha_type validation against child.TypeDetail would falsely reject every buy.
// Supported currency types in v1: CRYSTAL_MULTI=2, DAILY=3, RUPY_MULTI=7. Ticket flows
// (TICKET=4, TICKET_MULTI=5) and the rest are explicitly out of scope for the normal path.
// The tutorial path (type_detail=5, TICKET_MULTI) bypasses this guard — the starter pack
// is a free server-granted bonus, not a purchasable pack.
if (!isTutorialPath && child.TypeDetail is not (2 or 3 or 7))
// Supported type_details on the normal path:
// 1 CRYSTAL / 2 CRYSTAL_MULTI -> spend crystals
// 6 RUPY / 7 RUPY_MULTI -> spend rupees
// 3 DAILY -> spend rupees, once per UTC day
// 4 TICKET / 5 TICKET_MULTI -> consume child.ItemId from OwnedItemEntry
// Skin-overload types (8/9/13) and free-pack overlays (10/11/12) need extra
// selection / banner plumbing — kept 501 until the relevant flows land.
if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7))
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
var viewer = await _db.Viewers
.Include(v => v.PackOpenCounts)
.Include(v => v.GachaPointBalances)
.Include(v => v.MissionData)
.Include(v => v.Items).ThenInclude(i => i.Item)
.AsSplitQuery()
@@ -218,20 +310,20 @@ public class PackController : SVSimController
{
switch (child.TypeDetail)
{
case 2: // CRYSTAL_MULTI
case 1: // CRYSTAL (single)
case 2: // CRYSTAL_MULTI (10-pack)
{
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (viewer.Currency.Crystals < cost)
return BadRequest(new { error = "insufficient_crystals" });
viewer.Currency.Crystals -= cost;
long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
break;
}
case 7: // RUPY_MULTI
case 6: // RUPY (single)
case 7: // RUPY_MULTI (10-pack)
{
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (viewer.Currency.Rupees < cost)
return BadRequest(new { error = "insufficient_rupees" });
viewer.Currency.Rupees -= cost;
long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
case 3: // DAILY single — once per UTC day
@@ -243,10 +335,23 @@ public class PackController : SVSimController
if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date)
return BadRequest(new { error = "daily_free_already_claimed" });
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (cost > 0 && viewer.Currency.Rupees < cost)
return BadRequest(new { error = "insufficient_rupees" });
if (cost > 0) viewer.Currency.Rupees -= cost;
long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
case 4: // TICKET (single)
case 5: // TICKET_MULTI (10-pack)
{
if (child.ItemId is not long ticketItemId)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
int ticketsNeeded = child.Cost * packNumber;
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
if (owned is null || owned.Count < ticketsNeeded)
return BadRequest(new { error = "insufficient_tickets" });
owned.Count -= ticketsNeeded;
break;
}
}
@@ -267,9 +372,37 @@ public class PackController : SVSimController
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
int drawCount = child.IsDailySingle ? 1 : packNumber;
var draw = _opener.Draw(pack, _pools, drawCount, request.ExcludeCardIds, _rng);
var drawTable = await _drawTables.GetAsync(pack.Id);
if (drawTable is null)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "pack_draw_table_missing" });
// Owned card_ids for the rate-less Guaranteed-Leader-Card branch. Project to longs to
// avoid pulling viewer.Cards entities into memory. Shadow-FK access (EF.Property) per
// the project_ef_nav_include_pitfall memory.
var ownedCardIds = await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Cards)
.Select(c => (long)EF.Property<int>(c, "CardId"))
.ToListAsync();
var draw = _opener.Draw(
drawTable,
pack,
drawCount,
request.ExcludeCardIds ?? Array.Empty<long>(),
ownedCardIds,
_foils,
_rng);
var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId));
// Accrue gacha points (skip tutorial path — the starter pack isn't a real open).
if (!isTutorialPath)
{
_gachaPoint.Accrue(viewer, pack, child, drawCount);
await _db.SaveChangesAsync();
}
// Build reward_list. The service produces the type=5 (Card) entries with post-state counts
// plus any cosmetic grants. Currency entry (type=2 Crystals or type=9 Rupy) stays in the
// controller — it's a pack-purchase concern, not a card-grant concern. The client's
@@ -280,14 +413,25 @@ public class PackController : SVSimController
// Currency reward entries only apply to purchasable packs; tutorial path omits them.
if (!isTutorialPath)
{
var postViewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId);
if (child.TypeDetail == 2)
if (child.TypeDetail is 1 or 2)
{
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)postViewer.Currency.Crystals });
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) });
}
else if (child.TypeDetail == 7 || child.TypeDetail == 3)
else if (child.TypeDetail is 3 or 6 or 7)
{
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees });
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) });
}
else if (child.TypeDetail is 4 or 5 && child.ItemId is long ticketItemId)
{
// Item post-state count for the ticket we just consumed — client direct-assigns
// _userItemDict, so this must be the new total (project_wire_reward_list_post_state).
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
rewardList.Add(new RewardListEntry
{
RewardType = 4, // Item
RewardId = ticketItemId,
RewardNum = owned?.Count ?? 0, // post-state total
});
}
}
rewardList.AddRange(grant.RewardList);

View File

@@ -1,11 +1,11 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Enums;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.Database.Repositories.Deck;
using SVSim.Database.Repositories.Globals;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
using SVSim.EmulatedEntrypoint.Services;
@@ -13,18 +13,18 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class PracticeController : SVSimController
{
private readonly IDeckRepository _deckRepository;
private readonly IGlobalsRepository _globalsRepository;
private readonly IMissionProgressService _missionProgress;
private readonly IDeckListBuilder _deckListBuilder;
public PracticeController(
IDeckRepository deckRepository,
IGlobalsRepository globalsRepository,
IMissionProgressService missionProgress)
IMissionProgressService missionProgress,
IDeckListBuilder deckListBuilder)
{
_deckRepository = deckRepository;
_globalsRepository = globalsRepository;
_missionProgress = missionProgress;
_deckListBuilder = deckListBuilder;
}
/// <summary>
@@ -53,25 +53,19 @@ public class PracticeController : SVSimController
}
/// <summary>
/// /practice/deck_list — returns viewer's decks scoped by format (always Format.All
/// per spec, server can ignore the request field). Fetched via IDeckRepository so the
/// DeckCard.Card navigation is Included; going through the heavier viewer-graph query
/// drops that ThenInclude and ships 40 zeros instead of real card ids, which then
/// NREs the client's SBattleLoad.InitPlayer (CardCreator returns null on id=0).
/// /practice/deck_list — same wire shape as /deck/info (the client parses both via
/// DeckGroupListData), so it shares <see cref="IDeckListBuilder"/>. Always All-format per spec.
/// Unlike /deck/info this is a deck *select* screen, so empty "New Deck" slots are NOT padded
/// (padEmptySlots: false) — prod's practice capture returns the viewer's real decks unpadded,
/// plus the 8 per-class default decks and per-class leader-skin settings. The builder loads
/// decks via IDeckRepository (DeckCard.Card Included), so card_id_array carries real ids rather
/// than the 40 zeros that NRE the client's SBattleLoad.InitPlayer.
/// </summary>
[HttpPost("deck_list")]
public async Task<ActionResult<PracticeDeckListResponse>> DeckList(DeckFormatRequest request)
public async Task<ActionResult<DeckListResponse>> DeckList(DeckFormatRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var byFormat = await _deckRepository.GetDecksByFormats(viewerId, new[] { Format.Rotation, Format.Unlimited });
return new PracticeDeckListResponse
{
MaintenanceCardList = new List<long>(),
UserDeckRotation = byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList(),
UserDeckUnlimited = byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList(),
};
return await _deckListBuilder.BuildAsync(viewerId, Format.All, padEmptySlots: false);
}
/// <summary>

View File

@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
@@ -20,11 +21,17 @@ public class SleeveController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
private readonly ICollectionRepository _collection;
public SleeveController(SVSimDbContext db, RewardGrantService rewards)
public SleeveController(SVSimDbContext db, RewardGrantService rewards, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection)
{
_db = db;
_rewards = rewards;
_spend = spend;
_entitlements = entitlements;
_collection = collection;
}
[HttpPost("info")]
@@ -35,10 +42,12 @@ public class SleeveController : SVSimController
// is_purchased_product is "viewer owns at least one sleeve granted by this product".
// Loading the viewer's sleeve-id set once and checking each product against it avoids
// an N+1 over products.
var ownedSleeveIds = (await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Sleeves.Select(s => (long)s.Id))
.ToListAsync()).ToHashSet();
var ownedSleeveIds = _entitlements.IsFreeplay
? (await _collection.GetAllSleeveIds()).Select(id => (long)id).ToHashSet()
: (await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Sleeves.Select(s => (long)s.Id))
.ToListAsync()).ToHashSet();
var series = await _db.SleeveShopSeries
.Where(s => s.IsEnabled)
@@ -106,6 +115,9 @@ public class SleeveController : SVSimController
var viewer = await LoadViewerGraphAsync(viewerId);
if (_entitlements.IsFreeplay)
return BadRequest(new { error = "already_purchased" });
if (IsProductPurchased(product, viewer.Sleeves.Select(s => (long)s.Id).ToHashSet()))
return BadRequest(new { error = "already_purchased" });
@@ -122,20 +134,16 @@ public class SleeveController : SVSimController
case 1: // crystal
if (product.PriceCrystal is null)
return BadRequest(new { error = "price_not_available_for_currency" });
var crystalCost = (ulong)product.PriceCrystal.Value;
if (viewer.Currency.Crystals < crystalCost)
return BadRequest(new { error = "insufficient_crystals" });
viewer.Currency.Crystals -= crystalCost;
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals });
var crystalRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, product.PriceCrystal.Value);
if (!crystalRes.Success) return BadRequest(new { error = "insufficient_crystals" });
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)crystalRes.PostStateTotal });
break;
case 2: // rupy
if (product.PriceRupy is null)
return BadRequest(new { error = "price_not_available_for_currency" });
var rupyCost = (ulong)product.PriceRupy.Value;
if (viewer.Currency.Rupees < rupyCost)
return BadRequest(new { error = "insufficient_rupees" });
viewer.Currency.Rupees -= rupyCost;
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees });
var rupyRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, product.PriceRupy.Value);
if (!rupyRes.Success) return BadRequest(new { error = "insufficient_rupees" });
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)rupyRes.PostStateTotal });
break;
}

View File

@@ -30,12 +30,14 @@ public class SpotCardExchangeController : SVSimController
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly TimeProvider _time;
private readonly ICurrencySpendService _spend;
public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend)
{
_db = db;
_rewards = rewards;
_time = time;
_spend = spend;
}
[HttpPost("top")]
@@ -131,14 +133,14 @@ public class SpotCardExchangeController : SVSimController
// Debit spot points. Client-supplied exchange_point isn't authoritative — server uses
// catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry
// first, then grants.
if (viewer.Currency.SpotPoints < (ulong)entry.ExchangePoint)
var spotRes = await _spend.TrySpendAsync(viewer, SpendCurrency.SpotPoint, entry.ExchangePoint);
if (!spotRes.Success)
return BadRequest(new { error = "insufficient_spot_points" });
viewer.Currency.SpotPoints -= (ulong)entry.ExchangePoint;
rewardList.Add(new RewardListEntry
{
RewardType = (int)UserGoodsType.SpotCardPoint,
RewardId = 0,
RewardNum = checked((int)viewer.Currency.SpotPoints),
RewardNum = checked((int)spotRes.PostStateTotal),
});
// Grant the card itself via the existing card dispatcher (handles cosmetic cascade).

View File

@@ -234,7 +234,7 @@ public class ShadowverseTranslationMiddleware : IMiddleware
// Wrap the response in a datawrapper. Portal (no-encryption) endpoints emit an anonymous
// envelope — viewer/udid/sid stay zero/empty — matching the prod portal traffic shape
// captured in data_dumps/traffic_prod_deckcode.ndjson.
// captured in data_dumps/captures/traffic_prod_deckcode.ndjson.
DataWrapper wrappedResponseData = new DataWrapper
{
Data = responseData,

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick;
[MessagePackObject]
public class BattleResultsDto
{
/// <summary>Each entry is 0 (loss) or 1 (win). Native int array — matches capture.</summary>
[JsonPropertyName("result_list")] [Key("result_list")]
public List<int> ResultList { get; set; } = new();
[JsonPropertyName("win_count")] [Key("win_count")]
public int WinCount { get; set; }
}

View File

@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick;
[MessagePackObject]
public class CandidatePairDto
{
[JsonPropertyName("id")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("id")]
public long Id { get; set; }
[JsonPropertyName("turn")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("turn")]
public int Turn { get; set; }
[JsonPropertyName("set_num")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("set_num")]
public int SetNum { get; set; }
[JsonPropertyName("card_id_1")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("card_id_1")]
public long CardId1 { get; set; }
[JsonPropertyName("card_id_2")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("card_id_2")]
public long CardId2 { get; set; }
[JsonPropertyName("is_selected")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("is_selected")]
public int IsSelected { get; set; }
}

Some files were not shown because too many files have changed in this diff Show More