Compare commits

...

36 Commits

Author SHA1 Message Date
gamer147
39b38e3c80 Battlepass fix 2026-05-28 00:54:46 -04:00
gamer147
0f44a3482c fix(shops): smoke-test fallout from today's shop-cluster ship
Two issues caught in a real-client smoke run against the freshly
bootstrapped DB:

1. NRE in ShadowverseTranslationMiddleware for parameterless actions.
   Five new actions (Sleeve.Info, LeaderSkin.{Ids,Products},
   ItemPurchase.Info, SpotCardExchange.Top) took no parameters, but
   the middleware does
   `endpointDescriptor.Parameters.FirstOrDefault().ParameterType`
   to discover the request DTO — `FirstOrDefault` returns null on a
   zero-param action and `.ParameterType` NREs. Tests didn't catch it
   because the test client POSTs plain JSON, bypassing this path.
   Fix: each action now takes `BaseRequest _` matching the codebase
   convention (PuzzleController.Info, BattlePassController.Info, etc.),
   plus the middleware throws an actionable
   InvalidOperationException pointing at the convention so the next
   contributor doesn't repeat the mistake.

2. Leader-skin set sale showed up as "FREE / Claim" with empty
   Includes panel after the viewer bought every skin in a series
   with no configured bonus items. Root cause: ComputeRewardStatus
   emitted status=1 (not_got) when set_sales_status != 0 regardless
   of whether rewards.items was empty, and SkinPurchaseInfoTask.
   CreateSetSaleInfo flags `is_free=true` on (is_completed &&
   not_got). Prod ships status=0 when items is empty even with
   set_sales_status==1 — we now mirror that.

504 tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:57:12 -04:00
gamer147
7ef5f03eb3 feat(spot-card-exchange): /spot_card_exchange/{top,exchange} + SpotPoints currency
Final shop family. Schema additions:
- ViewerCurrency.SpotPoints (ulong) — new currency column on Viewers.
- SpotCardExchangeEntry — catalog (distinct from the pre-existing
  SpotCardEntry, which is the /load/index rental-cost concept).
- ViewerSpotCardExchange — standalone composite-PK table tracking
  (viewer, card, exchanged_at, is_pre_release_snapshot). Standalone
  avoids cartesian-explode on viewer-graph reads.

RewardGrantService gains a SpotCardPoint=12 currency case mirroring
the RedEther/Crystal pattern. Doc comment refreshed; SpotCard=11 and
SpotCardOnlyLatestCardPack=13 remain unimplemented with explanatory
NotSupportedException — captures show emitters always use Card=5 with
the spot-card-specific id.

Controller:
- /top: emits exactly 9 clan buckets [{"1": [cards]}, ...] matching
  prod's arbitrary single-key shape. exchange_status per-card (0=
  available, 1=already-exchanged, 2=LimitOver after pre-release cap).
  pre_relase_info WIRE TYPO PRESERVED ("relase" not "release").
- /exchange: server-authoritative price (client-supplied
  exchange_point ignored); debits SpotPoints with post-state-total
  reward_list entry; grants card via RewardGrantService.ApplyAsync
  (cosmetic cascade included); persists ViewerSpotCardExchange row.
  Insufficient points / already-exchanged / pre-release-limit all
  return 400 without partial state.

LoadController now populates /load/index spot_point from
viewer.Currency.SpotPoints (was always 0).

PreReleaseLimit hardcoded to 2 matching capture; promote to GameConfig
when captures show variance.

