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 BattlePassImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassSeasonImporter().ImportAsync(context, opts.SeedDir); await new BattlePassSeasonImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassRewardImporter().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 DailyLoginBonusImporter().ImportAsync(context, opts.SeedDir);
await new PreReleaseInfoImporter().ImportAsync(context, opts.SeedDir); await new PreReleaseInfoImporter().ImportAsync(context, opts.SeedDir);
await new CardListsImporter().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 PracticeOpponentImporter().ImportAsync(context, opts.SeedDir);
await new PaymentItemImporter().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(); var puzzleImporter = new PuzzleImporter();
await puzzleImporter.ImportGroupsAsync(context, opts.SeedDir); await puzzleImporter.ImportGroupsAsync(context, opts.SeedDir);
await puzzleImporter.ImportPuzzlesAsync(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"); 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 => modelBuilder.Entity("SVSim.Database.Models.ArenaSeasonConfig", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -516,6 +558,64 @@ namespace SVSim.Database.Migrations
b.ToTable("BattlePassLevels"); 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 => modelBuilder.Entity("SVSim.Database.Models.BattlePassRewardEntry", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -995,11 +1095,65 @@ namespace SVSim.Database.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Items"); 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 => modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1028,6 +1182,100 @@ namespace SVSim.Database.Migrations
b.ToTable("LeaderSkins"); 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 => modelBuilder.Entity("SVSim.Database.Models.LoadingExclusionCardEntry", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -1094,6 +1342,63 @@ namespace SVSim.Database.Migrations
b.ToTable("MasterPointRankingPeriods"); 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 => modelBuilder.Entity("SVSim.Database.Models.MyPageBackgroundEntry", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1800,6 +2105,62 @@ namespace SVSim.Database.Migrations
b.ToTable("Sleeves"); 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 => modelBuilder.Entity("SVSim.Database.Models.SpecialDeckFormatEntry", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1845,6 +2206,40 @@ namespace SVSim.Database.Migrations
b.ToTable("SpotCards"); 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 => modelBuilder.Entity("SVSim.Database.Models.UnlimitedRestrictionEntry", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -1895,13 +2290,44 @@ namespace SVSim.Database.Migrations
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("ShortUdid"), "ShortUdidSequence"); NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("ShortUdid"), "ShortUdidSequence");
b.Property<Guid?>("Udid")
.HasColumnType("uuid");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ShortUdid"); b.HasIndex("ShortUdid");
b.HasIndex("Udid")
.IsUnique();
b.ToTable("Viewers"); 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 => modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassClaimEntry", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -1981,6 +2407,87 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerBattlePassProgress"); 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 => modelBuilder.Entity("SVSim.Database.Models.ViewerPuzzleClear", b =>
{ {
b.Property<long>("ViewerId") b.Property<long>("ViewerId")
@@ -2000,6 +2507,27 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerPuzzleClears"); 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 => modelBuilder.Entity("SleeveEntryViewer", b =>
{ {
b.Property<int>("SleevesId") b.Property<int>("SleevesId")
@@ -2353,6 +2881,86 @@ namespace SVSim.Database.Migrations
b.Navigation("Class"); 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 => modelBuilder.Entity("SVSim.Database.Models.PackConfigEntry", b =>
{ {
b.OwnsMany("SVSim.Database.Models.PackBannerEntry", "Banners", b1 => b.OwnsMany("SVSim.Database.Models.PackBannerEntry", "Banners", b1 =>
@@ -2570,6 +3178,50 @@ namespace SVSim.Database.Migrations
b.Navigation("Sleeve"); 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 => modelBuilder.Entity("SVSim.Database.Models.Viewer", b =>
{ {
b.OwnsMany("SVSim.Database.Models.OwnedCardEntry", "Cards", b1 => b.OwnsMany("SVSim.Database.Models.OwnedCardEntry", "Cards", b1 =>
@@ -2672,6 +3324,9 @@ namespace SVSim.Database.Migrations
b1.HasKey("ViewerId", "Id"); b1.HasKey("ViewerId", "Id");
b1.HasIndex("AccountType", "AccountId")
.IsUnique();
b1.ToTable("SocialAccountConnection"); b1.ToTable("SocialAccountConnection");
b1.WithOwner("Viewer") b1.WithOwner("Viewer")
@@ -2790,6 +3445,9 @@ namespace SVSim.Database.Migrations
b1.Property<decimal>("Rupees") b1.Property<decimal>("Rupees")
.HasColumnType("numeric(20,0)"); .HasColumnType("numeric(20,0)");
b1.Property<decimal>("SpotPoints")
.HasColumnType("numeric(20,0)");
b1.Property<decimal>("SteamCrystals") b1.Property<decimal>("SteamCrystals")
.HasColumnType("numeric(20,0)"); .HasColumnType("numeric(20,0)");
@@ -2931,6 +3589,33 @@ namespace SVSim.Database.Migrations
b.Navigation("SocialAccountConnections"); 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 => modelBuilder.Entity("SleeveEntryViewer", b =>
{ {
b.HasOne("SVSim.Database.Models.SleeveEntry", null) b.HasOne("SVSim.Database.Models.SleeveEntry", null)
@@ -2961,6 +3646,11 @@ namespace SVSim.Database.Migrations
b.Navigation("LeaderSkins"); b.Navigation("LeaderSkins");
}); });
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinShopSeriesEntry", b =>
{
b.Navigation("Products");
});
modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b => modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b =>
{ {
b.Navigation("Puzzles"); b.Navigation("Puzzles");
@@ -2971,9 +3661,20 @@ namespace SVSim.Database.Migrations
b.Navigation("Cards"); b.Navigation("Cards");
}); });
modelBuilder.Entity("SVSim.Database.Models.SleeveShopSeriesEntry", b =>
{
b.Navigation("Products");
});
modelBuilder.Entity("SVSim.Database.Models.Viewer", b => modelBuilder.Entity("SVSim.Database.Models.Viewer", b =>
{ {
b.Navigation("Achievements");
b.Navigation("Decks"); b.Navigation("Decks");
b.Navigation("EventCounters");
b.Navigation("Missions");
}); });
#pragma warning restore 612, 618 #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; 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 class ItemEntry : BaseEntity<int>
{ {
public string Name { get; set; } = string.Empty; 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. /// A user within the game system.
/// </summary> /// </summary>
[Index(nameof(ShortUdid))] [Index(nameof(ShortUdid))]
[Index(nameof(Udid), IsUnique = true)]
public class Viewer : BaseEntity<long> public class Viewer : BaseEntity<long>
{ {
[DatabaseGenerated(DatabaseGeneratedOption.Identity)] [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
@@ -17,11 +18,18 @@ public class Viewer : BaseEntity<long>
/// This user's name displayed in game. /// This user's name displayed in game.
/// </summary> /// </summary>
public string DisplayName { get; set; } = String.Empty; public string DisplayName { get; set; } = String.Empty;
/// <summary> /// <summary>
/// This user's short identifier. /// This user's short identifier.
/// </summary> /// </summary>
public long ShortUdid { get; set; } 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; } 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<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 #endregion
#region Navigation Properties #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 LifeTotalCrystals { get; set; }
public ulong RedEther { get; set; } public ulong RedEther { get; set; }
public ulong Rupees { 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?> GetViewerBySocialConnection(SocialAccountType accountType, ulong socialId);
Task<Models.Viewer?> GetViewerWithSocials(long id); Task<Models.Viewer?> GetViewerWithSocials(long id);
Task<Models.Viewer?> GetViewerByShortUdid(long shortUdid); Task<Models.Viewer?> GetViewerByShortUdid(long shortUdid);
Task<Models.Viewer?> GetViewerByUdid(Guid udid);
Task<Models.Viewer> RegisterViewer(string displayName, SocialAccountType socialType, Task<Models.Viewer> RegisterViewer(string displayName, SocialAccountType socialType,
ulong socialAccountIdentifier, ulong? shortUdid = null); 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 Microsoft.EntityFrameworkCore;
using Npgsql;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Models.Config; using SVSim.Database.Models.Config;
@@ -70,6 +71,100 @@ public class ViewerRepository : IViewerRepository
public async Task<Models.Viewer> RegisterViewer(string displayName, SocialAccountType socialType, public async Task<Models.Viewer> RegisterViewer(string displayName, SocialAccountType socialType,
ulong socialAccountIdentifier, ulong? shortUdid = null) 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 Models.Viewer viewer = new Models.Viewer
{ {
@@ -79,12 +174,6 @@ public class ViewerRepository : IViewerRepository
var grants = _config.Get<DefaultGrantsConfig>(); var grants = _config.Get<DefaultGrantsConfig>();
var loadout = _config.Get<DefaultLoadoutConfig>(); var loadout = _config.Get<DefaultLoadoutConfig>();
viewer.SocialAccountConnections.Add(new SocialAccountConnection
{
AccountId = socialAccountIdentifier,
AccountType = socialType
});
viewer.Info.MaxFriends = player.MaxFriends; viewer.Info.MaxFriends = player.MaxFriends;
viewer.Info.CountryCode = "KOR"; viewer.Info.CountryCode = "KOR";
viewer.Info.BirthDate = DateTime.UtcNow; viewer.Info.BirthDate = DateTime.UtcNow;
@@ -133,8 +222,6 @@ public class ViewerRepository : IViewerRepository
.ToList(); .ToList();
viewer.LeaderSkins.AddRange(grantedSkins); viewer.LeaderSkins.AddRange(grantedSkins);
_dbContext.Set<Models.Viewer>().Add(viewer);
await _dbContext.SaveChangesAsync();
return viewer; return viewer;
} }
} }

View File

@@ -54,6 +54,12 @@ public class SVSimDbContext : DbContext
public DbSet<BattlePassRewardEntry> BattlePassRewards => Set<BattlePassRewardEntry>(); public DbSet<BattlePassRewardEntry> BattlePassRewards => Set<BattlePassRewardEntry>();
public DbSet<ViewerBattlePassProgressEntry> ViewerBattlePassProgress => Set<ViewerBattlePassProgressEntry>(); public DbSet<ViewerBattlePassProgressEntry> ViewerBattlePassProgress => Set<ViewerBattlePassProgressEntry>();
public DbSet<ViewerBattlePassClaimEntry> ViewerBattlePassClaims => Set<ViewerBattlePassClaimEntry>(); 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<DailyLoginBonusEntry> DailyLoginBonuses => Set<DailyLoginBonusEntry>();
public DbSet<BannerEntry> Banners => Set<BannerEntry>(); public DbSet<BannerEntry> Banners => Set<BannerEntry>();
public DbSet<ColosseumConfig> Colosseums => Set<ColosseumConfig>(); public DbSet<ColosseumConfig> Colosseums => Set<ColosseumConfig>();
@@ -64,6 +70,14 @@ public class SVSimDbContext : DbContext
public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>(); public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>();
public DbSet<BuildDeckSeriesEntry> BuildDeckSeries => Set<BuildDeckSeriesEntry>(); public DbSet<BuildDeckSeriesEntry> BuildDeckSeries => Set<BuildDeckSeriesEntry>();
public DbSet<BuildDeckProductEntry> BuildDeckProducts => Set<BuildDeckProductEntry>(); 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<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>(); public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>(); public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
@@ -151,6 +165,17 @@ public class SVSimDbContext : DbContext
b.HasIndex("ViewerId", "ProductId").IsUnique(); 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<BuildDeckSeriesEntry>().OwnsMany(s => s.SeriesRewards);
modelBuilder.Entity<BuildDeckProductEntry>().OwnsMany(p => p.Cards); modelBuilder.Entity<BuildDeckProductEntry>().OwnsMany(p => p.Cards);
modelBuilder.Entity<BuildDeckProductEntry>().OwnsMany(p => p.Rewards); modelBuilder.Entity<BuildDeckProductEntry>().OwnsMany(p => p.Rewards);
@@ -163,6 +188,35 @@ public class SVSimDbContext : DbContext
modelBuilder.Entity<BuildDeckProductEntry>().HasIndex(p => p.SeriesId); 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 => modelBuilder.Entity<CardCosmeticReward>(b =>
{ {
b.HasKey(r => new { r.CardId, r.Type, r.CosmeticId }); 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 }); 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); base.OnModelCreating(modelBuilder);
} }

View File

@@ -20,8 +20,9 @@ public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum)
/// ///
/// <para> /// <para>
/// <b>DO NOT reimplement reward dispatch in a controller or new helper.</b> This service handles /// <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, /// RedEther, Crystal, SpotCardPoint, Item, Card (with <see cref="CardCosmeticReward"/> cascade),
/// Degree, Rupy, Skin, MyPageBG — everything except SpotCard (TODO). Endpoint code that takes a /// 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"/> /// 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 /// 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 /// build a second dispatch table. Past duplicate implementations (ICardAcquisitionService in the
@@ -87,6 +88,10 @@ public sealed class RewardGrantService
viewer.Currency.RedEther += (ulong)num; viewer.Currency.RedEther += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.RedEther)); 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: case UserGoodsType.Item:
{ {
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId); var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
@@ -106,11 +111,11 @@ public sealed class RewardGrantService
case UserGoodsType.SpotCard: case UserGoodsType.SpotCard:
case UserGoodsType.SpotCardOnlyLatestCardPack: case UserGoodsType.SpotCardOnlyLatestCardPack:
// TODO: spot cards are currently global in our seed data; the existence of these // Spot-card-typed grants don't appear in captures — emitters always use Card=5
// reward types suggests there's a mix of global + per-player spot cards. Revisit // with the spot-card-specific id. These two enum slots remain unimplemented; if a
// when per-player spot-card infrastructure lands. // capture ever shows one in a reward_list we'll know to wire them up here.
throw new NotSupportedException( 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: default:
throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService"); 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")] [HttpPost("game_start")]
public async Task<GameStartResponse> GameStart(GameStartRequest request) 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.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SVSim.Database; 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.Requests.LeaderSkin;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin;
namespace SVSim.EmulatedEntrypoint.Controllers; namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary> /// <summary>
/// /leader_skin/* — per-class "active leader skin" preference. The per-CLASS setting is the /// /leader_skin/* — the leader-skin shop family.
/// fallback used when a deck has <c>leader_skin_id == 0</c>; per-deck overrides go through /// <list type="bullet">
/// /deck/update_leader_skin instead. /// <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> /// </summary>
[Route("leader_skin")] [Route("leader_skin")]
public class LeaderSkinController : SVSimController public class LeaderSkinController : SVSimController
{ {
private readonly SVSimDbContext _db; 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; _db = db;
_rewards = rewards;
_time = time;
} }
[HttpPost("set")] [HttpPost("set")]
@@ -28,8 +45,6 @@ public class LeaderSkinController : SVSimController
if (request.IsRandomLeaderSkin) 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, return StatusCode(StatusCodes.Status501NotImplemented,
new { error = "random_leader_skin_not_implemented" }); 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); var classData = viewer.Classes.FirstOrDefault(c => c.Class.Id == request.ClassId);
if (classData is null) return BadRequest(new { error = "unknown_class" }); 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); var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId);
if (skin is null) return BadRequest(new { error = "unknown_skin" }); if (skin is null) return BadRequest(new { error = "unknown_skin" });
if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" }); if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" });
@@ -61,4 +75,345 @@ public class LeaderSkinController : SVSimController
LeaderSkinIdList = new(), 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 System.Text.Json;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Models.Config; using SVSim.Database.Models.Config;
@@ -47,11 +48,14 @@ public class LoadController : SVSimController
private readonly ICardAcquisitionService _acquisition; private readonly ICardAcquisitionService _acquisition;
private readonly IGameConfigService _config; private readonly IGameConfigService _config;
private readonly IBattlePassService _battlePass; private readonly IBattlePassService _battlePass;
private readonly IViewerMissionStateService _missionState;
private readonly SVSimDbContext _db;
public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository, public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository,
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository, ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository,
ICardAcquisitionService acquisition, IGameConfigService config, ICardAcquisitionService acquisition, IGameConfigService config,
IBattlePassService battlePass) IBattlePassService battlePass, IViewerMissionStateService missionState,
SVSimDbContext db)
{ {
_viewerRepository = viewerRepository; _viewerRepository = viewerRepository;
_cardRepository = cardRepository; _cardRepository = cardRepository;
@@ -60,6 +64,8 @@ public class LoadController : SVSimController
_acquisition = acquisition; _acquisition = acquisition;
_config = config; _config = config;
_battlePass = battlePass; _battlePass = battlePass;
_missionState = missionState;
_db = db;
} }
[HttpPost("index")] [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, // (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. // the response payload would be one /load/index behind on newly-granted cosmetics.
await _acquisition.BackfillCosmeticsAsync(viewer.Id); 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); viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
if (viewer is null) if (viewer is null)
{ {
@@ -170,6 +181,7 @@ public class LoadController : SVSimController
UserInfo = new UserInfo(deviceType, viewer), UserInfo = new UserInfo(deviceType, viewer),
UserCurrency = new UserCurrency(viewer), UserCurrency = new UserCurrency(viewer),
UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(), UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
SpotPoint = checked((int)viewer.Currency.SpotPoints),
UserRotationDecks = new UserFormatDeckInfo UserRotationDecks = new UserFormatDeckInfo
{ {
UserDecks = viewer.Decks.Where(d => d.Format == Format.Rotation) 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.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers; namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -14,11 +15,16 @@ public class PracticeController : SVSimController
{ {
private readonly IDeckRepository _deckRepository; private readonly IDeckRepository _deckRepository;
private readonly IGlobalsRepository _globalsRepository; private readonly IGlobalsRepository _globalsRepository;
private readonly IMissionProgressService _missionProgress;
public PracticeController(IDeckRepository deckRepository, IGlobalsRepository globalsRepository) public PracticeController(
IDeckRepository deckRepository,
IGlobalsRepository globalsRepository,
IMissionProgressService missionProgress)
{ {
_deckRepository = deckRepository; _deckRepository = deckRepository;
_globalsRepository = globalsRepository; _globalsRepository = globalsRepository;
_missionProgress = missionProgress;
} }
/// <summary> /// <summary>
@@ -83,15 +89,29 @@ public class PracticeController : SVSimController
/// XP / no rewards. Class XP bookkeeping is deferred until a per-class XP store exists. /// XP / no rewards. Class XP bookkeeping is deferred until a per-class XP store exists.
/// </summary> /// </summary>
[HttpPost("finish")] [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, GetClassExperience = 0,
ClassExperience = 0, ClassExperience = 0,
ClassLevel = 1, ClassLevel = 1,
AchievedInfo = new Dictionary<string, object>(), AchievedInfo = new Dictionary<string, object>(),
RewardList = new List<Models.Dtos.Common.Reward>() 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 public class StoryController : SVSimController
{ {
private readonly IStoryService _service; 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("/story/section")]
[HttpPost("/main_story/section")] [HttpPost("/main_story/section")]
@@ -65,7 +70,28 @@ public class StoryController : SVSimController
public async Task<ActionResult<FinishResponse>> Finish(FinishRequest req) public async Task<ActionResult<FinishResponse>> Finish(FinishRequest req)
{ {
if (!TryGetViewerId(out long vid)) return Unauthorized(); 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")] [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.Database.Models;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Extensions; namespace SVSim.EmulatedEntrypoint.Extensions;
public static class HttpContextExtensions public static class HttpContextExtensions
{ {
private const string ViewerItemName = "SVSimViewer"; private const string ViewerItemName = "SVSimViewer";
public static Viewer? GetViewer(this HttpContext context) public static Viewer? GetViewer(this HttpContext context)
{ {
if (context.Items.TryGetValue(ViewerItemName, out object? viewer)) if (context.Items.TryGetValue(ViewerItemName, out object? viewer))
@@ -21,4 +23,18 @@ public static class HttpContextExtensions
context.Items[ViewerItemName] = viewer; context.Items[ViewerItemName] = viewer;
return 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; 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; object? data;
try try
{ {
@@ -166,7 +178,11 @@ public class ShadowverseTranslationMiddleware : IMiddleware
// populated (prod sends real numbers for the title check too, but 0 / 0 satisfies // 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). // the client's BaseTask.Parse which only reads result_code + servertime here).
ShortUdid = viewer?.ShortUdid ?? 0, 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")] [JsonPropertyName("result_code")]
[Key("result_code")] [Key("result_code")]
public int ResultCode { get; set; } 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