504 tests pass (was 496; +8 spot-card-exchange tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:23:07 -04:00
gamer147
a5999a3e9c feat(leader-skin): shop catalog + 5 endpoints (/products, /buy, /buy_set, /buy_set_item, /ids)
Schema: LeaderSkinShopSeries -> Products (owned rewards) + owned
SetCompletionRewards on the series; ViewerLeaderSkinSetClaim composite
PK (ViewerId, SeriesId) backs the /buy_set_item idempotent-claim check.

Importer mirrors SleeveShopImporter: idempotent find-or-create, owned
collections rewritten wholesale on rerun. 16 series, 104 products.

Controller (extends existing /set with 5 new endpoints):
- /products: dict-keyed-by-series_id-string wire shape. is_completed
  per-viewer, rewards.status from ViewerLeaderSkinSetClaim (0=no set
  sale, 1=available, 2=claimed) matching client RewardStatus enum.
- /buy: single skin, sales_type 1/2 dispatch, 3=>501.
- /buy_set: whole series at SetPrice; requires set_sales_status != 0;
  grants every product's rewards (RewardGrantService idempotent on
  already-owned cosmetics, so partial-set buys don't double-add).
- /buy_set_item: requires viewer owns every skin in series; idempotent
  on re-claim (returns 200 + empty reward_list, not 400) so client
  retries don't error.
- /ids: flat owned-skin-id list for badge refresh.

496 tests pass (was 486; +10 leader-skin-shop tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:55:09 -04:00
gamer147
559a170957 feat(item-purchase): /item_purchase/{info,purchase} + catalog
Schema: ItemPurchaseCatalogEntry (single table). Per-viewer quota tracked
via existing ViewerEventCounter keyed by "item_purchase:<id>" with period
JstPeriod.MonthKey when IsMonthlyReset else AllTime.

Controller:
- /info returns catalog + per-period rest (server-computed
  max(0, PurchaseLimit - counter)) + user_card_pack_ticket_list (every
  Items.Type==2 row joined to viewer count, zeros included — client
  unconditionally UpdateItemNum's each entry).
- /purchase: sold_out check before currency check (no counter increment
  on currency failure), inline TryDebit covers RedEther/Crystal/Rupy/Item
  with post-state-total reward_list entry, grant via RewardGrantService.
  Request `rest` accepted but ignored (server counter is canonical).

Importer mirrors PaymentItemImporter shape — idempotent find-or-create,
seed-missing rows preserved. 3 entries from the prod capture.

486 tests pass (was 476; +10 item_purchase tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:41:02 -04:00
gamer147
f237851e42 feat(sleeve): shop catalog + /sleeve/{info,buy} endpoints
Schema: SleeveShopSeries -> SleeveShopProducts -> Rewards (owned).
Migration AddSleeveShop creates 3 tables with FK cascade.

Importer mirrors BuildDeck pattern: find-or-create per series/product,
rewards replaced wholesale on rerun (owned collection). 10 series,
270 products imported from seeds/sleeve-shop.json.

Controller:
- /sleeve/info returns wire-faithful dict-keyed shape
  ({sleeve_list: {<series_id>: {product_info: {<product_id>: ...}}}}).
  is_purchased_product derived from viewer.Sleeves.Contains(sleeve_id).
- /sleeve/buy: sales_type 0=free / 1=crystal / 2=rupy / 3=ticket(501).
  Validates series_product mismatch, currency, already-purchased.
  Currency debited with post-state-total reward_list entry; cosmetic
  grants dispatched through RewardGrantService.ApplyAsync (covers
  sleeve + emblem bundled grants per product).

476 tests pass (was 466; +10 sleeve tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:09:45 -04:00
gamer147
6a03ff1bf6 feat(items): catalog import with Type + ThumbnailPath columns
ItemEntry gains Type (client item_type enum, 1=challenge, 2=card-pack
ticket, 3=premium orb, 4=colosseum, 5=orb piece, 6=skin/event ticket,
7=other) and ThumbnailPath. ItemImporter mirrors PaymentItemImporter
shape: find-or-create per item_id, save once, idempotent. Wired into
Bootstrap.Program and SVSimTestFactory.SeedGlobalsAsync. Unblocks
/item_purchase/info (filters card-pack tickets by Type==2) and any
reward grant of UserGoodsType.Item, which previously threw because
the catalog was empty.

466 tests pass (was 461).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 21:44:24 -04:00
gamer147
529fd13668 signup: close two concurrency holes from final review
(1) RegisterAnonymousViewer now catches the unique-violation
    race (SQLSTATE 23505 on Postgres / code 19 on SQLite) and
    re-reads by UDID, returning the existing row instead of
    surfacing 500 to the second concurrent /tool/signup caller.
    New repo test exercises the back-to-back register path.

(2) Add unique index on SocialAccountConnection (AccountType,
    AccountId). The auth handler's find-or-link path claimed
    this index existed as the dedup backstop; the claim was
    accurate as design intent but the schema was missing. Now
    matched. Comment in handler updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:46:19 -04:00
gamer147
26bb0ac268 auth: link Steam to UDID-keyed viewer on first authenticated request
After /tool/signup, the client has a viewer_id but no Steam social row.
The first authenticated request (typically /check/game_start) carries
the Steam ticket; if the SteamId lookup misses but the UDID resolves
to a viewer, attach the Steam social now. Subsequent requests hit the
fast SteamId path. Closes the CheckController.GameStart TODO that was
blocking fresh-client boot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:31:06 -04:00
gamer147
68367db214 feat(tool/signup): anonymous viewer creation keyed on UDID
POST /tool/signup upserts a Viewer keyed on the resolved request UDID
(via the existing SID->UDID dict). Stashes the viewer on HttpContext so
the translation middleware emits viewer_id/short_udid/udid in
data_headers. Empty data payload -- all signup outputs flow in
data_headers per spec. Idempotent: repeat signups for the same UDID
return the existing viewer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:24:55 -04:00
gamer147
7be0dabf87 dto: SignupRequest + empty SignupResponse
Request mirrors LoginPostParams (device telemetry); response is empty
because all signup outputs live in data_headers (viewer_id, short_udid,
udid). MessagePackObject + Key mirrors JsonPropertyName per project
convention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:16:11 -04:00
gamer147
859980af02 wire: echo UDID in DataHeaders on every response
SignUpTask.Parse validates data_headers.udid against Certification.Udid;
mismatch discards the response. Sourced from the same mappedUdid the
translation middleware uses to decrypt — never controller state. Other
endpoints carry the extra key; SignUpTask is the only reader.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:11:47 -04:00
gamer147
c8ee1e487f ext: HttpContext.GetUdid() over SID-mapping service
Mirrors how the translation middleware resolves the per-request UDID;
needed by ToolController.Signup and the SteamSession find-or-link
branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:07:42 -04:00
gamer147
f85589d208 repo(viewer): restore dropped rationale comment, add link-idempotency assertion
Review polish on the prior commit (30874c6):
- BuildDefaultViewer extract dropped the "filter out Id=0 placeholders
  and dedupe" comment from the leader-skin grant block — restored.
- LinkSteamToViewer test now calls link twice and asserts count stays
  at 1, exercising the alreadyLinked short-circuit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:06:35 -04:00
gamer147
30874c681f repo(viewer): add UDID lookup, anonymous register, Steam link helpers
Extracts the default-loadout body into a private BuildDefaultViewer
helper shared by the existing Steam-import path and a new
RegisterAnonymousViewer for /tool/signup. LinkSteamToViewer is the
seam SteamSessionAuthenticationHandler will call on first-Steam-touch
of a UDID-keyed viewer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:01:02 -04:00
gamer147
dffd7a9746 db: add nullable Viewer.Udid with partial unique index
Backstop for /tool/signup idempotency: signup-created viewers carry
the client's UDID (the AES key for that client's wire traffic);
admin-imported viewers stay null. Partial unique index allows the
column to coexist with pre-existing null rows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:54:15 -04:00
gamer147
8e35501954 feat(missions): emit progress events on story/finish and practice/finish
Story emits story_chapter_finish:<main|limited|event>:<story_id>.
Practice emits practice_win:<difficulty>:<enemy_class_id> on win only.

Practice catalog rows use opponent NAMES (e.g. practice_win:elite:arisa)
not numeric class_ids, so captured catalog rows won't match yet. The
infrastructure is in place; bridging numeric→name is a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:51:05 -04:00
gamer147
5693ec0302 feat(missions): /load/index materializes viewer mission/achievement state
EnsureCurrentAsync now takes viewerId (was Viewer), so it works with
LoadController's AsNoTracking-loaded detached viewers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:45:31 -04:00
gamer147
640a77ec6c feat(achievements): /achievement/receive_reward — RewardGrantService + level advance + cap
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:41:49 -04:00
gamer147
b65a437102 feat(missions): /mission/info, /mission/retire, /mission/change_receive_setting
Three endpoints + 9 integration tests. Captured-data-is-catalog: viewer's
achievement Level starts at MIN(Level) per type from the catalog (not 1),
so the assembler always has a row to render against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:35:40 -04:00
gamer147
574e9ca58b feat(missions): MissionAssembler — single DTO builder reused by all 4 endpoints
Tests intentionally deferred to controller integration tests (Tasks
18-21) which exercise the assembler end-to-end via the wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:31:38 -04:00
gamer147
df65b7a9c8 feat(missions): common DTOs (UserMission, UserAchievement, BPMonthlyMission, MissionInfoData)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:30:29 -04:00
gamer147
c9534d8fac feat(missions): ViewerMissionStateService — lazy materialize achievements + assign slots
Reads existing state from DB on each call (don't trust navigation
property — caller may pass it stale or double-tracked). Adds via DbSet
only, not via navigation property, to avoid EF double-tracking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:29:30 -04:00
gamer147
aad604a589 feat(missions): MissionProgressService — counter upsert + achievement claimable on threshold
Also wires IMissionCatalogRepository + IViewerMissionRepository +
IMissionProgressService into DI. Task 17's separate DI step is now
subsumed by these registrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:25:13 -04:00
gamer147
b38be1d953 feat(missions): JstPeriod helper — 02:00 JST anchored day/week/month keys
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:21:51 -04:00
gamer147
6fbf7cbc94 feat(missions): mission catalog + viewer mission repositories
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:21:07 -04:00
gamer147
8fd6bc10c1 chore(bootstrap): register 3 mission/achievement importers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:19:57 -04:00
gamer147
90cc5a9f5d feat(bp-monthly): BattlePassMonthlyMissionImporter, idempotent by (Y, M, order)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:18:03 -04:00
gamer147
6db800f286 feat(achievements): AchievementCatalogImporter, idempotent by (type, level)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:17:06 -04:00
gamer147
5df1822217 feat(missions): MissionCatalogImporter, idempotent upsert by id
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:14:58 -04:00
gamer147
f486c15d32 seed(bp-monthly): bp-monthly-missions.json — May 2026 (5 rows)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:13:51 -04:00
gamer147
8da91783b1 seed(achievements): achievement-catalog.json — 53 tiers / 52 types
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:13:12 -04:00
gamer147
6a66170677 seed(missions): mission-catalog.json — 5 missions
Captured from /mission/info responses across all traffic dumps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:12:15 -04:00
gamer147
ebba3c0eef feat(missions): wire 6 entities into DbContext + AddMissionsAndAchievements migration
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:11:25 -04:00
gamer147
062adefb99 feat(missions): add 3 viewer entities + Viewer collections
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:08:11 -04:00
gamer147
9988ed85df feat(missions): add 3 catalog entities (mission, achievement, BP monthly) 2026-05-27 10:01:27 -04:00
141 changed files with 50490 additions and 47 deletions

View File

@@ -0,0 +1,638 @@
[
{
"achievement_type": 1,
"level": 1,
"name": "Win 5 ranked matches as Forestcraft",
"require_number": 5,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"order_num": 1,
"event_type": "ranked_win:forestcraft",
"event_arg": null
},
{
"achievement_type": 2,
"level": 2,
"name": "Win 20 ranked matches as Swordcraft",
"require_number": 20,
"reward_type": 1,
"reward_detail_id": 0,
"reward_number": 20,
"order_num": 2,
"event_type": "ranked_win:swordcraft",
"event_arg": null
},
{
"achievement_type": 3,
"level": 1,
"name": "Win 5 ranked matches as Runecraft",
"require_number": 5,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"order_num": 3,
"event_type": "ranked_win:runecraft",
"event_arg": null
},
{
"achievement_type": 4,
"level": 1,
"name": "Win 5 ranked matches as Dragoncraft",
"require_number": 5,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"order_num": 4,
"event_type": "ranked_win:dragoncraft",
"event_arg": null
},
{
"achievement_type": 5,
"level": 1,
"name": "Win 5 ranked matches as Shadowcraft",
"require_number": 5,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"order_num": 5,
"event_type": "ranked_win:shadowcraft",
"event_arg": null
},
{
"achievement_type": 6,
"level": 3,
"name": "Win 50 ranked matches as Bloodcraft",
"require_number": 50,
"reward_type": 8,
"reward_detail_id": 106001,
"reward_number": 1,
"order_num": 6,
"event_type": "ranked_win:bloodcraft",
"event_arg": null
},
{
"achievement_type": 7,
"level": 1,
"name": "Win 5 ranked matches as Havencraft",
"require_number": 5,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"order_num": 7,
"event_type": "ranked_win:havencraft",
"event_arg": null
},
{
"achievement_type": 8,
"level": 1,
"name": "Win 5 ranked matches as Portalcraft",
"require_number": 5,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"order_num": 8,
"event_type": "ranked_win:portalcraft",
"event_arg": null
},
{
"achievement_type": 11,
"level": 1,
"name": "Reach level 10 in Forestcraft",
"require_number": 10,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"order_num": 9,
"event_type": "class_level_up:forestcraft",
"event_arg": null
},
{
"achievement_type": 12,
"level": 6,
"name": "Reach level 35 in Swordcraft",
"require_number": 35,
"reward_type": 5,
"reward_detail_id": 100211061,
"reward_number": 3,
"order_num": 10,
"event_type": "class_level_up:swordcraft",
"event_arg": null
},
{
"achievement_type": 12,
"level": 7,
"name": "Reach level 40 in Swordcraft",
"require_number": 40,
"reward_type": 5,
"reward_detail_id": 100214011,
"reward_number": 3,
"order_num": 10,
"event_type": "class_level_up:swordcraft",
"event_arg": null
},
{
"achievement_type": 13,
"level": 1,
"name": "Reach level 10 in Runecraft",
"require_number": 10,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"order_num": 11,
"event_type": "class_level_up:runecraft",
"event_arg": null
},
{
"achievement_type": 14,
"level": 3,
"name": "Reach level 20 in Dragoncraft",
"require_number": 20,
"reward_type": 5,
"reward_detail_id": 100011041,
"reward_number": 3,
"order_num": 12,
"event_type": "class_level_up:dragoncraft",
"event_arg": null
},
{
"achievement_type": 15,
"level": 2,
"name": "Reach level 15 in Shadowcraft",
"require_number": 15,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 30,
"order_num": 13,
"event_type": "class_level_up:shadowcraft",
"event_arg": null
},
{
"achievement_type": 16,
"level": 6,
"name": "Reach level 35 in Bloodcraft",
"require_number": 35,
"reward_type": 5,
"reward_detail_id": 100614011,
"reward_number": 3,
"order_num": 14,
"event_type": "class_level_up:bloodcraft",
"event_arg": null
},
{
"achievement_type": 17,
"level": 1,
"name": "Reach level 10 in Havencraft",
"require_number": 10,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"order_num": 15,
"event_type": "class_level_up:havencraft",
"event_arg": null
},
{
"achievement_type": 18,
"level": 1,
"name": "Reach level 10 in Portalcraft",
"require_number": 10,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"order_num": 16,
"event_type": "class_level_up:portalcraft",
"event_arg": null
},
{
"achievement_type": 28,
"level": 1,
"name": "Cleared Chapter 8: The Morning Star with 7 leaders without skipping the battle",
"require_number": 7,
"reward_type": 8,
"reward_detail_id": 110001,
"reward_number": 1,
"order_num": 17,
"event_type": "story_chapter_finish:main",
"event_arg": null
},
{
"achievement_type": 29,
"level": 1,
"name": "Cleared Chapter 12 of The Morning Star: Conclusion",
"require_number": 1,
"reward_type": 8,
"reward_detail_id": 110006,
"reward_number": 1,
"order_num": 71,
"event_type": "story_chapter_finish:main",
"event_arg": null
},
{
"achievement_type": 31,
"level": 3,
"name": "Win 50 ranked matches",
"require_number": 50,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"order_num": 18,
"event_type": "ranked_win",
"event_arg": null
},
{
"achievement_type": 32,
"level": 1,
"name": "Win 5 Challenge matches",
"require_number": 5,
"reward_type": 4,
"reward_detail_id": 10001,
"reward_number": 1,
"order_num": 19,
"event_type": "challenge_win",
"event_arg": null
},
{
"achievement_type": 41,
"level": 1,
"name": "Win all 5 Challenge matches 3 times",
"require_number": 3,
"reward_type": 4,
"reward_detail_id": 10001,
"reward_number": 1,
"order_num": 20,
"event_type": "challenge_full_clear",
"event_arg": null
},
{
"achievement_type": 50,
"level": 3,
"name": "Achieve Beginner 3 rank (Throwback Rotation or Unlimited)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 100,
"order_num": 25,
"event_type": "rank_achieved:beginner",
"event_arg": null
},
{
"achievement_type": 51,
"level": 4,
"name": "Achieve D3 rank (Throwback Rotation or Unlimited)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 100,
"order_num": 29,
"event_type": "rank_achieved:d",
"event_arg": null
},
{
"achievement_type": 52,
"level": 3,
"name": "Achieve C2 rank (Throwback Rotation or Unlimited)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 100,
"order_num": 32,
"event_type": "rank_achieved:c",
"event_arg": null
},
{
"achievement_type": 53,
"level": 1,
"name": "Achieve B0 rank (Throwback Rotation or Unlimited)",
"require_number": 1,
"reward_type": 8,
"reward_detail_id": 201003,
"reward_number": 1,
"order_num": 34,
"event_type": "rank_achieved:b",
"event_arg": null
},
{
"achievement_type": 54,
"level": 1,
"name": "Achieve A0 rank (Throwback Rotation or Unlimited)",
"require_number": 1,
"reward_type": 8,
"reward_detail_id": 201004,
"reward_number": 1,
"order_num": 38,
"event_type": "rank_achieved:a",
"event_arg": null
},
{
"achievement_type": 55,
"level": 1,
"name": "Achieve AA0 rank (Throwback Rotation or Unlimited)",
"require_number": 1,
"reward_type": 8,
"reward_detail_id": 201005,
"reward_number": 1,
"order_num": 42,
"event_type": "rank_achieved:aa",
"event_arg": null
},
{
"achievement_type": 56,
"level": 1,
"name": "Achieve Master rank (Throwback Rotation or Unlimited)",
"require_number": 1,
"reward_type": 8,
"reward_detail_id": 300002,
"reward_number": 1,
"order_num": 46,
"event_type": "rank_achieved:master",
"event_arg": null
},
{
"achievement_type": 61,
"level": 1,
"name": "Defeat Arisa on Elite difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 47,
"event_type": "practice_win:elite:arisa",
"event_arg": null
},
{
"achievement_type": 62,
"level": 1,
"name": "Defeat Erika on Elite difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 48,
"event_type": "practice_win:elite:erika",
"event_arg": null
},
{
"achievement_type": 63,
"level": 1,
"name": "Defeat Isabelle on Elite difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 49,
"event_type": "practice_win:elite:isabelle",
"event_arg": null
},
{
"achievement_type": 64,
"level": 1,
"name": "Defeat Rowen on Elite difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 50,
"event_type": "practice_win:elite:rowen",
"event_arg": null
},
{
"achievement_type": 65,
"level": 1,
"name": "Defeat Luna on Elite difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 51,
"event_type": "practice_win:elite:luna",
"event_arg": null
},
{
"achievement_type": 66,
"level": 1,
"name": "Defeat Urias on Elite difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 52,
"event_type": "practice_win:elite:urias",
"event_arg": null
},
{
"achievement_type": 67,
"level": 1,
"name": "Defeat Eris on Elite difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 53,
"event_type": "practice_win:elite:eris",
"event_arg": null
},
{
"achievement_type": 68,
"level": 7,
"name": "Battle 7 players in Private Match (without quitting).",
"require_number": 7,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 100,
"order_num": 70,
"event_type": "private_match_distinct_opponent",
"event_arg": null
},
{
"achievement_type": 71,
"level": 1,
"name": "Defeat Arisa on Elite 2 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 55,
"event_type": "practice_win:elite2:arisa",
"event_arg": null
},
{
"achievement_type": 72,
"level": 1,
"name": "Defeat Erika on Elite 2 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 56,
"event_type": "practice_win:elite2:erika",
"event_arg": null
},
{
"achievement_type": 73,
"level": 1,
"name": "Defeat Isabelle on Elite 2 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 57,
"event_type": "practice_win:elite2:isabelle",
"event_arg": null
},
{
"achievement_type": 74,
"level": 1,
"name": "Defeat Rowen on Elite 2 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 58,
"event_type": "practice_win:elite2:rowen",
"event_arg": null
},
{
"achievement_type": 75,
"level": 1,
"name": "Defeat Luna on Elite 2 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 59,
"event_type": "practice_win:elite2:luna",
"event_arg": null
},
{
"achievement_type": 76,
"level": 1,
"name": "Defeat Urias on Elite 2 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 60,
"event_type": "practice_win:elite2:urias",
"event_arg": null
},
{
"achievement_type": 77,
"level": 1,
"name": "Defeat Eris on Elite 2 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 61,
"event_type": "practice_win:elite2:eris",
"event_arg": null
},
{
"achievement_type": 81,
"level": 1,
"name": "Defeat Arisa on Elite 3 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 63,
"event_type": "practice_win:elite3:arisa",
"event_arg": null
},
{
"achievement_type": 82,
"level": 1,
"name": "Defeat Erika on Elite 3 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 64,
"event_type": "practice_win:elite3:erika",
"event_arg": null
},
{
"achievement_type": 83,
"level": 1,
"name": "Defeat Isabelle on Elite 3 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 65,
"event_type": "practice_win:elite3:isabelle",
"event_arg": null
},
{
"achievement_type": 84,
"level": 1,
"name": "Defeat Rowen on Elite 3 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 66,
"event_type": "practice_win:elite3:rowen",
"event_arg": null
},
{
"achievement_type": 85,
"level": 1,
"name": "Defeat Luna on Elite 3 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 67,
"event_type": "practice_win:elite3:luna",
"event_arg": null
},
{
"achievement_type": 86,
"level": 1,
"name": "Defeat Urias on Elite 3 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 68,
"event_type": "practice_win:elite3:urias",
"event_arg": null
},
{
"achievement_type": 87,
"level": 1,
"name": "Defeat Eris on Elite 3 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 69,
"event_type": "practice_win:elite3:eris",
"event_arg": null
},
{
"achievement_type": 168,
"level": 1,
"name": "Defeat Yuwan on Elite difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 54,
"event_type": "practice_win:elite:yuwan",
"event_arg": null
},
{
"achievement_type": 178,
"level": 1,
"name": "Defeat Yuwan on Elite 2 difficulty (Practice)",
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 200,
"order_num": 62,
"event_type": "practice_win:elite2:yuwan",
"event_arg": null
}
]

View File

@@ -0,0 +1,67 @@
[
{
"year": 2026,
"month": 5,
"order_num": 0,
"name": "Win 25 matches (Ranked or Arena)",
"require_number": 25,
"battle_pass_point": 1000,
"event_type": "ranked_or_arena_win",
"event_arg": null,
"reward_type": 7,
"reward_detail_id": 1215110100,
"reward_number": 1
},
{
"year": 2026,
"month": 5,
"order_num": 1,
"name": "Win 50 matches (Ranked or Arena)",
"require_number": 50,
"battle_pass_point": 1000,
"event_type": "ranked_or_arena_win",
"event_arg": null,
"reward_type": 6,
"reward_detail_id": 1215110100,
"reward_number": 1
},
{
"year": 2026,
"month": 5,
"order_num": 2,
"name": "Win 75 matches (Ranked or Arena)",
"require_number": 75,
"battle_pass_point": 1500,
"event_type": "ranked_or_arena_win",
"event_arg": null,
"reward_type": 7,
"reward_detail_id": 1298310100,
"reward_number": 1
},
{
"year": 2026,
"month": 5,
"order_num": 3,
"name": "Win 100 matches (Ranked or Arena)",
"require_number": 100,
"battle_pass_point": 1500,
"event_type": "ranked_or_arena_win",
"event_arg": null,
"reward_type": 6,
"reward_detail_id": 1298310100,
"reward_number": 1
},
{
"year": 2026,
"month": 5,
"order_num": 4,
"name": "Play 5 Challenge matches (without quitting)",
"require_number": 5,
"battle_pass_point": 500,
"event_type": "challenge_play",
"event_arg": null,
"reward_type": null,
"reward_detail_id": null,
"reward_number": null
}
]

View File

@@ -0,0 +1,38 @@
[
{
"purchase_id": 1,
"require_item_type": 1,
"require_item_id": 0,
"require_item_num": 5000,
"purchase_item_type": 4,
"purchase_item_id": 1000,
"purchase_item_num": 1,
"purchase_name": "[b]One Time Only![/b] Seer's Globe x1",
"is_monthly_reset": false,
"purchase_limit": 1
},
{
"purchase_id": 100002,
"require_item_type": 4,
"require_item_id": 1001,
"require_item_num": 5,
"purchase_item_type": 4,
"purchase_item_id": 1000,
"purchase_item_num": 1,
"purchase_name": "",
"is_monthly_reset": true,
"purchase_limit": 3
},
{
"purchase_id": 100003,
"require_item_type": 1,
"require_item_id": 0,
"require_item_num": 30000,
"purchase_item_type": 4,
"purchase_item_id": 1000,
"purchase_item_num": 1,
"purchase_name": "",
"is_monthly_reset": true,
"purchase_limit": 10
}
]

View File

@@ -0,0 +1,362 @@
[
{
"item_id": 1,
"name": "Challenge Ticket",
"type": 1,
"thumbnail_path": "ticket_1"
},
{
"item_id": 2,
"name": "Grand Prix Ticket",
"type": 4,
"thumbnail_path": "ticket_colosseum"
},
{
"item_id": 1000,
"name": "Seer's Globe",
"type": 3,
"thumbnail_path": "thumbnail_orb"
},
{
"item_id": 1001,
"name": "Seer's Globe Shards",
"type": 5,
"thumbnail_path": "thumbnail_orb_piece"
},
{
"item_id": 2001,
"name": "Umamusume Bingo Ticket",
"type": 6,
"thumbnail_path": "ticket_2001"
},
{
"item_id": 2002,
"name": "Chiikawa Bingo Ticket",
"type": 6,
"thumbnail_path": "ticket_2002"
},
{
"item_id": 2003,
"name": "7th Anniversary Ticket",
"type": 6,
"thumbnail_path": "ticket_2003"
},
{
"item_id": 2004,
"name": "Fennie Bingo Ticket",
"type": 6,
"thumbnail_path": "ticket_2004"
},
{
"item_id": 10001,
"name": "Classic Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10001"
},
{
"item_id": 10002,
"name": "Darkness Evolved Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10002"
},
{
"item_id": 10003,
"name": "Rise of Bahamut Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10003"
},
{
"item_id": 10004,
"name": "Tempest of the Gods Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10004"
},
{
"item_id": 10005,
"name": "Wonderland Dreams Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10005"
},
{
"item_id": 10006,
"name": "Starforged Legends Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10006"
},
{
"item_id": 10007,
"name": "Chronogenesis Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10007"
},
{
"item_id": 10008,
"name": "Dawnbreak, Nightedge Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10008"
},
{
"item_id": 10009,
"name": "Brigade of the Sky Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10009"
},
{
"item_id": 10010,
"name": "Omen of the Ten Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10010"
},
{
"item_id": 10011,
"name": "Altersphere Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10011"
},
{
"item_id": 10012,
"name": "Steel Rebellion Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10012"
},
{
"item_id": 10013,
"name": "Rebirth of Glory Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10013"
},
{
"item_id": 10014,
"name": "Verdant Conflict Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10014"
},
{
"item_id": 10015,
"name": "Ultimate Colosseum Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10015"
},
{
"item_id": 10016,
"name": "World Uprooted Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10016"
},
{
"item_id": 10017,
"name": "Fortune's Hand Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10017"
},
{
"item_id": 10018,
"name": "Storm Over Rivayle Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10018"
},
{
"item_id": 10019,
"name": "Eternal Awakening Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10019"
},
{
"item_id": 10020,
"name": "Darkness Over Vellsar Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10020"
},
{
"item_id": 10021,
"name": "Renascent Chronicles Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10021"
},
{
"item_id": 10022,
"name": "Dawn of Calamity Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10022"
},
{
"item_id": 10023,
"name": "Omen of Storms Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10023"
},
{
"item_id": 10024,
"name": "Edge of Paradise Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10024"
},
{
"item_id": 10025,
"name": "Roar of the Godwyrm Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10025"
},
{
"item_id": 10026,
"name": "Celestial Dragonblade Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10026"
},
{
"item_id": 10027,
"name": "Eightfold Abyss: Azvaldt Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10027"
},
{
"item_id": 10028,
"name": "Academy of Ages Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10028"
},
{
"item_id": 10029,
"name": "Heroes of Rivenbrandt Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10029"
},
{
"item_id": 10030,
"name": "Order Shift Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10030"
},
{
"item_id": 10031,
"name": "Resurgent Legends Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10031"
},
{
"item_id": 10032,
"name": "Heroes of Shadowverse Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10032"
},
{
"item_id": 60001,
"name": "4th Birthday Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60001"
},
{
"item_id": 60019,
"name": "Eternal Awakening Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60019"
},
{
"item_id": 60020,
"name": "Darkness Over Vellsar Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60020"
},
{
"item_id": 60021,
"name": "Renascent Chronicles Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60021"
},
{
"item_id": 60022,
"name": "Dawn of Calamity Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60022"
},
{
"item_id": 60023,
"name": "Omen of Storms Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60023"
},
{
"item_id": 60024,
"name": "Edge of Paradise Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60024"
},
{
"item_id": 60025,
"name": "Roar of the Godwyrm Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60025"
},
{
"item_id": 60026,
"name": "Celestial Dragonblade Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60026"
},
{
"item_id": 60027,
"name": "Eightfold Abyss: Azvaldt Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60027"
},
{
"item_id": 60028,
"name": "Academy of Ages Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60028"
},
{
"item_id": 60029,
"name": "Heroes of Rivenbrandt Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60029"
},
{
"item_id": 60030,
"name": "Order Shift Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60030"
},
{
"item_id": 60031,
"name": "Resurgent Legends Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60031"
},
{
"item_id": 60032,
"name": "Heroes of Shadowverse Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60032"
},
{
"item_id": 70001,
"name": "4th Birthday Leader Ticket",
"type": 7,
"thumbnail_path": "ticket_70001"
},
{
"item_id": 70002,
"name": "Champion's Battle Leader Ticket",
"type": 7,
"thumbnail_path": "ticket_70002"
},
{
"item_id": 80001,
"name": "Throwback Rotation Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_80001"
},
{
"item_id": 90001,
"name": "Legendary Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_90001"
},
{
"item_id": 92001,
"name": "4th Birthday Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_92001"
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
[
{
"id": 12,
"name": "Win 2 ranked matches",
"lot_type": 2,
"require_number": 2,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 30,
"battle_pass_point": 50,
"default_flag": false,
"event_type": "ranked_win",
"event_arg": null,
"start_time": 1779634776,
"end_time": null
},
{
"id": 16,
"name": "Win 1 match as Swordcraft (Ranked, Unranked, or Arena)",
"lot_type": 2,
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"battle_pass_point": 50,
"default_flag": false,
"event_type": "ranked_win:swordcraft",
"event_arg": null,
"start_time": 1505581389,
"end_time": null
},
{
"id": 20,
"name": "Win 1 match as Bloodcraft (Ranked, Unranked, or Arena)",
"lot_type": 2,
"require_number": 1,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 20,
"battle_pass_point": 50,
"default_flag": false,
"event_type": "ranked_win:bloodcraft",
"event_arg": null,
"start_time": 1505659633,
"end_time": null
},
{
"id": 332,
"name": "Daily Mission: Win 3 matches (Ranked, Unranked, or Arena)",
"lot_type": 6,
"require_number": 3,
"reward_type": 12,
"reward_detail_id": 0,
"reward_number": 400,
"battle_pass_point": 50,
"default_flag": true,
"event_type": "daily_match_win",
"event_arg": null,
"start_time": 1725317128,
"end_time": 1893542399
},
{
"id": 505,
"name": "Play 30 followers (Ranked, Unranked, or Arena)",
"lot_type": 2,
"require_number": 30,
"reward_type": 9,
"reward_detail_id": 0,
"reward_number": 50,
"battle_pass_point": 50,
"default_flag": false,
"event_type": "play_followers",
"event_arg": null,
"start_time": 1779772990,
"end_time": null
}
]

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,60 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of achievement-catalog rows from <c>seeds/achievement-catalog.json</c>.
/// Keyed by (AchievementType, Level) so re-running with new captures grows the ladder.
/// Rows missing from the seed are LEFT INTACT.
/// </summary>
public class AchievementCatalogImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var seed = SeedLoader.LoadList<AchievementCatalogSeed>(Path.Combine(seedDir, "achievement-catalog.json"));
if (seed.Count == 0)
{
Console.WriteLine("[AchievementCatalogImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.AchievementCatalog
.ToDictionaryAsync(e => (e.AchievementType, e.Level));
int created = 0, updated = 0;
var unmappedTypes = new HashSet<int>();
foreach (var s in seed)
{
if (s.AchievementType == 0 || s.Level == 0) continue;
var key = (s.AchievementType, s.Level);
var entry = existing.TryGetValue(key, out var ex) ? ex : new AchievementCatalogEntry
{
AchievementType = s.AchievementType,
Level = s.Level,
};
entry.Name = s.Name;
entry.RequireNumber = s.RequireNumber;
entry.RewardType = s.RewardType;
entry.RewardDetailId = s.RewardDetailId;
entry.RewardNumber = s.RewardNumber;
entry.OrderNum = s.OrderNum;
entry.EventType = s.EventType;
entry.EventArg = s.EventArg;
if (ex is null) { context.AchievementCatalog.Add(entry); existing[key] = entry; created++; }
else updated++;
if (s.EventType is null) unmappedTypes.Add(s.AchievementType);
}
await context.SaveChangesAsync();
Console.WriteLine($"[AchievementCatalogImporter] +{created}/~{updated}");
if (unmappedTypes.Count > 0)
{
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");
}
return created + updated;
}
}

View File

@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of BP monthly mission rows from <c>seeds/bp-monthly-missions.json</c>.
/// Keyed by (Year, Month, OrderNum). Rows missing from the seed are LEFT INTACT.
/// </summary>
public class BattlePassMonthlyMissionImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var seed = SeedLoader.LoadList<BattlePassMonthlyMissionSeed>(
Path.Combine(seedDir, "bp-monthly-missions.json"));
if (seed.Count == 0)
{
Console.WriteLine("[BattlePassMonthlyMissionImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.BattlePassMonthlyMissions
.ToDictionaryAsync(e => (e.Year, e.Month, e.OrderNum));
int created = 0, updated = 0;
var unmapped = new List<string>();
foreach (var s in seed)
{
if (s.Year == 0 || s.Month == 0) continue;
var key = (s.Year, s.Month, s.OrderNum);
var entry = existing.TryGetValue(key, out var ex)
? ex
: new BattlePassMonthlyMissionEntry
{
Year = s.Year, Month = s.Month, OrderNum = s.OrderNum,
};
entry.Name = s.Name;
entry.RequireNumber = s.RequireNumber;
entry.BattlePassPoint = s.BattlePassPoint;
entry.RewardType = s.RewardType;
entry.RewardDetailId = s.RewardDetailId;
entry.RewardNumber = s.RewardNumber;
entry.EventType = s.EventType;
entry.EventArg = s.EventArg;
if (ex is null) { context.BattlePassMonthlyMissions.Add(entry); existing[key] = entry; created++; }
else updated++;
if (s.EventType is null) unmapped.Add($"{s.Year}-{s.Month:00}/{s.OrderNum}");
}
await context.SaveChangesAsync();
Console.WriteLine($"[BattlePassMonthlyMissionImporter] +{created}/~{updated}");
if (unmapped.Count > 0)
{
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");
}
return created + updated;
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
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.
/// </summary>
public class ItemImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
string path = Path.Combine(seedDir, "items.json");
var seed = SeedLoader.LoadList<ItemSeed>(path);
if (seed.Count == 0)
{
Console.WriteLine("[ItemImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.Items.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.ItemId == 0) continue;
var entry = existing.TryGetValue(s.ItemId, out var ex)
? ex : new ItemEntry { Id = s.ItemId };
entry.Name = s.Name;
entry.Type = s.Type;
entry.ThumbnailPath = s.ThumbnailPath;
if (ex is null)
{
context.Items.Add(entry);
existing[s.ItemId] = entry;
created++;
}
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[ItemImporter] +{created}/~{updated}");
return created + updated;
}
}

View File

@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
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.
/// </summary>
public class ItemPurchaseImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
string path = Path.Combine(seedDir, "item-purchase.json");
var seed = SeedLoader.LoadList<ItemPurchaseSeed>(path);
if (seed.Count == 0)
{
Console.WriteLine("[ItemPurchaseImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.ItemPurchaseCatalog.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.PurchaseId == 0) continue;
var entry = existing.TryGetValue(s.PurchaseId, out var ex)
? ex : new ItemPurchaseCatalogEntry { Id = s.PurchaseId };
entry.RequireItemType = s.RequireItemType;
entry.RequireItemId = s.RequireItemId;
entry.RequireItemNum = s.RequireItemNum;
entry.PurchaseItemType = s.PurchaseItemType;
entry.PurchaseItemId = s.PurchaseItemId;
entry.PurchaseItemNum = s.PurchaseItemNum;
entry.PurchaseName = s.PurchaseName;
entry.IsMonthlyReset = s.IsMonthlyReset;
entry.PurchaseLimit = s.PurchaseLimit;
entry.IsEnabled = true;
if (ex is null)
{
context.ItemPurchaseCatalog.Add(entry);
existing[s.PurchaseId] = entry;
created++;
}
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[ItemPurchaseImporter] +{created}/~{updated}");
return created + updated;
}
}

View File

@@ -0,0 +1,115 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// 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.
/// </summary>
public class LeaderSkinShopImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
string path = Path.Combine(seedDir, "leader-skin-shop.json");
var seed = SeedLoader.LoadList<LeaderSkinShopSeriesSeed>(path);
if (seed.Count == 0)
{
Console.WriteLine("[LeaderSkinShopImporter] No seed rows; skipping.");
return 0;
}
var existingSeries = await context.LeaderSkinShopSeries
.Include(s => s.SetCompletionRewards)
.Include(s => s.Products).ThenInclude(p => p.Rewards)
.ToDictionaryAsync(s => s.Id);
int createdSeries = 0, updatedSeries = 0, createdProducts = 0, updatedProducts = 0;
foreach (var s in seed)
{
if (s.SeriesId == 0) continue;
if (!existingSeries.TryGetValue(s.SeriesId, out var series))
{
series = new LeaderSkinShopSeriesEntry { Id = s.SeriesId };
context.LeaderSkinShopSeries.Add(series);
existingSeries[s.SeriesId] = series;
createdSeries++;
}
else updatedSeries++;
series.IsNew = s.IsNew;
series.IsEnabled = true;
series.SetSalesStatus = s.SetSalesStatus;
series.SetPriceCrystal = s.SetPriceCrystal;
series.SetPriceRupy = s.SetPriceRupy;
series.SetPriceTicket = s.SetPriceTicket;
series.SetPriceTicketId = s.SetPriceTicketId;
// SetCompletionRewardStatus stays at the catalog default 0 — per-viewer claim state
// is computed at request time from ViewerLeaderSkinSetClaim, not from this column.
series.SetCompletionRewardStatus = 0;
// Replace owned collections wholesale on rerun.
series.SetCompletionRewards.Clear();
foreach (var r in s.SetCompletionRewards.OrderBy(r => r.OrderIndex))
{
series.SetCompletionRewards.Add(new LeaderSkinShopSeriesRewardEntry
{
OrderIndex = r.OrderIndex,
RewardType = r.RewardType,
RewardDetailId = r.RewardDetailId,
RewardNumber = r.RewardNumber,
});
}
var existingProducts = series.Products.ToDictionary(p => p.Id);
foreach (var p in s.Products)
{
if (p.ProductId == 0) continue;
if (!existingProducts.TryGetValue(p.ProductId, out var product))
{
product = new LeaderSkinShopProductEntry { Id = p.ProductId };
series.Products.Add(product);
createdProducts++;
}
else updatedProducts++;
product.SeriesId = s.SeriesId;
product.LeaderSkinId = p.LeaderSkinId;
product.ProductNameKey = p.ProductNameKey;
product.IntroductionKey = p.IntroductionKey;
product.CvNameKey = p.CvNameKey;
product.SinglePriceCrystal = p.SinglePriceCrystal;
product.SinglePriceRupy = p.SinglePriceRupy;
product.SinglePriceTicket = p.SinglePriceTicket;
product.TicketNumber = p.TicketNumber;
product.TicketItemId = p.TicketItemId;
product.IsEnabled = true;
product.Rewards.Clear();
foreach (var r in p.Rewards.OrderBy(r => r.OrderIndex))
{
product.Rewards.Add(new LeaderSkinShopProductRewardEntry
{
OrderIndex = r.OrderIndex,
RewardType = r.RewardType,
RewardDetailId = r.RewardDetailId,
RewardNumber = r.RewardNumber,
});
}
}
}
await context.SaveChangesAsync();
Console.WriteLine(
$"[LeaderSkinShopImporter] series +{createdSeries}/~{updatedSeries}, " +
$"products +{createdProducts}/~{updatedProducts}");
return createdSeries + updatedSeries;
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of mission catalog rows from <c>seeds/mission-catalog.json</c>.
/// Rows missing from the seed are LEFT INTACT (so hand-added catalog rows survive).
/// </summary>
public class MissionCatalogImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var seed = SeedLoader.LoadList<MissionCatalogSeed>(Path.Combine(seedDir, "mission-catalog.json"));
if (seed.Count == 0)
{
Console.WriteLine("[MissionCatalogImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.MissionCatalog.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
var unmapped = new List<int>();
foreach (var s in seed)
{
if (s.Id == 0) continue;
var entry = existing.TryGetValue(s.Id, out var ex) ? ex : new MissionCatalogEntry { Id = s.Id };
entry.Name = s.Name;
entry.LotType = s.LotType;
entry.RequireNumber = s.RequireNumber;
entry.RewardType = s.RewardType;
entry.RewardDetailId = s.RewardDetailId;
entry.RewardNumber = s.RewardNumber;
entry.BattlePassPoint = s.BattlePassPoint;
entry.DefaultFlag = s.DefaultFlag;
entry.EventType = s.EventType;
entry.EventArg = s.EventArg;
entry.StartTime = s.StartTime;
entry.EndTime = s.EndTime;
if (ex is null) { context.MissionCatalog.Add(entry); existing[s.Id] = entry; created++; }
else updated++;
if (s.EventType is null) unmapped.Add(s.Id);
}
await context.SaveChangesAsync();
Console.WriteLine($"[MissionCatalogImporter] +{created}/~{updated}");
if (unmapped.Count > 0)
{
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");
}
return created + updated;
}
}

View File

@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
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.
/// Rows missing from the seed are LEFT INTACT (so manual test fixtures survive re-runs).
/// </summary>
public class SleeveShopImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
string path = Path.Combine(seedDir, "sleeve-shop.json");
var seed = SeedLoader.LoadList<SleeveShopSeriesSeed>(path);
if (seed.Count == 0)
{
Console.WriteLine("[SleeveShopImporter] No seed rows; skipping.");
return 0;
}
var existingSeries = await context.SleeveShopSeries
.Include(s => s.Products).ThenInclude(p => p.Rewards)
.ToDictionaryAsync(s => s.Id);
int createdSeries = 0, updatedSeries = 0, createdProducts = 0, updatedProducts = 0;
foreach (var s in seed)
{
if (s.SeriesId == 0) continue;
if (!existingSeries.TryGetValue(s.SeriesId, out var series))
{
series = new SleeveShopSeriesEntry { Id = s.SeriesId };
context.SleeveShopSeries.Add(series);
existingSeries[s.SeriesId] = series;
createdSeries++;
}
else updatedSeries++;
series.IsNew = s.IsNew;
series.IsEnabled = true;
var existingProducts = series.Products.ToDictionary(p => p.Id);
foreach (var p in s.Products)
{
if (p.ProductId == 0) continue;
if (!existingProducts.TryGetValue(p.ProductId, out var product))
{
product = new SleeveShopProductEntry { Id = p.ProductId };
series.Products.Add(product);
createdProducts++;
}
else updatedProducts++;
product.SeriesId = s.SeriesId;
product.NameKey = p.NameKey;
product.PriceCrystal = p.PriceCrystal;
product.PriceRupy = p.PriceRupy;
product.IsEnabled = true;
// Rewards: replace wholesale (owned collection — EF will issue DELETE+INSERT
// anyway, and the wire shape is canonical per re-extract).
product.Rewards.Clear();
foreach (var r in p.Rewards.OrderBy(r => r.OrderIndex))
{
product.Rewards.Add(new SleeveShopProductRewardEntry
{
OrderIndex = r.OrderIndex,
RewardType = r.RewardType,
RewardDetailId = r.RewardDetailId,
RewardNumber = r.RewardNumber,
});
}
}
}
await context.SaveChangesAsync();
Console.WriteLine(
$"[SleeveShopImporter] series +{createdSeries}/~{updatedSeries}, " +
$"products +{createdProducts}/~{updatedProducts}");
return createdSeries + updatedSeries;
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
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
/// LEFT INTACT.
/// </summary>
public class SpotCardExchangeImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
string path = Path.Combine(seedDir, "spot-card-exchange.json");
var seed = SeedLoader.LoadList<SpotCardExchangeSeed>(path);
if (seed.Count == 0)
{
Console.WriteLine("[SpotCardExchangeImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.SpotCardExchangeCatalog.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.CardId == 0) continue;
var entry = existing.TryGetValue(s.CardId, out var ex)
? ex : new SpotCardExchangeEntry { Id = s.CardId };
entry.ClassId = s.ClassId;
entry.ExchangePoint = s.ExchangePoint;
entry.TsRotationId = s.TsRotationId;
entry.IsPreRelease = s.IsPreRelease;
entry.IsEnabled = true;
if (ex is null)
{
context.SpotCardExchangeCatalog.Add(entry);
existing[s.CardId] = entry;
created++;
}
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[SpotCardExchangeImporter] +{created}/~{updated}");
return created + updated;
}
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class AchievementCatalogSeed
{
[JsonPropertyName("achievement_type")] public int AchievementType { get; set; }
[JsonPropertyName("level")] public int Level { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("require_number")] public int RequireNumber { get; set; }
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
[JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
[JsonPropertyName("reward_number")] public int RewardNumber { get; set; }
[JsonPropertyName("order_num")] public int OrderNum { get; set; }
[JsonPropertyName("event_type")] public string? EventType { get; set; }
[JsonPropertyName("event_arg")] public int? EventArg { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class BattlePassMonthlyMissionSeed
{
[JsonPropertyName("year")] public int Year { get; set; }
[JsonPropertyName("month")] public int Month { get; set; }
[JsonPropertyName("order_num")] public int OrderNum { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("require_number")] public int RequireNumber { get; set; }
[JsonPropertyName("battle_pass_point")] public int BattlePassPoint { get; set; }
[JsonPropertyName("reward_type")] public int? RewardType { get; set; }
[JsonPropertyName("reward_detail_id")] public long? RewardDetailId { get; set; }
[JsonPropertyName("reward_number")] public int? RewardNumber { get; set; }
[JsonPropertyName("event_type")] public string? EventType { get; set; }
[JsonPropertyName("event_arg")] public int? EventArg { get; set; }
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class ItemPurchaseSeed
{
[JsonPropertyName("purchase_id")] public int PurchaseId { get; set; }
[JsonPropertyName("require_item_type")] public int RequireItemType { get; set; }
[JsonPropertyName("require_item_id")] public long RequireItemId { get; set; }
[JsonPropertyName("require_item_num")] public int RequireItemNum { get; set; }
[JsonPropertyName("purchase_item_type")] public int PurchaseItemType { get; set; }
[JsonPropertyName("purchase_item_id")] public long PurchaseItemId { get; set; }
[JsonPropertyName("purchase_item_num")] public int PurchaseItemNum { get; set; }
[JsonPropertyName("purchase_name")] public string PurchaseName { get; set; } = "";
[JsonPropertyName("is_monthly_reset")] public bool IsMonthlyReset { get; set; }
[JsonPropertyName("purchase_limit")] public int PurchaseLimit { get; set; }
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class ItemSeed
{
[JsonPropertyName("item_id")] public int ItemId { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("type")] public int Type { get; set; }
[JsonPropertyName("thumbnail_path")] public string ThumbnailPath { get; set; } = "";
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class LeaderSkinShopSeriesSeed
{
[JsonPropertyName("series_id")] public int SeriesId { get; set; }
[JsonPropertyName("is_new")] public bool IsNew { get; set; }
[JsonPropertyName("set_sales_status")] public int SetSalesStatus { get; set; }
[JsonPropertyName("set_price_crystal")] public int? SetPriceCrystal { get; set; }
[JsonPropertyName("set_price_rupy")] public int? SetPriceRupy { get; set; }
[JsonPropertyName("set_price_ticket")] public int? SetPriceTicket { get; set; }
[JsonPropertyName("set_price_ticket_id")] public long? SetPriceTicketId { get; set; }
[JsonPropertyName("set_completion_rewards")] public List<LeaderSkinShopRewardSeed> SetCompletionRewards { get; set; } = new();
[JsonPropertyName("products")] public List<LeaderSkinShopProductSeed> Products { get; set; } = new();
}
public sealed class LeaderSkinShopProductSeed
{
[JsonPropertyName("product_id")] public int ProductId { get; set; }
[JsonPropertyName("leader_skin_id")] public int LeaderSkinId { get; set; }
[JsonPropertyName("product_name_key")] public string ProductNameKey { get; set; } = "";
[JsonPropertyName("introduction_key")] public string IntroductionKey { get; set; } = "";
[JsonPropertyName("cv_name_key")] public string CvNameKey { get; set; } = "";
[JsonPropertyName("single_price_crystal")] public int? SinglePriceCrystal { get; set; }
[JsonPropertyName("single_price_rupy")] public int? SinglePriceRupy { get; set; }
[JsonPropertyName("single_price_ticket")] public int? SinglePriceTicket { get; set; }
[JsonPropertyName("ticket_number")] public int? TicketNumber { get; set; }
[JsonPropertyName("ticket_item_id")] public long? TicketItemId { get; set; }
[JsonPropertyName("rewards")] public List<LeaderSkinShopRewardSeed> Rewards { get; set; } = new();
}
public sealed class LeaderSkinShopRewardSeed
{
[JsonPropertyName("order_index")] public int OrderIndex { get; set; }
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
[JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
[JsonPropertyName("reward_number")] public int RewardNumber { get; set; }
}

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
/// <summary>Mirrors a single entry in <c>seeds/mission-catalog.json</c>.</summary>
public sealed class MissionCatalogSeed
{
[JsonPropertyName("id")] public int Id { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("lot_type")] public int LotType { get; set; }
[JsonPropertyName("require_number")] public int RequireNumber { get; set; }
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
[JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
[JsonPropertyName("reward_number")] public int RewardNumber { get; set; }
[JsonPropertyName("battle_pass_point")] public int BattlePassPoint { get; set; }
[JsonPropertyName("default_flag")] public bool DefaultFlag { get; set; }
[JsonPropertyName("event_type")] public string? EventType { get; set; }
[JsonPropertyName("event_arg")] public int? EventArg { get; set; }
[JsonPropertyName("start_time")] public long StartTime { get; set; }
[JsonPropertyName("end_time")] public long? EndTime { get; set; }
}

View File

@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class SleeveShopSeriesSeed
{
[JsonPropertyName("series_id")] public int SeriesId { get; set; }
[JsonPropertyName("is_new")] public bool IsNew { get; set; }
[JsonPropertyName("products")] public List<SleeveShopProductSeed> Products { get; set; } = new();
}
public sealed class SleeveShopProductSeed
{
[JsonPropertyName("product_id")] public int ProductId { get; set; }
[JsonPropertyName("name_key")] public string NameKey { get; set; } = "";
[JsonPropertyName("price_crystal")] public int? PriceCrystal { get; set; }
[JsonPropertyName("price_rupy")] public int? PriceRupy { get; set; }
[JsonPropertyName("rewards")] public List<SleeveShopRewardSeed> Rewards { get; set; } = new();
}
public sealed class SleeveShopRewardSeed
{
[JsonPropertyName("order_index")] public int OrderIndex { get; set; }
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
[JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
[JsonPropertyName("reward_number")] public int RewardNumber { get; set; }
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class SpotCardExchangeSeed
{
[JsonPropertyName("card_id")] public long CardId { get; set; }
[JsonPropertyName("class")] public int ClassId { get; set; }
[JsonPropertyName("exchange_point")] public int ExchangePoint { get; set; }
[JsonPropertyName("ts_rotation_id")] public long TsRotationId { get; set; }
[JsonPropertyName("is_pre_release")] public bool IsPreRelease { get; set; }
}

View File

@@ -87,6 +87,9 @@ public static class Program
await new BattlePassImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassSeasonImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassRewardImporter().ImportAsync(context, opts.SeedDir);
await new MissionCatalogImporter().ImportAsync(context, opts.SeedDir);
await new AchievementCatalogImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassMonthlyMissionImporter().ImportAsync(context, opts.SeedDir);
await new DailyLoginBonusImporter().ImportAsync(context, opts.SeedDir);
await new PreReleaseInfoImporter().ImportAsync(context, opts.SeedDir);
await new CardListsImporter().ImportAsync(context, opts.SeedDir);
@@ -94,6 +97,11 @@ public static class Program
await new PracticeOpponentImporter().ImportAsync(context, opts.SeedDir);
await new PaymentItemImporter().ImportAsync(context, opts.SeedDir);
await new ItemImporter().ImportAsync(context, opts.SeedDir);
await new SleeveShopImporter().ImportAsync(context, opts.SeedDir);
await new ItemPurchaseImporter().ImportAsync(context, opts.SeedDir);
await new LeaderSkinShopImporter().ImportAsync(context, opts.SeedDir);
await new SpotCardExchangeImporter().ImportAsync(context, opts.SeedDir);
var puzzleImporter = new PuzzleImporter();
await puzzleImporter.ImportGroupsAsync(context, opts.SeedDir);
await puzzleImporter.ImportPuzzlesAsync(context, opts.SeedDir);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,223 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddMissionsAndAchievements : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AchievementCatalog",
columns: table => new
{
AchievementType = table.Column<int>(type: "integer", nullable: false),
Level = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
RequireNumber = table.Column<int>(type: "integer", nullable: false),
RewardType = table.Column<int>(type: "integer", nullable: false),
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
RewardNumber = table.Column<int>(type: "integer", nullable: false),
OrderNum = table.Column<int>(type: "integer", nullable: false),
EventType = table.Column<string>(type: "text", nullable: true),
EventArg = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AchievementCatalog", x => new { x.AchievementType, x.Level });
});
migrationBuilder.CreateTable(
name: "BattlePassMonthlyMissions",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Year = table.Column<int>(type: "integer", nullable: false),
Month = table.Column<int>(type: "integer", nullable: false),
OrderNum = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
RequireNumber = table.Column<int>(type: "integer", nullable: false),
BattlePassPoint = table.Column<int>(type: "integer", nullable: false),
RewardType = table.Column<int>(type: "integer", nullable: true),
RewardDetailId = table.Column<long>(type: "bigint", nullable: true),
RewardNumber = table.Column<int>(type: "integer", nullable: true),
EventType = table.Column<string>(type: "text", nullable: true),
EventArg = 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_BattlePassMonthlyMissions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "MissionCatalog",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
LotType = table.Column<int>(type: "integer", nullable: false),
RequireNumber = table.Column<int>(type: "integer", nullable: false),
RewardType = table.Column<int>(type: "integer", nullable: false),
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
RewardNumber = table.Column<int>(type: "integer", nullable: false),
BattlePassPoint = table.Column<int>(type: "integer", nullable: false),
DefaultFlag = table.Column<bool>(type: "boolean", nullable: false),
EventType = table.Column<string>(type: "text", nullable: true),
EventArg = table.Column<int>(type: "integer", nullable: true),
StartTime = table.Column<long>(type: "bigint", nullable: false),
EndTime = table.Column<long>(type: "bigint", 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_MissionCatalog", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ViewerAchievements",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
AchievementType = table.Column<int>(type: "integer", nullable: false),
Level = table.Column<int>(type: "integer", nullable: false),
AchievementStatus = table.Column<int>(type: "integer", nullable: false),
NowAchievedLevel = table.Column<int>(type: "integer", nullable: false),
ResultAnnounceSawLevel = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerAchievements", x => new { x.ViewerId, x.AchievementType });
table.ForeignKey(
name: "FK_ViewerAchievements_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ViewerEventCounters",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
EventKey = table.Column<string>(type: "text", nullable: false),
Period = table.Column<string>(type: "text", nullable: false),
Count = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerEventCounters", x => new { x.ViewerId, x.EventKey, x.Period });
table.ForeignKey(
name: "FK_ViewerEventCounters_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ViewerMissions",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ViewerId = table.Column<long>(type: "bigint", nullable: false),
MissionCatalogId = table.Column<int>(type: "integer", nullable: false),
Slot = table.Column<int>(type: "integer", nullable: false),
AssignedAt = table.Column<long>(type: "bigint", nullable: false),
ClaimedAt = table.Column<long>(type: "bigint", nullable: true),
MissionStatus = table.Column<int>(type: "integer", 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_ViewerMissions", x => x.Id);
table.ForeignKey(
name: "FK_ViewerMissions_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AchievementCatalog_AchievementType",
table: "AchievementCatalog",
column: "AchievementType");
migrationBuilder.CreateIndex(
name: "IX_AchievementCatalog_EventType_EventArg",
table: "AchievementCatalog",
columns: new[] { "EventType", "EventArg" });
migrationBuilder.CreateIndex(
name: "IX_BattlePassMonthlyMissions_Year_Month",
table: "BattlePassMonthlyMissions",
columns: new[] { "Year", "Month" });
migrationBuilder.CreateIndex(
name: "IX_BattlePassMonthlyMissions_Year_Month_OrderNum",
table: "BattlePassMonthlyMissions",
columns: new[] { "Year", "Month", "OrderNum" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MissionCatalog_EventType_EventArg",
table: "MissionCatalog",
columns: new[] { "EventType", "EventArg" });
migrationBuilder.CreateIndex(
name: "IX_MissionCatalog_LotType",
table: "MissionCatalog",
column: "LotType");
migrationBuilder.CreateIndex(
name: "IX_ViewerEventCounters_ViewerId_Period",
table: "ViewerEventCounters",
columns: new[] { "ViewerId", "Period" });
migrationBuilder.CreateIndex(
name: "IX_ViewerMissions_ViewerId",
table: "ViewerMissions",
column: "ViewerId");
migrationBuilder.CreateIndex(
name: "IX_ViewerMissions_ViewerId_Slot",
table: "ViewerMissions",
columns: new[] { "ViewerId", "Slot" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AchievementCatalog");
migrationBuilder.DropTable(
name: "BattlePassMonthlyMissions");
migrationBuilder.DropTable(
name: "MissionCatalog");
migrationBuilder.DropTable(
name: "ViewerAchievements");
migrationBuilder.DropTable(
name: "ViewerEventCounters");
migrationBuilder.DropTable(
name: "ViewerMissions");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddViewerUdid : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "Udid",
table: "Viewers",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Viewers_Udid",
table: "Viewers",
column: "Udid",
unique: true,
filter: "\"Udid\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Viewers_Udid",
table: "Viewers");
migrationBuilder.DropColumn(
name: "Udid",
table: "Viewers");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddSocialAccountConnectionUniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_SocialAccountConnection_AccountType_AccountId",
table: "SocialAccountConnection",
columns: new[] { "AccountType", "AccountId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_SocialAccountConnection_AccountType_AccountId",
table: "SocialAccountConnection");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddItemTypeAndThumbnail : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ThumbnailPath",
table: "Items",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<int>(
name: "Type",
table: "Items",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ThumbnailPath",
table: "Items");
migrationBuilder.DropColumn(
name: "Type",
table: "Items");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddSleeveShop : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SleeveShopSeries",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
IsNew = table.Column<bool>(type: "boolean", nullable: false),
IsEnabled = 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_SleeveShopSeries", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SleeveShopProducts",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
SeriesId = table.Column<int>(type: "integer", nullable: false),
NameKey = table.Column<string>(type: "text", nullable: false),
PriceCrystal = table.Column<int>(type: "integer", nullable: true),
PriceRupy = table.Column<int>(type: "integer", nullable: true),
IsEnabled = 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_SleeveShopProducts", x => x.Id);
table.ForeignKey(
name: "FK_SleeveShopProducts_SleeveShopSeries_SeriesId",
column: x => x.SeriesId,
principalTable: "SleeveShopSeries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SleeveShopProductRewardEntry",
columns: table => new
{
SleeveShopProductEntryId = table.Column<int>(type: "integer", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
OrderIndex = table.Column<int>(type: "integer", nullable: false),
RewardType = table.Column<int>(type: "integer", nullable: false),
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
RewardNumber = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SleeveShopProductRewardEntry", x => new { x.SleeveShopProductEntryId, x.Id });
table.ForeignKey(
name: "FK_SleeveShopProductRewardEntry_SleeveShopProducts_SleeveShopP~",
column: x => x.SleeveShopProductEntryId,
principalTable: "SleeveShopProducts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_SleeveShopProducts_SeriesId",
table: "SleeveShopProducts",
column: "SeriesId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SleeveShopProductRewardEntry");
migrationBuilder.DropTable(
name: "SleeveShopProducts");
migrationBuilder.DropTable(
name: "SleeveShopSeries");
}
}
}

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 AddItemPurchaseCatalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ItemPurchaseCatalog",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
RequireItemType = table.Column<int>(type: "integer", nullable: false),
RequireItemId = table.Column<long>(type: "bigint", nullable: false),
RequireItemNum = table.Column<int>(type: "integer", nullable: false),
PurchaseItemType = table.Column<int>(type: "integer", nullable: false),
PurchaseItemId = table.Column<long>(type: "bigint", nullable: false),
PurchaseItemNum = table.Column<int>(type: "integer", nullable: false),
PurchaseName = table.Column<string>(type: "text", nullable: false),
IsMonthlyReset = table.Column<bool>(type: "boolean", nullable: false),
PurchaseLimit = table.Column<int>(type: "integer", nullable: false),
IsEnabled = 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_ItemPurchaseCatalog", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ItemPurchaseCatalog");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddLeaderSkinShop : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LeaderSkinShopSeries",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
IsNew = table.Column<bool>(type: "boolean", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
SetSalesStatus = table.Column<int>(type: "integer", nullable: false),
SetPriceCrystal = table.Column<int>(type: "integer", nullable: true),
SetPriceRupy = table.Column<int>(type: "integer", nullable: true),
SetPriceTicket = table.Column<int>(type: "integer", nullable: true),
SetPriceTicketId = table.Column<long>(type: "bigint", nullable: true),
SetCompletionRewardStatus = table.Column<int>(type: "integer", 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_LeaderSkinShopSeries", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ViewerLeaderSkinSetClaims",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
SeriesId = table.Column<int>(type: "integer", nullable: false),
ClaimedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerLeaderSkinSetClaims", x => new { x.ViewerId, x.SeriesId });
});
migrationBuilder.CreateTable(
name: "LeaderSkinShopProducts",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
SeriesId = table.Column<int>(type: "integer", nullable: false),
LeaderSkinId = table.Column<int>(type: "integer", nullable: false),
ProductNameKey = table.Column<string>(type: "text", nullable: false),
IntroductionKey = table.Column<string>(type: "text", nullable: false),
CvNameKey = table.Column<string>(type: "text", nullable: false),
SinglePriceCrystal = table.Column<int>(type: "integer", nullable: true),
SinglePriceRupy = table.Column<int>(type: "integer", nullable: true),
SinglePriceTicket = table.Column<int>(type: "integer", nullable: true),
TicketNumber = table.Column<int>(type: "integer", nullable: true),
TicketItemId = table.Column<long>(type: "bigint", nullable: true),
IsEnabled = 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_LeaderSkinShopProducts", x => x.Id);
table.ForeignKey(
name: "FK_LeaderSkinShopProducts_LeaderSkinShopSeries_SeriesId",
column: x => x.SeriesId,
principalTable: "LeaderSkinShopSeries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LeaderSkinShopSeriesRewardEntry",
columns: table => new
{
LeaderSkinShopSeriesEntryId = table.Column<int>(type: "integer", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
OrderIndex = table.Column<int>(type: "integer", nullable: false),
RewardType = table.Column<int>(type: "integer", nullable: false),
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
RewardNumber = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LeaderSkinShopSeriesRewardEntry", x => new { x.LeaderSkinShopSeriesEntryId, x.Id });
table.ForeignKey(
name: "FK_LeaderSkinShopSeriesRewardEntry_LeaderSkinShopSeries_Leader~",
column: x => x.LeaderSkinShopSeriesEntryId,
principalTable: "LeaderSkinShopSeries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LeaderSkinShopProductRewardEntry",
columns: table => new
{
LeaderSkinShopProductEntryId = table.Column<int>(type: "integer", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
OrderIndex = table.Column<int>(type: "integer", nullable: false),
RewardType = table.Column<int>(type: "integer", nullable: false),
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
RewardNumber = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LeaderSkinShopProductRewardEntry", x => new { x.LeaderSkinShopProductEntryId, x.Id });
table.ForeignKey(
name: "FK_LeaderSkinShopProductRewardEntry_LeaderSkinShopProducts_Lea~",
column: x => x.LeaderSkinShopProductEntryId,
principalTable: "LeaderSkinShopProducts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_LeaderSkinShopProducts_SeriesId",
table: "LeaderSkinShopProducts",
column: "SeriesId");
migrationBuilder.CreateIndex(
name: "IX_ViewerLeaderSkinSetClaims_ViewerId",
table: "ViewerLeaderSkinSetClaims",
column: "ViewerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LeaderSkinShopProductRewardEntry");
migrationBuilder.DropTable(
name: "LeaderSkinShopSeriesRewardEntry");
migrationBuilder.DropTable(
name: "ViewerLeaderSkinSetClaims");
migrationBuilder.DropTable(
name: "LeaderSkinShopProducts");
migrationBuilder.DropTable(
name: "LeaderSkinShopSeries");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddSpotCardExchange : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "Currency_SpotPoints",
table: "Viewers",
type: "numeric(20,0)",
nullable: false,
defaultValue: 0m);
migrationBuilder.CreateTable(
name: "SpotCardExchangeCatalog",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false),
CardId = table.Column<long>(type: "bigint", nullable: false),
ClassId = table.Column<int>(type: "integer", nullable: false),
ExchangePoint = table.Column<int>(type: "integer", nullable: false),
TsRotationId = table.Column<long>(type: "bigint", nullable: false),
IsPreRelease = table.Column<bool>(type: "boolean", nullable: false),
IsEnabled = 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_SpotCardExchangeCatalog", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ViewerSpotCardExchanges",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
CardId = table.Column<long>(type: "bigint", nullable: false),
IsPreRelease = table.Column<bool>(type: "boolean", nullable: false),
ExchangedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerSpotCardExchanges", x => new { x.ViewerId, x.CardId });
});
migrationBuilder.CreateIndex(
name: "IX_ViewerSpotCardExchanges_ViewerId",
table: "ViewerSpotCardExchanges",
column: "ViewerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SpotCardExchangeCatalog");
migrationBuilder.DropTable(
name: "ViewerSpotCardExchanges");
migrationBuilder.DropColumn(
name: "Currency_SpotPoints",
table: "Viewers");
}
}
}

View File

@@ -370,6 +370,48 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerStoryProgress");
});
modelBuilder.Entity("SVSim.Database.Models.AchievementCatalogEntry", b =>
{
b.Property<int>("AchievementType")
.HasColumnType("integer");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<int?>("EventArg")
.HasColumnType("integer");
b.Property<string>("EventType")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("OrderNum")
.HasColumnType("integer");
b.Property<int>("RequireNumber")
.HasColumnType("integer");
b.Property<long>("RewardDetailId")
.HasColumnType("bigint");
b.Property<int>("RewardNumber")
.HasColumnType("integer");
b.Property<int>("RewardType")
.HasColumnType("integer");
b.HasKey("AchievementType", "Level");
b.HasIndex("AchievementType");
b.HasIndex("EventType", "EventArg");
b.ToTable("AchievementCatalog");
});
modelBuilder.Entity("SVSim.Database.Models.ArenaSeasonConfig", b =>
{
b.Property<int>("Id")
@@ -516,6 +558,64 @@ namespace SVSim.Database.Migrations
b.ToTable("BattlePassLevels");
});
modelBuilder.Entity("SVSim.Database.Models.BattlePassMonthlyMissionEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("BattlePassPoint")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EventArg")
.HasColumnType("integer");
b.Property<string>("EventType")
.HasColumnType("text");
b.Property<int>("Month")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("OrderNum")
.HasColumnType("integer");
b.Property<int>("RequireNumber")
.HasColumnType("integer");
b.Property<long?>("RewardDetailId")
.HasColumnType("bigint");
b.Property<int?>("RewardNumber")
.HasColumnType("integer");
b.Property<int?>("RewardType")
.HasColumnType("integer");
b.Property<int>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Year", "Month");
b.HasIndex("Year", "Month", "OrderNum")
.IsUnique();
b.ToTable("BattlePassMonthlyMissions");
});
modelBuilder.Entity("SVSim.Database.Models.BattlePassRewardEntry", b =>
{
b.Property<long>("Id")
@@ -995,11 +1095,65 @@ namespace SVSim.Database.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Items");
});
modelBuilder.Entity("SVSim.Database.Models.ItemPurchaseCatalogEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsMonthlyReset")
.HasColumnType("boolean");
b.Property<long>("PurchaseItemId")
.HasColumnType("bigint");
b.Property<int>("PurchaseItemNum")
.HasColumnType("integer");
b.Property<int>("PurchaseItemType")
.HasColumnType("integer");
b.Property<int>("PurchaseLimit")
.HasColumnType("integer");
b.Property<string>("PurchaseName")
.IsRequired()
.HasColumnType("text");
b.Property<long>("RequireItemId")
.HasColumnType("bigint");
b.Property<int>("RequireItemNum")
.HasColumnType("integer");
b.Property<int>("RequireItemType")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("ItemPurchaseCatalog");
});
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b =>
{
b.Property<int>("Id")
@@ -1028,6 +1182,100 @@ namespace SVSim.Database.Migrations
b.ToTable("LeaderSkins");
});
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinShopProductEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<string>("CvNameKey")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<string>("IntroductionKey")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("LeaderSkinId")
.HasColumnType("integer");
b.Property<string>("ProductNameKey")
.IsRequired()
.HasColumnType("text");
b.Property<int>("SeriesId")
.HasColumnType("integer");
b.Property<int?>("SinglePriceCrystal")
.HasColumnType("integer");
b.Property<int?>("SinglePriceRupy")
.HasColumnType("integer");
b.Property<int?>("SinglePriceTicket")
.HasColumnType("integer");
b.Property<long?>("TicketItemId")
.HasColumnType("bigint");
b.Property<int?>("TicketNumber")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("LeaderSkinShopProducts");
});
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinShopSeriesEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsNew")
.HasColumnType("boolean");
b.Property<int>("SetCompletionRewardStatus")
.HasColumnType("integer");
b.Property<int?>("SetPriceCrystal")
.HasColumnType("integer");
b.Property<int?>("SetPriceRupy")
.HasColumnType("integer");
b.Property<int?>("SetPriceTicket")
.HasColumnType("integer");
b.Property<long?>("SetPriceTicketId")
.HasColumnType("bigint");
b.Property<int>("SetSalesStatus")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("LeaderSkinShopSeries");
});
modelBuilder.Entity("SVSim.Database.Models.LoadingExclusionCardEntry", b =>
{
b.Property<long>("Id")
@@ -1094,6 +1342,63 @@ namespace SVSim.Database.Migrations
b.ToTable("MasterPointRankingPeriods");
});
modelBuilder.Entity("SVSim.Database.Models.MissionCatalogEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<int>("BattlePassPoint")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DefaultFlag")
.HasColumnType("boolean");
b.Property<long?>("EndTime")
.HasColumnType("bigint");
b.Property<int?>("EventArg")
.HasColumnType("integer");
b.Property<string>("EventType")
.HasColumnType("text");
b.Property<int>("LotType")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RequireNumber")
.HasColumnType("integer");
b.Property<long>("RewardDetailId")
.HasColumnType("bigint");
b.Property<int>("RewardNumber")
.HasColumnType("integer");
b.Property<int>("RewardType")
.HasColumnType("integer");
b.Property<long>("StartTime")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("LotType");
b.HasIndex("EventType", "EventArg");
b.ToTable("MissionCatalog");
});
modelBuilder.Entity("SVSim.Database.Models.MyPageBackgroundEntry", b =>
{
b.Property<int>("Id")
@@ -1800,6 +2105,62 @@ namespace SVSim.Database.Migrations
b.ToTable("Sleeves");
});
modelBuilder.Entity("SVSim.Database.Models.SleeveShopProductEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("NameKey")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("PriceCrystal")
.HasColumnType("integer");
b.Property<int?>("PriceRupy")
.HasColumnType("integer");
b.Property<int>("SeriesId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("SleeveShopProducts");
});
modelBuilder.Entity("SVSim.Database.Models.SleeveShopSeriesEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsNew")
.HasColumnType("boolean");
b.HasKey("Id");
b.ToTable("SleeveShopSeries");
});
modelBuilder.Entity("SVSim.Database.Models.SpecialDeckFormatEntry", b =>
{
b.Property<int>("Id")
@@ -1845,6 +2206,40 @@ namespace SVSim.Database.Migrations
b.ToTable("SpotCards");
});
modelBuilder.Entity("SVSim.Database.Models.SpotCardExchangeEntry", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint");
b.Property<long>("CardId")
.HasColumnType("bigint");
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>("ExchangePoint")
.HasColumnType("integer");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsPreRelease")
.HasColumnType("boolean");
b.Property<long>("TsRotationId")
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("SpotCardExchangeCatalog");
});
modelBuilder.Entity("SVSim.Database.Models.UnlimitedRestrictionEntry", b =>
{
b.Property<long>("Id")
@@ -1895,13 +2290,44 @@ namespace SVSim.Database.Migrations
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("ShortUdid"), "ShortUdidSequence");
b.Property<Guid?>("Udid")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ShortUdid");
b.HasIndex("Udid")
.IsUnique();
b.ToTable("Viewers");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerAchievement", b =>
{
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.Property<int>("AchievementType")
.HasColumnType("integer");
b.Property<int>("AchievementStatus")
.HasColumnType("integer");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<int>("NowAchievedLevel")
.HasColumnType("integer");
b.Property<int>("ResultAnnounceSawLevel")
.HasColumnType("integer");
b.HasKey("ViewerId", "AchievementType");
b.ToTable("ViewerAchievements");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassClaimEntry", b =>
{
b.Property<long>("Id")
@@ -1981,6 +2407,87 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerBattlePassProgress");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerEventCounter", b =>
{
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.Property<string>("EventKey")
.HasColumnType("text");
b.Property<string>("Period")
.HasColumnType("text");
b.Property<int>("Count")
.HasColumnType("integer");
b.HasKey("ViewerId", "EventKey", "Period");
b.HasIndex("ViewerId", "Period");
b.ToTable("ViewerEventCounters");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerLeaderSkinSetClaim", b =>
{
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.Property<int>("SeriesId")
.HasColumnType("integer");
b.Property<DateTime>("ClaimedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ViewerId", "SeriesId");
b.HasIndex("ViewerId");
b.ToTable("ViewerLeaderSkinSetClaims");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerMission", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("AssignedAt")
.HasColumnType("bigint");
b.Property<long?>("ClaimedAt")
.HasColumnType("bigint");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<int>("MissionCatalogId")
.HasColumnType("integer");
b.Property<int>("MissionStatus")
.HasColumnType("integer");
b.Property<int>("Slot")
.HasColumnType("integer");
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ViewerId");
b.HasIndex("ViewerId", "Slot")
.IsUnique();
b.ToTable("ViewerMissions");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerPuzzleClear", b =>
{
b.Property<long>("ViewerId")
@@ -2000,6 +2507,27 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerPuzzleClears");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerSpotCardExchange", b =>
{
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.Property<long>("CardId")
.HasColumnType("bigint");
b.Property<DateTime>("ExchangedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsPreRelease")
.HasColumnType("boolean");
b.HasKey("ViewerId", "CardId");
b.HasIndex("ViewerId");
b.ToTable("ViewerSpotCardExchanges");
});
modelBuilder.Entity("SleeveEntryViewer", b =>
{
b.Property<int>("SleevesId")
@@ -2353,6 +2881,86 @@ namespace SVSim.Database.Migrations
b.Navigation("Class");
});
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinShopProductEntry", b =>
{
b.HasOne("SVSim.Database.Models.LeaderSkinShopSeriesEntry", "Series")
.WithMany("Products")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("SVSim.Database.Models.LeaderSkinShopProductRewardEntry", "Rewards", b1 =>
{
b1.Property<int>("LeaderSkinShopProductEntryId")
.HasColumnType("integer");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("OrderIndex")
.HasColumnType("integer");
b1.Property<long>("RewardDetailId")
.HasColumnType("bigint");
b1.Property<int>("RewardNumber")
.HasColumnType("integer");
b1.Property<int>("RewardType")
.HasColumnType("integer");
b1.HasKey("LeaderSkinShopProductEntryId", "Id");
b1.ToTable("LeaderSkinShopProductRewardEntry");
b1.WithOwner()
.HasForeignKey("LeaderSkinShopProductEntryId");
});
b.Navigation("Rewards");
b.Navigation("Series");
});
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinShopSeriesEntry", b =>
{
b.OwnsMany("SVSim.Database.Models.LeaderSkinShopSeriesRewardEntry", "SetCompletionRewards", b1 =>
{
b1.Property<int>("LeaderSkinShopSeriesEntryId")
.HasColumnType("integer");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("OrderIndex")
.HasColumnType("integer");
b1.Property<long>("RewardDetailId")
.HasColumnType("bigint");
b1.Property<int>("RewardNumber")
.HasColumnType("integer");
b1.Property<int>("RewardType")
.HasColumnType("integer");
b1.HasKey("LeaderSkinShopSeriesEntryId", "Id");
b1.ToTable("LeaderSkinShopSeriesRewardEntry");
b1.WithOwner()
.HasForeignKey("LeaderSkinShopSeriesEntryId");
});
b.Navigation("SetCompletionRewards");
});
modelBuilder.Entity("SVSim.Database.Models.PackConfigEntry", b =>
{
b.OwnsMany("SVSim.Database.Models.PackBannerEntry", "Banners", b1 =>
@@ -2570,6 +3178,50 @@ namespace SVSim.Database.Migrations
b.Navigation("Sleeve");
});
modelBuilder.Entity("SVSim.Database.Models.SleeveShopProductEntry", b =>
{
b.HasOne("SVSim.Database.Models.SleeveShopSeriesEntry", "Series")
.WithMany("Products")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("SVSim.Database.Models.SleeveShopProductRewardEntry", "Rewards", b1 =>
{
b1.Property<int>("SleeveShopProductEntryId")
.HasColumnType("integer");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("OrderIndex")
.HasColumnType("integer");
b1.Property<long>("RewardDetailId")
.HasColumnType("bigint");
b1.Property<int>("RewardNumber")
.HasColumnType("integer");
b1.Property<int>("RewardType")
.HasColumnType("integer");
b1.HasKey("SleeveShopProductEntryId", "Id");
b1.ToTable("SleeveShopProductRewardEntry");
b1.WithOwner()
.HasForeignKey("SleeveShopProductEntryId");
});
b.Navigation("Rewards");
b.Navigation("Series");
});
modelBuilder.Entity("SVSim.Database.Models.Viewer", b =>
{
b.OwnsMany("SVSim.Database.Models.OwnedCardEntry", "Cards", b1 =>
@@ -2672,6 +3324,9 @@ namespace SVSim.Database.Migrations
b1.HasKey("ViewerId", "Id");
b1.HasIndex("AccountType", "AccountId")
.IsUnique();
b1.ToTable("SocialAccountConnection");
b1.WithOwner("Viewer")
@@ -2790,6 +3445,9 @@ namespace SVSim.Database.Migrations
b1.Property<decimal>("Rupees")
.HasColumnType("numeric(20,0)");
b1.Property<decimal>("SpotPoints")
.HasColumnType("numeric(20,0)");
b1.Property<decimal>("SteamCrystals")
.HasColumnType("numeric(20,0)");
@@ -2931,6 +3589,33 @@ namespace SVSim.Database.Migrations
b.Navigation("SocialAccountConnections");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerAchievement", b =>
{
b.HasOne("SVSim.Database.Models.Viewer", null)
.WithMany("Achievements")
.HasForeignKey("ViewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("SVSim.Database.Models.ViewerEventCounter", b =>
{
b.HasOne("SVSim.Database.Models.Viewer", null)
.WithMany("EventCounters")
.HasForeignKey("ViewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("SVSim.Database.Models.ViewerMission", b =>
{
b.HasOne("SVSim.Database.Models.Viewer", null)
.WithMany("Missions")
.HasForeignKey("ViewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("SleeveEntryViewer", b =>
{
b.HasOne("SVSim.Database.Models.SleeveEntry", null)
@@ -2961,6 +3646,11 @@ namespace SVSim.Database.Migrations
b.Navigation("LeaderSkins");
});
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinShopSeriesEntry", b =>
{
b.Navigation("Products");
});
modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b =>
{
b.Navigation("Puzzles");
@@ -2971,9 +3661,20 @@ namespace SVSim.Database.Migrations
b.Navigation("Cards");
});
modelBuilder.Entity("SVSim.Database.Models.SleeveShopSeriesEntry", b =>
{
b.Navigation("Products");
});
modelBuilder.Entity("SVSim.Database.Models.Viewer", b =>
{
b.Navigation("Achievements");
b.Navigation("Decks");
b.Navigation("EventCounters");
b.Navigation("Missions");
});
#pragma warning restore 612, 618
}

View File

@@ -0,0 +1,23 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One tier of an achievement. PK is composite (AchievementType, Level). Rows are seeded from
/// <c>seeds/achievement-catalog.json</c>. The captured tier IS the max tier in our world —
/// max_level on the wire is computed as MAX(Level) per AchievementType at /mission/info time.
/// Inherits Id from BaseEntity but the Id is unused; PK is configured in DbContext.
/// </summary>
public class AchievementCatalogEntry
{
public int AchievementType { get; set; }
public int Level { get; set; }
public string Name { get; set; } = "";
public int RequireNumber { get; set; }
public int RewardType { get; set; }
public long RewardDetailId { get; set; }
public int RewardNumber { get; set; }
public int OrderNum { get; set; }
public string? EventType { get; set; }
public int? EventArg { get; set; }
}

View File

@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations.Schema;
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One row of the BP monthly mission list, keyed to a specific (Year, Month).
/// `RewardType` is nullable because some monthly missions only award BP points (capture shows
/// the "Play 5 Challenge matches" entry has no reward_info block on wire).
/// Id is auto-generated — override BaseEntity's [DatabaseGenerated(None)] default.
/// </summary>
public class BattlePassMonthlyMissionEntry : BaseEntity<int>
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public override int Id { get; set; }
public int Year { get; set; }
public int Month { get; set; }
public int OrderNum { get; set; }
public string Name { get; set; } = "";
public int RequireNumber { get; set; }
public int BattlePassPoint { get; set; }
public int? RewardType { get; set; }
public long? RewardDetailId { get; set; }
public int? RewardNumber { get; set; }
public string? EventType { get; set; }
public int? EventArg { get; set; }
}

View File

@@ -2,7 +2,21 @@ using SVSim.Database.Common;
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
/// 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.
/// </summary>
public class ItemEntry : BaseEntity<int>
{
public string Name { get; set; } = string.Empty;
/// <summary>Client-side item_type enum (1-7). Drives shop categorisation, e.g.
/// <c>user_card_pack_ticket_list</c> in /item_purchase/info filters on Type == 2.</summary>
public int Type { get; set; }
/// <summary>Sprite key, e.g. <c>"ticket_10032"</c>. Empty when unknown.</summary>
public string ThumbnailPath { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,39 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One row of the /item_purchase/info catalog — an exchange the user can perform N times per
/// period (monthly or lifetime) by spending <c>RequireItem*</c> to acquire <c>PurchaseItem*</c>.
/// PK = wire <c>purchase_id</c>.
/// <para>
/// Both sides reference <see cref="Enums.UserGoodsType"/>. Captures show the common shape is
/// currency-for-item (RedEther 5000 → Seer's Globe ×1) or item-for-item (Orb Shard ×5 →
/// Seer's Globe ×1). Per-viewer remaining quota lives in
/// <see cref="ViewerEventCounter"/> keyed by <c>"item_purchase:{Id}"</c>.
/// </para>
/// </summary>
public class ItemPurchaseCatalogEntry : BaseEntity<int>
{
public int RequireItemType { get; set; }
public long RequireItemId { get; set; }
public int RequireItemNum { get; set; }
public int PurchaseItemType { get; set; }
public long PurchaseItemId { get; set; }
public int PurchaseItemNum { get; set; }
/// <summary>
/// SystemText-ready display name. May be empty — the client falls back to a templated name
/// built from <c>UserGoods.getUserGoodsName + count</c> via SystemText key "Shop_0132".
/// </summary>
public string PurchaseName { get; set; } = string.Empty;
/// <summary>True → quota resets at the start of each JST month. False → lifetime quota.</summary>
public bool IsMonthlyReset { get; set; }
/// <summary>Per-period purchase cap. Wire <c>rest</c> = max(0, PurchaseLimit - counter).</summary>
public int PurchaseLimit { get; set; }
public bool IsEnabled { get; set; }
}

View File

@@ -0,0 +1,36 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One purchasable leader-skin product. PK = wire product_id (small ints in captures — e.g. 31,
/// 165, 166). FK <see cref="SeriesId"/>. <see cref="LeaderSkinId"/> points at the
/// <see cref="LeaderSkinEntry"/> the buyer ends up owning.
/// </summary>
public class LeaderSkinShopProductEntry : BaseEntity<int>
{
public int SeriesId { get; set; }
public int LeaderSkinId { get; set; }
/// <summary>SystemText keys — resolved client-side via Data.Master.GetLeaderSkinProductText.</summary>
public string ProductNameKey { get; set; } = string.Empty;
public string IntroductionKey { get; set; } = string.Empty;
public string CvNameKey { get; set; } = string.Empty;
/// <summary>
/// Per-product price for solo buy. Captures consistently show crystal/rupy parity for
/// regular skins (500c / 500r single, 400 unit-price when bought as set). Nullable so
/// promotions can offer one currency without the other.
/// </summary>
public int? SinglePriceCrystal { get; set; }
public int? SinglePriceRupy { get; set; }
public int? SinglePriceTicket { get; set; }
public int? TicketNumber { get; set; }
public long? TicketItemId { get; set; }
public bool IsEnabled { get; set; }
public List<LeaderSkinShopProductRewardEntry> Rewards { get; set; } = new();
public LeaderSkinShopSeriesEntry? Series { get; set; }
}

View File

@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// One per-buy reward attached to a leader-skin product. Owned by
/// <see cref="LeaderSkinShopProductEntry"/>. Captures show each skin product bundles 3 rewards:
/// the skin itself (type=10), the matching emblem (type=7), and the matching sleeve (type=6).
/// </summary>
[Owned]
public class LeaderSkinShopProductRewardEntry
{
public int OrderIndex { get; set; }
public int RewardType { get; set; } // Wizard.UserGoods.Type
public long RewardDetailId { get; set; }
public int RewardNumber { get; set; }
}

View File

@@ -0,0 +1,33 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One leader-skin-shop series (a themed collection — e.g. "7th Anniversary Skins").
/// PK = wire series_id. <see cref="SetSalesStatus"/> controls whether the per-series
/// "buy whole set" UI is offered: 0=none (single-skin purchases only), non-zero=set sale active.
/// When set-active, the set-price + set-completion-reward fields are populated.
/// </summary>
public class LeaderSkinShopSeriesEntry : BaseEntity<int>
{
public bool IsNew { get; set; }
public bool IsEnabled { get; set; }
/// <summary>SkinSeriesPurchaseInfo.eSetSalesStatus — 0=None.</summary>
public int SetSalesStatus { get; set; }
public int? SetPriceCrystal { get; set; }
public int? SetPriceRupy { get; set; }
public int? SetPriceTicket { get; set; }
public long? SetPriceTicketId { get; set; }
/// <summary>
/// SkinSeriesPurchaseInfo.RewardStatus — 0=none. The per-VIEWER claim state is computed
/// at request time from <see cref="ViewerLeaderSkinSetClaim"/>; this column is the catalog
/// default surfaced when no viewer is in context (or when set_sales_status==0).
/// </summary>
public int SetCompletionRewardStatus { get; set; }
public List<LeaderSkinShopProductEntry> Products { get; set; } = new();
public List<LeaderSkinShopSeriesRewardEntry> SetCompletionRewards { get; set; } = new();
}

View File

@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// One set-completion bonus item attached to a leader-skin series. Owned by
/// <see cref="LeaderSkinShopSeriesEntry"/>. Granted by /leader_skin/buy_set_item once the
/// viewer owns every skin in the series. Wire shape: entries inside
/// <c>rewards.items[]</c> on the per-series block of /leader_skin/products.
/// </summary>
[Owned]
public class LeaderSkinShopSeriesRewardEntry
{
public int OrderIndex { get; set; }
public int RewardType { get; set; }
public long RewardDetailId { get; set; }
public int RewardNumber { get; set; }
}

View File

@@ -0,0 +1,26 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One mission template. Id = wire mission_id. Rows are seeded from
/// <c>seeds/mission-catalog.json</c> (extracted from /mission/info captures).
/// LotType 2 = weekly rotation slot; LotType 6 = daily slot (per UserMission.GEM_MISSION_TYPE).
/// EventType is the catalog-side key the progress service matches against; NULL means the row
/// was captured but no event mapping has been added yet (importer logs a warning).
/// </summary>
public class MissionCatalogEntry : BaseEntity<int>
{
public string Name { get; set; } = "";
public int LotType { get; set; }
public int RequireNumber { get; set; }
public int RewardType { get; set; }
public long RewardDetailId { get; set; }
public int RewardNumber { get; set; }
public int BattlePassPoint { get; set; }
public bool DefaultFlag { get; set; }
public string? EventType { get; set; }
public int? EventArg { get; set; }
public long StartTime { get; set; }
public long? EndTime { get; set; }
}

View File

@@ -0,0 +1,32 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One purchasable sleeve product. PK = wire product_id (e.g. 301901). FK SeriesId.
/// <para>
/// Both <see cref="PriceCrystal"/> and <see cref="PriceRupy"/> are nullable. At least one must be
/// populated for an enabled product (both zero = free, both null = invalid). Sleeves don't have
/// the two-tier intro/regular pricing that BuildDeck products use — one price per currency.
/// </para>
/// <para>
/// <see cref="Rewards"/> drives both the catalog display (in /sleeve/info) and the actual grant
/// list (in /sleeve/buy). The capture shows each sleeve product grants a sleeve (type=6) and an
/// emblem (type=7) — both faithful reward_detail_ids that exist in the cosmetic catalogs.
/// </para>
/// </summary>
public class SleeveShopProductEntry : BaseEntity<int>
{
public int SeriesId { get; set; }
/// <summary>Wire `name` field — SystemText key like "sleeve_138". Localised client-side.</summary>
public string NameKey { get; set; } = string.Empty;
public int? PriceCrystal { get; set; }
public int? PriceRupy { get; set; }
public bool IsEnabled { get; set; }
public List<SleeveShopProductRewardEntry> Rewards { get; set; } = new();
public SleeveShopSeriesEntry? Series { get; set; }
}

View File

@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// One per-buy reward attached to a sleeve product. Owned by <see cref="SleeveShopProductEntry"/>.
/// Wire shape: one entry of the product-level `rewards` array in /sleeve/info. Order is
/// preserved by <see cref="OrderIndex"/> since the wire shape is an ordered array, not a dict.
/// </summary>
[Owned]
public class SleeveShopProductRewardEntry
{
public int OrderIndex { get; set; }
public int RewardType { get; set; } // Wizard.UserGoods.Type
public long RewardDetailId { get; set; }
public int RewardNumber { get; set; }
}

View File

@@ -0,0 +1,16 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One sleeve-shop series (a themed collection — e.g. series 3019 "BattlePass sleeves",
/// series 3004 "Granblue Fantasy collab"). PK = wire series_id. IsEnabled gates whether
/// /sleeve/info renders this series.
/// </summary>
public class SleeveShopSeriesEntry : BaseEntity<int>
{
public bool IsNew { get; set; }
public bool IsEnabled { get; set; }
public List<SleeveShopProductEntry> Products { get; set; } = new();
}

View File

@@ -0,0 +1,30 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One catalog entry of the /spot_card_exchange/top shop — a card the viewer can buy with
/// spot points. PK = wire card_id. Distinct from <see cref="SpotCardEntry"/> (which is the
/// /load/index data.spot_cards rental-cost list — a different concept).
/// <para>
/// <see cref="TsRotationId"/> matches the card_set_id; cards cycle out of the exchange when
/// their set rotates. <see cref="IsPreRelease"/> distinguishes the pre-release-pool subset
/// gated by <c>pre_release_spot_card_exchange_limit</c>.
/// </para>
/// </summary>
public class SpotCardExchangeEntry : BaseEntity<long>
{
public long CardId { get => Id; set => Id = value; }
/// <summary>Wire <c>class</c> field — clan id (0=Neutral, 1=Forestcraft, ..., 8).</summary>
public int ClassId { get; set; }
public int ExchangePoint { get; set; }
/// <summary>Wire <c>ts_rotation_id</c> — card_set_id this card belongs to.</summary>
public long TsRotationId { get; set; }
public bool IsPreRelease { get; set; }
public bool IsEnabled { get; set; }
}

View File

@@ -8,6 +8,7 @@ namespace SVSim.Database.Models;
/// A user within the game system.
/// </summary>
[Index(nameof(ShortUdid))]
[Index(nameof(Udid), IsUnique = true)]
public class Viewer : BaseEntity<long>
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
@@ -17,11 +18,18 @@ public class Viewer : BaseEntity<long>
/// This user's name displayed in game.
/// </summary>
public string DisplayName { get; set; } = String.Empty;
/// <summary>
/// This user's short identifier.
/// </summary>
public long ShortUdid { get; set; }
/// <summary>
/// The client's full UDID (AES key for the wire protocol). Set when the viewer is created
/// via <c>/tool/signup</c>; null for viewers created via the admin Steam-import path. Unique
/// when present — the partial filter is declared in the migration.
/// </summary>
public Guid? Udid { get; set; }
public DateTime LastLogin { get; set; }
@@ -59,6 +67,12 @@ public class Viewer : BaseEntity<long>
public List<ViewerBuildDeckProductPurchase> BuildDeckPurchases { get; set; } = new List<ViewerBuildDeckProductPurchase>();
public List<ViewerMission> Missions { get; set; } = new List<ViewerMission>();
public List<ViewerAchievement> Achievements { get; set; } = new List<ViewerAchievement>();
public List<ViewerEventCounter> EventCounters { get; set; } = new List<ViewerEventCounter>();
#endregion
#region Navigation Properties

View File

@@ -0,0 +1,17 @@
namespace SVSim.Database.Models;
/// <summary>
/// Per-viewer state for one achievement type. Composite PK (ViewerId, AchievementType) configured
/// in DbContext. <c>Level</c> is the viewer's current tier; <c>max_level</c> on the wire is
/// derived from catalog as MAX(Level) per type. Lazy-created at /load/index time — one row per
/// AchievementCatalogEntries.AchievementType that the viewer doesn't yet have a row for.
/// </summary>
public class ViewerAchievement
{
public long ViewerId { get; set; }
public int AchievementType { get; set; }
public int Level { get; set; } = 1;
public int AchievementStatus { get; set; }
public int NowAchievedLevel { get; set; }
public int ResultAnnounceSawLevel { get; set; }
}

View File

@@ -14,4 +14,11 @@ public class ViewerCurrency
public ulong LifeTotalCrystals { get; set; }
public ulong RedEther { get; set; }
public ulong Rupees { get; set; }
/// <summary>
/// Spot card points — currency earned from battles/missions, spent at /spot_card_exchange/exchange.
/// Wire field <c>spot_point</c> in /load/index and /spot_card_exchange/top; reward_type 12
/// (<see cref="Enums.UserGoodsType.SpotCardPoint"/>) in reward_list entries.
/// </summary>
public ulong SpotPoints { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace SVSim.Database.Models;
/// <summary>
/// Per-viewer "how many times has this happened" counter. Composite PK
/// (ViewerId, EventKey, Period). Period strings: "all-time", "month:YYYY-MM",
/// "week:YYYY-W##", "day:YYYY-MM-DD" — all JST-anchored with 02:00 day-boundary.
/// Single source of truth for total_count / done_number on every wire shape.
/// </summary>
public class ViewerEventCounter
{
public long ViewerId { get; set; }
public string EventKey { get; set; } = "";
public string Period { get; set; } = "";
public int Count { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row per (viewer, leader-skin series) marking that the viewer has claimed the
/// series-completion bonus via /leader_skin/buy_set_item. Composite PK (ViewerId, SeriesId).
/// Standalone table (not a Viewer owned collection) to avoid the cartesian-explode pitfall
/// when loading the viewer graph — claim state is checked per-series, not per-viewer-load.
/// </summary>
public class ViewerLeaderSkinSetClaim
{
public long ViewerId { get; set; }
public int SeriesId { get; set; }
public DateTime ClaimedAt { get; set; }
}

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations.Schema;
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One assigned mission slot for a viewer. <c>Id</c> is the wire <c>UserMission.id</c> — echoed
/// back as the retire-request parameter, auto-generated. Slot 0 = daily (lot_type=6),
/// Slots 1..3 = weekly (lot_type=2). Progress (<c>total_count</c> on the wire) is NOT stored
/// here — it's read from <see cref="ViewerEventCounter"/> at response-build time, keyed by the
/// catalog row's EventType.
/// </summary>
public class ViewerMission : BaseEntity<long>
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public override long Id { get; set; }
public long ViewerId { get; set; }
public int MissionCatalogId { get; set; }
public int Slot { get; set; }
public long AssignedAt { get; set; }
public long? ClaimedAt { get; set; }
public int MissionStatus { get; set; } = 1;
}

View File

@@ -0,0 +1,16 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row per (viewer, exchanged card). Composite PK (ViewerId, CardId). Standalone table
/// (not a Viewer owned collection) to avoid cartesian-explode on viewer-graph reads.
/// <see cref="IsPreRelease"/> snapshot at exchange time so the pre-release counter can be
/// computed without joining back to <see cref="SpotCardExchangeEntry"/> (and to survive
/// catalog edits that re-classify a card).
/// </summary>
public class ViewerSpotCardExchange
{
public long ViewerId { get; set; }
public long CardId { get; set; }
public bool IsPreRelease { get; set; }
public DateTime ExchangedAt { get; set; }
}

View File

@@ -0,0 +1,29 @@
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Mission;
public interface IMissionCatalogRepository
{
Task<List<MissionCatalogEntry>> GetByLotTypeAsync(int lotType, CancellationToken ct);
Task<List<MissionCatalogEntry>> GetByIdsAsync(IReadOnlyCollection<int> ids, CancellationToken ct);
Task<MissionCatalogEntry?> GetByIdAsync(int id, CancellationToken ct);
Task<List<MissionCatalogEntry>> GetByEventTypesAsync(IReadOnlyCollection<string> eventTypes, CancellationToken ct);
Task<List<AchievementCatalogEntry>> GetAchievementsByEventTypesAsync(IReadOnlyCollection<string> eventTypes, CancellationToken ct);
/// <summary>All distinct achievement_type values present in the catalog. Used by /load/index materialization.</summary>
Task<List<int>> GetAllAchievementTypesAsync(CancellationToken ct);
/// <summary>MIN(Level) per achievement_type — the "starting tier" for new viewers when the
/// catalog doesn't contain a level-1 row. With our captured-data-is-catalog model, a fresh
/// viewer starts at whatever the lowest captured tier is for that type.</summary>
Task<IReadOnlyDictionary<int, int>> GetMinLevelByAchievementTypeAsync(CancellationToken ct);
/// <summary>MAX(Level) per achievement_type — cached. Used to compute wire max_level.</summary>
Task<IReadOnlyDictionary<int, int>> GetMaxLevelByAchievementTypeAsync(CancellationToken ct);
/// <summary>Catalog row at (type, level), or null if no such tier has been captured.</summary>
Task<AchievementCatalogEntry?> GetAchievementAsync(int achievementType, int level, CancellationToken ct);
Task<List<BattlePassMonthlyMissionEntry>> GetMonthlyMissionsAsync(int year, int month, CancellationToken ct);
}

View File

@@ -0,0 +1,34 @@
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Mission;
public interface IViewerMissionRepository
{
Task<List<ViewerMission>> GetMissionsAsync(long viewerId, CancellationToken ct);
Task<ViewerMission?> GetMissionByIdAsync(long viewerId, long missionId, CancellationToken ct);
Task<List<ViewerAchievement>> GetAchievementsAsync(long viewerId, CancellationToken ct);
Task<ViewerAchievement?> GetAchievementAsync(long viewerId, int achievementType, CancellationToken ct);
/// <summary>Reads counter rows for (viewerId, eventKey IN list, period IN list). Empty inputs return [].</summary>
Task<List<ViewerEventCounter>> GetCountersAsync(
long viewerId,
IReadOnlyCollection<string> eventKeys,
IReadOnlyCollection<string> periods,
CancellationToken ct);
/// <summary>Single-row counter read. Returns 0 if no row exists.</summary>
Task<int> GetCounterAsync(long viewerId, string eventKey, string period, CancellationToken ct);
/// <summary>Add a viewer mission row (in-memory; caller saves).</summary>
void AddMission(ViewerMission row);
/// <summary>Remove a viewer mission row (in-memory; caller saves).</summary>
void RemoveMission(ViewerMission row);
/// <summary>Add a viewer achievement row (in-memory; caller saves).</summary>
void AddAchievement(ViewerAchievement row);
/// <summary>Upsert a counter delta (in-memory; caller saves). Creates the row if missing.</summary>
Task UpsertCounterAsync(long viewerId, string eventKey, string period, int delta, CancellationToken ct);
}

View File

@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Mission;
public sealed class MissionCatalogRepository : IMissionCatalogRepository
{
private readonly SVSimDbContext _db;
// Process-level cache for the derived MAX(Level) lookup. Cleared on host restart
// (re-bootstrap is the only legitimate way to mutate the catalog at runtime).
private static IReadOnlyDictionary<int, int>? _maxLevelCache;
private static readonly SemaphoreSlim _maxLevelLock = new(1, 1);
public MissionCatalogRepository(SVSimDbContext db) { _db = db; }
public Task<List<MissionCatalogEntry>> GetByLotTypeAsync(int lotType, CancellationToken ct) =>
_db.MissionCatalog.AsNoTracking().Where(e => e.LotType == lotType).ToListAsync(ct);
public Task<List<MissionCatalogEntry>> GetByIdsAsync(IReadOnlyCollection<int> ids, CancellationToken ct) =>
_db.MissionCatalog.AsNoTracking().Where(e => ids.Contains(e.Id)).ToListAsync(ct);
public Task<MissionCatalogEntry?> GetByIdAsync(int id, CancellationToken ct) =>
_db.MissionCatalog.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id, ct);
public Task<List<MissionCatalogEntry>> GetByEventTypesAsync(IReadOnlyCollection<string> eventTypes, CancellationToken ct) =>
_db.MissionCatalog.AsNoTracking()
.Where(e => e.EventType != null && eventTypes.Contains(e.EventType))
.ToListAsync(ct);
public Task<List<AchievementCatalogEntry>> GetAchievementsByEventTypesAsync(IReadOnlyCollection<string> eventTypes, CancellationToken ct) =>
_db.AchievementCatalog.AsNoTracking()
.Where(e => e.EventType != null && eventTypes.Contains(e.EventType))
.ToListAsync(ct);
public Task<List<int>> GetAllAchievementTypesAsync(CancellationToken ct) =>
_db.AchievementCatalog.AsNoTracking()
.Select(e => e.AchievementType).Distinct()
.ToListAsync(ct);
public async Task<IReadOnlyDictionary<int, int>> GetMaxLevelByAchievementTypeAsync(CancellationToken ct)
{
if (_maxLevelCache is not null) return _maxLevelCache;
await _maxLevelLock.WaitAsync(ct);
try
{
if (_maxLevelCache is null)
{
var pairs = await _db.AchievementCatalog.AsNoTracking()
.GroupBy(e => e.AchievementType)
.Select(g => new { Type = g.Key, Max = g.Max(e => e.Level) })
.ToListAsync(ct);
_maxLevelCache = pairs.ToDictionary(p => p.Type, p => p.Max);
}
return _maxLevelCache;
}
finally { _maxLevelLock.Release(); }
}
public async Task<IReadOnlyDictionary<int, int>> GetMinLevelByAchievementTypeAsync(CancellationToken ct)
{
var pairs = await _db.AchievementCatalog.AsNoTracking()
.GroupBy(e => e.AchievementType)
.Select(g => new { Type = g.Key, Min = g.Min(e => e.Level) })
.ToListAsync(ct);
return pairs.ToDictionary(p => p.Type, p => p.Min);
}
public Task<AchievementCatalogEntry?> GetAchievementAsync(int achievementType, int level, CancellationToken ct) =>
_db.AchievementCatalog.AsNoTracking()
.FirstOrDefaultAsync(e => e.AchievementType == achievementType && e.Level == level, ct);
public Task<List<BattlePassMonthlyMissionEntry>> GetMonthlyMissionsAsync(int year, int month, CancellationToken ct) =>
_db.BattlePassMonthlyMissions.AsNoTracking()
.Where(e => e.Year == year && e.Month == month)
.OrderBy(e => e.OrderNum).ToListAsync(ct);
}

View File

@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Mission;
public sealed class ViewerMissionRepository : IViewerMissionRepository
{
private readonly SVSimDbContext _db;
public ViewerMissionRepository(SVSimDbContext db) { _db = db; }
public Task<List<ViewerMission>> GetMissionsAsync(long viewerId, CancellationToken ct) =>
_db.ViewerMissions.Where(e => e.ViewerId == viewerId).OrderBy(e => e.Slot).ToListAsync(ct);
public Task<ViewerMission?> GetMissionByIdAsync(long viewerId, long missionId, CancellationToken ct) =>
_db.ViewerMissions.FirstOrDefaultAsync(e => e.ViewerId == viewerId && e.Id == missionId, ct);
public Task<List<ViewerAchievement>> GetAchievementsAsync(long viewerId, CancellationToken ct) =>
_db.ViewerAchievements.Where(e => e.ViewerId == viewerId).ToListAsync(ct);
public Task<ViewerAchievement?> GetAchievementAsync(long viewerId, int achievementType, CancellationToken ct) =>
_db.ViewerAchievements.FirstOrDefaultAsync(
e => e.ViewerId == viewerId && e.AchievementType == achievementType, ct);
public Task<List<ViewerEventCounter>> GetCountersAsync(
long viewerId,
IReadOnlyCollection<string> eventKeys,
IReadOnlyCollection<string> periods,
CancellationToken ct)
{
if (eventKeys.Count == 0 || periods.Count == 0) return Task.FromResult(new List<ViewerEventCounter>());
return _db.ViewerEventCounters.AsNoTracking()
.Where(e => e.ViewerId == viewerId
&& eventKeys.Contains(e.EventKey)
&& periods.Contains(e.Period))
.ToListAsync(ct);
}
public async Task<int> GetCounterAsync(long viewerId, string eventKey, string period, CancellationToken ct)
{
var row = await _db.ViewerEventCounters.AsNoTracking()
.FirstOrDefaultAsync(
e => e.ViewerId == viewerId && e.EventKey == eventKey && e.Period == period, ct);
return row?.Count ?? 0;
}
public void AddMission(ViewerMission row) => _db.ViewerMissions.Add(row);
public void RemoveMission(ViewerMission row) => _db.ViewerMissions.Remove(row);
public void AddAchievement(ViewerAchievement row) => _db.ViewerAchievements.Add(row);
public async Task UpsertCounterAsync(long viewerId, string eventKey, string period, int delta, CancellationToken ct)
{
var row = await _db.ViewerEventCounters.FirstOrDefaultAsync(
e => e.ViewerId == viewerId && e.EventKey == eventKey && e.Period == period, ct);
if (row is null)
{
_db.ViewerEventCounters.Add(new ViewerEventCounter
{
ViewerId = viewerId, EventKey = eventKey, Period = period, Count = delta,
});
}
else
{
row.Count += delta;
}
}
}

View File

@@ -7,7 +7,10 @@ public interface IViewerRepository
Task<Models.Viewer?> GetViewerBySocialConnection(SocialAccountType accountType, ulong socialId);
Task<Models.Viewer?> GetViewerWithSocials(long id);
Task<Models.Viewer?> GetViewerByShortUdid(long shortUdid);
Task<Models.Viewer?> GetViewerByUdid(Guid udid);
Task<Models.Viewer> RegisterViewer(string displayName, SocialAccountType socialType,
ulong socialAccountIdentifier, ulong? shortUdid = null);
}
Task<Models.Viewer> RegisterAnonymousViewer(Guid udid);
Task LinkSteamToViewer(long viewerId, ulong steamId);
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
@@ -70,6 +71,100 @@ public class ViewerRepository : IViewerRepository
public async Task<Models.Viewer> RegisterViewer(string displayName, SocialAccountType socialType,
ulong socialAccountIdentifier, ulong? shortUdid = null)
{
var viewer = await BuildDefaultViewer(displayName);
viewer.SocialAccountConnections.Add(new SocialAccountConnection
{
AccountId = socialAccountIdentifier,
AccountType = socialType
});
_dbContext.Set<Models.Viewer>().Add(viewer);
await _dbContext.SaveChangesAsync();
return viewer;
}
public async Task<Models.Viewer?> GetViewerByUdid(Guid udid)
{
if (udid == Guid.Empty) return null;
return await _dbContext.Set<Models.Viewer>()
.AsNoTracking()
.FirstOrDefaultAsync(v => v.Udid == udid);
}
public async Task<Models.Viewer> RegisterAnonymousViewer(Guid udid)
{
if (udid == Guid.Empty)
throw new InvalidOperationException("Cannot register viewer for empty UDID.");
var viewer = await BuildDefaultViewer("Player");
viewer.Udid = udid;
_dbContext.Set<Models.Viewer>().Add(viewer);
try
{
await _dbContext.SaveChangesAsync();
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// Concurrent signup for the same UDID raced us to the unique index. The other request
// already committed a viewer with this UDID — re-read and return it. Detach the local
// entity first so EF doesn't keep trying to insert the now-orphaned graph.
//
// Cross-engine: Postgres surfaces this as Npgsql.PostgresException SqlState "23505";
// SQLite (test backend) surfaces it as Microsoft.Data.Sqlite.SqliteException with
// SqliteErrorCode 19 (SQLITE_CONSTRAINT). Matched by type-name to avoid pulling a
// Sqlite package dep into SVSim.Database.
_dbContext.Entry(viewer).State = EntityState.Detached;
var existing = await GetViewerByUdid(udid)
?? throw new InvalidOperationException(
$"Got unique-violation on Udid={udid} insert but subsequent lookup found no row. " +
"This shouldn't happen — likely transaction isolation issue.");
return existing;
}
return viewer;
}
/// <summary>
/// Returns true if the given <see cref="DbUpdateException"/> wraps a backend-level unique-
/// constraint violation. Postgres → SqlState "23505"; SQLite → SqliteErrorCode 19.
/// </summary>
private static bool IsUniqueViolation(DbUpdateException ex)
{
if (ex.InnerException is Npgsql.PostgresException pgEx && pgEx.SqlState == "23505")
{
return true;
}
// Match SQLite by type name so this assembly doesn't take a dep on Microsoft.Data.Sqlite.
// Test backend (SQLite in-memory) raises SqliteException with SqliteErrorCode 19 on UNIQUE
// constraint violations.
var inner = ex.InnerException;
if (inner is not null && inner.GetType().FullName == "Microsoft.Data.Sqlite.SqliteException")
{
var prop = inner.GetType().GetProperty("SqliteErrorCode");
if (prop?.GetValue(inner) is int code && code == 19) return true;
}
return false;
}
public async Task LinkSteamToViewer(long viewerId, ulong steamId)
{
var viewer = await _dbContext.Set<Models.Viewer>()
.Include(v => v.SocialAccountConnections)
.FirstOrDefaultAsync(v => v.Id == viewerId)
?? throw new InvalidOperationException($"Viewer {viewerId} not found for Steam link.");
bool alreadyLinked = viewer.SocialAccountConnections.Any(sac =>
sac.AccountType == SocialAccountType.Steam && sac.AccountId == steamId);
if (alreadyLinked) return;
viewer.SocialAccountConnections.Add(new SocialAccountConnection
{
AccountId = steamId,
AccountType = SocialAccountType.Steam
});
await _dbContext.SaveChangesAsync();
}
private async Task<Models.Viewer> BuildDefaultViewer(string displayName)
{
Models.Viewer viewer = new Models.Viewer
{
@@ -79,12 +174,6 @@ public class ViewerRepository : IViewerRepository
var grants = _config.Get<DefaultGrantsConfig>();
var loadout = _config.Get<DefaultLoadoutConfig>();
viewer.SocialAccountConnections.Add(new SocialAccountConnection
{
AccountId = socialAccountIdentifier,
AccountType = socialType
});
viewer.Info.MaxFriends = player.MaxFriends;
viewer.Info.CountryCode = "KOR";
viewer.Info.BirthDate = DateTime.UtcNow;
@@ -133,8 +222,6 @@ public class ViewerRepository : IViewerRepository
.ToList();
viewer.LeaderSkins.AddRange(grantedSkins);
_dbContext.Set<Models.Viewer>().Add(viewer);
await _dbContext.SaveChangesAsync();
return viewer;
}
}

View File

@@ -54,6 +54,12 @@ public class SVSimDbContext : DbContext
public DbSet<BattlePassRewardEntry> BattlePassRewards => Set<BattlePassRewardEntry>();
public DbSet<ViewerBattlePassProgressEntry> ViewerBattlePassProgress => Set<ViewerBattlePassProgressEntry>();
public DbSet<ViewerBattlePassClaimEntry> ViewerBattlePassClaims => Set<ViewerBattlePassClaimEntry>();
public DbSet<MissionCatalogEntry> MissionCatalog => Set<MissionCatalogEntry>();
public DbSet<AchievementCatalogEntry> AchievementCatalog => Set<AchievementCatalogEntry>();
public DbSet<BattlePassMonthlyMissionEntry> BattlePassMonthlyMissions => Set<BattlePassMonthlyMissionEntry>();
public DbSet<ViewerMission> ViewerMissions => Set<ViewerMission>();
public DbSet<ViewerAchievement> ViewerAchievements => Set<ViewerAchievement>();
public DbSet<ViewerEventCounter> ViewerEventCounters => Set<ViewerEventCounter>();
public DbSet<DailyLoginBonusEntry> DailyLoginBonuses => Set<DailyLoginBonusEntry>();
public DbSet<BannerEntry> Banners => Set<BannerEntry>();
public DbSet<ColosseumConfig> Colosseums => Set<ColosseumConfig>();
@@ -64,6 +70,14 @@ public class SVSimDbContext : DbContext
public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>();
public DbSet<BuildDeckSeriesEntry> BuildDeckSeries => Set<BuildDeckSeriesEntry>();
public DbSet<BuildDeckProductEntry> BuildDeckProducts => Set<BuildDeckProductEntry>();
public DbSet<SleeveShopSeriesEntry> SleeveShopSeries => Set<SleeveShopSeriesEntry>();
public DbSet<SleeveShopProductEntry> SleeveShopProducts => Set<SleeveShopProductEntry>();
public DbSet<ItemPurchaseCatalogEntry> ItemPurchaseCatalog => Set<ItemPurchaseCatalogEntry>();
public DbSet<LeaderSkinShopSeriesEntry> LeaderSkinShopSeries => Set<LeaderSkinShopSeriesEntry>();
public DbSet<LeaderSkinShopProductEntry> LeaderSkinShopProducts => Set<LeaderSkinShopProductEntry>();
public DbSet<ViewerLeaderSkinSetClaim> ViewerLeaderSkinSetClaims => Set<ViewerLeaderSkinSetClaim>();
public DbSet<SpotCardExchangeEntry> SpotCardExchangeCatalog => Set<SpotCardExchangeEntry>();
public DbSet<ViewerSpotCardExchange> ViewerSpotCardExchanges => Set<ViewerSpotCardExchange>();
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
@@ -151,6 +165,17 @@ public class SVSimDbContext : DbContext
b.HasIndex("ViewerId", "ProductId").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-
// touch requests can both pass the .Any(...) check in LinkSteamToViewer, but the second
// SaveChanges() throws unique-violation and surfaces a clean 500 instead of silently
// appending duplicate connections.
modelBuilder.Entity<Viewer>().OwnsMany(v => v.SocialAccountConnections, b =>
{
b.HasIndex("AccountType", "AccountId").IsUnique();
});
modelBuilder.Entity<BuildDeckSeriesEntry>().OwnsMany(s => s.SeriesRewards);
modelBuilder.Entity<BuildDeckProductEntry>().OwnsMany(p => p.Cards);
modelBuilder.Entity<BuildDeckProductEntry>().OwnsMany(p => p.Rewards);
@@ -163,6 +188,35 @@ public class SVSimDbContext : DbContext
modelBuilder.Entity<BuildDeckProductEntry>().HasIndex(p => p.SeriesId);
modelBuilder.Entity<SleeveShopProductEntry>().OwnsMany(p => p.Rewards);
modelBuilder.Entity<SleeveShopProductEntry>()
.HasOne(p => p.Series)
.WithMany(s => s.Products)
.HasForeignKey(p => p.SeriesId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SleeveShopProductEntry>().HasIndex(p => p.SeriesId);
modelBuilder.Entity<LeaderSkinShopSeriesEntry>().OwnsMany(s => s.SetCompletionRewards);
modelBuilder.Entity<LeaderSkinShopProductEntry>().OwnsMany(p => p.Rewards);
modelBuilder.Entity<LeaderSkinShopProductEntry>()
.HasOne(p => p.Series)
.WithMany(s => s.Products)
.HasForeignKey(p => p.SeriesId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<LeaderSkinShopProductEntry>().HasIndex(p => p.SeriesId);
modelBuilder.Entity<ViewerLeaderSkinSetClaim>(b =>
{
b.HasKey(c => new { c.ViewerId, c.SeriesId });
b.HasIndex(c => c.ViewerId);
});
modelBuilder.Entity<ViewerSpotCardExchange>(b =>
{
b.HasKey(e => new { e.ViewerId, e.CardId });
b.HasIndex(e => e.ViewerId);
});
modelBuilder.Entity<CardCosmeticReward>(b =>
{
b.HasKey(r => new { r.CardId, r.Type, r.CosmeticId });
@@ -242,6 +296,46 @@ public class SVSimDbContext : DbContext
b.HasIndex(e => new { e.ViewerId, e.SeasonId });
});
modelBuilder.Entity<MissionCatalogEntry>(b =>
{
b.HasKey(e => e.Id);
b.Property(e => e.Id).ValueGeneratedNever();
b.HasIndex(e => e.LotType);
b.HasIndex(e => new { e.EventType, e.EventArg });
});
modelBuilder.Entity<AchievementCatalogEntry>(b =>
{
b.HasKey(e => new { e.AchievementType, e.Level });
b.HasIndex(e => e.AchievementType);
b.HasIndex(e => new { e.EventType, e.EventArg });
});
modelBuilder.Entity<BattlePassMonthlyMissionEntry>(b =>
{
b.HasKey(e => e.Id);
b.HasIndex(e => new { e.Year, e.Month, e.OrderNum }).IsUnique();
b.HasIndex(e => new { e.Year, e.Month });
});
modelBuilder.Entity<ViewerMission>(b =>
{
b.HasKey(e => e.Id);
b.HasIndex(e => new { e.ViewerId, e.Slot }).IsUnique();
b.HasIndex(e => e.ViewerId);
});
modelBuilder.Entity<ViewerAchievement>(b =>
{
b.HasKey(e => new { e.ViewerId, e.AchievementType });
});
modelBuilder.Entity<ViewerEventCounter>(b =>
{
b.HasKey(e => new { e.ViewerId, e.EventKey, e.Period });
b.HasIndex(e => new { e.ViewerId, e.Period });
});
base.OnModelCreating(modelBuilder);
}

View File

@@ -20,8 +20,9 @@ public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum)
///
/// <para>
/// <b>DO NOT reimplement reward dispatch in a controller or new helper.</b> This service handles
/// RedEther, Crystal, Item, Card (with <see cref="CardCosmeticReward"/> cascade), Sleeve, Emblem,
/// Degree, Rupy, Skin, MyPageBG — everything except SpotCard (TODO). Endpoint code that takes a
/// RedEther, Crystal, SpotCardPoint, Item, Card (with <see cref="CardCosmeticReward"/> cascade),
/// Sleeve, Emblem, Degree, Rupy, Skin, MyPageBG — everything except the dead-letter SpotCard /
/// SpotCardOnlyLatestCardPack slots (use Card=5 instead). Endpoint code that takes a
/// list of <c>(type, id, num)</c> tuples should iterate and call <see cref="ApplyAsync"/>
/// per tuple — never switch on type yourself, never filter to "only card-typed rewards", never
/// build a second dispatch table. Past duplicate implementations (ICardAcquisitionService in the
@@ -87,6 +88,10 @@ public sealed class RewardGrantService
viewer.Currency.RedEther += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.RedEther));
case UserGoodsType.SpotCardPoint:
viewer.Currency.SpotPoints += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.SpotPoints));
case UserGoodsType.Item:
{
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
@@ -106,11 +111,11 @@ public sealed class RewardGrantService
case UserGoodsType.SpotCard:
case UserGoodsType.SpotCardOnlyLatestCardPack:
// TODO: spot cards are currently global in our seed data; the existence of these
// reward types suggests there's a mix of global + per-player spot cards. Revisit
// when per-player spot-card infrastructure lands.
// Spot-card-typed grants don't appear in captures — emitters always use Card=5
// with the spot-card-specific id. These two enum slots remain unimplemented; if a
// capture ever shows one in a reward_list we'll know to wire them up here.
throw new NotSupportedException(
$"{type} rewards are not yet supported — see SpotCard TODO in RewardGrantService.");
$"{type} rewards are not yet supported — emitters use Card=5 instead.");
default:
throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService");

View File

@@ -0,0 +1,133 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Mission;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos.Achievement;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /achievement/* — claim achievement rewards. Wire shape mirrors AchievementReceiveRewardTask.cs.
/// </summary>
[Route("achievement")]
public class AchievementController : SVSimController
{
private const int FailureResultCode = 2;
private readonly SVSimDbContext _db;
private readonly IMissionCatalogRepository _catalog;
private readonly IViewerMissionStateService _state;
private readonly IMissionAssembler _assembler;
private readonly RewardGrantService _grantService;
public AchievementController(
SVSimDbContext db,
IMissionCatalogRepository catalog,
IViewerMissionStateService state,
IMissionAssembler assembler,
RewardGrantService grantService)
{
_db = db;
_catalog = catalog;
_state = state;
_assembler = assembler;
_grantService = grantService;
}
[HttpPost("receive_reward")]
public async Task<IActionResult> ReceiveReward(
AchievementReceiveRewardRequest request, CancellationToken ct)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// Load viewer with all the collections RewardGrantService may need to mutate.
var viewer = await _db.Viewers
.Include(v => v.MissionData)
.Include(v => v.Currency)
.Include(v => v.Cards)
.Include(v => v.Items)
.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, ct);
await _state.EnsureCurrentAsync(viewer.Id, ct);
await _db.SaveChangesAsync(ct);
// Re-read viewer's achievement for this type after state-service materialization.
var ach = await _db.ViewerAchievements
.FirstOrDefaultAsync(a => a.ViewerId == viewerId && a.AchievementType == request.AchievementType, ct);
if (ach is null || ach.Level != request.Level)
{
return Ok(new { result_code = FailureResultCode });
}
var catalogRow = await _catalog.GetAchievementAsync(request.AchievementType, request.Level, ct);
if (catalogRow is null)
{
return Ok(new { result_code = FailureResultCode });
}
// Grant via the canonical RewardGrantService primitive.
var granted = await _grantService.ApplyAsync(
viewer,
(UserGoodsType)catalogRow.RewardType,
catalogRow.RewardDetailId,
catalogRow.RewardNumber,
ct);
// Advance viewer's level by 1. If no catalog row exists at the new level (i.e. just
// claimed the highest captured tier), max_level on the wire stays the same and the
// UI shows "claimed at max" until catalog grows.
ach.Level += 1;
var maxLevelByType = await _catalog.GetMaxLevelByAchievementTypeAsync(ct);
if (maxLevelByType.TryGetValue(request.AchievementType, out int maxLevel)
&& ach.Level > maxLevel)
{
ach.AchievementStatus = 2;
}
else
{
ach.AchievementStatus = 0;
}
ach.NowAchievedLevel = request.Level;
await _db.SaveChangesAsync(ct);
var dto = await _assembler.BuildAsync(viewer, ct);
var resp = new AchievementReceiveRewardResponse
{
UserMissionList = dto.UserMissionList,
UserAchievementList = dto.UserAchievementList,
BattlePassMonthlyMission = dto.BattlePassMonthlyMission,
IsChangeMission = dto.IsChangeMission,
CanChangeMissionTime = dto.CanChangeMissionTime,
IsChangeReceiveType = dto.IsChangeReceiveType,
CanChangeReceiveTypeTime = dto.CanChangeReceiveTypeTime,
MissionReceiveType = dto.MissionReceiveType,
RewardList = granted.Select(g => new RewardGrantDto
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
}).ToList(),
TotalReceiveCountList = granted.Select(g => new TotalReceiveCountDto
{
RewardType = g.RewardType,
RewardDetailId = g.RewardId,
RewardCount = g.RewardNum,
ItemType = 0,
IsUsable = true,
}).ToList(),
};
return Ok(resp);
}
}

View File

@@ -30,10 +30,6 @@ public class CheckController : SVSimController
});
}
// TODO: spec lists this as anonymous (identity from SHORT_UDID), but the base controller's
// [Authorize] still applies. For now requires a Steam-linked viewer; new-user bootstrap (where
// the server creates a viewer + returns rewrite_viewer_id) is deferred until the boot flow is
// exercised end-to-end with a real client.
[HttpPost("game_start")]
public async Task<GameStartResponse> GameStart(GameStartRequest request)
{

View File

@@ -0,0 +1,216 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /item_purchase/* — the generic item shop where viewers spend item-or-currency to acquire
/// other items (e.g. RedEther → Seer's Globe, Orb Shards → Seer's Globe). Per-viewer monthly
/// or lifetime quota tracked via <see cref="ViewerEventCounter"/>.
/// </summary>
[Route("item_purchase")]
public class ItemPurchaseController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly TimeProvider _time;
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
{
_db = db;
_rewards = rewards;
_time = time;
}
[HttpPost("info")]
public async Task<ActionResult<ItemPurchaseInfoResponse>> Info(BaseRequest _)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var catalog = await _db.ItemPurchaseCatalog
.Where(c => c.IsEnabled)
.OrderBy(c => c.Id)
.ToListAsync();
var now = _time.GetUtcNow();
var monthKey = JstPeriod.MonthKey(now);
var keys = catalog.Select(c => CounterKey(c.Id)).ToList();
var counters = await _db.ViewerEventCounters
.Where(c => c.ViewerId == viewerId && keys.Contains(c.EventKey))
.ToListAsync();
var info = new List<ItemPurchaseEntryDto>(catalog.Count);
foreach (var c in catalog)
{
int count = CounterCount(counters, c, monthKey);
info.Add(new ItemPurchaseEntryDto
{
PurchaseId = c.Id,
RequireItemType = c.RequireItemType,
RequireItemId = c.RequireItemId,
RequireItemNum = c.RequireItemNum,
PurchaseItemType = c.PurchaseItemType,
PurchaseItemId = c.PurchaseItemId,
PurchaseItemNum = c.PurchaseItemNum,
PurchaseName = c.PurchaseName,
IsMonthlyReset = c.IsMonthlyReset ? 1 : 0,
Rest = Math.Max(0, c.PurchaseLimit - count),
});
}
// user_card_pack_ticket_list: every item with Type == 2 paired with the viewer's count
// (zero counts included — the client unconditionally calls UpdateItemNum per entry).
var ticketItems = await _db.Items
.Where(i => i.Type == 2)
.OrderByDescending(i => i.Id)
.ToListAsync();
var ownedByItemId = (await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Items)
.Select(oi => new { oi.Item.Id, oi.Count })
.ToListAsync())
.ToDictionary(x => x.Id, x => x.Count);
var ticketList = ticketItems.Select(i => new UserCardPackTicketDto
{
ItemId = i.Id,
Number = ownedByItemId.TryGetValue(i.Id, out var cnt) ? cnt : 0,
}).ToList();
return new ItemPurchaseInfoResponse
{
ItemPurchaseInfo = info,
UserCardPackTicketList = ticketList,
};
}
[HttpPost("purchase")]
public async Task<ActionResult<ItemPurchasePurchaseResponse>> Purchase(ItemPurchasePurchaseRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var entry = await _db.ItemPurchaseCatalog.FindAsync(request.PurchaseId);
if (entry is null || !entry.IsEnabled)
return BadRequest(new { error = "unknown_purchase" });
var now = _time.GetUtcNow();
var period = entry.IsMonthlyReset ? JstPeriod.MonthKey(now) : JstPeriod.AllTime;
var key = CounterKey(entry.Id);
var counter = await _db.ViewerEventCounters
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.EventKey == key && c.Period == period);
int currentCount = counter?.Count ?? 0;
int rest = entry.PurchaseLimit - currentCount;
if (rest <= 0)
return BadRequest(new { error = "sold_out" });
var viewer = await LoadViewerGraphAsync(viewerId);
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);
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState);
// Grant the purchase side through the central dispatcher.
var granted = await _rewards.ApplyAsync(viewer,
(UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
// Increment the per-period counter.
if (counter is null)
{
_db.ViewerEventCounters.Add(new ViewerEventCounter
{
ViewerId = viewerId,
EventKey = key,
Period = period,
Count = 1,
});
}
else
{
counter.Count++;
}
await _db.SaveChangesAsync();
return new ItemPurchasePurchaseResponse { RewardList = rewardList };
}
/// <summary>
/// Debit <paramref name="num"/> of (<paramref name="type"/>, <paramref name="detailId"/>)
/// 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(
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);
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);
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);
case UserGoodsType.Item:
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
if (owned is null || owned.Count < num)
return (null, "insufficient_item");
owned.Count -= num;
return (new RewardListEntry { RewardType = 4, RewardId = detailId, RewardNum = owned.Count }, null);
default:
return (null, $"debit_type_not_supported:{type}");
}
}
private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}";
private static int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey)
{
var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime;
return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0;
}
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}

View File

@@ -1,24 +1,41 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /leader_skin/* — per-class "active leader skin" preference. The per-CLASS setting is the
/// fallback used when a deck has <c>leader_skin_id == 0</c>; per-deck overrides go through
/// /deck/update_leader_skin instead.
/// /leader_skin/* — the leader-skin shop family.
/// <list type="bullet">
/// <item><c>/set</c>: per-class equipped-skin preference (the fallback when a deck has
/// <c>leader_skin_id == 0</c>). Per-deck overrides go through /deck/update_leader_skin.</item>
/// <item><c>/products</c>: shop catalog (dict-keyed by series_id).</item>
/// <item><c>/buy</c>: single-skin purchase. Currency dispatch crystal/rupy/ticket(501).</item>
/// <item><c>/buy_set</c>: whole-series purchase at set discount.</item>
/// <item><c>/buy_set_item</c>: claim series-completion bonus (idempotent via
/// <see cref="ViewerLeaderSkinSetClaim"/>).</item>
/// <item><c>/ids</c>: flat list of owned skin ids for badge refresh.</item>
/// </list>
/// </summary>
[Route("leader_skin")]
public class LeaderSkinController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly TimeProvider _time;
public LeaderSkinController(SVSimDbContext db)
public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
{
_db = db;
_rewards = rewards;
_time = time;
}
[HttpPost("set")]
@@ -28,8 +45,6 @@ public class LeaderSkinController : SVSimController
if (request.IsRandomLeaderSkin)
{
// Random-skin mode needs a per-viewer per-class shuffle pool, which we don't
// persist yet (ViewerClassData has no list field for it). Punt for now.
return StatusCode(StatusCodes.Status501NotImplemented,
new { error = "random_leader_skin_not_implemented" });
}
@@ -44,7 +59,6 @@ public class LeaderSkinController : SVSimController
var classData = viewer.Classes.FirstOrDefault(c => c.Class.Id == request.ClassId);
if (classData is null) return BadRequest(new { error = "unknown_class" });
// Skin must (a) exist in the catalog, (b) match the target class, (c) be owned by the viewer.
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" });
@@ -61,4 +75,345 @@ public class LeaderSkinController : SVSimController
LeaderSkinIdList = new(),
};
}
[HttpPost("ids")]
public async Task<ActionResult<LeaderSkinIdsResponse>> Ids(BaseRequest _)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var ids = await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
.OrderBy(id => id)
.ToListAsync();
return new LeaderSkinIdsResponse { UserLeaderSkinIds = ids };
}
[HttpPost("products")]
public async Task<ActionResult<Dictionary<string, SkinSeriesDto>>> Products(BaseRequest _)
{
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 claimedSeries = (await _db.ViewerLeaderSkinSetClaims
.Where(c => c.ViewerId == viewerId)
.Select(c => c.SeriesId)
.ToListAsync()).ToHashSet();
var series = await _db.LeaderSkinShopSeries
.Where(s => s.IsEnabled)
.Include(s => s.SetCompletionRewards)
.Include(s => s.Products.Where(p => p.IsEnabled)).ThenInclude(p => p.Rewards)
.OrderBy(s => s.Id)
.ToListAsync();
var result = new Dictionary<string, SkinSeriesDto>();
foreach (var s in series)
{
var products = s.Products.OrderBy(p => p.Id).Select(p => ToProductDto(p, ownedSkinIds)).ToList();
bool seriesCompleted = products.Count > 0 && products.All(p => p.IsPurchased);
int rewardStatus = ComputeRewardStatus(s, seriesCompleted, claimedSeries.Contains(s.Id));
result[s.Id.ToString()] = new SkinSeriesDto
{
SeriesId = s.Id,
IsCompleted = seriesCompleted,
IsNew = s.IsNew,
SetSalesStatus = s.SetSalesStatus,
Rewards = new SkinSeriesRewardsDto
{
Status = rewardStatus,
Items = s.SetCompletionRewards.OrderBy(r => r.OrderIndex).Select(r => new SkinSeriesRewardItemDto
{
RewardType = r.RewardType,
RewardDetailId = r.RewardDetailId,
RewardNumber = r.RewardNumber,
}).ToList(),
},
SetPrices = new SkinSeriesSetPricesDto
{
SetPriceCrystal = s.SetPriceCrystal,
SetPriceRupy = s.SetPriceRupy,
SetPriceTicket = s.SetPriceTicket,
TicketId = s.SetPriceTicketId,
},
Products = products,
};
}
return result;
}
[HttpPost("buy")]
public async Task<ActionResult<LeaderSkinBuyResponse>> Buy(LeaderSkinBuyRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
if (request.SalesType is 3)
return StatusCode(StatusCodes.Status501NotImplemented,
new { error = "ticket_currency_path_not_implemented" });
if (request.SalesType is < 0 or > 3)
return BadRequest(new { error = "invalid_sales_type" });
var product = await _db.LeaderSkinShopProducts
.Include(p => p.Rewards)
.Include(p => p.Series)
.FirstOrDefaultAsync(p => p.Id == request.ProductId);
if (product is null) return NotFound(new { error = "unknown_product" });
if (!product.IsEnabled || product.Series is not { IsEnabled: true })
return BadRequest(new { error = "product_not_available" });
var viewer = await LoadViewerGraphAsync(viewerId);
// Already-purchased = viewer owns the leader_skin this product grants.
if (viewer.LeaderSkins.Any(s => s.Id == product.LeaderSkinId))
return BadRequest(new { error = "already_purchased" });
var rewardList = new List<RewardListEntry>();
var debit = 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);
await ApplyRewardsAsync(viewer, product.Rewards, rewardList);
await _db.SaveChangesAsync();
return new LeaderSkinBuyResponse { RewardList = rewardList };
}
[HttpPost("buy_set")]
public async Task<ActionResult<LeaderSkinBuyResponse>> BuySet(LeaderSkinBuySetRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
if (request.SalesType is 3)
return StatusCode(StatusCodes.Status501NotImplemented,
new { error = "ticket_currency_path_not_implemented" });
if (request.SalesType is < 0 or > 3)
return BadRequest(new { error = "invalid_sales_type" });
var series = await _db.LeaderSkinShopSeries
.Include(s => s.Products.Where(p => p.IsEnabled)).ThenInclude(p => p.Rewards)
.FirstOrDefaultAsync(s => s.Id == request.SeriesId);
if (series is null) return NotFound(new { error = "unknown_series" });
if (!series.IsEnabled || series.SetSalesStatus == 0)
return BadRequest(new { error = "set_sale_not_active" });
var viewer = await LoadViewerGraphAsync(viewerId);
var rewardList = new List<RewardListEntry>();
var debit = 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);
// Grant every product's rewards; RewardGrantService is idempotent on already-owned
// cosmetics, so partial-set buyers don't double-add.
foreach (var p in series.Products.OrderBy(p => p.Id))
{
await ApplyRewardsAsync(viewer, p.Rewards, rewardList);
}
await _db.SaveChangesAsync();
return new LeaderSkinBuyResponse { RewardList = rewardList };
}
[HttpPost("buy_set_item")]
public async Task<ActionResult<LeaderSkinBuyResponse>> BuySetItem(LeaderSkinBuySetItemRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var series = await _db.LeaderSkinShopSeries
.Include(s => s.SetCompletionRewards)
.Include(s => s.Products.Where(p => p.IsEnabled))
.FirstOrDefaultAsync(s => s.Id == request.SeriesId);
if (series is null) return NotFound(new { error = "unknown_series" });
// Check claim hasn't been made already (idempotent — returns empty reward_list rather
// than 400 so the client doesn't error if it retries).
var existingClaim = await _db.ViewerLeaderSkinSetClaims
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.SeriesId == series.Id);
if (existingClaim is not null)
return new LeaderSkinBuyResponse { RewardList = new() };
var viewer = await LoadViewerGraphAsync(viewerId);
// Must own every skin in the series to claim the bonus.
var ownedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet();
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => ownedSkinIds.Contains(p.LeaderSkinId));
if (!ownsAll)
return BadRequest(new { error = "series_not_completed" });
var rewardList = new List<RewardListEntry>();
await ApplyRewardsAsync(viewer, series.SetCompletionRewards, rewardList);
_db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim
{
ViewerId = viewerId,
SeriesId = series.Id,
ClaimedAt = _time.GetUtcNow().UtcDateTime,
});
await _db.SaveChangesAsync();
return new LeaderSkinBuyResponse { RewardList = rewardList };
}
/// <summary>
/// Computes the per-viewer <c>rewards.status</c> for a series:
/// 0=none — set_sales_status==0 OR no bonus items configured (matches prod, which ships
/// status=0 for series where items[] is empty even when set_sales_status==1)
/// 1=not_got — bonus exists, series completed by viewer, bonus unclaimed
/// 2=got — viewer claimed the bonus
/// 1 (effectively "available later") when set sale active with bonus and viewer hasn't
/// completed the series.
/// The 1/2 distinction matches the client enum (RewardStatus.not_got vs .got).
/// <para>
/// Important: emitting status=1 when items[] is empty triggers the client's
/// <c>is_completed &amp;&amp; not_got</c> branch in SkinPurchaseInfoTask.CreateSetSaleInfo,
/// which marks the set sale as FREE and renders a useless "claim" button for a
/// nonexistent bonus. Always return 0 when there's nothing to claim.
/// </para>
/// </summary>
private static int ComputeRewardStatus(LeaderSkinShopSeriesEntry series, bool seriesCompleted, bool claimed)
{
if (series.SetSalesStatus == 0) return 0;
if (series.SetCompletionRewards.Count == 0) return 0;
if (claimed) return 2;
if (seriesCompleted) return 1;
return 1;
}
private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, HashSet<int> ownedSkinIds)
{
bool isPurchased = ownedSkinIds.Contains(p.LeaderSkinId);
return new SkinProductDto
{
ProductId = p.Id,
LeaderSkinId = p.LeaderSkinId,
ProductName = p.ProductNameKey,
Introduction = p.IntroductionKey,
CvName = p.CvNameKey,
IsPurchased = isPurchased,
Sale = new SkinProductSaleDto
{
SinglePriceCrystal = p.SinglePriceCrystal,
SinglePriceRupy = p.SinglePriceRupy,
SinglePriceTicket = p.SinglePriceTicket,
TicketNumber = p.TicketNumber,
ItemId = p.TicketItemId,
},
Rewards = p.Rewards.OrderBy(r => r.OrderIndex).Select(r => new SkinProductRewardDto
{
RewardType = r.RewardType,
RewardDetailId = r.RewardDetailId,
RewardNumber = r.RewardNumber,
IsOwned = IsRewardOwned(r, ownedSkinIds),
}).ToList(),
};
}
/// <summary>
/// A bundled reward shows as "owned" when the viewer already has the cosmetic. For now we
/// only flag the Skin reward (type==10) against the viewer's skin collection — the cascaded
/// emblem/sleeve typically come with the skin, so the heuristic is "skin owned → all three
/// bundle items are de-facto owned." Refine later if a capture shows independent state.
/// </summary>
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, HashSet<int> ownedSkinIds)
{
// Skin reward: direct check.
if (r.RewardType == (int)UserGoodsType.Skin)
return ownedSkinIds.Contains((int)r.RewardDetailId);
// Other types: we don't have the full cosmetic-owned graph in scope here. The product's
// sibling Skin reward tells us whether the bundle was purchased; piggy-back on that by
// letting the caller pre-compute IsPurchased. Conservative default: not owned.
return false;
}
private (RewardListEntry? PostState, string? Error) DebitProductPrice(
Viewer viewer, LeaderSkinShopProductEntry product, int salesType)
{
return salesType switch
{
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"),
};
}
private (RewardListEntry? PostState, string? Error) DebitSetPrice(
Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType)
{
return salesType switch
{
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"),
};
}
private static (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);
}
private static (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);
}
private async Task ApplyRewardsAsync<T>(
Viewer viewer, IEnumerable<T> rewards, List<RewardListEntry> rewardList) where T : notnull
{
foreach (var r in rewards)
{
var (type, detailId, number) = ExtractTuple(r);
var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)type, detailId, number);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
}
}
private static (int Type, long Id, int Num) ExtractTuple(object reward) => reward switch
{
LeaderSkinShopProductRewardEntry p => (p.RewardType, p.RewardDetailId, p.RewardNumber),
LeaderSkinShopSeriesRewardEntry s => (s.RewardType, s.RewardDetailId, s.RewardNumber),
_ => throw new InvalidOperationException($"unexpected reward type {reward.GetType().Name}"),
};
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.LeaderSkins)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
@@ -47,11 +48,14 @@ public class LoadController : SVSimController
private readonly ICardAcquisitionService _acquisition;
private readonly IGameConfigService _config;
private readonly IBattlePassService _battlePass;
private readonly IViewerMissionStateService _missionState;
private readonly SVSimDbContext _db;
public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository,
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository,
ICardAcquisitionService acquisition, IGameConfigService config,
IBattlePassService battlePass)
IBattlePassService battlePass, IViewerMissionStateService missionState,
SVSimDbContext db)
{
_viewerRepository = viewerRepository;
_cardRepository = cardRepository;
@@ -60,6 +64,8 @@ public class LoadController : SVSimController
_acquisition = acquisition;
_config = config;
_battlePass = battlePass;
_missionState = missionState;
_db = db;
}
[HttpPost("index")]
@@ -83,6 +89,11 @@ public class LoadController : SVSimController
// (on a separate tracked instance) won't appear on this snapshot. Without the re-fetch,
// the response payload would be one /load/index behind on newly-granted cosmetics.
await _acquisition.BackfillCosmeticsAsync(viewer.Id);
// Lazy-materialize mission/achievement state. Idempotent — safe to call every /load/index.
await _missionState.EnsureCurrentAsync(viewer.Id);
await _db.SaveChangesAsync();
viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
if (viewer is null)
{
@@ -170,6 +181,7 @@ public class LoadController : SVSimController
UserInfo = new UserInfo(deviceType, viewer),
UserCurrency = new UserCurrency(viewer),
UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
SpotPoint = checked((int)viewer.Currency.SpotPoints),
UserRotationDecks = new UserFormatDeckInfo
{
UserDecks = viewer.Decks.Where(d => d.Format == Format.Rotation)

View File

@@ -0,0 +1,124 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Mission;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Mission;
using SVSim.EmulatedEntrypoint.Models.Dtos.Mission;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /mission/* — daily/weekly mission slots + achievement claim flow. Wire shapes mirror
/// MissionInfoDetail.cs + Wizard/Mission*Task.cs.
/// </summary>
[Route("mission")]
public class MissionController : SVSimController
{
private const int RetireCooldownSeconds = 75600; // 21h per capture
private const int FailureResultCode = 2;
private readonly SVSimDbContext _db;
private readonly IViewerMissionStateService _state;
private readonly IMissionAssembler _assembler;
private readonly IMissionCatalogRepository _catalog;
private readonly IViewerMissionRepository _viewerRepo;
private readonly TimeProvider _time;
public MissionController(
SVSimDbContext db,
IViewerMissionStateService state,
IMissionAssembler assembler,
IMissionCatalogRepository catalog,
IViewerMissionRepository viewerRepo,
TimeProvider time)
{
_db = db;
_state = state;
_assembler = assembler;
_catalog = catalog;
_viewerRepo = viewerRepo;
_time = time;
}
[HttpPost("info")]
public async Task<IActionResult> Info(BaseRequest request, CancellationToken ct)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var viewer = await LoadViewer(viewerId, ct);
await _state.EnsureCurrentAsync(viewer.Id, ct);
await _db.SaveChangesAsync(ct);
var dto = await _assembler.BuildAsync(viewer, ct);
return Ok(dto);
}
[HttpPost("retire")]
public async Task<IActionResult> Retire(MissionRetireRequest request, CancellationToken ct)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var viewer = await LoadViewer(viewerId, ct);
var missions = await _viewerRepo.GetMissionsAsync(viewerId, ct);
var target = missions.FirstOrDefault(m => m.Id == request.Id);
if (target is null)
{
return Ok(new { result_code = FailureResultCode });
}
var catalogRow = await _catalog.GetByIdAsync(target.MissionCatalogId, ct);
if (catalogRow is null || catalogRow.LotType != 2)
{
return Ok(new { result_code = FailureResultCode });
}
var pool = await _catalog.GetByLotTypeAsync(2, ct);
var assignedIds = missions
.Where(m => m.Slot != target.Slot)
.Select(m => m.MissionCatalogId).ToHashSet();
var candidates = pool.Where(p => p.Id != target.MissionCatalogId && !assignedIds.Contains(p.Id)).ToList();
if (candidates.Count == 0)
{
return Ok(new { result_code = FailureResultCode });
}
var pick = candidates[Random.Shared.Next(candidates.Count)];
var now = _time.GetUtcNow();
_viewerRepo.RemoveMission(target);
_viewerRepo.AddMission(new ViewerMission
{
ViewerId = viewerId,
MissionCatalogId = pick.Id,
Slot = target.Slot,
AssignedAt = now.ToUnixTimeSeconds(),
MissionStatus = 1,
});
viewer.MissionData.MissionChangeTime = now.AddSeconds(RetireCooldownSeconds).UtcDateTime;
await _db.SaveChangesAsync(ct);
var dto = await _assembler.BuildAsync(viewer, ct);
return Ok(dto);
}
[HttpPost("change_receive_setting")]
public async Task<IActionResult> ChangeReceiveSetting(MissionChangeReceiveSettingRequest request, CancellationToken ct)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var viewer = await LoadViewer(viewerId, ct);
viewer.MissionData.MissionReceiveType = request.MissionReceiveType;
await _db.SaveChangesAsync(ct);
var dto = await _assembler.BuildAsync(viewer, ct);
return Ok(dto);
}
private Task<Viewer> LoadViewer(long viewerId, CancellationToken ct) =>
_db.Viewers
.Include(v => v.MissionData)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId, ct);
}

View File

@@ -7,6 +7,7 @@ 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.Practice;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -14,11 +15,16 @@ public class PracticeController : SVSimController
{
private readonly IDeckRepository _deckRepository;
private readonly IGlobalsRepository _globalsRepository;
private readonly IMissionProgressService _missionProgress;
public PracticeController(IDeckRepository deckRepository, IGlobalsRepository globalsRepository)
public PracticeController(
IDeckRepository deckRepository,
IGlobalsRepository globalsRepository,
IMissionProgressService missionProgress)
{
_deckRepository = deckRepository;
_globalsRepository = globalsRepository;
_missionProgress = missionProgress;
}
/// <summary>
@@ -83,15 +89,29 @@ public class PracticeController : SVSimController
/// XP / no rewards. Class XP bookkeeping is deferred until a per-class XP store exists.
/// </summary>
[HttpPost("finish")]
public Task<PracticeFinishResponse> Finish(PracticeFinishRequest request)
public async Task<PracticeFinishResponse> Finish(PracticeFinishRequest request)
{
return Task.FromResult(new PracticeFinishResponse
// Mission/achievement progress hook. Catalog rows for practice_win achievements use
// opponent NAMES (e.g. "practice_win:elite:arisa") — we only have numeric class_id /
// difficulty here, so we emit numeric forms. Bridging numeric→name to match captured
// catalog rows is a follow-up; the infrastructure is in place.
if (request.IsWin == 1 && TryGetViewerId(out long viewerId))
{
await _missionProgress.RecordEventAsync(viewerId, new[]
{
"practice_win",
$"practice_win:{request.Difficulty}",
$"practice_win:{request.Difficulty}:{request.EnemyClassId}",
});
}
return new PracticeFinishResponse
{
GetClassExperience = 0,
ClassExperience = 0,
ClassLevel = 1,
AchievedInfo = new Dictionary<string, object>(),
RewardList = new List<Models.Dtos.Common.Reward>()
});
};
}
}

View File

@@ -0,0 +1,190 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Sleeve;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Sleeve;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /sleeve/* — the sleeve shop. Catalog + single-product purchase. No series-completion bonus
/// (sleeves are sold individually; the leader-skin shop is the family with set-buys).
/// </summary>
[Route("sleeve")]
public class SleeveController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
public SleeveController(SVSimDbContext db, RewardGrantService rewards)
{
_db = db;
_rewards = rewards;
}
[HttpPost("info")]
public async Task<ActionResult<SleeveInfoResponse>> Info(BaseRequest _)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// 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 series = await _db.SleeveShopSeries
.Where(s => s.IsEnabled)
.Include(s => s.Products.Where(p => p.IsEnabled)).ThenInclude(p => p.Rewards)
.OrderBy(s => s.Id)
.ToListAsync();
var sleeveList = new Dictionary<string, SleeveSeriesDto>();
foreach (var s in series)
{
var products = new Dictionary<string, SleeveProductDto>();
foreach (var p in s.Products.OrderBy(p => p.Id))
{
products[p.Id.ToString()] = new SleeveProductDto
{
ProductId = p.Id,
Name = p.NameKey,
PriceCrystal = p.PriceCrystal,
PriceRupy = p.PriceRupy,
IsPurchasedProduct = IsProductPurchased(p, ownedSleeveIds),
Rewards = p.Rewards.OrderBy(r => r.OrderIndex).Select(r => new SleeveProductRewardDto
{
RewardType = r.RewardType,
RewardDetailId = r.RewardDetailId,
RewardNumber = r.RewardNumber,
}).ToList(),
};
}
sleeveList[s.Id.ToString()] = new SleeveSeriesDto
{
SeriesId = s.Id,
IsNew = s.IsNew,
ProductInfo = products,
};
}
return new SleeveInfoResponse { SleeveList = sleeveList };
}
[HttpPost("buy")]
public async Task<ActionResult<SleeveBuyResponse>> Buy(SleeveBuyRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
if (request.SalesType is 3)
return StatusCode(StatusCodes.Status501NotImplemented,
new { error = "ticket_currency_path_not_implemented" });
if (request.SalesType is < 0 or > 3)
return BadRequest(new { error = "invalid_sales_type" });
var product = await _db.SleeveShopProducts
.Include(p => p.Rewards)
.Include(p => p.Series)
.FirstOrDefaultAsync(p => p.Id == request.ProductId);
if (product is null) return NotFound(new { error = "unknown_product" });
if (!product.IsEnabled || product.Series is not { IsEnabled: true })
return BadRequest(new { error = "product_not_available" });
// Defence-in-depth: client also sends series_id; reject mismatches so a misencoded
// request can't accidentally bypass per-series state we'll later add (e.g. series-new flag).
if (product.SeriesId != request.SeriesId)
return BadRequest(new { error = "series_product_mismatch" });
var viewer = await LoadViewerGraphAsync(viewerId);
if (IsProductPurchased(product, viewer.Sleeves.Select(s => (long)s.Id).ToHashSet()))
return BadRequest(new { error = "already_purchased" });
// Pricing: capture-confirmed shape is single-price-per-currency (no intro/regular tiers
// like BuildDeck). At least one of crystal/rupy must match the chosen sales_type;
// sales_type==0 means "free", which requires both prices == 0.
var rewardList = new List<RewardListEntry>();
switch (request.SalesType)
{
case 0: // free
if (!(product.PriceCrystal == 0 && product.PriceRupy == 0))
return BadRequest(new { error = "price_not_available_for_currency" });
break;
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 });
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 });
break;
}
// Grant each catalog reward through the central dispatcher — covers sleeve (6), emblem
// (7), and any future bundled grants. ApplyAsync returns post-state-aware reward entries
// suitable for emission as-is.
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
{
var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
}
await _db.SaveChangesAsync();
return new SleeveBuyResponse { RewardList = rewardList };
}
/// <summary>
/// A product is "purchased" once the viewer owns at least one of its sleeve-typed reward
/// grants. Emblem/other grants aren't load-bearing for this check — a viewer who somehow
/// ended up with the emblem but not the sleeve (e.g. partial gift) should still be allowed
/// to buy the product to pick up the sleeve.
/// </summary>
private static bool IsProductPurchased(SleeveShopProductEntry product, HashSet<long> ownedSleeveIds)
{
foreach (var r in product.Rewards)
{
if (r.RewardType == (int)UserGoodsType.Sleeve && ownedSleeveIds.Contains(r.RewardDetailId))
return true;
}
return false;
}
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}

View File

@@ -0,0 +1,193 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.SpotCardExchange;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.SpotCardExchange;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /spot_card_exchange/* — trade spot points for individual cards from the rotating exchange
/// pool. Spot points are earned from battles/missions (not implemented here — earners live in
/// battle/mission finish reward emitters via <see cref="RewardGrantService"/> +
/// <see cref="UserGoodsType.SpotCardPoint"/>).
/// </summary>
[Route("spot_card_exchange")]
public class SpotCardExchangeController : SVSimController
{
/// <summary>
/// Pre-release exchange cap. Captures show "2" — global limit, not per-card. When
/// IsPreRelease is active on the catalog level we honour this; otherwise the cap is
/// effectively unbounded (UI never shows the warning).
/// </summary>
private const int PreReleaseLimit = 2;
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly TimeProvider _time;
public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
{
_db = db;
_rewards = rewards;
_time = time;
}
[HttpPost("top")]
public async Task<ActionResult<SpotCardExchangeTopResponse>> Top(BaseRequest _)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var viewer = await _db.Viewers
.Where(v => v.Id == viewerId)
.Select(v => new { v.Currency.SpotPoints })
.FirstOrDefaultAsync();
if (viewer is null) return Unauthorized();
var catalog = await _db.SpotCardExchangeCatalog
.Where(c => c.IsEnabled)
.OrderBy(c => c.Id)
.ToListAsync();
var exchanges = await _db.ViewerSpotCardExchanges
.Where(e => e.ViewerId == viewerId)
.ToListAsync();
var exchangedIds = exchanges.Select(e => e.CardId).ToHashSet();
int preReleaseExchangedCount = exchanges.Count(e => e.IsPreRelease);
bool preReleaseActive = catalog.Any(c => c.IsPreRelease);
bool preReleaseLimitHit = preReleaseExchangedCount >= PreReleaseLimit;
// Build the 9-clan-bucket dict-of-arrays. Every clan slot is present even when empty;
// the inner dict always uses key "1" matching the captured prod shape.
var byClan = new List<Dictionary<string, List<SpotCardExchangeCardDto>>>(9);
for (int clan = 0; clan < 9; clan++)
{
byClan.Add(new Dictionary<string, List<SpotCardExchangeCardDto>>
{
["1"] = new List<SpotCardExchangeCardDto>(),
});
}
foreach (var c in catalog)
{
int clanIdx = Math.Clamp(c.ClassId, 0, 8);
byClan[clanIdx]["1"].Add(new SpotCardExchangeCardDto
{
CardId = c.Id,
ExchangeStatus = ComputeExchangeStatus(c, exchangedIds, preReleaseLimitHit),
ExchangePoint = c.ExchangePoint.ToString(),
Class = c.ClassId.ToString(),
IsPreRelease = c.IsPreRelease,
TsRotationId = c.TsRotationId.ToString(),
});
}
return new SpotCardExchangeTopResponse
{
SpotPoint = checked((int)viewer.SpotPoints),
ExchangeableCardList = byClan,
SoonCycleOutCardSetId = string.Empty, // No captured value to derive; spec allows ""
PreReleaseInfo = new PreReleaseInfoDto
{
IsPreRelease = preReleaseActive,
PreReleaseSpotCardExchangeCount = preReleaseExchangedCount,
PreReleaseSpotCardExchangeLimit = PreReleaseLimit,
},
};
}
[HttpPost("exchange")]
public async Task<ActionResult<SpotCardExchangeResponse>> Exchange(SpotCardExchangeRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var entry = await _db.SpotCardExchangeCatalog.FindAsync((long)request.CardId);
if (entry is null || !entry.IsEnabled)
return BadRequest(new { error = "unknown_card" });
// Already-exchanged guard — each catalog row is one card per viewer.
var existingExchange = await _db.ViewerSpotCardExchanges
.FirstOrDefaultAsync(e => e.ViewerId == viewerId && e.CardId == entry.Id);
if (existingExchange is not null)
return BadRequest(new { error = "already_exchanged" });
if (entry.IsPreRelease)
{
int prCount = await _db.ViewerSpotCardExchanges
.CountAsync(e => e.ViewerId == viewerId && e.IsPreRelease);
if (prCount >= PreReleaseLimit)
return BadRequest(new { error = "pre_release_limit_reached" });
}
var viewer = await LoadViewerGraphAsync(viewerId);
var rewardList = new List<RewardListEntry>();
// 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)
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),
});
// Grant the card itself via the existing card dispatcher (handles cosmetic cascade).
var granted = await _rewards.ApplyAsync(viewer, UserGoodsType.Card, entry.Id, 1);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
_db.ViewerSpotCardExchanges.Add(new ViewerSpotCardExchange
{
ViewerId = viewerId,
CardId = entry.Id,
IsPreRelease = entry.IsPreRelease,
ExchangedAt = _time.GetUtcNow().UtcDateTime,
});
await _db.SaveChangesAsync();
return new SpotCardExchangeResponse { RewardList = rewardList };
}
/// <summary>
/// Maps to <see cref="Wizard.SpotCardExchangeInfo.ExchangeStatus"/>:
/// 0 = EnableExchange
/// 1 = AlreadyExchange (viewer has already exchanged this card)
/// 2 = LimitOver (pre-release card and viewer hit the global pre-release cap)
/// Insufficient-balance is NOT surfaced here — the client greys those out by comparing
/// <c>spot_point</c> to <c>exchange_point</c>.
/// </summary>
private static int ComputeExchangeStatus(SpotCardExchangeEntry c, HashSet<long> exchangedIds, bool preReleaseLimitHit)
{
if (exchangedIds.Contains(c.Id)) return 1;
if (c.IsPreRelease && preReleaseLimitHit) return 2;
return 0;
}
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}

View File

@@ -11,7 +11,12 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class StoryController : SVSimController
{
private readonly IStoryService _service;
public StoryController(IStoryService service) { _service = service; }
private readonly IMissionProgressService _missionProgress;
public StoryController(IStoryService service, IMissionProgressService missionProgress)
{
_service = service;
_missionProgress = missionProgress;
}
[HttpPost("/story/section")]
[HttpPost("/main_story/section")]
@@ -65,7 +70,28 @@ public class StoryController : SVSimController
public async Task<ActionResult<FinishResponse>> Finish(FinishRequest req)
{
if (!TryGetViewerId(out long vid)) return Unauthorized();
return await _service.FinishAsync(ResolveApiType(), req, vid);
var result = await _service.FinishAsync(ResolveApiType(), req, vid);
// Emit story-chapter-finish events for mission/achievement progress.
var apiType = ResolveApiType();
var prefix = apiType switch
{
StoryApiType.Main => "main",
StoryApiType.Limited => "limited",
StoryApiType.Event => "event",
_ => null,
};
if (prefix is not null && req.StoryId != 0)
{
await _missionProgress.RecordEventAsync(vid, new[]
{
"story_chapter_finish",
$"story_chapter_finish:{prefix}",
$"story_chapter_finish:{prefix}:{req.StoryId}",
});
}
return result;
}
[HttpPost("/main_story/all_finish")]

View File

@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
namespace SVSim.EmulatedEntrypoint.Controllers;
public class ToolController : SVSimController
{
private readonly ILogger<ToolController> _logger;
private readonly IViewerRepository _viewerRepository;
public ToolController(ILogger<ToolController> logger, IViewerRepository viewerRepository)
{
_logger = logger;
_viewerRepository = viewerRepository;
}
/// <summary>
/// <c>POST /tool/signup</c> — the client's first request on a fresh boot. Creates (or returns
/// the existing) Viewer keyed on the request's UDID. The interesting outputs (viewer_id,
/// short_udid, udid) all flow back via <c>data_headers</c>, populated by the translation
/// middleware after this action returns — we just need to stash the viewer on HttpContext so
/// the middleware picks it up the same way the auth handler does for logged-in endpoints.
///
/// Spec: <c>docs/api-spec/endpoints/pre-login/tool-signup.md</c>.
/// </summary>
[AllowAnonymous]
[HttpPost("signup")]
public async Task<SignupResponse> Signup([FromBody] SignupRequest request)
{
Guid? maybeUdid = HttpContext.GetUdid();
if (maybeUdid is not Guid udid || udid == Guid.Empty)
{
throw new InvalidOperationException(
"Cannot register viewer: request has no resolvable UDID (missing UDID/SID headers, or " +
"SessionidMappingMiddleware couldn't decode the UDID header).");
}
var viewer = await _viewerRepository.GetViewerByUdid(udid)
?? await _viewerRepository.RegisterAnonymousViewer(udid);
HttpContext.SetViewer(viewer);
_logger.LogInformation("Signup resolved for udid={Udid} → viewer_id={ViewerId}, short_udid={ShortUdid}.",
udid, viewer.Id, viewer.ShortUdid);
return new SignupResponse();
}
}

View File

@@ -1,11 +1,13 @@
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Extensions;
public static class HttpContextExtensions
{
private const string ViewerItemName = "SVSimViewer";
public static Viewer? GetViewer(this HttpContext context)
{
if (context.Items.TryGetValue(ViewerItemName, out object? viewer))
@@ -21,4 +23,18 @@ public static class HttpContextExtensions
context.Items[ViewerItemName] = viewer;
return viewer;
}
}
/// <summary>
/// Resolves the client's UDID for this request by looking up the SID header in the
/// in-memory SID→UDID dict that <see cref="Middlewares.SessionidMappingMiddleware"/>
/// populates from the UDID header. Returns null when the SID isn't mapped (e.g. the
/// request didn't carry a UDID header at all, or carried an undecodable one).
/// </summary>
public static Guid? GetUdid(this HttpContext context)
{
string? sid = context.Request.Headers[NetworkConstants.SessionIdHeaderName];
if (sid is null) return null;
var sessionService = context.RequestServices.GetService<ShadowverseSessionService>();
return sessionService?.GetUdidFromSessionId(sid);
}
}

View File

@@ -100,7 +100,19 @@ public class ShadowverseTranslationMiddleware : IMiddleware
throw;
}
Type requestType = endpointDescriptor.Parameters.FirstOrDefault().ParameterType;
var firstParam = endpointDescriptor.Parameters.FirstOrDefault();
if (firstParam is null)
{
// Action method has no parameters — middleware can't bind the (encrypted+msgpacked)
// body to anything. The codebase convention is to take a BaseRequest even for body-
// less endpoints (see e.g. PuzzleController.Info(BaseRequest _)). Fail loud with a
// specific message rather than NREing below on .ParameterType.
throw new InvalidOperationException(
$"Action {endpointDescriptor.DisplayName} has no parameters; the SV translation " +
"middleware needs at least one to bind the decrypted body. Add a BaseRequest parameter " +
"(or a derived DTO) — see other *Info/*Top actions for the convention.");
}
Type requestType = firstParam.ParameterType;
object? data;
try
{
@@ -166,7 +178,11 @@ public class ShadowverseTranslationMiddleware : IMiddleware
// populated (prod sends real numbers for the title check too, but 0 / 0 satisfies
// the client's BaseTask.Parse which only reads result_code + servertime here).
ShortUdid = viewer?.ShortUdid ?? 0,
ViewerId = viewer?.Id ?? 0
ViewerId = viewer?.Id ?? 0,
// Echo the decrypted-against UDID. Most clients ignore this field; SignUpTask.Parse
// requires it (validates against Certification.Udid on the response). Comes from
// mappedUdid (the value used for AES); never from controller state.
Udid = mappedUdid?.ToString() ?? ""
}
};

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Achievement;
[MessagePackObject]
public class AchievementReceiveRewardRequest : BaseRequest
{
[Key("achievement_type")]
[JsonPropertyName("achievement_type")]
public int AchievementType { get; set; }
[Key("level")]
[JsonPropertyName("level")]
public int Level { get; set; }
}

View File

@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Mission;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Achievement;
[MessagePackObject(keyAsPropertyName: true)]
public class TotalReceiveCountDto
{
[Key(0)][JsonPropertyName("reward_type")] public int RewardType { get; set; }
[Key(1)][JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
[Key(2)][JsonPropertyName("reward_count")] public int RewardCount { get; set; }
[Key(3)][JsonPropertyName("item_type")] public int ItemType { get; set; }
[Key(4)][JsonPropertyName("is_usable")] public bool IsUsable { get; set; } = true;
}
[MessagePackObject(keyAsPropertyName: true)]
public class RewardGrantDto
{
[Key(0)][JsonPropertyName("reward_type")] public int RewardType { get; set; }
[Key(1)][JsonPropertyName("reward_id")] public long RewardId { get; set; }
[Key(2)][JsonPropertyName("reward_num")] public int RewardNum { get; set; }
}
/// <summary>
/// /achievement/receive_reward response — MissionInfoDataDto + two extras consumed by
/// PlayerStaticData.UpdateHaveUserGoodsNumByJsonData per AchievementReceiveRewardTask.cs:33.
/// </summary>
public sealed class AchievementReceiveRewardResponse : MissionInfoDataDto
{
[JsonPropertyName("total_receive_count_list")] public List<TotalReceiveCountDto> TotalReceiveCountList { get; set; } = new();
[JsonPropertyName("reward_list")] public List<RewardGrantDto> RewardList { get; set; } = new();
}

View File

@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.Mission;
/// <summary>
/// Inner reward block. STRING-typed on wire (capture confirms reward_type/reward_detail_id/
/// reward_number all serialize as JSON strings here, unlike UserMission where they're int).
/// </summary>
[MessagePackObject(keyAsPropertyName: true)]
public class BPMonthlyMissionRewardInfoDto
{
[Key(0)][JsonPropertyName("reward_type")] public string RewardType { get; set; } = "";
[Key(1)][JsonPropertyName("reward_detail_id")] public string RewardDetailId { get; set; } = "";
[Key(2)][JsonPropertyName("reward_number")] public string RewardNumber { get; set; } = "";
}
/// <summary>
/// One BP monthly mission. reward_info is OPTIONAL — capture shows "Play 5 Challenge matches"
/// has no reward_info block (only BP points). Global WhenWritingNull policy omits when null.
/// </summary>
[MessagePackObject(keyAsPropertyName: true)]
public class BPMonthlyMissionDto
{
[Key(0)][JsonPropertyName("name")] public string Name { get; set; } = "";
[Key(1)][JsonPropertyName("is_cleared")] public bool IsCleared { get; set; }
[Key(2)][JsonPropertyName("require_number")] public int RequireNumber { get; set; }
[Key(3)][JsonPropertyName("done_number")] public int DoneNumber { get; set; }
[Key(4)][JsonPropertyName("battle_pass_point")] public int BattlePassPoint { get; set; }
[Key(5)][JsonPropertyName("reward_info")] public BPMonthlyMissionRewardInfoDto? RewardInfo { get; set; }
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.Mission;
/// <summary>
/// Outer block. Date strings use the capture's space-separated JST format
/// ("2026-05-01 02:00:00"). The whole block is omitted from /mission/info when no monthly
/// missions are seeded for the current month.
/// </summary>
[MessagePackObject(keyAsPropertyName: true)]
public class BPMonthlyMissionsDto
{
[Key(0)][JsonPropertyName("start_date")] public string StartDate { get; set; } = "";
[Key(1)][JsonPropertyName("end_date")] public string EndDate { get; set; } = "";
[Key(2)][JsonPropertyName("mission_list")] public List<BPMonthlyMissionDto> MissionList { get; set; } = new();
}

View File

@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.Mission;
/// <summary>
/// Top-level payload for /mission/info responses (also reused by /mission/retire,
/// /mission/change_receive_setting; /achievement/receive_reward adds reward_list +
/// total_receive_count_list to this shape via inheritance).
///
/// CanChangeMissionTime is wire-required to be present (capture shows null when active).
/// Override [JsonIgnore(Condition = Never)] per memory project_wire_null_policy.
/// </summary>
[MessagePackObject(keyAsPropertyName: true)]
public class MissionInfoDataDto
{
[Key(0)][JsonPropertyName("user_mission_list")] public List<UserMissionDto> UserMissionList { get; set; } = new();
[Key(1)][JsonPropertyName("is_change_mission")] public bool IsChangeMission { get; set; }
[Key(2)]
[JsonPropertyName("can_change_mission_time")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public long? CanChangeMissionTime { get; set; }
[Key(3)][JsonPropertyName("is_change_receive_type")] public bool IsChangeReceiveType { get; set; }
[Key(4)]
[JsonPropertyName("can_change_receive_type_time")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public long? CanChangeReceiveTypeTime { get; set; }
[Key(5)][JsonPropertyName("user_achievement_list")] public List<UserAchievementDto> UserAchievementList { get; set; } = new();
[Key(6)][JsonPropertyName("mission_receive_type")] public string MissionReceiveType { get; set; } = "0";
[Key(7)]
[JsonPropertyName("battle_pass_monthly_mission")]
public BPMonthlyMissionsDto? BattlePassMonthlyMission { get; set; }
}

View File

@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.Mission;
/// <summary>
/// Wire shape of UserAchievement (per MissionInfoDetail.cs:98-116). ios/android are always
/// empty strings in our world. max_level is computed from catalog (MAX(Level) per type).
/// </summary>
[MessagePackObject(keyAsPropertyName: true)]
public class UserAchievementDto
{
[Key(0)][JsonPropertyName("achievement_type")] public int AchievementType { get; set; }
[Key(1)][JsonPropertyName("achievement_status")] public int AchievementStatus { get; set; }
[Key(2)][JsonPropertyName("level")] public int Level { get; set; }
[Key(3)][JsonPropertyName("now_achieved_level")] public int NowAchievedLevel { get; set; }
[Key(4)][JsonPropertyName("result_announce_saw_level")] public int ResultAnnounceSawLevel { get; set; }
[Key(5)][JsonPropertyName("total_count")] public int TotalCount { get; set; }
[Key(6)][JsonPropertyName("achievement_name")] public string AchievementName { get; set; } = "";
[Key(7)][JsonPropertyName("require_number")] public int RequireNumber { get; set; }
[Key(8)][JsonPropertyName("reward_type")] public int RewardType { get; set; }
[Key(9)][JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
[Key(10)][JsonPropertyName("reward_number")] public int RewardNumber { get; set; }
[Key(11)][JsonPropertyName("max_level")] public int MaxLevel { get; set; }
[Key(12)][JsonPropertyName("order_num")] public int OrderNum { get; set; }
[Key(13)][JsonPropertyName("ios")] public string Ios { get; set; } = "";
[Key(14)][JsonPropertyName("android")] public string Android { get; set; } = "";
}

View File

@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.Mission;
/// <summary>
/// Wire shape of UserMission (per MissionInfoDetail.cs:75-95). lot_type and battle_pass_point
/// are STRING-typed on wire (client uses .ToInt() but emits as string in capture). All other
/// scalar fields are int. end_time omitted when null per UserMission.Parse() optional read.
/// </summary>
[MessagePackObject(keyAsPropertyName: true)]
public class UserMissionDto
{
[Key(0)][JsonPropertyName("id")] public long Id { get; set; }
[Key(1)][JsonPropertyName("mission_id")] public int MissionId { get; set; }
[Key(2)][JsonPropertyName("total_count")] public int TotalCount { get; set; }
[Key(3)][JsonPropertyName("mission_status")] public int MissionStatus { get; set; }
[Key(4)][JsonPropertyName("display_order")] public int DisplayOrder { get; set; }
[Key(5)][JsonPropertyName("mission_name")] public string MissionName { get; set; } = "";
[Key(6)][JsonPropertyName("lot_type")] public string LotType { get; set; } = "";
[Key(7)][JsonPropertyName("battle_pass_point")] public string BattlePassPoint { get; set; } = "";
[Key(8)][JsonPropertyName("require_number")] public int RequireNumber { get; set; }
[Key(9)][JsonPropertyName("reward_type")] public int RewardType { get; set; }
[Key(10)][JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
[Key(11)][JsonPropertyName("reward_number")] public int RewardNumber { get; set; }
[Key(12)][JsonPropertyName("default_flag")] public bool DefaultFlag { get; set; }
[Key(13)][JsonPropertyName("start_time")] public long StartTime { get; set; }
[Key(14)][JsonPropertyName("end_time")] public long? EndTime { get; set; }
}

View File

@@ -21,4 +21,15 @@ public class DataHeaders
[JsonPropertyName("result_code")]
[Key("result_code")]
public int ResultCode { get; set; }
}
/// <summary>
/// Echoed UDID. Read by <c>SignUpTask.Parse</c> to validate response identity (client logs
/// <c>udid一致しません</c> and discards the response on mismatch); ignored by every other
/// client task. Always set by <c>ShadowverseTranslationMiddleware</c> from the request's
/// resolved UDID — never from controller state. Empty string when the SID→UDID lookup misses
/// (request without UDID/SID headers).
/// </summary>
[JsonPropertyName("udid")]
[Key("udid")]
public string Udid { get; set; } = "";
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Mission;
[MessagePackObject]
public class MissionChangeReceiveSettingRequest : BaseRequest
{
[Key("mission_receive_type")]
[JsonPropertyName("mission_receive_type")]
public int MissionReceiveType { get; set; }
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Mission;
[MessagePackObject]
public class MissionRetireRequest : BaseRequest
{
[Key("id")]
[JsonPropertyName("id")]
public long Id { get; set; }
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
/// <summary>
/// /item_purchase/purchase request body. <c>rest</c> is the client's locally-cached remaining
/// quota — used as an optional optimistic-concurrency check on the server. Not authoritative;
/// the server's own counter is canonical.
/// </summary>
[MessagePackObject]
public class ItemPurchasePurchaseRequest : BaseRequest
{
[JsonPropertyName("purchase_id")]
[Key("purchase_id")]
public int PurchaseId { get; set; }
[JsonPropertyName("rest")]
[Key("rest")]
public int Rest { get; set; }
}

View File

@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
/// <summary>
/// /leader_skin/buy request body. sales_type is ShopCommonUtility.SalesType:
/// 0=free, 1=crystal, 2=rupy, 3=ticket (v1: 3 returns 501 — no ticket-priced skin captured).
/// <see cref="ItemId"/> is the ticket item id when paying with a ticket, null otherwise.
/// </summary>
[MessagePackObject]
public class LeaderSkinBuyRequest : BaseRequest
{
[JsonPropertyName("product_id")]
[Key("product_id")]
public int ProductId { get; set; }
[JsonPropertyName("sales_type")]
[Key("sales_type")]
public int SalesType { get; set; }
[JsonPropertyName("item_id")]
[Key("item_id")]
public long? ItemId { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
/// <summary>
/// /leader_skin/buy_set_item — claim the series-completion bonus once every skin in the series
/// is owned. <c>sales_type</c> field exists on the client's param class but is never set; server
/// ignores it.
/// </summary>
[MessagePackObject]
public class LeaderSkinBuySetItemRequest : BaseRequest
{
[JsonPropertyName("series_id")]
[Key("series_id")]
public int SeriesId { get; set; }
}

View File

@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
/// <summary>
/// /leader_skin/buy_set — purchase every skin in a series in one call (cheaper per-skin).
/// </summary>
[MessagePackObject]
public class LeaderSkinBuySetRequest : BaseRequest
{
[JsonPropertyName("series_id")]
[Key("series_id")]
public int SeriesId { get; set; }
[JsonPropertyName("sales_type")]
[Key("sales_type")]
public int SalesType { get; set; }
[JsonPropertyName("item_id")]
[Key("item_id")]
public long? ItemId { get; set; }
}

View File

@@ -0,0 +1,40 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
/// <summary>
/// <c>POST /tool/signup</c> request body. Spec:
/// <c>docs/api-spec/endpoints/pre-login/tool-signup.md</c>. Client source:
/// <c>Shadowverse_Code_2026-05-23/Cute/SignUpTask.cs</c> (LoginPostParams).
///
/// All fields are device telemetry; the server doesn't use them in v1 but still binds them so
/// the request shape matches the spec exactly.
/// </summary>
[MessagePackObject]
public class SignupRequest
{
[JsonPropertyName("device_name")]
[Key("device_name")]
public string DeviceName { get; set; } = "";
[JsonPropertyName("client_type")]
[Key("client_type")]
public string ClientType { get; set; } = "";
[JsonPropertyName("os_version")]
[Key("os_version")]
public string OsVersion { get; set; } = "";
[JsonPropertyName("app_version")]
[Key("app_version")]
public string AppVersion { get; set; } = "";
[JsonPropertyName("resource_version")]
[Key("resource_version")]
public string ResourceVersion { get; set; } = "";
[JsonPropertyName("carrier")]
[Key("carrier")]
public string Carrier { get; set; } = "";
}

View File

@@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Sleeve;
/// <summary>
/// /sleeve/buy request body. sales_type is ShopCommonUtility.SalesType:
/// 0=free, 1=crystal, 2=rupy, 3=ticket (v1: 3 returns 501, no ticket-priced sleeve captured).
/// </summary>
[MessagePackObject]
public class SleeveBuyRequest : BaseRequest
{
[JsonPropertyName("series_id")]
[Key("series_id")]
public int SeriesId { get; set; }
[JsonPropertyName("product_id")]
[Key("product_id")]
public int ProductId { get; set; }
[JsonPropertyName("sales_type")]
[Key("sales_type")]
public int SalesType { get; set; }
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.SpotCardExchange;
/// <summary>
/// /spot_card_exchange/exchange request — trade <see cref="ExchangePoint"/> spot points for
/// the card identified by <see cref="CardId"/>. The exchange_point field is the client's view
/// of the price (sanity-check it against the catalog server-side).
/// </summary>
[MessagePackObject]
public class SpotCardExchangeRequest : BaseRequest
{
[JsonPropertyName("card_id")]
[Key("card_id")]
public int CardId { get; set; }
[JsonPropertyName("exchange_point")]
[Key("exchange_point")]
public int ExchangePoint { get; set; }
}

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