Compare commits

..

105 Commits

Author SHA1 Message Date
gamer147
1960e28298 refactor(auth): decouple Steam handler from request DTO shape
Translation middleware now extracts viewer_id/steam_id/steam_session_ticket
from the decrypted msgpack dict into HttpContext.Items before the typed
DTO deserialize. The Steam handler reads from there instead of re-parsing
Request.Body — so authed action DTOs no longer need to inherit BaseRequest
to keep the auth fields alive through the msgpack→DTO→JSON pivot.

Retires the recurring footgun documented in
docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md
(2026-05-25 basic-puzzle, 2026-05-28 deck-code, 2026-06-02 Phase 3 Bot,
2026-06-10 profile/index + item_acquire_history/info + user_mypage/update).

Pinned by AuthDecouplingTests — posts an encrypted msgpack body to
/profile/index (DTO does not inherit BaseRequest) through the real
translation middleware + auth handler and asserts 200. Adds an
EncryptedMsgpackHelper + useRealAuthHandler factory flag, reusable for
future wire-shape tests.

ProfileIndexRequest, ItemAcquireHistoryInfoRequest, and
UserMyPageUpdateRequest revert to the naked shape — the per-DTO
workarounds become vestigial under the new architecture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 12:29:10 -04:00
gamer147
b18b24b085 test(ranking): literal-prod-JSON wire-shape parity
Per feedback_wire_shape_tests: controller round-trip tests using the
same DTO can't catch wire-key/wire-type drift. Asserts parsing of and
snake_case emission for the period list + monthly ranking shapes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 10:48:51 -04:00
gamer147
f743b27696 feat(ranking): stub /ranking/* (6 endpoints)
Rankings menu opens. Period picker shows deterministic monthly schedule.
Every leaderboard returns { period, ranking: [] }.

Endpoints:
- /ranking/get_viewable_ranking_period_list
- /ranking/master_point_{rotation,unlimited}_info
- /ranking/rank_match_class_win_{rotation,unlimited}_info
- /ranking/two_pick_win_info

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 10:46:00 -04:00
gamer147
80f249f8a2 feat(ranking): add RankingPeriodSchedule helper
Pure deterministic monthly period generator for the four ranking
families. Anchor dates derived from prod capture (2026-06-09): id=1 is
each family's launch month in JST; id=N is anchor + N-1 months. Used
by /ranking/get_viewable_ranking_period_list to render the period
picker and by per-family leaderboard endpoints to echo the requested
period back.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 10:36:51 -04:00
gamer147
b4aa07577f fix(friend): add BaseRequest body param to 6 body-less actions
ShadowverseTranslationMiddleware throws InvalidOperationException when
a Unity client posts an encrypted msgpack body to an action with zero
[FromBody] parameters — it has no target type for the deserializer.
Tests pass because they post JSON directly with no UnityPlayer UA and
the middleware short-circuits. Same defect already fixed on /replay/info
in 216dcab; this catches up the friend system shipped 2026-06-09.

Fixed actions: info, receive_apply_info, send_apply_info,
played_together_info, reject_apply_all, cancel_apply_all.

Tests updated to post the BaseRequest auth fields so [ApiController]
model validation passes (BaseRequest.ViewerId is non-nullable string).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:54:50 -04:00
gamer147
b54a47f333 test(replay): literal-prod-JSON wire-shape parity for /replay/info
Per feedback_wire_shape_tests (2026-05-28): controller round-trip tests
using the same DTO can't catch wire-key/wire-type drift. Asserts
parsing of, snake_case emission for, and MessagePack round-trip
through the captured prod frame.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:24:24 -04:00
gamer147
0996074287 feat(replay): wire arena two-pick finish hook
Same pattern as rank-battle: DoMatching stashes context; Finish takes
it and records history + played-together. Opponent identity is left
as placeholder fields until the resolver carries it through.

Test seeds an active ViewerArenaTwoPickRun so RecordBattleResultAsync
does not throw no_active_run during the e2e flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:22:19 -04:00
gamer147
81aac701f4 feat(replay): wire finish hook for rank-battle family
Finish now consumes the stashed BattleContext, records a
ViewerBattleHistory row (idempotent + retention-capped), and
calls IPlayedTogetherWriter for human PvP (skipped for AI).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:15:12 -04:00
gamer147
a81289311f feat(replay): stash battle context at /ai_*/start time
AiStartInternal now writes a BattleContext keyed by viewer id; the next
commit consumes it in /finish to write a ViewerBattleHistory row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:10:01 -04:00
gamer147
b44354315a fix(replay-tests): supply BaseRequest auth fields in PostAsJsonAsync bodies
Following the 216dcab fix that added [FromBody] BaseRequest _ to the
Info action, the existing tests' empty new {} payloads no longer satisfy
[ApiController] model validation (BaseRequest.ViewerId is non-nullable
string). Use the same EmptyBody() shape as RankBattleControllerTests
to mirror the production wire.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:06:36 -04:00
gamer147
216dcab316 fix(replay): add BaseRequest body param to /replay/info
The translation middleware throws InvalidOperationException when an
action has no body parameters and a Unity client sends an encrypted
msgpack body. Tests pass without it because they post JSON directly
and bypass the middleware (no UnityPlayer User-Agent).

See RankBattleController.ForceFinish for the documented convention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:03:37 -04:00
gamer147
c6259e5a14 feat(replay): add ReplayController for /replay/{info,detail}
/replay/info reads from ReplayHistoryReader, newest-first, capped at 50.
/replay/detail returns 400 with result_code=99 - local cache is the
canonical playback source so this endpoint is cache-miss fallback only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:59:51 -04:00
gamer147
2f7a2305da feat(replay): add /replay/info and /replay/detail DTOs
All numeric fields on ReplayInfoItemDto ship as string — matches prod
capture (frame 96 of traffic_prod_misc_clicking.ndjson). api-spec doc
will be updated separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:51:00 -04:00
gamer147
86d86f6ead feat(replay): add ReplayHistoryReader for newest-first list query
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:48:18 -04:00
gamer147
2b6c7bd6a4 feat(replay): add BattleHistoryWriter with 50-row per-viewer retention
Idempotent on (ViewerId, BattleId); evicts oldest CreateTime row when
at cap. No-op when ctx is null (server-restart safety).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:43:50 -04:00
gamer147
869f9ce13d feat(replay): add BattleContextStore for start->finish handoff
Bridges the start-time -> finish-time gap. /finish carries neither
battle_id nor opponent identity; this store holds both for the finish
handler to compose a ViewerBattleHistory row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:38:37 -04:00
gamer147
0bb0f46abc feat(replay): add ViewerBattleHistory entity + migration
New table backs /replay/info; composite PK (ViewerId, BattleId), index on
(ViewerId, CreateTime) for the newest-first list query. 50-row per-viewer
retention enforced by BattleHistoryWriter (next commit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:32:05 -04:00
gamer147
2d65fcd91c test(friend): end-to-end multi-viewer apply→approve→friendship flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:14:45 -04:00
gamer147
d848f4a03f test(friend): literal wire-shape round-trip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:13:16 -04:00
gamer147
2d13f0b72d feat(friend): FriendController with 12 endpoints + integration tests 2026-06-09 22:12:00 -04:00
gamer147
a6e5c9f0bc feat(friend): wire DTOs for /friend/* endpoints
12 files: 3 entry types (FriendEntryDto, FriendApplyEntryDto,
PlayedTogetherEntryDto), 5 response wrappers, 4 request DTOs.
All carry [MessagePackObject] + [Key] + [JsonPropertyName] per convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:06:36 -04:00
gamer147
b5b4781693 feat(friend): bulk apply ops + IPlayedTogetherWriter with retention cap
Implements RejectAllAppliesAsync, CancelAllAppliesAsync (ExecuteDelete bulk
deletes on incoming/outgoing applies respectively) and RecordAsync (upsert
played-together row with 50-row per-viewer retention eviction). 4 new tests
added; all 1186 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:04:04 -04:00
gamer147
17591a6ebd feat(friend): implement single-target write methods on FriendService
SendApplyAsync, ApproveApplyAsync, RejectApplyAsync, CancelApplyAsync,
RejectFriendAsync all implemented. 11 new tests appended; all 23 friend
tests pass, full suite 1182/1182 green.
2026-06-09 21:59:32 -04:00
gamer147
d078f275f8 feat(friend): implement 5 read methods on FriendService + register DI + read test suite
GetFriendsAsync, GetReceiveAppliesAsync, GetSendAppliesAsync, GetPlayedTogetherAsync,
SearchAsync all implemented. LoadViewerProjectionAsync materialises the full Viewer
entity (with Include/ThenInclude for SelectedEmblem/Degree) then projects in-memory —
avoids the EF Core limitation where Include is silently ignored under Select.
FriendService + IPlayedTogetherWriter registered as Scoped in Program.cs.
12 read tests, all green; full suite 1171/1171 still passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:53:10 -04:00
gamer147
300eee36e9 feat(friend): IFriendService + IPlayedTogetherWriter + DTO records
Task 3: service contract (interface + DTOs) and FriendService skeleton.
All methods throw NotImplementedException; Tasks 4-6 fill in the logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:44:51 -04:00
gamer147
f40ecb8ca7 feat(friend): migration for friend system tables
Generates AddFriendSystemTables migration: ViewerFriends (composite PK,
2 FK cascades), ViewerFriendApplies (int identity PK, unique on
From/To, 2 FK cascades), ViewerPlayedTogethers (composite PK, Owner FK
only). Includes composite/unique indexes. 4/4 FriendPersistenceTests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:42:41 -04:00
gamer147
1813217c16 feat(friend): add ViewerFriend + ViewerFriendApply + ViewerPlayedTogether entities
Lays the persistence foundation for the /friend/* API surface. Three new
model classes with composite PKs / unique constraints / FK cascades registered
on SVSimDbContext; 4/4 persistence tests pass on SQLite in-memory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:40:08 -04:00
gamer147
c1eec9057a test(gift): claim Card/Sleeve presents; reject unsupported types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 20:58:53 -04:00
gamer147
742058403c refactor(campaign): delegate gift-reward-type check to GiftRewardTypes
Delete local IsSupportedGiftRewardType and replace its single call site with
GiftRewardTypes.IsSupported — Card (5) and Sleeve (6) are now accepted.
Update unsupported-type test sentinel from 5 (Card) to 11 (SpotCard).
Add Card and Sleeve success-path tests; full suite 1152/1152.
2026-06-09 20:52:47 -04:00
gamer147
b2a5b69423 fix(gift): wire reward_type is UserGoodsType integer, not legacy 1/4/9 encoding
Replace WireRewardTypeToUserGoodsType switch with a validating identity cast backed
by GiftRewardTypes.IsSupported. Wire type 1 is RedEther (UserGoodsType.RedEther),
not Crystal (UserGoodsType.Crystal=2); the old switch silently granted the wrong
wallet for every tutorial-completion claim. Update all 5 GiftControllerTests assertions
and 1 TutorialFlowEndToEndTests assertion to expect RedEther instead of Crystals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 20:45:49 -04:00
gamer147
366a71688d feat(gift): add GiftRewardTypes supported-set helper
Centralizes which UserGoodsType values IInventoryTransaction.GrantAsync
can handle in the gift-inbox flow; both GiftController (post-fix) and
CampaignController will consult this instead of duplicating the set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 20:39:10 -04:00
gamer147
0d32d2167b feat(campaign): CampaignController.RegisterSerialCode + DTOs + tests
Adds POST /campaign/regist_serial_code with 9 integration tests covering
success path, all disqualifier conditions (unknown/disabled/expired/future/
already-redeemed/unsupported-reward-type), 401 on missing auth, and case-
sensitivity. IsSupportedGiftRewardType uses gift-wire literals (1/4/9) not
UserGoodsType enum values, matching GiftController.WireRewardTypeToUserGoodsType.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 18:51:03 -04:00
gamer147
9f7e78691b feat(serial-code): migration for SerialCode tables
Adds AddSerialCodeTables migration: SerialCodes, SerialCodeRewards,
ViewerSerialCodeRedemptions with FKs, cascade deletes, unique index
on Code, and composite index on (SerialCodeId, Slot).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:45:18 -04:00
gamer147
206be77a86 feat(serial-code): add SerialCode + SerialCodeReward + ViewerSerialCodeRedemption entities
Three new EF entities for /campaign/regist_serial_code: SerialCodeEntry (code, message,
window, enabled flag), SerialCodeRewardEntry (FK child, per-slot reward), and
ViewerSerialCodeRedemption (composite-PK redemption record). Registered in SVSimDbContext
with unique index on Code and cascade FK constraints. 3/3 persistence tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:42:10 -04:00
gamer147
b117fe825c test(profile): literal wire-shape round-trip for UserClass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:41:11 -04:00
gamer147
11215bd69f feat(profile): ProfileController + DTOs + integration tests
Add /profile/index endpoint that returns user_rank_match_total_win (stubbed 0)
and user_class_list built from viewer Classes + owned LeaderSkins. Six NUnit
integration tests cover zero wins, all classes present, level/exp/default skin,
leader_skin_id_list population, is_random_leader_skin round-trip, and 401 on
unauthenticated access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:37:35 -04:00
gamer147
f204656f4d refactor(user-class): require owned skin list, read IsRandomLeaderSkin from data 2026-06-09 17:31:06 -04:00
gamer147
da6b448598 feat(viewer): migration for ViewerClassData.IsRandomLeaderSkin
Adds AddViewerClassDataIsRandomLeaderSkin migration: boolean column on
the ViewerClassData shadow table, nullable: false, defaultValue: false.
Also updates the model snapshot to reflect the new bool property.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:27:16 -04:00
gamer147
2f4420bf15 feat(viewer): add ViewerClassData.IsRandomLeaderSkin column
Adds the bool column (defaults false) to the [Owned] ViewerClassData
entity and a two-test persistence suite that verifies round-trip and
default-value behaviour via SQLite/EnsureCreated.
2026-06-09 17:24:32 -04:00
gamer147
b50d69d3a5 test(user-mypage): assert /user_mypage/update returns 401 without auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:04:12 -04:00
gamer147
483cc1c1e0 feat(mypage): /mypage/index reflects persisted bg selection
Wires MyPageController.Index to read Viewer.MyPageBgId/SelectType/BgRotation
instead of emitting an empty MyPageBgSetting placeholder; adds Include for
MyPageBgRotation in ViewerRepository.GetViewerByShortUdid; adds fresh-viewer
zero-defaults test and write→read round-trip test (9/9 controller tests pass).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:53:34 -04:00
gamer147
6123a64b37 test(user-mypage): explain Clear() + cover unparseable rotation entries
Add a comment above MyPageBgRotation.Clear() explaining the EF OwnsMany
delete-on-clear semantics. Add a 7th test covering garbage/"" entries in
mypage_id_list falling back to BgId=0 while adjacent valid entries are
unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:50:38 -04:00
gamer147
9ff6c70faf feat(user-mypage): UserMyPageController + DTOs + persistence tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:46:49 -04:00
gamer147
b447f5032d fix(mypage): wire mypage_id/select_type/mypage_id_list as strings
Prod capture (traffic_prod_misc_clicking.ndjson) shows all three
MyPageBgSetting fields arrive as decimal strings, not ints.  Update the
DTO from int/int/List<int> to string/string/List<string> with "0"/empty
defaults, and add a literal wire-shape round-trip test pinning the
exact JSON against the capture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:40:38 -04:00
gamer147
51ef460d39 feat(viewer): migration for mypage bg selection
Adds AddViewerMyPageBgSelection migration: two int scalars on Viewers
(MyPageBgId, MyPageBgSelectType default 0) and ViewerMyPageBgRotation
owned table with composite PK (ViewerId, Slot), FK cascade to Viewers.
Also adds ToTable(ViewerMyPageBgRotation) to OwnsMany config so EF
uses the correct table name instead of defaulting to the entity class.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:33:12 -04:00
gamer147
ee808a60a2 feat(viewer): add MyPageBgSelectType + MyPageBgId scalars + MyPageBgRotation owned collection
Adds BGType persistence (0=Deck/1=CustomBG/2=RandomBG) to Viewer via two scalar
columns and an owned collection keyed (ViewerId, Slot). Two persistence tests
confirm round-trip and zero-defaults on fresh viewers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:26:48 -04:00
gamer147
8de78ba7ed test(inventory): lock Unknown-source fallthrough behaviour
Adds Commit_writes_history_row_with_Unknown_source_when_caller_omits_Source
to InventoryHistoryTests — asserts that BeginAsync without a configure callback
writes AcquireType=0 / Message="Unknown", per spec §10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:33:04 -04:00
gamer147
5334263793 feat(inventory): tag remaining BeginAsync call sites for acquire history
Add GrantSource.CardCraft (16) for card crafting via red ether, and tag
CardInventoryRepository.Create accordingly. LoadController backfill and
ArenaTwoPickService.Entry are debit/infrastructure-only — left as Unknown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:09:43 -04:00
gamer147
c2c3abc6f0 feat(gift): tag receive-gift tx as AdminGrant for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:08:27 -04:00
gamer147
6ec6a9c3fc feat(spot-card): tag exchange tx as GachaPointExchange for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:08:03 -04:00
gamer147
d759465cf2 feat(achievement): tag receive-reward tx as AchievementReward for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:07:42 -04:00
gamer147
349d7f32cd feat(leader-skin): tag buy tx as LeaderSkinBuy for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:07:19 -04:00
gamer147
30cb4727f6 feat(sleeve): tag buy tx as SleeveBuy for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:06:47 -04:00
gamer147
38a21e5e72 feat(build-deck): tag buy tx as BuildDeckBuy for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:06:23 -04:00
gamer147
5cf3cf70e8 feat(item-purchase): tag purchase tx as ItemPurchase for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:05:59 -04:00
gamer147
259e3ebe29 feat(2pick): tag finish-reward tx as ArenaTwoPickFinish for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:05:37 -04:00
gamer147
c72b692560 feat(battle-pass): tag claim tx as BattlePassClaim for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:05:09 -04:00
gamer147
d767944a83 feat(story): tag finish-reward tx as StoryFinish for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:04:11 -04:00
gamer147
21ee113d21 feat(puzzle): tag inventory tx as PuzzleReward for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:03:45 -04:00
gamer147
4aa1367b6f feat(pack): tag gacha-point exchange tx for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:03:21 -04:00
gamer147
f1d96ff554 feat(pack): tag inventory tx as PackOpen for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:02:56 -04:00
gamer147
9130d6de11 test(inventory): pack-open shape produces acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:59:39 -04:00
gamer147
d01294ebb4 test(item-acquire-history): literal wire-shape round-trip 2026-06-09 14:57:18 -04:00
gamer147
37d89aa602 refactor(inventory): share retention cap + invariant-culture date format
Introduce InventoryHistoryConfig.RetentionRowsPerViewer as the single
source of truth for the 300-row audit-log cap; InventoryTransaction
aliases it and ItemAcquireHistoryController.Take() references it
directly so the two sites cannot drift. Also adds CultureInfo.InvariantCulture
to the AcquireTime.ToString() call, matching every other WireDateFormat
site in the codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:55:43 -04:00
gamer147
f9a971a546 feat(item-acquire-history): controller + DTOs
Add ItemAcquireHistoryController (POST /item_acquire_history/info) with its
three DTOs and two integration tests (ordering + empty-viewer). The endpoint
reads ViewerAcquireHistory rows written by InventoryTransaction.CommitAsync,
ordered newest-first, capped at 300. Tests access doc.RootElement.histories
directly (no envelope wrapper in the test path — middleware skips non-UnityPlayer UA).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:49:43 -04:00
gamer147
00fbf1a185 docs(inventory): explain two-phase prune query (SQLite constraint)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:44:57 -04:00
gamer147
77ad614258 feat(inventory): prune acquire history above 300-row cap
Adds PruneAcquireHistoryAsync to InventoryTransaction.CommitAsync; runs
inside the open DB transaction after history rows are flushed, keeping at
most 300 rows per viewer (oldest discarded). Adds a covering test that
verifies the cap and per-viewer isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:41:04 -04:00
gamer147
fb1e6829b7 refactor(inventory): consolidate IsCurrency, skip num=0 grants in history
- Drop IsWalletCurrency (duplicate of IsCurrency); use IsCurrency in WriteAcquireHistory.
- Add comment on first SaveChangesAsync in CommitAsync explaining the two-phase flush.
- Guard WriteAcquireHistory loop with grant.Num == 0 check so synthetic DebitItem post-state ops do not produce history rows.
- Add InventoryHistoryTests.Commit_writes_no_history_row_for_item_debit to lock in the fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:37:50 -04:00
gamer147
bea5a1efd4 feat(inventory): write acquire history rows on commit
CommitAsync now calls WriteAcquireHistory() between the two SaveChanges
calls: iterates _ops, skips SpendOps, writes one ViewerAcquireHistoryEntry
per GrantOp. Cascade rows get GrantSource.CardCosmeticCascade; wallet
currencies zero RewardDetailId; all rows in a single commit share one
DateTime.UtcNow timestamp. Closes _source plumbing from Task 5.

5 new tests added (46 total inventory, 0 failures).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:32:51 -04:00
gamer147
015c7ce259 feat(inventory): thread GrantSource into InventoryTransaction
Add _source field + ctor parameter (between freeplay and log) to
InventoryTransaction; pass loadCfg.Source from InventoryService.BeginAsync.
Field is captured but not yet consumed — Task 6 wires it into history rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:28:05 -04:00
gamer147
f394529c8c feat(inventory): migration for viewer_acquire_history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:24:36 -04:00
gamer147
51595b0c7d refactor(inventory): drop redundant index name + IsRequired on ViewerAcquireHistory
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:22:53 -04:00
gamer147
0d036e1bff feat(inventory): add ViewerAcquireHistoryEntry entity + DbSet
Adds the ViewerAcquireHistoryEntry model (8 fields: Id, ViewerId,
RewardType, RewardDetailId, RewardCount, AcquireType, Message,
AcquireTime), registers DbSet<ViewerAcquireHistoryEntry> on
SVSimDbContext, configures model (PK, FK cascade to Viewer, MaxLength
64 on Message, composite index on ViewerId/AcquireTime/Id), and adds a
DbSet round-trip integration test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:19:00 -04:00
gamer147
82dc877639 feat(inventory): add Source to InventoryLoadConfig
Adds a `GrantSource Source { get; set; }` property (defaults to
`GrantSource.Unknown`) to `InventoryLoadConfig`. Plumbing-only — no
behavior change; callers that don't set `Source` get Unknown rows,
greppable via `acquire_type=0` in dev.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:13:56 -04:00
gamer147
645c32e11c docs(inventory): annotate GrantSource gap + For() exception
Add comment above AdminGrant = 99 explaining the intentional 16–98 gap,
and add <exception> XML doc on GrantSourceMessages.For() so IDE tooling
surfaces the ArgumentOutOfRangeException.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:11:45 -04:00
gamer147
9f65326449 feat(inventory): add GrantSource enum + message lookup
Introduces GrantSource (17 values, DB-persisted) and GrantSourceMessages.For()
for the item-acquire-history feature. Values 1/2 mirror prod captures;
coverage test verifies every enum value has a non-empty message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:08:01 -04:00
gamer147
998402ebc3 refactor(tutorial-presents): promote static catalogue to seed-driven TutorialPresentEntries table
The five tutorial gifts every fresh viewer is given at signup used to live as a
static C# array in SVSim.Database/SeedData/TutorialPresents.cs — outside the
seed-JSON pipeline used by all 40+ other globals tables. Editing a gift required
a code change + rebuild instead of a JSON edit + bootstrap re-run.

Now authored in SVSim.Bootstrap/Data/seeds/tutorial-presents.json and loaded into
a new TutorialPresentEntries table via TutorialPresentsImporter (clear-and-rewrite,
mirroring HomeDialogs). ViewerRepository.RegisterAnonymousViewer reads the table
at signup and projects each row into a ViewerPresent with Source="tutorial".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 09:53:10 -04:00
gamer147
7118b92522 refactor(pack): type PackChildGachaEntry.TypeDetail as CardPackType enum 2026-06-09 08:48:16 -04:00
gamer147
833bd85d36 data(seed): regenerate packs.json including free-pack children from event-crate capture 2026-06-08 21:51:05 -04:00
gamer147
57d231cd56 feat(pack): /pack/open supports type_detail=10 FREE_PACKS with per-campaign daily quota 2026-06-08 21:43:04 -04:00
gamer147
6c7e8ae8ad feat(pack): /pack/info filters spent free-pack children and emits campaign metadata 2026-06-08 21:38:26 -04:00
gamer147
b9c29b53d9 feat(pack): add free-pack metadata fields to PackChildGachaDto 2026-06-08 21:34:49 -04:00
gamer147
7d7cf699f8 feat(viewer): add FreePackClaims owned collection for daily-free quota tracking 2026-06-08 21:34:20 -04:00
gamer147
d762c5766f feat(pack): persist daily_free_gacha_count on PackChildGachaEntry 2026-06-08 21:31:02 -04:00
gamer147
7e4a9654b2 feat(seed): add DailyFreeGachaCount to PackChildGachaSeed 2026-06-08 21:23:14 -04:00
gamer147
feee6e7c09 test(tutorial-e2e): seed tutorial presents — RegisterViewer no longer auto-seeds 2026-06-08 20:51:42 -04:00
gamer147
83e89455e2 test(signup): assert tutorial presents seeded by RegisterAnonymousViewer 2026-06-08 20:46:27 -04:00
gamer147
7a582f310e test(gift): prod-URL coverage and state=3 delete behavior 2026-06-08 20:45:49 -04:00
gamer147
f1d881b26a fix(gift): drop RowVersion (SQLite incompatible) + restore wire reward_type map
[Timestamp] byte[] doesn't work under SQLite (the test backend) — EF
expects the DB to populate it on insert, but SQLite has no equivalent
of Postgres's xmin. The WHERE Status = Unclaimed filter plus
IInventoryService's viewer-level concurrency is the practical defense;
RowVersion was only a backstop. Regenerated the migration without the
RowVersion column.

Wire reward_type on the gift endpoint uses a gift-specific scheme that
diverges from UserGoodsType for currencies: wire 1 = Crystal (enum=2),
wire 9 = Rupy (enum=9), wire 4 = Item (enum=4). A naked cast resolves
wire 1 to UserGoodsType.RedEther and silently grants the wrong wallet
— restored the explicit WireRewardTypeToUserGoodsType map from the old
tutorial controller.

Retrofits existing GiftControllerTests to call SeedTutorialPresentsAsync
on the new helper (RegisterViewer doesn't auto-seed; only the prod
signup path does). All 7 existing tests pass.
2026-06-08 20:44:52 -04:00
gamer147
ca36792be3 feat(db): migration — add viewer_presents, drop viewer_claimed_tutorial_gifts 2026-06-08 20:38:44 -04:00
gamer147
6098682162 refactor(db): remove ViewerClaimedTutorialGift — replaced by ViewerPresent.Status 2026-06-08 20:38:09 -04:00
gamer147
fafd7ea183 test(gift): add SeedTutorialPresentsAsync helper 2026-06-08 20:37:26 -04:00
gamer147
2b35ae0890 feat(gift): unified GiftController over ViewerPresent + route aliases 2026-06-08 20:36:44 -04:00
gamer147
c1d7cd2441 feat(dtos): add PresentMapper.ToWire(ViewerPresent) 2026-06-08 20:35:59 -04:00
gamer147
bf51dabcff refactor(dtos): promote PresentDto to Common/ 2026-06-08 20:35:42 -04:00
gamer147
2ce399ff87 feat(signup): seed tutorial gifts as ViewerPresent rows on /tool/signup 2026-06-08 20:34:48 -04:00
gamer147
f991ef762f feat(db): add TutorialPresents seed list 2026-06-08 20:34:11 -04:00
gamer147
eea596c6ec feat(db): wire ViewerPresent into SVSimDbContext with indexes 2026-06-08 20:33:50 -04:00
gamer147
a6a8c6b1a4 feat(db): add ViewerPresent entity for unified gift inbox 2026-06-08 20:33:10 -04:00
gamer147
ce32a9c6b7 feat(home-dialog): seed file + importer + bootstrap hookup
Mirrors banners pattern: clear-and-rewrite from per-table JSON seed.
Ships one entry pointing at parent_gacha_id 80032 to match the
2026-06-03 prod capture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:57:38 -04:00
gamer147
9d6a5cc3b9 feat(home-dialog): populate home_dialog_list on /mypage/index
Walk-down behavior: each call emits the highest-priority unfired
active dialog; subsequent calls walk to the next-priority entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:55:48 -04:00
gamer147
7e757ebcd2 feat(home-dialog): per-session suppression tracker
Singleton keyed by ShortUdid; lock on per-viewer set to avoid
cross-viewer contention. Process lifetime — restart re-fires.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:53:33 -04:00
gamer147
6d60edaa2a feat(home-dialog): IGlobalsRepository.GetActiveHomeDialogsAsync
Window is [begin, end) — exclusive upper bound. Ordered priority-DESC
then Id-ASC so the controller can break on the first match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:52:32 -04:00
gamer147
7a82f4e189 feat(home-dialog): add HomeDialogEntry entity + migration
DDL-only per migrations-are-ddl-only convention. Seeded by
SVSim.Bootstrap MyPageGlobalsImporter (T5) — no HasData.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:51:12 -04:00
gamer147
d3488c3bc6 fix(viewer): default ClassExp.Level to 1 for new viewers
Client RankMatchUI.onOpen indexes _classCharaExpList[level - 1]
unconditionally; level 0 (the prior default) throws IOOR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:49:45 -04:00
185 changed files with 60805 additions and 3009 deletions

View File

@@ -0,0 +1,14 @@
[
{
"id": 1,
"title_text_id": "HomeDialog_0066",
"image": "home_dialog_000312",
"button_list": [
{ "button_text_id": "HomeDialog_0002", "scene": "card_pack", "status": "80032" }
],
"begin_time": "2026-06-01 02:00:00",
"end_time": "2026-07-01 01:59:59",
"type": 1,
"priority": 0
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
[
{ "present_id": "71478626", "reward_type": 1, "reward_detail_id": 0, "reward_count": 400, "item_type": null, "message": "For completing the tutorial" },
{ "present_id": "71478627", "reward_type": 9, "reward_detail_id": 0, "reward_count": 100, "item_type": null, "message": "For completing the tutorial" },
{ "present_id": "71478628", "reward_type": 4, "reward_detail_id": 1, "reward_count": 3, "item_type": 1, "message": "For completing the tutorial" },
{ "present_id": "71478629", "reward_type": 4, "reward_detail_id": 80001, "reward_count": 40, "item_type": 2, "message": "For completing the tutorial" },
{ "present_id": "71478630", "reward_type": 4, "reward_detail_id": 90001, "reward_count": 1, "item_type": 2, "message": "For completing the tutorial" }
]

View File

@@ -190,5 +190,42 @@
"dialog_title": "Dia_BuyCard_005_Title"
}
]
},
{
"parent_gacha_id": 80032,
"base_pack_id": 80001,
"gacha_type": 1,
"pack_category": 1,
"poster_type": 0,
"commence_date": "2026-06-01 02:00:00",
"complete_date": "2026-07-01 01:59:59",
"sleeve_id": 5090001,
"special_sleeve_id": 0,
"override_draw_effect_pack_id": 80001,
"override_ui_effect_pack_id": 80001,
"gacha_detail": "Throwback test pack with a free-pack-of-the-day child.",
"is_hide": true,
"is_new": false,
"is_pre_release": false,
"is_enabled": false,
"open_count_limit": 0,
"sales_period_time": "2026-07-01 01:59:59",
"gacha_point": null,
"child_gachas": [
{
"gacha_id": 780032,
"type_detail": 10,
"cost": 1,
"card_count": 8,
"item_id": null,
"is_daily_single": false,
"override_increase_gacha_point": 0,
"purchase_limit_count": 1,
"daily_free_gacha_count": 1,
"free_gacha_campaign_id": 49,
"campaign_name": "New Season Release Bonus"
}
],
"banners": []
}
]

View File

@@ -46,6 +46,41 @@ public class MyPageGlobalsImporter
return seed.Count;
}
public async Task<int> ImportHomeDialogsAsync(SVSimDbContext context, string seedDir)
{
var seed = SeedLoader.LoadList<HomeDialogSeed>(Path.Combine(seedDir, "home-dialogs.json"));
if (seed.Count == 0)
{
Console.WriteLine("[MyPageGlobalsImporter] No home-dialog seed rows; skipping.");
return 0;
}
// Clear-and-rewrite — same semantics as banners. Seed file is authoritative.
var existing = await context.HomeDialogEntries.ToListAsync();
context.HomeDialogEntries.RemoveRange(existing);
foreach (var s in seed)
{
context.HomeDialogEntries.Add(new HomeDialogEntry
{
Id = s.Id,
TitleTextId = s.TitleTextId,
Image = s.Image,
ButtonListJson = s.ButtonList.ValueKind == JsonValueKind.Undefined
? "[]"
: JsonSerializer.Serialize(s.ButtonList),
BeginTime = ImporterBase.ParseWireDateTime(s.BeginTime),
EndTime = ImporterBase.ParseWireDateTime(s.EndTime),
Type = s.Type,
Priority = s.Priority,
});
}
await context.SaveChangesAsync();
Console.WriteLine($"[MyPageGlobalsImporter] HomeDialogs: -{existing.Count}/+{seed.Count}");
return seed.Count;
}
public async Task<int> ImportColosseumAsync(SVSimDbContext context, string seedDir)
{
var s = SeedLoader.LoadObject<ColosseumSeed>(Path.Combine(seedDir, "colosseum.json"));

View File

@@ -70,13 +70,14 @@ public class PackImporter
pack.ChildGachas.Add(new PackChildGachaEntry
{
GachaId = c.GachaId,
TypeDetail = c.TypeDetail,
TypeDetail = (CardPackType)c.TypeDetail,
Cost = c.Cost,
CardCount = c.CardCount,
ItemId = c.ItemId,
IsDailySingle = c.IsDailySingle,
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint,
PurchaseLimitCount = c.PurchaseLimitCount,
DailyFreeGachaCount = c.DailyFreeGachaCount,
FreeGachaCampaignId = c.FreeGachaCampaignId,
CampaignName = c.CampaignName,
});
@@ -144,13 +145,14 @@ public class PackImporter
pack.ChildGachas.Add(new PackChildGachaEntry
{
GachaId = c.GachaId,
TypeDetail = c.TypeDetail,
TypeDetail = (CardPackType)c.TypeDetail,
Cost = c.Cost,
CardCount = c.CardCount,
ItemId = c.ItemId,
IsDailySingle = c.IsDailySingle,
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint,
PurchaseLimitCount = c.PurchaseLimitCount,
DailyFreeGachaCount = c.DailyFreeGachaCount,
FreeGachaCampaignId = c.FreeGachaCampaignId,
CampaignName = c.CampaignName,
});

View File

@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Loads the tutorial-gift catalogue (<c>tutorial-presents.json</c>) into the
/// <c>TutorialPresentEntries</c> table. Clear-and-rewrite — the seed file is authoritative;
/// hand-edits to the table are not preserved.
///
/// Read side: <c>ViewerRepository.RegisterAnonymousViewer</c> reads this table and projects
/// each row into a <c>ViewerPresent</c> with Source="tutorial" at signup time.
/// </summary>
public class TutorialPresentsImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var seed = SeedLoader.LoadList<TutorialPresentSeed>(
Path.Combine(seedDir, "tutorial-presents.json"));
if (seed.Count == 0)
{
Console.WriteLine("[TutorialPresentsImporter] No tutorial-present seed rows; skipping.");
return 0;
}
var existing = await context.TutorialPresentEntries.ToListAsync();
context.TutorialPresentEntries.RemoveRange(existing);
foreach (var s in seed)
{
context.TutorialPresentEntries.Add(new TutorialPresentEntry
{
PresentId = s.PresentId,
RewardType = s.RewardType,
RewardDetailId = s.RewardDetailId,
RewardCount = s.RewardCount,
ItemType = s.ItemType,
Message = s.Message,
});
}
await context.SaveChangesAsync();
Console.WriteLine($"[TutorialPresentsImporter] TutorialPresentEntries: -{existing.Count}/+{seed.Count}");
return seed.Count;
}
}

View File

@@ -0,0 +1,16 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class HomeDialogSeed
{
[JsonPropertyName("id")] public int Id { get; set; }
[JsonPropertyName("title_text_id")] public string TitleTextId { get; set; } = "";
[JsonPropertyName("image")] public string Image { get; set; } = "";
[JsonPropertyName("button_list")] public JsonElement ButtonList { get; set; }
[JsonPropertyName("begin_time")] public string BeginTime { get; set; } = "";
[JsonPropertyName("end_time")] public string EndTime { get; set; } = "";
[JsonPropertyName("type")] public int? Type { get; set; }
[JsonPropertyName("priority")] public int Priority { get; set; }
}

View File

@@ -43,6 +43,7 @@ public sealed class PackChildGachaSeed
[JsonPropertyName("is_daily_single")] public bool IsDailySingle { get; set; }
[JsonPropertyName("override_increase_gacha_point")] public int OverrideIncreaseGachaPoint { get; set; }
[JsonPropertyName("purchase_limit_count")] public int PurchaseLimitCount { get; set; }
[JsonPropertyName("daily_free_gacha_count")] public int DailyFreeGachaCount { get; set; }
[JsonPropertyName("free_gacha_campaign_id")] public int? FreeGachaCampaignId { get; set; }
[JsonPropertyName("campaign_name")] public string? CampaignName { get; set; }
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class TutorialPresentSeed
{
[JsonPropertyName("present_id")] public string PresentId { get; set; } = "";
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
[JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
[JsonPropertyName("reward_count")] public long RewardCount { get; set; }
[JsonPropertyName("item_type")] public int? ItemType { get; set; }
[JsonPropertyName("message")] public string Message { get; set; } = "";
}

View File

@@ -115,6 +115,9 @@ public static class Program
await mypage.ImportSealedAsync(context, opts.SeedDir);
await mypage.ImportMasterPointRankingPeriodAsync(context, opts.SeedDir);
await mypage.ImportSpecialDeckFormatsAsync(context, opts.SeedDir);
await mypage.ImportHomeDialogsAsync(context, opts.SeedDir);
await new TutorialPresentsImporter().ImportAsync(context, opts.SeedDir);
await new DefaultDeckImporter().ImportAsync(context, opts.SeedDir);
await new PackImporter().ImportAsync(context, opts.SeedDir);

View File

@@ -0,0 +1,24 @@
namespace SVSim.Database.Enums;
/// <summary>
/// Mirrors <c>GachaUI.CardPackType</c> in the decompiled client
/// (<c>Shadowverse_Code/GachaUI.cs</c> line 11). Wire value = (int)enum, carried on
/// /pack/info as <c>child_gacha_info[].type_detail</c>.
/// </summary>
public enum CardPackType
{
None = 0,
Crystal = 1,
CrystalMulti = 2,
Daily = 3,
Ticket = 4,
TicketMulti = 5,
Rupy = 6,
RupyMulti = 7,
CrystalSpecial = 8,
CrystalSelectSkin = 9,
FreePacks = 10,
FreePackWithSkin = 11,
RotationStarterPack = 12,
CrystalAcquireSkinCardPack = 13,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddHomeDialogEntries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "HomeDialogEntries",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
TitleTextId = table.Column<string>(type: "text", nullable: false),
Image = table.Column<string>(type: "text", nullable: false),
ButtonListJson = table.Column<string>(type: "jsonb", nullable: false),
BeginTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
EndTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Type = table.Column<int>(type: "integer", nullable: true),
Priority = 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_HomeDialogEntries", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_HomeDialogEntries_BeginTime_EndTime",
table: "HomeDialogEntries",
columns: new[] { "BeginTime", "EndTime" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_HomeDialogEntries_BeginTime_EndTime",
table: "HomeDialogEntries");
migrationBuilder.DropTable(
name: "HomeDialogEntries");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddViewerPresents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ViewerClaimedTutorialGifts");
migrationBuilder.CreateTable(
name: "ViewerPresents",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ViewerId = table.Column<long>(type: "bigint", nullable: false),
PresentId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Status = table.Column<byte>(type: "smallint", nullable: false),
RewardType = table.Column<int>(type: "integer", nullable: false),
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
RewardCount = table.Column<long>(type: "bigint", nullable: false),
ConditionNumber = table.Column<int>(type: "integer", nullable: false),
PresentLimitType = table.Column<int>(type: "integer", nullable: false),
RewardLimitTime = table.Column<long>(type: "bigint", nullable: false),
ItemType = table.Column<int>(type: "integer", nullable: true),
Message = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ClaimedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Source = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerPresents", x => x.Id);
table.ForeignKey(
name: "FK_ViewerPresents_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ViewerPresents_ViewerId_PresentId",
table: "ViewerPresents",
columns: new[] { "ViewerId", "PresentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ViewerPresents_ViewerId_Status_CreatedAt",
table: "ViewerPresents",
columns: new[] { "ViewerId", "Status", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ViewerPresents");
migrationBuilder.CreateTable(
name: "ViewerClaimedTutorialGifts",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
PresentId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ClaimedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerClaimedTutorialGifts", x => new { x.ViewerId, x.PresentId });
table.ForeignKey(
name: "FK_ViewerClaimedTutorialGifts_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddViewerFreePackClaims : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ViewerFreePackClaim",
columns: table => new
{
FreeGachaCampaignId = table.Column<int>(type: "integer", nullable: false),
ViewerId = table.Column<long>(type: "bigint", nullable: false),
ClaimCount = table.Column<int>(type: "integer", nullable: false),
LastClaimedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerFreePackClaim", x => new { x.ViewerId, x.FreeGachaCampaignId });
table.ForeignKey(
name: "FK_ViewerFreePackClaim_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ViewerFreePackClaim");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddTutorialPresentEntries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TutorialPresentEntries",
columns: table => new
{
PresentId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
RewardType = table.Column<int>(type: "integer", nullable: false),
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
RewardCount = table.Column<long>(type: "bigint", nullable: false),
ItemType = table.Column<int>(type: "integer", nullable: true),
Message = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TutorialPresentEntries", x => x.PresentId);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TutorialPresentEntries");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddViewerAcquireHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ViewerAcquireHistory",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ViewerId = table.Column<long>(type: "bigint", nullable: false),
RewardType = table.Column<int>(type: "integer", nullable: false),
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
RewardCount = table.Column<int>(type: "integer", nullable: false),
AcquireType = table.Column<int>(type: "integer", nullable: false),
Message = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
AcquireTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerAcquireHistory", x => x.Id);
table.ForeignKey(
name: "FK_ViewerAcquireHistory_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ViewerAcquireHistory_ViewerId_AcquireTime_Id",
table: "ViewerAcquireHistory",
columns: new[] { "ViewerId", "AcquireTime", "Id" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ViewerAcquireHistory");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddViewerMyPageBgSelection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "MyPageBgId",
table: "Viewers",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MyPageBgSelectType",
table: "Viewers",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "ViewerMyPageBgRotation",
columns: table => new
{
Slot = table.Column<int>(type: "integer", nullable: false),
ViewerId = table.Column<long>(type: "bigint", nullable: false),
BgId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerMyPageBgRotation", x => new { x.ViewerId, x.Slot });
table.ForeignKey(
name: "FK_ViewerMyPageBgRotation_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ViewerMyPageBgRotation");
migrationBuilder.DropColumn(
name: "MyPageBgId",
table: "Viewers");
migrationBuilder.DropColumn(
name: "MyPageBgSelectType",
table: "Viewers");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddViewerClassDataIsRandomLeaderSkin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsRandomLeaderSkin",
table: "ViewerClassData",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsRandomLeaderSkin",
table: "ViewerClassData");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddSerialCodeTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SerialCodes",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Code = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Message = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
StartAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
EndAt = table.Column<DateTime>(type: "timestamp with time zone", 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_SerialCodes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SerialCodeRewards",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SerialCodeId = table.Column<int>(type: "integer", nullable: false),
Slot = table.Column<int>(type: "integer", nullable: false),
RewardType = table.Column<int>(type: "integer", nullable: false),
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
RewardCount = 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_SerialCodeRewards", x => x.Id);
table.ForeignKey(
name: "FK_SerialCodeRewards_SerialCodes_SerialCodeId",
column: x => x.SerialCodeId,
principalTable: "SerialCodes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ViewerSerialCodeRedemptions",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
SerialCodeId = table.Column<int>(type: "integer", nullable: false),
RedeemedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerSerialCodeRedemptions", x => new { x.ViewerId, x.SerialCodeId });
table.ForeignKey(
name: "FK_ViewerSerialCodeRedemptions_SerialCodes_SerialCodeId",
column: x => x.SerialCodeId,
principalTable: "SerialCodes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ViewerSerialCodeRedemptions_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_SerialCodeRewards_SerialCodeId_Slot",
table: "SerialCodeRewards",
columns: new[] { "SerialCodeId", "Slot" });
migrationBuilder.CreateIndex(
name: "IX_SerialCodes_Code",
table: "SerialCodes",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ViewerSerialCodeRedemptions_SerialCodeId",
table: "ViewerSerialCodeRedemptions",
column: "SerialCodeId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SerialCodeRewards");
migrationBuilder.DropTable(
name: "ViewerSerialCodeRedemptions");
migrationBuilder.DropTable(
name: "SerialCodes");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddFriendSystemTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ViewerFriendApplies",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
FromViewerId = table.Column<long>(type: "bigint", nullable: false),
ToViewerId = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
MissionType = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerFriendApplies", x => x.Id);
table.ForeignKey(
name: "FK_ViewerFriendApplies_Viewers_FromViewerId",
column: x => x.FromViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ViewerFriendApplies_Viewers_ToViewerId",
column: x => x.ToViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ViewerFriends",
columns: table => new
{
OwnerViewerId = table.Column<long>(type: "bigint", nullable: false),
FriendViewerId = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerFriends", x => new { x.OwnerViewerId, x.FriendViewerId });
table.ForeignKey(
name: "FK_ViewerFriends_Viewers_FriendViewerId",
column: x => x.FriendViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ViewerFriends_Viewers_OwnerViewerId",
column: x => x.OwnerViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ViewerPlayedTogethers",
columns: table => new
{
OwnerViewerId = table.Column<long>(type: "bigint", nullable: false),
OpponentViewerId = table.Column<long>(type: "bigint", nullable: false),
PlayedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
PlayedMode = table.Column<int>(type: "integer", nullable: false),
BattleType = table.Column<int>(type: "integer", nullable: false),
DeckFormat = table.Column<int>(type: "integer", nullable: false),
TwoPickType = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerPlayedTogethers", x => new { x.OwnerViewerId, x.OpponentViewerId });
table.ForeignKey(
name: "FK_ViewerPlayedTogethers_Viewers_OwnerViewerId",
column: x => x.OwnerViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ViewerFriendApplies_FromViewerId_ToViewerId",
table: "ViewerFriendApplies",
columns: new[] { "FromViewerId", "ToViewerId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ViewerFriendApplies_ToViewerId",
table: "ViewerFriendApplies",
column: "ToViewerId");
migrationBuilder.CreateIndex(
name: "IX_ViewerFriends_FriendViewerId",
table: "ViewerFriends",
column: "FriendViewerId");
migrationBuilder.CreateIndex(
name: "IX_ViewerFriends_OwnerViewerId_CreatedAt",
table: "ViewerFriends",
columns: new[] { "OwnerViewerId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_ViewerPlayedTogethers_OwnerViewerId_PlayedAt",
table: "ViewerPlayedTogethers",
columns: new[] { "OwnerViewerId", "PlayedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ViewerFriendApplies");
migrationBuilder.DropTable(
name: "ViewerFriends");
migrationBuilder.DropTable(
name: "ViewerPlayedTogethers");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddViewerBattleHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ViewerBattleHistories",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
BattleId = table.Column<long>(type: "bigint", nullable: false),
BattleType = table.Column<int>(type: "integer", nullable: false),
DeckFormat = table.Column<int>(type: "integer", nullable: false),
TwoPickType = table.Column<int>(type: "integer", nullable: false),
IsLimitTurn = table.Column<int>(type: "integer", nullable: false),
SelfClassId = table.Column<int>(type: "integer", nullable: false),
SelfSubClassId = table.Column<int>(type: "integer", nullable: false),
SelfCharaId = table.Column<int>(type: "integer", nullable: false),
SelfRotationId = table.Column<string>(type: "text", nullable: false),
OpponentClassId = table.Column<int>(type: "integer", nullable: false),
OpponentSubClassId = table.Column<int>(type: "integer", nullable: false),
OpponentCharaId = table.Column<int>(type: "integer", nullable: false),
OpponentName = table.Column<string>(type: "text", nullable: false),
OpponentCountryCode = table.Column<string>(type: "text", nullable: false),
OpponentEmblemId = table.Column<long>(type: "bigint", nullable: false),
OpponentDegreeId = table.Column<long>(type: "bigint", nullable: false),
OpponentRotationId = table.Column<string>(type: "text", nullable: false),
IsWin = table.Column<bool>(type: "boolean", nullable: false),
BattleStartTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreateTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerBattleHistories", x => new { x.ViewerId, x.BattleId });
});
migrationBuilder.CreateIndex(
name: "IX_ViewerBattleHistories_ViewerId_CreateTime",
table: "ViewerBattleHistories",
columns: new[] { "ViewerId", "CreateTime" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ViewerBattleHistories");
}
}
}

View File

@@ -1176,6 +1176,46 @@ namespace SVSim.Database.Migrations
b.ToTable("GameConfigs");
});
modelBuilder.Entity("SVSim.Database.Models.HomeDialogEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<DateTime>("BeginTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("ButtonListJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("EndTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Image")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<string>("TitleTextId")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("HomeDialogEntries");
});
modelBuilder.Entity("SVSim.Database.Models.ItemEntry", b =>
{
b.Property<int>("Id")
@@ -2168,6 +2208,83 @@ namespace SVSim.Database.Migrations
b.ToTable("SealedSeasons");
});
modelBuilder.Entity("SVSim.Database.Models.SerialCodeEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("EndAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime?>("StartAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("SerialCodes");
});
modelBuilder.Entity("SVSim.Database.Models.SerialCodeRewardEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<int>("RewardCount")
.HasColumnType("integer");
b.Property<long>("RewardDetailId")
.HasColumnType("bigint");
b.Property<int>("RewardType")
.HasColumnType("integer");
b.Property<int>("SerialCodeId")
.HasColumnType("integer");
b.Property<int>("Slot")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("SerialCodeId", "Slot");
b.ToTable("SerialCodeRewards");
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b =>
{
b.Property<long>("Id")
@@ -2490,6 +2607,33 @@ namespace SVSim.Database.Migrations
b.ToTable("StoryDecks");
});
modelBuilder.Entity("SVSim.Database.Models.TutorialPresentEntry", b =>
{
b.Property<string>("PresentId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("ItemType")
.HasColumnType("integer");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<long>("RewardCount")
.HasColumnType("bigint");
b.Property<long>("RewardDetailId")
.HasColumnType("bigint");
b.Property<int>("RewardType")
.HasColumnType("integer");
b.HasKey("PresentId");
b.ToTable("TutorialPresentEntries");
});
modelBuilder.Entity("SVSim.Database.Models.UnlimitedRestrictionEntry", b =>
{
b.Property<long>("Id")
@@ -2533,6 +2677,12 @@ namespace SVSim.Database.Migrations
b.Property<DateTime>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<int>("MyPageBgId")
.HasColumnType("integer");
b.Property<int>("MyPageBgSelectType")
.HasColumnType("integer");
b.Property<long>("ShortUdid")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
@@ -2578,6 +2728,44 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerAchievements");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerAcquireHistoryEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("AcquireTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("AcquireType")
.HasColumnType("integer");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("RewardCount")
.HasColumnType("integer");
b.Property<long>("RewardDetailId")
.HasColumnType("bigint");
b.Property<int>("RewardType")
.HasColumnType("integer");
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ViewerId", "AcquireTime", "Id");
b.ToTable("ViewerAcquireHistory");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerArenaTwoPickRun", b =>
{
b.Property<long>("Id")
@@ -2655,6 +2843,83 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerArenaTwoPickRuns");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerBattleHistory", b =>
{
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.Property<long>("BattleId")
.HasColumnType("bigint");
b.Property<DateTime>("BattleStartTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("BattleType")
.HasColumnType("integer");
b.Property<DateTime>("CreateTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("DeckFormat")
.HasColumnType("integer");
b.Property<int>("IsLimitTurn")
.HasColumnType("integer");
b.Property<bool>("IsWin")
.HasColumnType("boolean");
b.Property<int>("OpponentCharaId")
.HasColumnType("integer");
b.Property<int>("OpponentClassId")
.HasColumnType("integer");
b.Property<string>("OpponentCountryCode")
.IsRequired()
.HasColumnType("text");
b.Property<long>("OpponentDegreeId")
.HasColumnType("bigint");
b.Property<long>("OpponentEmblemId")
.HasColumnType("bigint");
b.Property<string>("OpponentName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("OpponentRotationId")
.IsRequired()
.HasColumnType("text");
b.Property<int>("OpponentSubClassId")
.HasColumnType("integer");
b.Property<int>("SelfCharaId")
.HasColumnType("integer");
b.Property<int>("SelfClassId")
.HasColumnType("integer");
b.Property<string>("SelfRotationId")
.IsRequired()
.HasColumnType("text");
b.Property<int>("SelfSubClassId")
.HasColumnType("integer");
b.Property<int>("TwoPickType")
.HasColumnType("integer");
b.HasKey("ViewerId", "BattleId");
b.HasIndex("ViewerId", "CreateTime")
.HasDatabaseName("IX_ViewerBattleHistories_ViewerId_CreateTime");
b.ToTable("ViewerBattleHistories");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassClaimEntry", b =>
{
b.Property<long>("Id")
@@ -2734,23 +2999,6 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerBattlePassProgress");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerClaimedTutorialGift", b =>
{
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.Property<string>("PresentId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("ClaimedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ViewerId", "PresentId");
b.ToTable("ViewerClaimedTutorialGifts");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerEventCounter", b =>
{
b.Property<long>("ViewerId")
@@ -2772,6 +3020,56 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerEventCounters");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerFriend", b =>
{
b.Property<long>("OwnerViewerId")
.HasColumnType("bigint");
b.Property<long>("FriendViewerId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("OwnerViewerId", "FriendViewerId");
b.HasIndex("FriendViewerId");
b.HasIndex("OwnerViewerId", "CreatedAt");
b.ToTable("ViewerFriends");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerFriendApply", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("FromViewerId")
.HasColumnType("bigint");
b.Property<int>("MissionType")
.HasColumnType("integer");
b.Property<long>("ToViewerId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ToViewerId");
b.HasIndex("FromViewerId", "ToViewerId")
.IsUnique();
b.ToTable("ViewerFriendApplies");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerLeaderSkinSetClaim", b =>
{
b.Property<long>("ViewerId")
@@ -2832,6 +3130,100 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerMissions");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerPlayedTogether", b =>
{
b.Property<long>("OwnerViewerId")
.HasColumnType("bigint");
b.Property<long>("OpponentViewerId")
.HasColumnType("bigint");
b.Property<int>("BattleType")
.HasColumnType("integer");
b.Property<int>("DeckFormat")
.HasColumnType("integer");
b.Property<DateTime>("PlayedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("PlayedMode")
.HasColumnType("integer");
b.Property<int>("TwoPickType")
.HasColumnType("integer");
b.HasKey("OwnerViewerId", "OpponentViewerId");
b.HasIndex("OwnerViewerId", "PlayedAt");
b.ToTable("ViewerPlayedTogethers");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerPresent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("ClaimedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("ConditionNumber")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("ItemType")
.HasColumnType("integer");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PresentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("PresentLimitType")
.HasColumnType("integer");
b.Property<long>("RewardCount")
.HasColumnType("bigint");
b.Property<long>("RewardDetailId")
.HasColumnType("bigint");
b.Property<long>("RewardLimitTime")
.HasColumnType("bigint");
b.Property<int>("RewardType")
.HasColumnType("integer");
b.Property<string>("Source")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<byte>("Status")
.HasColumnType("smallint");
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ViewerId", "PresentId")
.IsUnique();
b.HasIndex("ViewerId", "Status", "CreatedAt");
b.ToTable("ViewerPresents");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerPuzzleClear", b =>
{
b.Property<long>("ViewerId")
@@ -2851,6 +3243,24 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerPuzzleClears");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerSerialCodeRedemption", b =>
{
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.Property<int>("SerialCodeId")
.HasColumnType("integer");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ViewerId", "SerialCodeId");
b.HasIndex("SerialCodeId");
b.ToTable("ViewerSerialCodeRedemptions");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerSpotCardExchange", b =>
{
b.Property<long>("ViewerId")
@@ -3354,6 +3764,9 @@ namespace SVSim.Database.Migrations
b1.Property<int>("Cost")
.HasColumnType("integer");
b1.Property<int>("DailyFreeGachaCount")
.HasColumnType("integer");
b1.Property<int?>("FreeGachaCampaignId")
.HasColumnType("integer");
@@ -3420,6 +3833,15 @@ namespace SVSim.Database.Migrations
b.Navigation("Group");
});
modelBuilder.Entity("SVSim.Database.Models.SerialCodeRewardEntry", b =>
{
b.HasOne("SVSim.Database.Models.SerialCodeEntry", null)
.WithMany("Rewards")
.HasForeignKey("SerialCodeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b =>
{
b.HasOne("SVSim.Database.Models.ClassEntry", "Class")
@@ -3568,6 +3990,25 @@ namespace SVSim.Database.Migrations
modelBuilder.Entity("SVSim.Database.Models.Viewer", b =>
{
b.OwnsMany("SVSim.Database.Models.MyPageBgRotationEntry", "MyPageBgRotation", b1 =>
{
b1.Property<long>("ViewerId")
.HasColumnType("bigint");
b1.Property<int>("Slot")
.HasColumnType("integer");
b1.Property<int>("BgId")
.HasColumnType("integer");
b1.HasKey("ViewerId", "Slot");
b1.ToTable("ViewerMyPageBgRotation", (string)null);
b1.WithOwner()
.HasForeignKey("ViewerId");
});
b.OwnsMany("SVSim.Database.Models.OwnedCardEntry", "Cards", b1 =>
{
b1.Property<long>("ViewerId")
@@ -3724,6 +4165,9 @@ namespace SVSim.Database.Migrations
b1.Property<int>("Exp")
.HasColumnType("integer");
b1.Property<bool>("IsRandomLeaderSkin")
.HasColumnType("boolean");
b1.Property<int>("LeaderSkinId")
.HasColumnType("integer");
@@ -3803,6 +4247,28 @@ namespace SVSim.Database.Migrations
.HasForeignKey("ViewerId");
});
b.OwnsMany("SVSim.Database.Models.ViewerFreePackClaim", "FreePackClaims", b1 =>
{
b1.Property<long>("ViewerId")
.HasColumnType("bigint");
b1.Property<int>("FreeGachaCampaignId")
.HasColumnType("integer");
b1.Property<int>("ClaimCount")
.HasColumnType("integer");
b1.Property<DateTime>("LastClaimedAt")
.HasColumnType("timestamp with time zone");
b1.HasKey("ViewerId", "FreeGachaCampaignId");
b1.ToTable("ViewerFreePackClaim");
b1.WithOwner()
.HasForeignKey("ViewerId");
});
b.OwnsMany("SVSim.Database.Models.ViewerGachaPointBalance", "GachaPointBalances", b1 =>
{
b1.Property<long>("ViewerId")
@@ -3979,6 +4445,8 @@ namespace SVSim.Database.Migrations
b.Navigation("Currency")
.IsRequired();
b.Navigation("FreePackClaims");
b.Navigation("GachaPointBalances");
b.Navigation("GachaPointReceived");
@@ -3991,6 +4459,8 @@ namespace SVSim.Database.Migrations
b.Navigation("MissionData")
.IsRequired();
b.Navigation("MyPageBgRotation");
b.Navigation("PackOpenCounts");
b.Navigation("SocialAccountConnections");
@@ -4005,15 +4475,13 @@ namespace SVSim.Database.Migrations
.IsRequired();
});
modelBuilder.Entity("SVSim.Database.Models.ViewerClaimedTutorialGift", b =>
modelBuilder.Entity("SVSim.Database.Models.ViewerAcquireHistoryEntry", b =>
{
b.HasOne("SVSim.Database.Models.Viewer", "Viewer")
b.HasOne("SVSim.Database.Models.Viewer", null)
.WithMany()
.HasForeignKey("ViewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Viewer");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerEventCounter", b =>
@@ -4025,6 +4493,36 @@ namespace SVSim.Database.Migrations
.IsRequired();
});
modelBuilder.Entity("SVSim.Database.Models.ViewerFriend", b =>
{
b.HasOne("SVSim.Database.Models.Viewer", null)
.WithMany()
.HasForeignKey("FriendViewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SVSim.Database.Models.Viewer", null)
.WithMany()
.HasForeignKey("OwnerViewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("SVSim.Database.Models.ViewerFriendApply", b =>
{
b.HasOne("SVSim.Database.Models.Viewer", null)
.WithMany()
.HasForeignKey("FromViewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SVSim.Database.Models.Viewer", null)
.WithMany()
.HasForeignKey("ToViewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("SVSim.Database.Models.ViewerMission", b =>
{
b.HasOne("SVSim.Database.Models.Viewer", null)
@@ -4034,6 +4532,41 @@ namespace SVSim.Database.Migrations
.IsRequired();
});
modelBuilder.Entity("SVSim.Database.Models.ViewerPlayedTogether", b =>
{
b.HasOne("SVSim.Database.Models.Viewer", null)
.WithMany()
.HasForeignKey("OwnerViewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("SVSim.Database.Models.ViewerPresent", b =>
{
b.HasOne("SVSim.Database.Models.Viewer", "Viewer")
.WithMany()
.HasForeignKey("ViewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Viewer");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerSerialCodeRedemption", b =>
{
b.HasOne("SVSim.Database.Models.SerialCodeEntry", null)
.WithMany()
.HasForeignKey("SerialCodeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SVSim.Database.Models.Viewer", null)
.WithMany()
.HasForeignKey("ViewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("SleeveEntryViewer", b =>
{
b.HasOne("SVSim.Database.Models.SleeveEntry", null)
@@ -4074,6 +4607,11 @@ namespace SVSim.Database.Migrations
b.Navigation("Puzzles");
});
modelBuilder.Entity("SVSim.Database.Models.SerialCodeEntry", b =>
{
b.Navigation("Rewards");
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardSetEntry", b =>
{
b.Navigation("Cards");

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations.Schema;
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One mypage home-dialog popup from /mypage/index data.home_dialog_list. Id is authored in
/// the seed file (no stable wire ID; see banners.json for the same pattern). The dialog fires
/// once per viewer per server-process lifetime — see IHomeDialogSessionTracker.
/// </summary>
public class HomeDialogEntry : BaseEntity<int>
{
public string TitleTextId { get; set; } = string.Empty;
public string Image { get; set; } = string.Empty;
/// <summary>jsonb — List&lt;HomeDialogButtonSeed&gt; serialized verbatim. Deserialized in
/// MyPageController via JsonbReadOptions.</summary>
[Column(TypeName = "jsonb")]
public string ButtonListJson { get; set; } = "[]";
public DateTime BeginTime { get; set; }
public DateTime EndTime { get; set; }
/// <summary>Wire "type" — client parser ignores it but prod sends "1". Nullable so we
/// omit when unset; serialized as a string per <c>HomeDialog.Type</c> on the DTO.</summary>
public int? Type { get; set; }
/// <summary>Tiebreaker when multiple entries are active. Higher wins; ID asc breaks
/// further ties. Each /mypage/index call emits the highest-priority unfired entry.</summary>
public int Priority { get; set; }
}

View File

@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// One row per (viewer, slot) in the viewer's saved MyPage BG rotation pool. The client posts
/// the full pool on every <c>/user_mypage/update</c> regardless of mode, so the server overwrites
/// it atomically each time. Slot is the 0-based position; order is preserved for the
/// <c>/mypage/index</c> echo.
/// </summary>
[Owned]
public class MyPageBgRotationEntry
{
public int Slot { get; set; }
public int BgId { get; set; }
}

View File

@@ -1,26 +1,24 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
/// <summary>
/// One sub-option inside a pack (single-open / 10-open / ticket / daily-free).
/// Wire shape: one entry of <c>child_gacha_info</c> in /pack/info. Owned by PackConfigEntry.
/// <c>TypeDetail</c> corresponds to <c>GachaUI.CardPackType</c>:
/// 1=CRYSTAL, 2=CRYSTAL_MULTI, 3=DAILY, 4=TICKET, 5=TICKET_MULTI, 6=RUPY, 7=RUPY_MULTI,
/// 8=CRYSTAL_SPECIAL, 9=CRYSTAL_SELECT_SKIN, 10=FREE_PACKS, 11=FREE_PACK_WITH_SKIN,
/// 12=ROTATION_STARTER_PACK, 13=CRYSTAL_ACQUIRE_SKIN_CARD_PACK.
/// </summary>
[Owned]
public class PackChildGachaEntry
{
public int GachaId { get; set; }
public int TypeDetail { get; set; }
public CardPackType TypeDetail { get; set; }
public int Cost { get; set; }
public int CardCount { get; set; }
public long? ItemId { get; set; }
public bool IsDailySingle { get; set; }
public int OverrideIncreaseGachaPoint { get; set; }
public int PurchaseLimitCount { get; set; }
public int DailyFreeGachaCount { get; set; }
public int? FreeGachaCampaignId { get; set; }
public string? CampaignName { get; set; }
}

View File

@@ -0,0 +1,27 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// Top-level entity for a promotional serial code. Admin inserts these directly via SQL;
/// there is no JSON seed or admin endpoint. Case-sensitive match on <see cref="Code"/>.
/// </summary>
public class SerialCodeEntry : BaseEntity<int>
{
/// <summary>User-typed code. Case-sensitive; unique index enforces no duplicates.</summary>
public string Code { get; set; } = string.Empty;
/// <summary>Player-facing mail body, copied onto every <c>ViewerPresent</c> created at redemption.</summary>
public string Message { get; set; } = string.Empty;
/// <summary>When the code becomes valid. NULL = always valid from creation.</summary>
public DateTime? StartAt { get; set; }
/// <summary>When the code expires. NULL = never expires.</summary>
public DateTime? EndAt { get; set; }
/// <summary>Admin kill-switch. False = treat as if it doesn't exist.</summary>
public bool IsEnabled { get; set; }
public List<SerialCodeRewardEntry> Rewards { get; set; } = new List<SerialCodeRewardEntry>();
}

View File

@@ -0,0 +1,24 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One reward slot belonging to a <see cref="SerialCodeEntry"/>. On redemption each row
/// becomes one <see cref="ViewerPresent"/> in the player's gift inbox.
/// </summary>
public class SerialCodeRewardEntry : BaseEntity<int>
{
public int SerialCodeId { get; set; }
/// <summary>0-based ordering within the code's rewards.</summary>
public int Slot { get; set; }
/// <summary>UserGoodsType cast to int (matches the wire convention used elsewhere).</summary>
public int RewardType { get; set; }
/// <summary>Detail id for the goods. 0 for wallet currencies.</summary>
public long RewardDetailId { get; set; }
/// <summary>Positive integer count.</summary>
public int RewardCount { get; set; }
}

View File

@@ -0,0 +1,18 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row in the tutorial-gift catalogue every fresh viewer is given at signup. Authored in
/// <c>SVSim.Bootstrap/Data/seeds/tutorial-presents.json</c>; <see cref="PresentId"/> is the
/// wire-stable identifier and serves as the primary key. <c>ViewerRepository.RegisterAnonymousViewer</c>
/// reads this table and projects each row into a <see cref="ViewerPresent"/> with Source="tutorial".
/// </summary>
public class TutorialPresentEntry
{
public string PresentId { get; set; } = string.Empty;
public int RewardType { get; set; }
public long RewardDetailId { get; set; }
public long RewardCount { get; set; }
public int? ItemType { get; set; }
public string Message { get; set; } = string.Empty;
}

View File

@@ -33,6 +33,12 @@ public class Viewer : BaseEntity<long>
public DateTime LastLogin { get; set; }
/// <summary>BGType enum: 0=Deck, 1=CustomBG, 2=RandomBG. Default 0 = follow equipped deck's leader skin.</summary>
public int MyPageBgSelectType { get; set; }
/// <summary>The single chosen MyPageBG cosmetic id, used when SelectType=CustomBG. 0 = none.</summary>
public int MyPageBgId { get; set; }
#region Owned
public ViewerInfo Info { get; set; } = new ViewerInfo();
@@ -65,6 +71,10 @@ public class Viewer : BaseEntity<long>
public List<ViewerPackOpenCount> PackOpenCounts { get; set; } = new List<ViewerPackOpenCount>();
public List<ViewerFreePackClaim> FreePackClaims { get; set; } = new List<ViewerFreePackClaim>();
public List<MyPageBgRotationEntry> MyPageBgRotation { get; set; } = new List<MyPageBgRotationEntry>();
public List<ViewerGachaPointBalance> GachaPointBalances { get; set; } = new List<ViewerGachaPointBalance>();
public List<ViewerGachaPointReceived> GachaPointReceived { get; set; } = new List<ViewerGachaPointReceived>();

View File

@@ -0,0 +1,30 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row per grant emitted by <c>InventoryTransaction.CommitAsync</c>. Rendered as the
/// <c>histories[]</c> array on <c>POST /item_acquire_history/info</c>. Capped at 300 rows
/// per viewer; oldest pruned on commit.
/// </summary>
public sealed class ViewerAcquireHistoryEntry
{
public long Id { get; set; }
public long ViewerId { get; set; }
/// <summary>UserGoodsType cast to int; matches the wire <c>reward_type</c>.</summary>
public int RewardType { get; set; }
/// <summary>Detail id for the goods; 0 for wallet currencies.</summary>
public long RewardDetailId { get; set; }
/// <summary>Delta granted in this row — NOT a post-state total.</summary>
public int RewardCount { get; set; }
/// <summary>GrantSource cast to int; matches the wire <c>acquire_type</c>.</summary>
public int AcquireType { get; set; }
/// <summary>Pre-localized text the client renders verbatim. Capped at 64 chars.</summary>
public string Message { get; set; } = string.Empty;
/// <summary>Server UTC at commit time. Stamped once per <c>CommitAsync</c>, identical across all rows in that commit.</summary>
public DateTime AcquireTime { get; set; }
}

View File

@@ -0,0 +1,39 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row per recent battle the viewer participated in, surfaced by /replay/info.
/// Composite PK on (ViewerId, BattleId). Retention: 50 rows per viewer, oldest
/// evicted on insert (see <see cref="Services.Replay.BattleHistoryWriter"/>).
///
/// The battle payload itself is NOT stored here — the client uses its local
/// <c>NewReplay/&lt;battle_id&gt;/</c> cache for playback. See
/// <c>docs/superpowers/specs/2026-06-10-replay-info-design.md</c>.
/// </summary>
public class ViewerBattleHistory
{
public long ViewerId { get; set; }
public long BattleId { get; set; }
public int BattleType { get; set; }
public int DeckFormat { get; set; }
public int TwoPickType { get; set; }
public int IsLimitTurn { get; set; }
public int SelfClassId { get; set; }
public int SelfSubClassId { get; set; }
public int SelfCharaId { get; set; }
public string SelfRotationId { get; set; } = "0";
public int OpponentClassId { get; set; }
public int OpponentSubClassId { get; set; }
public int OpponentCharaId { get; set; }
public string OpponentName { get; set; } = "";
public string OpponentCountryCode { get; set; } = "";
public long OpponentEmblemId { get; set; }
public long OpponentDegreeId { get; set; }
public string OpponentRotationId { get; set; } = "0";
public bool IsWin { get; set; }
public DateTime BattleStartTime { get; set; }
public DateTime CreateTime { get; set; }
}

View File

@@ -1,14 +0,0 @@
namespace SVSim.Database.Models;
/// <summary>
/// Records that a viewer has claimed a specific tutorial gift present_id. Composite key
/// (ViewerId, PresentId) — viewer can't claim the same present twice.
/// </summary>
public class ViewerClaimedTutorialGift
{
public long ViewerId { get; set; }
public string PresentId { get; set; } = string.Empty;
public DateTime ClaimedAt { get; set; }
public Viewer Viewer { get; set; } = null!;
}

View File

@@ -7,7 +7,14 @@ public class ViewerClassData
{
public int Level { get; set; }
public int Exp { get; set; }
/// <summary>
/// Per-class "use random leader skin from owned pool" preference. Defaults to false.
/// No client-side setter exists today (only per-deck random-leader-skin endpoints exist);
/// persisted now so when/if a class-level toggle is discovered, the write target exists.
/// </summary>
public bool IsRandomLeaderSkin { get; set; }
#region Navigation Properties
public ClassEntry Class { get; set; } = new ClassEntry();

View File

@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// One row per (viewer, free_gacha_campaign_id). Counts claims and remembers when the last one
/// landed so the controller can gate the daily quota. Owned collection on <see cref="Viewer"/>.
/// </summary>
[Owned]
public class ViewerFreePackClaim
{
public int FreeGachaCampaignId { get; set; }
public int ClaimCount { get; set; }
public DateTime LastClaimedAt { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row per direction of a friendship. Approving an apply creates two rows
/// (A → B and B → A). <see cref="FriendViewerId"/> from a played-together row
/// can be self-joined against this table to detect an existing friendship.
/// </summary>
public class ViewerFriend
{
public long OwnerViewerId { get; set; }
public long FriendViewerId { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace SVSim.Database.Models;
/// <summary>
/// One pending friend application. <see cref="Id"/> is the wire <c>apply_id</c>
/// (auto-generated). Unique on <c>(FromViewerId, ToViewerId)</c> — a viewer can only
/// have one outstanding apply to any given target.
/// </summary>
public class ViewerFriendApply
{
public int Id { get; set; }
public long FromViewerId { get; set; }
public long ToViewerId { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>Beginner-friend campaign tag. Defaults to 0 (no campaign). Surfaces as optional <c>mission_type</c> on the wire.</summary>
public int MissionType { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row per (owner, opponent) pair. Upserted on each new battle so the table
/// holds at most one row per opponent. Per-viewer 50-row retention cap pruned
/// by <c>IPlayedTogetherWriter.RecordAsync</c>.
/// </summary>
public class ViewerPlayedTogether
{
public long OwnerViewerId { get; set; }
public long OpponentViewerId { get; set; }
public DateTime PlayedAt { get; set; }
public int PlayedMode { get; set; }
public int BattleType { get; set; }
public int DeckFormat { get; set; }
public int TwoPickType { get; set; }
}

View File

@@ -0,0 +1,48 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row per gift in a viewer's inbox. Replaces the tutorial-only
/// <c>ViewerClaimedTutorialGift</c> receipts model with a unified status-enum row that
/// serves both /gift/top + /gift/receive_gift (prod) and /tutorial/gift_top +
/// /tutorial/gift_receive (tutorial alias).
/// </summary>
public class ViewerPresent
{
public long Id { get; set; }
public long ViewerId { get; set; }
public Viewer Viewer { get; set; } = null!;
/// <summary>Wire id ("71409625" in the prod capture). String to match the wire.</summary>
public string PresentId { get; set; } = string.Empty;
public PresentStatus Status { get; set; }
/// <summary>UserGoodsType-compatible int. Wire is stringified — see PresentMapper.</summary>
public int RewardType { get; set; }
public long RewardDetailId { get; set; }
public long RewardCount { get; set; }
public int ConditionNumber { get; set; }
public int PresentLimitType { get; set; }
public long RewardLimitTime { get; set; }
public int? ItemType { get; set; }
public string Message { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? ClaimedAt { get; set; }
/// <summary>
/// Free-form provenance tag for future producers ("tutorial", "challenge_win",
/// "payment_refund:&lt;txid&gt;", "event:&lt;id&gt;"). Nothing in the receive handler reads
/// this today — the tutorial-step advance is route-gated, not Source-gated.
/// </summary>
public string? Source { get; set; }
}
public enum PresentStatus : byte
{
Unclaimed = 0,
Claimed = 1,
Deleted = 2,
Expired = 3,
}

View File

@@ -0,0 +1,13 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row per (viewer, code) redemption. Composite PK on <c>(ViewerId, SerialCodeId)</c>
/// enforces the single-use-per-viewer guarantee at the DB layer; the controller catches
/// the unique-constraint violation as a race-condition backstop.
/// </summary>
public class ViewerSerialCodeRedemption
{
public long ViewerId { get; set; }
public int SerialCodeId { get; set; }
public DateTime RedeemedAt { get; set; }
}

View File

@@ -136,7 +136,7 @@ public class CardInventoryRepository : ICardInventoryRepository
// Mutation phase via InventoryService transaction — freeplay-aware RedEther debit,
// card grants with cosmetic cascade.
await using var tx = await _inv.BeginAsync(viewerId);
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg.Source = GrantSource.CardCraft);
var spendResult = await tx.TrySpendAsync(SpendCurrency.RedEther, (long)totalCost);
if (!spendResult.Success)

View File

@@ -65,6 +65,13 @@ public class GlobalsRepository : IGlobalsRepository
public Task<List<BannerEntry>> GetBanners() =>
_dbContext.Banners.AsNoTracking().OrderBy(b => b.Id).ToListAsync();
public async Task<IReadOnlyList<HomeDialogEntry>> GetActiveHomeDialogsAsync(DateTime nowUtc) =>
await _dbContext.HomeDialogEntries.AsNoTracking()
.Where(e => e.BeginTime <= nowUtc && e.EndTime > nowUtc)
.OrderByDescending(e => e.Priority)
.ThenBy(e => e.Id)
.ToListAsync();
public Task<ColosseumConfig?> GetCurrentColosseum() =>
_dbContext.Colosseums.AsNoTracking().FirstOrDefaultAsync(e => e.Id == 1);

View File

@@ -21,6 +21,7 @@ public interface IGlobalsRepository
Task<List<BattlePassLevelEntry>> GetBattlePassLevels();
Task<List<DailyLoginBonusEntry>> GetDailyLoginBonus();
Task<List<BannerEntry>> GetBanners();
Task<IReadOnlyList<HomeDialogEntry>> GetActiveHomeDialogsAsync(DateTime nowUtc);
Task<ColosseumConfig?> GetCurrentColosseum();
Task<SealedConfig?> GetCurrentSealedSeason();
Task<MasterPointRankingPeriodEntry?> GetCurrentMasterPointPeriod();

View File

@@ -66,6 +66,7 @@ public class ViewerRepository : IViewerRepository
.Include(v => v.Degrees)
.Include(v => v.LeaderSkins).ThenInclude(ls => ls.Class)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.MyPageBgRotation)
.FirstOrDefaultAsync(viewer => viewer.ShortUdid == shortUdid);
}
@@ -110,6 +111,36 @@ public class ViewerRepository : IViewerRepository
var viewer = await BuildDefaultViewer("");
viewer.Udid = udid;
_dbContext.Set<Models.Viewer>().Add(viewer);
// Eager-seed the tutorial gifts so the inbox is populated by the time the tutorial
// walks the user to it (which happens AFTER initial battles, per the gift-inbox
// design). The catalogue lives in TutorialPresentEntries (loaded from
// SVSim.Bootstrap/Data/seeds/tutorial-presents.json); if the catalogue is empty
// (bootstrap not run) signup still succeeds with an empty inbox. The unique
// (ViewerId, PresentId) index is the backstop against double-seeding on a retried
// signup. Both inserts commit in a single SaveChanges.
var tutorialPresents = await _dbContext.Set<TutorialPresentEntry>()
.AsNoTracking()
.OrderBy(p => p.PresentId)
.ToListAsync();
var createdAt = DateTime.UtcNow;
foreach (var spec in tutorialPresents)
{
_dbContext.Set<ViewerPresent>().Add(new ViewerPresent
{
Viewer = viewer, // EF wires up ViewerId via the nav after Insert
PresentId = spec.PresentId,
Status = PresentStatus.Unclaimed,
RewardType = spec.RewardType,
RewardDetailId = spec.RewardDetailId,
RewardCount = spec.RewardCount,
ItemType = spec.ItemType,
Message = spec.Message,
CreatedAt = createdAt,
Source = "tutorial",
});
}
try
{
await _dbContext.SaveChangesAsync();
@@ -272,7 +303,9 @@ public class ViewerRepository : IViewerRepository
{
Class = ce,
Exp = 0,
Level = 0,
// Client unconditionally indexes `_classCharaExpList[level - 1]` in
// RankMatchUI.onOpen → CharacterExps.GetClassCharacterExps; level 0 throws IOOR.
Level = 1,
LeaderSkin = skin ?? new LeaderSkinEntry { Id = 0, Name = "<missing>", ClassId = ce.Id }
};
}).ToList();

View File

@@ -62,6 +62,7 @@ public class SVSimDbContext : DbContext
public DbSet<ViewerEventCounter> ViewerEventCounters => Set<ViewerEventCounter>();
public DbSet<DailyLoginBonusEntry> DailyLoginBonuses => Set<DailyLoginBonusEntry>();
public DbSet<BannerEntry> Banners => Set<BannerEntry>();
public DbSet<HomeDialogEntry> HomeDialogEntries => Set<HomeDialogEntry>();
public DbSet<ColosseumConfig> Colosseums => Set<ColosseumConfig>();
public DbSet<SealedConfig> SealedSeasons => Set<SealedConfig>();
public DbSet<MasterPointRankingPeriodEntry> MasterPointRankingPeriods => Set<MasterPointRankingPeriodEntry>();
@@ -100,11 +101,22 @@ public class SVSimDbContext : DbContext
public DbSet<ViewerStoryProgress> ViewerStoryProgress => Set<ViewerStoryProgress>();
public DbSet<ViewerStoryBranchUnlock> ViewerStoryBranchUnlocks => Set<ViewerStoryBranchUnlock>();
public DbSet<ViewerClaimedTutorialGift> ViewerClaimedTutorialGifts => Set<ViewerClaimedTutorialGift>();
public DbSet<ViewerPresent> ViewerPresents => Set<ViewerPresent>();
public DbSet<TutorialPresentEntry> TutorialPresentEntries => Set<TutorialPresentEntry>();
public DbSet<ViewerAcquireHistoryEntry> ViewerAcquireHistory => Set<ViewerAcquireHistoryEntry>();
public DbSet<ArenaTwoPickReward> ArenaTwoPickRewards { get; set; } = null!;
public DbSet<ViewerArenaTwoPickRun> ViewerArenaTwoPickRuns { get; set; } = null!;
public DbSet<SerialCodeEntry> SerialCodes => Set<SerialCodeEntry>();
public DbSet<SerialCodeRewardEntry> SerialCodeRewards => Set<SerialCodeRewardEntry>();
public DbSet<ViewerSerialCodeRedemption> ViewerSerialCodeRedemptions => Set<ViewerSerialCodeRedemption>();
public DbSet<ViewerFriend> ViewerFriends => Set<ViewerFriend>();
public DbSet<ViewerFriendApply> ViewerFriendApplies => Set<ViewerFriendApply>();
public DbSet<ViewerPlayedTogether> ViewerPlayedTogethers => Set<ViewerPlayedTogether>();
public DbSet<ViewerBattleHistory> ViewerBattleHistories => Set<ViewerBattleHistory>();
#endregion
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
@@ -163,6 +175,19 @@ public class SVSimDbContext : DbContext
e.HasIndex(x => new { x.PackId, x.Slot, x.Tier });
});
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
modelBuilder.Entity<Viewer>().OwnsMany(v => v.FreePackClaims, b =>
{
b.WithOwner().HasForeignKey("ViewerId");
b.HasKey("ViewerId", nameof(ViewerFreePackClaim.FreeGachaCampaignId));
b.Property(x => x.FreeGachaCampaignId).ValueGeneratedNever();
});
modelBuilder.Entity<Viewer>().OwnsMany(v => v.MyPageBgRotation, b =>
{
b.ToTable("ViewerMyPageBgRotation");
b.WithOwner().HasForeignKey("ViewerId");
b.HasKey("ViewerId", nameof(MyPageBgRotationEntry.Slot));
b.Property(x => x.Slot).ValueGeneratedNever();
});
// OwnedCardEntry and OwnedItemEntry use composite PK (ViewerId, Id) where Id is auto-
// generated, which silently permits multiple rows per (Viewer, Card) or (Viewer, Item).
@@ -365,11 +390,110 @@ public class SVSimDbContext : DbContext
b.HasIndex(e => new { e.ViewerId, e.Period });
});
modelBuilder.Entity<ViewerClaimedTutorialGift>(b =>
modelBuilder.Entity<ViewerPresent>(b =>
{
b.HasKey(g => new { g.ViewerId, g.PresentId });
b.HasOne(g => g.Viewer).WithMany().HasForeignKey(g => g.ViewerId).OnDelete(DeleteBehavior.Cascade);
b.Property(g => g.PresentId).HasMaxLength(64);
b.HasKey(p => p.Id);
b.Property(p => p.PresentId).HasMaxLength(64);
b.Property(p => p.Source).HasMaxLength(64);
b.HasOne(p => p.Viewer)
.WithMany()
.HasForeignKey(p => p.ViewerId)
.OnDelete(DeleteBehavior.Cascade);
// Drives /gift/top — partition by status, then chronological.
b.HasIndex(p => new { p.ViewerId, p.Status, p.CreatedAt });
// One row per (viewer, present_id) — backstop against double-seeding and
// double-enqueue from future producers.
b.HasIndex(p => new { p.ViewerId, p.PresentId }).IsUnique();
});
modelBuilder.Entity<TutorialPresentEntry>(b =>
{
b.HasKey(p => p.PresentId);
b.Property(p => p.PresentId).HasMaxLength(64);
});
modelBuilder.Entity<ViewerAcquireHistoryEntry>(b =>
{
b.HasKey(e => e.Id);
b.Property(e => e.Id).ValueGeneratedOnAdd();
b.Property(e => e.Message).HasMaxLength(64);
b.HasOne<Viewer>()
.WithMany()
.HasForeignKey(e => e.ViewerId)
.OnDelete(DeleteBehavior.Cascade);
b.HasIndex(e => new { e.ViewerId, e.AcquireTime, e.Id });
});
modelBuilder.Entity<SerialCodeEntry>(b =>
{
b.HasKey(e => e.Id);
b.Property(e => e.Id).ValueGeneratedOnAdd();
b.Property(e => e.Code).HasMaxLength(64).IsRequired();
b.Property(e => e.Message).HasMaxLength(255);
b.HasIndex(e => e.Code).IsUnique();
b.HasMany(e => e.Rewards)
.WithOne()
.HasForeignKey(r => r.SerialCodeId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<SerialCodeRewardEntry>(b =>
{
b.HasKey(e => e.Id);
b.Property(e => e.Id).ValueGeneratedOnAdd();
b.HasIndex(e => new { e.SerialCodeId, e.Slot });
});
modelBuilder.Entity<ViewerSerialCodeRedemption>(b =>
{
b.HasKey(e => new { e.ViewerId, e.SerialCodeId });
b.HasOne<Viewer>()
.WithMany()
.HasForeignKey(e => e.ViewerId)
.OnDelete(DeleteBehavior.Cascade);
b.HasOne<SerialCodeEntry>()
.WithMany()
.HasForeignKey(e => e.SerialCodeId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ViewerFriend>(b =>
{
b.HasKey(e => new { e.OwnerViewerId, e.FriendViewerId });
b.HasIndex(e => new { e.OwnerViewerId, e.CreatedAt });
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.OwnerViewerId).OnDelete(DeleteBehavior.Cascade);
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.FriendViewerId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ViewerFriendApply>(b =>
{
b.HasKey(e => e.Id);
b.HasIndex(e => new { e.FromViewerId, e.ToViewerId }).IsUnique();
b.HasIndex(e => e.ToViewerId);
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.FromViewerId).OnDelete(DeleteBehavior.Cascade);
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.ToViewerId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ViewerPlayedTogether>(b =>
{
b.HasKey(e => new { e.OwnerViewerId, e.OpponentViewerId });
b.HasIndex(e => new { e.OwnerViewerId, e.PlayedAt });
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.OwnerViewerId).OnDelete(DeleteBehavior.Cascade);
// OpponentViewerId is NOT an FK — we want survivors' history to outlive a deleted opponent.
});
modelBuilder.Entity<ViewerBattleHistory>(b =>
{
b.HasKey(e => new { e.ViewerId, e.BattleId });
b.HasIndex(e => new { e.ViewerId, e.CreateTime })
.HasDatabaseName("IX_ViewerBattleHistories_ViewerId_CreateTime");
b.Property(e => e.SelfRotationId).IsRequired();
b.Property(e => e.OpponentName).IsRequired();
b.Property(e => e.OpponentCountryCode).IsRequired();
b.Property(e => e.OpponentRotationId).IsRequired();
});
base.OnModelCreating(modelBuilder);

View File

@@ -0,0 +1,84 @@
namespace SVSim.Database.Services.Friend;
/// <summary>
/// One friend in the requested viewer's friend list. Wire shape carries 15 fields;
/// most are cosmetic ints emitted as strings (matches prod). Numeric fields
/// (viewer_id, rank, emblem_id, degree_id) ship as native ints.
/// </summary>
public sealed record FriendEntry(
int ViewerId,
string Name,
string CountryCode,
int Rank,
long EmblemId,
int DegreeId,
string LastPlayTime, // "yyyy-MM-dd HH:mm:ss"
string DeviceType,
string MaxFriend,
string IsReceivedTwoPickMission,
string Birth,
string MissionChangeTime,
string MissionReceiveType,
string IsOfficial,
string IsOfficialMarkDisplayed);
/// <summary>
/// One friend apply (sent or received). Wire field <c>id</c> is the apply's PK.
/// </summary>
public sealed record FriendApplyEntry(
int Id,
int ViewerId, // OTHER viewer's id
string Name,
string CountryCode,
int Rank,
long EmblemId,
int DegreeId,
string LastPlayTime,
string CreateTime,
int MissionType); // 0 when omitted on the wire
/// <summary>
/// One recent-opponent row. <see cref="FriendStatus"/> is computed at read time:
/// 0 = NO_ACTION, 1 = IS_FRIEND, 2 = IS_SEND (caller has outgoing apply),
/// 3 = IS_RECEIVED (caller has incoming apply from opponent).
/// <see cref="FriendApplyId"/> is the relevant apply's PK when status is 2 or 3, else 0.
/// </summary>
public sealed record PlayedTogetherEntry(
int ViewerId,
string Name,
string CountryCode,
int Rank,
long EmblemId,
int DegreeId,
string LastPlayTime,
string PlayedTime,
int FriendStatus,
int FriendApplyId,
int PlayedMode,
int BattleType,
int DeckFormat,
int TwoPickType);
public sealed record FriendInfoResult(
IReadOnlyList<FriendEntry> Friends,
int Count,
int MaxCount);
public sealed record ReceiveApplyInfoResult(
IReadOnlyList<FriendApplyEntry> ReceiveApplies,
int ApproveApplyCount);
public sealed record SendApplyInfoResult(
IReadOnlyList<FriendApplyEntry> SendApplies,
int RemainingApplyCount,
int SendApplyMaxCount);
public sealed record PlayedTogetherResult(
IReadOnlyList<PlayedTogetherEntry> Histories);
/// <summary>Context recorded by <see cref="IPlayedTogetherWriter.RecordAsync"/>.</summary>
public sealed record BattleParticipationContext(
int PlayedMode,
int BattleType,
int DeckFormat,
int TwoPickType);

View File

@@ -0,0 +1,404 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database.Models;
namespace SVSim.Database.Services.Friend;
public sealed class FriendService : IFriendService, IPlayedTogetherWriter
{
internal const int FriendMaxCount = 110;
internal const int SendApplyMaxCount = 110;
internal const int PlayedTogetherRetention = 50;
// Cosmetic field defaults matching the prod capture's "no campaign, normal player" state.
internal const string DefaultDeviceType = "2";
internal const string DefaultMaxFriend = "110";
internal const string DefaultIsReceivedTwoPickMission = "1";
internal const string DefaultBirth = "0";
internal const string DefaultMissionChangeTime = "2017-09-15 02:36:09";
internal const string DefaultMissionReceiveType = "0";
internal const string DefaultIsOfficial = "0";
internal const string DefaultIsOfficialMarkDisplayed = "0";
private readonly SVSimDbContext _db;
private readonly ILogger<FriendService> _log;
public FriendService(SVSimDbContext db, ILogger<FriendService> log)
{
_db = db;
_log = log;
}
public async Task<FriendInfoResult> GetFriendsAsync(long viewerId, CancellationToken ct)
{
var friendIds = await _db.ViewerFriends
.AsNoTracking()
.Where(f => f.OwnerViewerId == viewerId)
.OrderBy(f => f.CreatedAt).ThenBy(f => f.FriendViewerId)
.Select(f => f.FriendViewerId)
.ToListAsync(ct);
var friends = new List<FriendEntry>(friendIds.Count);
foreach (var friendId in friendIds)
{
var entry = await BuildFriendEntryAsync(friendId, ct);
if (entry is not null) friends.Add(entry);
}
return new FriendInfoResult(friends, friends.Count, FriendMaxCount);
}
public async Task<ReceiveApplyInfoResult> GetReceiveAppliesAsync(long viewerId, CancellationToken ct)
{
var rows = await _db.ViewerFriendApplies
.Where(a => a.ToViewerId == viewerId)
.OrderBy(a => a.CreatedAt).ThenBy(a => a.Id)
.AsNoTracking()
.ToListAsync(ct);
var applies = new List<FriendApplyEntry>(rows.Count);
foreach (var row in rows)
applies.Add(await BuildApplyEntryAsync(row.Id, row.FromViewerId, row.CreatedAt, row.MissionType, ct));
return new ReceiveApplyInfoResult(applies, ApproveApplyCount: 0);
}
public async Task<SendApplyInfoResult> GetSendAppliesAsync(long viewerId, CancellationToken ct)
{
var rows = await _db.ViewerFriendApplies
.Where(a => a.FromViewerId == viewerId)
.OrderBy(a => a.CreatedAt).ThenBy(a => a.Id)
.AsNoTracking()
.ToListAsync(ct);
var applies = new List<FriendApplyEntry>(rows.Count);
foreach (var row in rows)
applies.Add(await BuildApplyEntryAsync(row.Id, row.ToViewerId, row.CreatedAt, row.MissionType, ct));
int remaining = Math.Max(0, SendApplyMaxCount - rows.Count);
return new SendApplyInfoResult(applies, remaining, SendApplyMaxCount);
}
public async Task<PlayedTogetherResult> GetPlayedTogetherAsync(long viewerId, CancellationToken ct)
{
var rows = await _db.ViewerPlayedTogethers
.Where(p => p.OwnerViewerId == viewerId)
.OrderByDescending(p => p.PlayedAt)
.AsNoTracking()
.ToListAsync(ct);
var entries = new List<PlayedTogetherEntry>(rows.Count);
foreach (var row in rows)
{
var opp = await LoadViewerProjectionAsync(row.OpponentViewerId, ct);
if (opp is null) continue; // opponent deleted; skip the dead row
bool isFriend = await _db.ViewerFriends.AsNoTracking()
.AnyAsync(f => f.OwnerViewerId == viewerId && f.FriendViewerId == row.OpponentViewerId, ct);
int friendStatus = 0;
int friendApplyId = 0;
if (isFriend)
{
friendStatus = 1;
}
else
{
var sent = await _db.ViewerFriendApplies.AsNoTracking()
.Where(a => a.FromViewerId == viewerId && a.ToViewerId == row.OpponentViewerId)
.Select(a => (int?)a.Id).FirstOrDefaultAsync(ct);
if (sent is { } sId) { friendStatus = 2; friendApplyId = sId; }
else
{
var recv = await _db.ViewerFriendApplies.AsNoTracking()
.Where(a => a.FromViewerId == row.OpponentViewerId && a.ToViewerId == viewerId)
.Select(a => (int?)a.Id).FirstOrDefaultAsync(ct);
if (recv is { } rId) { friendStatus = 3; friendApplyId = rId; }
}
}
entries.Add(new PlayedTogetherEntry(
(int)opp.Id,
opp.DisplayName,
opp.CountryCode,
ResolveRank(opp.DisplayName),
opp.EmblemId,
opp.DegreeId,
FormatWireTimestamp(opp.LastLogin),
FormatWireTimestamp(row.PlayedAt),
friendStatus,
friendApplyId,
row.PlayedMode,
row.BattleType,
row.DeckFormat,
row.TwoPickType));
}
return new PlayedTogetherResult(entries);
}
public async Task<FriendEntry?> SearchAsync(long viewerId, int targetViewerId, CancellationToken ct)
{
if (targetViewerId == (int)viewerId) return null;
return await BuildFriendEntryAsync(targetViewerId, ct);
}
public async Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct)
{
if (targetViewerId == (int)viewerId)
{
_log.LogDebug("SendApply self-target ignored for viewer {ViewerId}", viewerId);
return;
}
bool targetExists = await _db.Viewers.AsNoTracking().AnyAsync(v => v.Id == targetViewerId, ct);
if (!targetExists)
{
_log.LogDebug("SendApply target {Target} not found", targetViewerId);
return;
}
bool alreadyFriends = await _db.ViewerFriends.AsNoTracking()
.AnyAsync(f => f.OwnerViewerId == viewerId && f.FriendViewerId == targetViewerId, ct);
if (alreadyFriends)
{
_log.LogDebug("SendApply ignored — viewer {ViewerId} already friends with {Target}", viewerId, targetViewerId);
return;
}
bool alreadyPending = await _db.ViewerFriendApplies.AsNoTracking()
.AnyAsync(a => a.FromViewerId == viewerId && a.ToViewerId == targetViewerId, ct);
if (alreadyPending) return;
int outgoingCount = await _db.ViewerFriendApplies.CountAsync(a => a.FromViewerId == viewerId, ct);
if (outgoingCount >= SendApplyMaxCount)
{
_log.LogInformation("SendApply hit cap of {Cap} for viewer {ViewerId}", SendApplyMaxCount, viewerId);
return;
}
_db.ViewerFriendApplies.Add(new ViewerFriendApply
{
FromViewerId = viewerId,
ToViewerId = targetViewerId,
CreatedAt = DateTime.UtcNow,
MissionType = 0,
});
await _db.SaveChangesAsync(ct);
}
public async Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct)
{
var apply = await _db.ViewerFriendApplies
.FirstOrDefaultAsync(a => a.Id == applyId && a.ToViewerId == viewerId, ct);
if (apply is null)
{
_log.LogDebug("ApproveApply {ApplyId} not addressed to viewer {ViewerId}", applyId, viewerId);
return;
}
long otherViewer = apply.FromViewerId;
int myFriendCount = await _db.ViewerFriends.CountAsync(f => f.OwnerViewerId == viewerId, ct);
int otherFriendCount = await _db.ViewerFriends.CountAsync(f => f.OwnerViewerId == otherViewer, ct);
if (myFriendCount >= FriendMaxCount || otherFriendCount >= FriendMaxCount)
{
_log.LogInformation("ApproveApply hit friend cap (me={Me}, other={Other})", myFriendCount, otherFriendCount);
return;
}
var now = DateTime.UtcNow;
await using var tx = await _db.Database.BeginTransactionAsync(ct);
_db.ViewerFriendApplies.Remove(apply);
// Clean reverse-direction apply if it exists.
var reverse = await _db.ViewerFriendApplies
.FirstOrDefaultAsync(a => a.FromViewerId == viewerId && a.ToViewerId == otherViewer, ct);
if (reverse is not null) _db.ViewerFriendApplies.Remove(reverse);
_db.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = viewerId, FriendViewerId = otherViewer, CreatedAt = now });
_db.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = otherViewer, FriendViewerId = viewerId, CreatedAt = now });
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
public async Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct)
{
var apply = await _db.ViewerFriendApplies
.FirstOrDefaultAsync(a => a.Id == applyId && a.ToViewerId == viewerId, ct);
if (apply is null) return;
_db.ViewerFriendApplies.Remove(apply);
await _db.SaveChangesAsync(ct);
}
public async Task CancelApplyAsync(long viewerId, int applyId, CancellationToken ct)
{
var apply = await _db.ViewerFriendApplies
.FirstOrDefaultAsync(a => a.Id == applyId && a.FromViewerId == viewerId, ct);
if (apply is null) return;
_db.ViewerFriendApplies.Remove(apply);
await _db.SaveChangesAsync(ct);
}
public async Task RejectAllAppliesAsync(long viewerId, CancellationToken ct)
{
await _db.ViewerFriendApplies
.Where(a => a.ToViewerId == viewerId)
.ExecuteDeleteAsync(ct);
}
public async Task CancelAllAppliesAsync(long viewerId, CancellationToken ct)
{
await _db.ViewerFriendApplies
.Where(a => a.FromViewerId == viewerId)
.ExecuteDeleteAsync(ct);
}
public async Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct)
{
var rows = await _db.ViewerFriends
.Where(f =>
(f.OwnerViewerId == viewerId && f.FriendViewerId == targetViewerId) ||
(f.OwnerViewerId == targetViewerId && f.FriendViewerId == viewerId))
.ToListAsync(ct);
if (rows.Count == 0) return;
_db.ViewerFriends.RemoveRange(rows);
await _db.SaveChangesAsync(ct);
}
public async Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct)
{
if (ownerViewerId == opponentViewerId) return;
var now = DateTime.UtcNow;
var existing = await _db.ViewerPlayedTogethers
.FirstOrDefaultAsync(p => p.OwnerViewerId == ownerViewerId && p.OpponentViewerId == opponentViewerId, ct);
if (existing is null)
{
// Enforce per-viewer retention BEFORE insert: if at cap, drop the oldest first.
int currentCount = await _db.ViewerPlayedTogethers.CountAsync(p => p.OwnerViewerId == ownerViewerId, ct);
if (currentCount >= PlayedTogetherRetention)
{
var toEvict = await _db.ViewerPlayedTogethers
.Where(p => p.OwnerViewerId == ownerViewerId)
.OrderBy(p => p.PlayedAt).ThenBy(p => p.OpponentViewerId)
.FirstAsync(ct);
_db.ViewerPlayedTogethers.Remove(toEvict);
}
_db.ViewerPlayedTogethers.Add(new ViewerPlayedTogether
{
OwnerViewerId = ownerViewerId,
OpponentViewerId = opponentViewerId,
PlayedAt = now,
PlayedMode = ctx.PlayedMode,
BattleType = ctx.BattleType,
DeckFormat = ctx.DeckFormat,
TwoPickType = ctx.TwoPickType,
});
}
else
{
existing.PlayedAt = now;
existing.PlayedMode = ctx.PlayedMode;
existing.BattleType = ctx.BattleType;
existing.DeckFormat = ctx.DeckFormat;
existing.TwoPickType = ctx.TwoPickType;
}
await _db.SaveChangesAsync(ct);
}
// --- helpers ---
private sealed record ViewerProjection(
long Id,
string DisplayName,
DateTime LastLogin,
string CountryCode,
long EmblemId,
int DegreeId);
/// <summary>
/// Loads a Viewer with Info + cosmetic nav refs, then projects to a slim record.
/// We materialise the full entity rather than using Select() because EF Core
/// ignores Include/ThenInclude when a Select projection is present.
/// </summary>
private async Task<ViewerProjection?> LoadViewerProjectionAsync(long viewerId, CancellationToken ct)
{
var v = await _db.Viewers
.AsNoTracking()
.Where(x => x.Id == viewerId)
.Include(x => x.Info).ThenInclude(i => i.SelectedEmblem)
.Include(x => x.Info).ThenInclude(i => i.SelectedDegree)
.FirstOrDefaultAsync(ct);
if (v is null) return null;
return new ViewerProjection(
v.Id,
v.DisplayName,
v.LastLogin,
v.Info.CountryCode,
v.Info.SelectedEmblem?.Id ?? 0,
v.Info.SelectedDegree?.Id ?? 0);
}
private async Task<FriendEntry?> BuildFriendEntryAsync(long friendViewerId, CancellationToken ct)
{
var v = await LoadViewerProjectionAsync(friendViewerId, ct);
if (v is null) return null;
return new FriendEntry(
ViewerId: (int)v.Id,
Name: v.DisplayName,
CountryCode: v.CountryCode,
Rank: ResolveRank(v.DisplayName),
EmblemId: v.EmblemId,
DegreeId: v.DegreeId,
LastPlayTime: FormatWireTimestamp(v.LastLogin),
DeviceType: DefaultDeviceType,
MaxFriend: DefaultMaxFriend,
IsReceivedTwoPickMission: DefaultIsReceivedTwoPickMission,
Birth: DefaultBirth,
MissionChangeTime: DefaultMissionChangeTime,
MissionReceiveType: DefaultMissionReceiveType,
IsOfficial: DefaultIsOfficial,
IsOfficialMarkDisplayed: DefaultIsOfficialMarkDisplayed);
}
private async Task<FriendApplyEntry> BuildApplyEntryAsync(int applyId, long otherViewerId, DateTime createdAt, int missionType, CancellationToken ct)
{
var v = await LoadViewerProjectionAsync(otherViewerId, ct);
// If viewer was deleted between apply creation and now, emit a placeholder so the wire doesn't break.
var displayName = v?.DisplayName ?? string.Empty;
var lastLogin = v?.LastLogin ?? DateTime.UnixEpoch;
var countryCode = v?.CountryCode ?? string.Empty;
var emblemId = v?.EmblemId ?? 0;
var degreeId = v?.DegreeId ?? 0;
return new FriendApplyEntry(
Id: applyId,
ViewerId: (int)otherViewerId,
Name: displayName,
CountryCode: countryCode,
Rank: ResolveRank(displayName),
EmblemId: emblemId,
DegreeId: degreeId,
LastPlayTime: FormatWireTimestamp(lastLogin),
CreateTime: FormatWireTimestamp(createdAt),
MissionType: missionType);
}
/// <summary>
/// Rank derivation. We don't track per-viewer rank yet; always 1. Hook here when rank data lands.
/// </summary>
private static int ResolveRank(string _) => 1;
private static string FormatWireTimestamp(DateTime dt) =>
dt.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,30 @@
namespace SVSim.Database.Services.Friend;
public interface IFriendService
{
Task<FriendInfoResult> GetFriendsAsync(long viewerId, CancellationToken ct);
Task<ReceiveApplyInfoResult> GetReceiveAppliesAsync(long viewerId, CancellationToken ct);
Task<SendApplyInfoResult> GetSendAppliesAsync(long viewerId, CancellationToken ct);
Task<PlayedTogetherResult> GetPlayedTogetherAsync(long viewerId, CancellationToken ct);
/// <summary>Returns null when not found, self-search, or any error.</summary>
Task<FriendEntry?> SearchAsync(long viewerId, int targetViewerId, CancellationToken ct);
/// <summary>No-op if target missing, self, already friends, already-pending apply, or at outgoing-apply cap.</summary>
Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct);
/// <summary>No-op if apply not addressed to caller, would push either side past friend cap. Cleans reverse-direction apply if present.</summary>
Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct);
/// <summary>No-op if apply not addressed to caller.</summary>
Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct);
/// <summary>No-op if apply not sent by caller.</summary>
Task CancelApplyAsync(long viewerId, int applyId, CancellationToken ct);
Task RejectAllAppliesAsync(long viewerId, CancellationToken ct);
Task CancelAllAppliesAsync(long viewerId, CancellationToken ct);
/// <summary>Deletes both directions of the friendship (A→B and B→A).</summary>
Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct);
}

View File

@@ -0,0 +1,11 @@
namespace SVSim.Database.Services.Friend;
/// <summary>
/// Records a recent-opponent entry on the owner viewer. Upserts the (owner, opponent)
/// row to PlayedAt = now, enforces a 50-row per-viewer retention cap by deleting the
/// owner's oldest row when at cap. No-op if owner equals opponent.
/// </summary>
public interface IPlayedTogetherWriter
{
Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct);
}

View File

@@ -0,0 +1,65 @@
namespace SVSim.Database.Services.Inventory;
/// <summary>
/// Logical source of a grant routed through <see cref="IInventoryTransaction.GrantAsync"/>.
/// Stored verbatim in <c>viewer_acquire_history.AcquireType</c> and surfaced on the
/// <c>/item_acquire_history/info</c> wire as <c>acquire_type</c>.
/// </summary>
/// <remarks>
/// Values are persisted to the database — renumbering after ship requires a migration.
/// Values 1 and 2 mirror the prod capture in
/// <c>data_dumps/captures/traffic_prod_misc_clicking.ndjson</c>; the rest are our own.
/// </remarks>
public enum GrantSource
{
Unknown = 0,
DailyBonus = 1,
PackOpen = 2,
PuzzleReward = 3,
StoryFinish = 4,
BattlePassClaim = 5,
MissionReward = 6,
ArenaTwoPickFinish = 7,
ItemPurchase = 8,
BuildDeckBuy = 9,
SleeveBuy = 10,
LeaderSkinBuy = 11,
GachaPointExchange = 12,
AchievementReward = 13,
SerialCodeRedeem = 14,
CardCosmeticCascade = 15,
CardCraft = 16,
// Reserved high to stay visually distinct from gameplay sources; 1798 are intentionally unused.
AdminGrant = 99,
}
/// <summary>
/// Pre-localized text written into the <c>message</c> field of an item-acquire-history row.
/// The client renders this string verbatim, so all entries are user-facing English.
/// </summary>
public static class GrantSourceMessages
{
/// <exception cref="ArgumentOutOfRangeException">An unmapped <see cref="GrantSource"/> value was passed.</exception>
public static string For(GrantSource source) => source switch
{
GrantSource.Unknown => "Unknown",
GrantSource.DailyBonus => "Daily Bonus",
GrantSource.PackOpen => "From buying card packs",
GrantSource.PuzzleReward => "From puzzle reward",
GrantSource.StoryFinish => "From story reward",
GrantSource.BattlePassClaim => "From battle pass reward",
GrantSource.MissionReward => "From mission reward",
GrantSource.ArenaTwoPickFinish => "From 2Pick reward",
GrantSource.ItemPurchase => "From shop purchase",
GrantSource.BuildDeckBuy => "From starter set purchase",
GrantSource.SleeveBuy => "From sleeve purchase",
GrantSource.LeaderSkinBuy => "From leader skin purchase",
GrantSource.GachaPointExchange => "From point exchange",
GrantSource.AchievementReward => "From achievement reward",
GrantSource.SerialCodeRedeem => "From serial code",
GrantSource.CardCosmeticCascade => "Card cosmetic",
GrantSource.CardCraft => "From card crafting",
GrantSource.AdminGrant => "From admin grant",
_ => throw new ArgumentOutOfRangeException(nameof(source), source, "Unhandled GrantSource"),
};
}

View File

@@ -0,0 +1,15 @@
namespace SVSim.Database.Services.Inventory;
/// <summary>
/// Shared knobs for the viewer-acquire-history audit log. The write-side prune cap
/// (in <c>InventoryTransaction</c>) and the read-side page size (in
/// <c>ItemAcquireHistoryController</c>) both reference these constants so they cannot drift.
/// </summary>
public static class InventoryHistoryConfig
{
/// <summary>
/// Maximum rows kept per viewer. Older rows are pruned by
/// <c>InventoryTransaction.CommitAsync</c>; the read endpoint pages exactly this many.
/// </summary>
public const int RetentionRowsPerViewer = 300;
}

View File

@@ -9,11 +9,23 @@ namespace SVSim.Database.Services.Inventory;
/// Caller-supplied extra <c>.Include</c> chains on top of the canonical viewer-inventory query
/// in <see cref="IInventoryService.BeginAsync"/>. Use to bring in extra collections needed by
/// the calling controller (e.g. <c>MissionData</c>, <c>BuildDeckPurchases</c>).
/// <para>
/// Also carries the <see cref="Source"/> tag that <see cref="IInventoryTransaction.CommitAsync"/>
/// stamps onto every <c>viewer_acquire_history</c> row written from this transaction. Callers
/// that don't set <see cref="Source"/> end up with <see cref="GrantSource.Unknown"/> rows;
/// grep for <c>acquire_type=0</c> in dev to find unmigrated sites.
/// </para>
/// </summary>
public sealed class InventoryLoadConfig
{
internal List<Func<IQueryable<Viewer>, IQueryable<Viewer>>> Includes { get; } = new();
/// <summary>
/// Logical source of every grant queued in this transaction. Defaults to
/// <see cref="GrantSource.Unknown"/>.
/// </summary>
public GrantSource Source { get; set; } = GrantSource.Unknown;
public InventoryLoadConfig WithInclude<TProperty>(
Expression<Func<Viewer, TProperty>> path)
{

View File

@@ -57,7 +57,7 @@ public sealed class InventoryService : IInventoryService
var freeplay = _config.Get<FreeplayConfig>();
var dbTx = await _db.Database.BeginTransactionAsync(ct);
return new InventoryTransaction(_db, dbTx, viewer, freeplay, _log);
return new InventoryTransaction(_db, dbTx, viewer, freeplay, loadCfg.Source, _log);
}
public long EffectiveBalance(Viewer viewer, SpendCurrency currency)

View File

@@ -9,10 +9,13 @@ namespace SVSim.Database.Services.Inventory;
internal sealed class InventoryTransaction : IInventoryTransaction
{
private const int AcquireHistoryRetention = InventoryHistoryConfig.RetentionRowsPerViewer;
private readonly SVSimDbContext _db;
private readonly IDbContextTransaction _dbTx;
private readonly ILogger _log;
private readonly FreeplayConfig _freeplay;
private readonly GrantSource _source;
private bool _committed;
public Viewer Viewer { get; }
@@ -29,12 +32,14 @@ internal sealed class InventoryTransaction : IInventoryTransaction
IDbContextTransaction dbTx,
Viewer viewer,
FreeplayConfig freeplay,
GrantSource source,
ILogger log)
{
_db = db;
_dbTx = dbTx;
Viewer = viewer;
_freeplay = freeplay;
_source = source;
_log = log;
}
@@ -273,7 +278,14 @@ internal sealed class InventoryTransaction : IInventoryTransaction
{
ThrowIfCommitted();
// Flush entity mutations first so audit-history rows are staged on top of post-commit state.
await _db.SaveChangesAsync(ct);
WriteAcquireHistory();
await _db.SaveChangesAsync(ct);
await PruneAcquireHistoryAsync(ct);
await _dbTx.CommitAsync(ct);
_committed = true;
@@ -282,6 +294,51 @@ internal sealed class InventoryTransaction : IInventoryTransaction
return new InventoryCommitResult(rewardList, deltas);
}
private async Task PruneAcquireHistoryAsync(CancellationToken ct)
{
// Two-phase: SQLite (used in tests) cannot translate Skip+OrderBy inside ExecuteDeleteAsync.
var overflowIds = await _db.ViewerAcquireHistory
.Where(h => h.ViewerId == Viewer.Id)
.OrderByDescending(h => h.AcquireTime).ThenByDescending(h => h.Id)
.Skip(AcquireHistoryRetention)
.Select(h => h.Id)
.ToListAsync(ct);
if (overflowIds.Count == 0) return;
await _db.ViewerAcquireHistory
.Where(h => overflowIds.Contains(h.Id))
.ExecuteDeleteAsync(ct);
}
private void WriteAcquireHistory()
{
var now = DateTime.UtcNow;
var primaryMessage = GrantSourceMessages.For(_source);
var cascadeMessage = GrantSourceMessages.For(GrantSource.CardCosmeticCascade);
foreach (var op in _ops)
{
if (op is not GrantOp grant) continue;
if (grant.Num == 0) continue; // skip synthetic post-state grants (e.g. DebitItem)
var rowSource = grant.IsCascade ? GrantSource.CardCosmeticCascade : _source;
var rowMessage = grant.IsCascade ? cascadeMessage : primaryMessage;
var detailId = IsCurrency(grant.Type) ? 0L : grant.DetailId;
_db.ViewerAcquireHistory.Add(new ViewerAcquireHistoryEntry
{
ViewerId = Viewer.Id,
RewardType = (int)grant.Type,
RewardDetailId = detailId,
RewardCount = grant.Num,
AcquireType = (int)rowSource,
Message = rowMessage,
AcquireTime = now,
});
}
}
private IReadOnlyList<GrantedReward> BuildRewardList()
{
// Pass 1 — for each currency type, find the last op (spend OR grant) that touched it

View File

@@ -0,0 +1,26 @@
namespace SVSim.Database.Services.Replay;
/// <summary>
/// Per-viewer battle context captured at start time (do_matching/start) and consumed
/// at finish time. Lives in <see cref="IBattleContextStore"/> for the duration of a
/// single battle. See docs/superpowers/specs/2026-06-10-replay-info-design.md.
/// </summary>
public sealed record BattleContext(
long BattleId,
int BattleType,
int DeckFormat,
int TwoPickType,
int SelfClassId,
int SelfSubClassId,
int SelfCharaId,
string SelfRotationId,
int OpponentViewerId,
string OpponentName,
int OpponentClassId,
int OpponentSubClassId,
int OpponentCharaId,
string OpponentCountryCode,
long OpponentEmblemId,
long OpponentDegreeId,
string OpponentRotationId,
DateTime BattleStartTime);

View File

@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database.Models;
namespace SVSim.Database.Services.Replay;
public sealed class BattleHistoryWriter : IBattleHistoryWriter
{
internal const int RetentionCap = 50;
private readonly SVSimDbContext _db;
private readonly ILogger<BattleHistoryWriter> _log;
public BattleHistoryWriter(SVSimDbContext db, ILogger<BattleHistoryWriter> log)
{
_db = db;
_log = log;
}
public async Task RecordAsync(long viewerId, BattleContext? ctx, bool isWin, CancellationToken ct)
{
if (ctx is null)
{
_log.LogWarning(
"BattleHistoryWriter.RecordAsync called with null context for viewer {ViewerId} - " +
"likely missed start-time Set (server restart or non-tracked family). Skipping.",
viewerId);
return;
}
var existing = await _db.ViewerBattleHistories
.AnyAsync(h => h.ViewerId == viewerId && h.BattleId == ctx.BattleId, ct);
if (existing) return; // idempotent
var count = await _db.ViewerBattleHistories
.CountAsync(h => h.ViewerId == viewerId, ct);
if (count >= RetentionCap)
{
var oldest = await _db.ViewerBattleHistories
.Where(h => h.ViewerId == viewerId)
.OrderBy(h => h.CreateTime)
.FirstAsync(ct);
_db.ViewerBattleHistories.Remove(oldest);
}
_db.ViewerBattleHistories.Add(new ViewerBattleHistory
{
ViewerId = viewerId,
BattleId = ctx.BattleId,
BattleType = ctx.BattleType,
DeckFormat = ctx.DeckFormat,
TwoPickType = ctx.TwoPickType,
IsLimitTurn = 0,
SelfClassId = ctx.SelfClassId,
SelfSubClassId = ctx.SelfSubClassId,
SelfCharaId = ctx.SelfCharaId,
SelfRotationId = ctx.SelfRotationId,
OpponentClassId = ctx.OpponentClassId,
OpponentSubClassId = ctx.OpponentSubClassId,
OpponentCharaId = ctx.OpponentCharaId,
OpponentName = ctx.OpponentName,
OpponentCountryCode = ctx.OpponentCountryCode,
OpponentEmblemId = ctx.OpponentEmblemId,
OpponentDegreeId = ctx.OpponentDegreeId,
OpponentRotationId = ctx.OpponentRotationId,
IsWin = isWin,
BattleStartTime = ctx.BattleStartTime,
CreateTime = DateTime.UtcNow,
});
await _db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,17 @@
namespace SVSim.Database.Services.Replay;
/// <summary>
/// In-memory per-viewer battle context store. Bridges the start-time → finish-time
/// gap: the /finish request body carries neither battle_id nor opponent identity,
/// so this stash holds everything the finish hook needs to compose a
/// ViewerBattleHistory row.
/// </summary>
public interface IBattleContextStore
{
/// <summary>Store the viewer's active battle context. Overwrites any prior entry.</summary>
void Set(long viewerId, BattleContext ctx);
/// <summary>Atomic read+clear. Returns null when no context (server restart,
/// non-tracked family, already taken). Finish handlers must tolerate null.</summary>
BattleContext? TakeFor(long viewerId);
}

View File

@@ -0,0 +1,16 @@
namespace SVSim.Database.Services.Replay;
/// <summary>
/// Persists battle finishes to ViewerBattleHistory for the /replay/info list view.
/// </summary>
public interface IBattleHistoryWriter
{
/// <summary>
/// Insert a history row for (viewerId, ctx.BattleId). No-op when ctx is null
/// (missing context = server restart mid-battle; warn-log and continue).
/// Idempotent on the composite PK — duplicate calls skip silently.
/// Enforces 50-row per-viewer retention by evicting the oldest CreateTime row
/// when at cap before insert.
/// </summary>
Task RecordAsync(long viewerId, BattleContext? ctx, bool isWin, CancellationToken ct);
}

View File

@@ -0,0 +1,7 @@
namespace SVSim.Database.Services.Replay;
public interface IReplayHistoryReader
{
/// <summary>Newest-first by CreateTime. Caps at <paramref name="take"/> (default 50).</summary>
Task<IReadOnlyList<ReplayHistoryEntry>> GetRecentAsync(long viewerId, int take, CancellationToken ct);
}

View File

@@ -0,0 +1,20 @@
using System.Collections.Concurrent;
namespace SVSim.Database.Services.Replay;
/// <summary>
/// <see cref="ConcurrentDictionary{TKey, TValue}"/>-backed in-memory store.
/// Lives as a singleton in DI. Server restart drops in-flight contexts —
/// acceptable per spec (history is best-effort; finish handlers warn-log
/// and continue when context is missing).
/// </summary>
public sealed class InMemoryBattleContextStore : IBattleContextStore
{
private readonly ConcurrentDictionary<long, BattleContext> _contexts = new();
public void Set(long viewerId, BattleContext ctx)
=> _contexts[viewerId] = ctx;
public BattleContext? TakeFor(long viewerId)
=> _contexts.TryRemove(viewerId, out var ctx) ? ctx : null;
}

View File

@@ -0,0 +1,27 @@
namespace SVSim.Database.Services.Replay;
/// <summary>
/// Read-side row returned by <see cref="IReplayHistoryReader"/>. The /replay/info
/// controller maps this to its wire DTO (all-stringified per prod capture).
/// </summary>
public sealed record ReplayHistoryEntry(
long BattleId,
int BattleType,
int DeckFormat,
int TwoPickType,
int IsLimitTurn,
int SelfClassId,
int SelfSubClassId,
int SelfCharaId,
string SelfRotationId,
int OpponentClassId,
int OpponentSubClassId,
int OpponentCharaId,
string OpponentName,
string OpponentCountryCode,
long OpponentEmblemId,
long OpponentDegreeId,
string OpponentRotationId,
bool IsWin,
DateTime BattleStartTime,
DateTime CreateTime);

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Services.Replay;
public sealed class ReplayHistoryReader : IReplayHistoryReader
{
private readonly SVSimDbContext _db;
public ReplayHistoryReader(SVSimDbContext db) => _db = db;
public async Task<IReadOnlyList<ReplayHistoryEntry>> GetRecentAsync(long viewerId, int take, CancellationToken ct)
{
return await _db.ViewerBattleHistories
.AsNoTracking()
.Where(h => h.ViewerId == viewerId)
.OrderByDescending(h => h.CreateTime)
.Take(take)
.Select(h => new ReplayHistoryEntry(
h.BattleId, h.BattleType, h.DeckFormat, h.TwoPickType, h.IsLimitTurn,
h.SelfClassId, h.SelfSubClassId, h.SelfCharaId, h.SelfRotationId,
h.OpponentClassId, h.OpponentSubClassId, h.OpponentCharaId,
h.OpponentName, h.OpponentCountryCode,
h.OpponentEmblemId, h.OpponentDegreeId, h.OpponentRotationId,
h.IsWin, h.BattleStartTime, h.CreateTime))
.ToListAsync(ct);
}
}

View File

@@ -69,7 +69,7 @@ public class AchievementController : SVSimController
}
// Open inventory tx and grant via InventoryService.
await using var tx = await _inv.BeginAsync(viewerId, ct);
await using var tx = await _inv.BeginAsync(viewerId, ct, cfg => cfg.Source = GrantSource.AchievementReward);
var granted = await tx.GrantAsync(
catalogRow.RewardType,

View File

@@ -1,5 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.BattleNode.Bridge;
using SVSim.Database.Enums;
using SVSim.Database.Services.Friend;
using SVSim.Database.Services.Replay;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Matching;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
@@ -13,15 +17,24 @@ public class ArenaTwoPickBattleController : SVSimController
private readonly IArenaTwoPickService _svc;
private readonly IMatchContextBuilder _matchContextBuilder;
private readonly IMatchingResolver _resolver;
private readonly IBattleContextStore _battleContextStore;
private readonly IBattleHistoryWriter _historyWriter;
private readonly IPlayedTogetherWriter _playedTogetherWriter;
public ArenaTwoPickBattleController(
IArenaTwoPickService svc,
IMatchContextBuilder matchContextBuilder,
IMatchingResolver resolver)
IMatchingResolver resolver,
IBattleContextStore battleContextStore,
IBattleHistoryWriter historyWriter,
IPlayedTogetherWriter playedTogetherWriter)
{
_svc = svc;
_matchContextBuilder = matchContextBuilder;
_resolver = resolver;
_battleContextStore = battleContextStore;
_historyWriter = historyWriter;
_playedTogetherWriter = playedTogetherWriter;
}
[HttpPost("do_matching")]
@@ -34,6 +47,38 @@ public class ArenaTwoPickBattleController : SVSimController
{
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), ct);
if (r.BattleId is not null && long.TryParse(r.BattleId, out var battleIdLong))
{
_battleContextStore.Set(vid, new BattleContext(
BattleId: battleIdLong,
// Two-pick wire battle_type — see docs/api-spec/common/types.ts.md
// #battle-types. Captured prod frames use 4 for both private match
// AND arena two-pick contexts; if a future capture disagrees, refine.
BattleType: 4,
DeckFormat: Format.TwoPick.ToApi(), // wire-int 10
TwoPickType: 0, // captured "0"; refine once tracked on MatchContext
SelfClassId: (int)ctx.ClassId, // CardClass enum
SelfSubClassId: 0,
SelfCharaId: int.TryParse(ctx.CharaId, out var ch) ? ch : 0,
SelfRotationId: "0",
// MatchContext (SVSim.BattleNode/Bridge/MatchContext.cs) does NOT carry
// opponent identity — the resolver returns only the BattleId. Leave
// opponent placeholders; when the two-pick matchmaking flow plumbs the
// second player's MatchContext through to the resolver result, fill
// these from there (and stash for both players).
OpponentViewerId: 0,
OpponentName: "",
OpponentClassId: 0,
OpponentSubClassId: 0,
OpponentCharaId: 0,
OpponentCountryCode: "",
OpponentEmblemId: 0,
OpponentDegreeId: 0,
OpponentRotationId: "0",
BattleStartTime: DateTime.UtcNow));
}
return Ok(new DoMatchingResponseDto
{
MatchingState = r.MatchingState,
@@ -48,12 +93,30 @@ public class ArenaTwoPickBattleController : SVSimController
}
[HttpPost("finish")]
public async Task<IActionResult> Finish([FromBody] BattleFinishRequest req)
public async Task<IActionResult> Finish([FromBody] BattleFinishRequest req, CancellationToken ct = default)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
try
{
var result = await _svc.RecordBattleResultAsync(vid, req.BattleResult == 1);
var battleCtx = _battleContextStore.TakeFor(vid);
bool isWin = req.BattleResult == 1;
await _historyWriter.RecordAsync(vid, battleCtx, isWin, ct);
if (battleCtx is { OpponentViewerId: > 0 })
{
await _playedTogetherWriter.RecordAsync(
vid,
battleCtx.OpponentViewerId,
new BattleParticipationContext(
PlayedMode: 0,
BattleType: battleCtx.BattleType,
DeckFormat: battleCtx.DeckFormat,
TwoPickType: battleCtx.TwoPickType),
ct);
}
var result = await _svc.RecordBattleResultAsync(vid, isWin);
return Ok(new BattleFinishResponseDto
{
BattleResult = result.BattleResult,

View File

@@ -171,8 +171,11 @@ public class BuildDeckController : SVSimController
}
// Open the inventory transaction — loads canonical graph + BuildDeckPurchases.
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted,
cfg => cfg.WithInclude(v => v.BuildDeckPurchases));
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg =>
{
cfg.Source = GrantSource.BuildDeckBuy;
cfg.WithInclude(v => v.BuildDeckPurchases);
});
var viewer = tx.Viewer;
// Debit currency

View File

@@ -0,0 +1,86 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Models.Dtos.Campaign;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /campaign/* — promotional surfaces. Currently just <c>regist_serial_code</c>.
/// </summary>
[Route("campaign")]
public sealed class CampaignController : SVSimController
{
private const int FailureResultCode = 4202;
private readonly SVSimDbContext _db;
public CampaignController(SVSimDbContext db) => _db = db;
[HttpPost("regist_serial_code")]
public async Task<IActionResult> RegisterSerialCode(
[FromBody] RegisterSerialCodeRequest request,
CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var now = DateTime.UtcNow;
var code = await _db.SerialCodes
.Include(c => c.Rewards)
.FirstOrDefaultAsync(c => c.Code == request.SerialCode, ct);
if (code is null) return Fail();
if (!code.IsEnabled) return Fail();
if (code.StartAt is { } start && start > now) return Fail();
if (code.EndAt is { } end && end < now) return Fail();
bool alreadyRedeemed = await _db.ViewerSerialCodeRedemptions
.AnyAsync(r => r.ViewerId == viewerId && r.SerialCodeId == code.Id, ct);
if (alreadyRedeemed) return Fail();
if (code.Rewards.Any(r => !GiftRewardTypes.IsSupported(r.RewardType))) return Fail();
try
{
_db.ViewerSerialCodeRedemptions.Add(new ViewerSerialCodeRedemption
{
ViewerId = viewerId,
SerialCodeId = code.Id,
RedeemedAt = now,
});
foreach (var reward in code.Rewards.OrderBy(r => r.Slot))
{
_db.ViewerPresents.Add(new ViewerPresent
{
ViewerId = viewerId,
PresentId = Guid.NewGuid().ToString("N").Substring(0, 16),
Status = PresentStatus.Unclaimed,
RewardType = reward.RewardType,
RewardDetailId = reward.RewardDetailId,
RewardCount = reward.RewardCount,
Message = code.Message,
CreatedAt = now,
Source = $"serial_code:{code.Id}",
});
}
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// Race: two concurrent redeems for the same (viewer, code). The composite PK
// on ViewerSerialCodeRedemption rejects the second one; treat as already-redeemed.
return Fail();
}
return Ok(new RegisterSerialCodeResponse { IsComplete = true });
}
private IActionResult Fail() => Ok(new { result_code = FailureResultCode });
}

View File

@@ -0,0 +1,186 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Services.Friend;
using SVSim.EmulatedEntrypoint.Models.Dtos.Friend;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /friend/* — viewer-scoped friend system. 5 reads + 7 writes. All writes are
/// "silent rejection" on failure (cap exceeded, not addressed to caller, etc.) — the client
/// pass-through Parse()s don't differentiate.
/// </summary>
[Route("friend")]
public sealed class FriendController : SVSimController
{
private readonly IFriendService _friend;
public FriendController(IFriendService friend) => _friend = friend;
[HttpPost("info")]
public async Task<ActionResult<FriendInfoResponse>> Info([FromBody] BaseRequest _, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var result = await _friend.GetFriendsAsync(viewerId, ct);
return new FriendInfoResponse
{
Friends = result.Friends.Select(ToWire).ToList(),
FriendCount = result.Count,
FriendMaxCount = result.MaxCount,
};
}
[HttpPost("receive_apply_info")]
public async Task<ActionResult<ReceiveApplyInfoResponse>> ReceiveApplyInfo([FromBody] BaseRequest _, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var result = await _friend.GetReceiveAppliesAsync(viewerId, ct);
return new ReceiveApplyInfoResponse
{
ReceiveApplies = result.ReceiveApplies.Select(ToWire).ToList(),
ApproveApplyCount = result.ApproveApplyCount,
};
}
[HttpPost("send_apply_info")]
public async Task<ActionResult<SendApplyInfoResponse>> SendApplyInfo([FromBody] BaseRequest _, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var result = await _friend.GetSendAppliesAsync(viewerId, ct);
return new SendApplyInfoResponse
{
SendApplies = result.SendApplies.Select(ToWire).ToList(),
RemainingApplyCount = result.RemainingApplyCount,
SendApplyMaxCount = result.SendApplyMaxCount,
};
}
[HttpPost("played_together_info")]
public async Task<ActionResult<PlayedTogetherInfoResponse>> PlayedTogetherInfo([FromBody] BaseRequest _, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var result = await _friend.GetPlayedTogetherAsync(viewerId, ct);
return new PlayedTogetherInfoResponse
{
Histories = result.Histories.Select(ToWire).ToList(),
};
}
[HttpPost("search_user")]
public async Task<ActionResult<SearchUserResponse>> SearchUser([FromBody] SearchUserRequest req, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var hit = await _friend.SearchAsync(viewerId, req.SearchViewerId, ct);
return new SearchUserResponse
{
UserInfo = hit is null ? new object() : ToWire(hit),
};
}
[HttpPost("send_apply")]
public async Task<IActionResult> SendApply([FromBody] SendApplyRequest req, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
await _friend.SendApplyAsync(viewerId, req.FriendId, ct);
return Ok(new { });
}
[HttpPost("approve_apply")]
public async Task<IActionResult> ApproveApply([FromBody] ApplyIdRequest req, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
await _friend.ApproveApplyAsync(viewerId, req.ApplyId, ct);
return Ok(new { });
}
[HttpPost("reject_apply")]
public async Task<IActionResult> RejectApply([FromBody] ApplyIdRequest req, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
await _friend.RejectApplyAsync(viewerId, req.ApplyId, ct);
return Ok(new { });
}
[HttpPost("cancel_apply")]
public async Task<IActionResult> CancelApply([FromBody] ApplyIdRequest req, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
await _friend.CancelApplyAsync(viewerId, req.ApplyId, ct);
return Ok(new { });
}
[HttpPost("reject_apply_all")]
public async Task<IActionResult> RejectApplyAll([FromBody] BaseRequest _, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
await _friend.RejectAllAppliesAsync(viewerId, ct);
return Ok(new { });
}
[HttpPost("cancel_apply_all")]
public async Task<IActionResult> CancelApplyAll([FromBody] BaseRequest _, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
await _friend.CancelAllAppliesAsync(viewerId, ct);
return Ok(new { });
}
[HttpPost("reject_friend")]
public async Task<IActionResult> RejectFriend([FromBody] RejectFriendRequest req, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
await _friend.RejectFriendAsync(viewerId, req.FriendId, ct);
return Ok(new { });
}
private static FriendEntryDto ToWire(FriendEntry e) => new()
{
DeviceType = e.DeviceType,
Name = e.Name,
CountryCode = e.CountryCode,
MaxFriend = e.MaxFriend,
LastPlayTime = e.LastPlayTime,
IsReceivedTwoPickMission = e.IsReceivedTwoPickMission,
Birth = e.Birth,
MissionChangeTime = e.MissionChangeTime,
MissionReceiveType = e.MissionReceiveType,
IsOfficial = e.IsOfficial,
IsOfficialMarkDisplayed = e.IsOfficialMarkDisplayed,
ViewerId = e.ViewerId,
Rank = e.Rank,
EmblemId = e.EmblemId,
DegreeId = e.DegreeId,
};
private static FriendApplyEntryDto ToWire(FriendApplyEntry e) => new()
{
Id = e.Id,
ViewerId = e.ViewerId,
Name = e.Name,
CountryCode = e.CountryCode,
Rank = e.Rank,
EmblemId = e.EmblemId,
DegreeId = e.DegreeId,
LastPlayTime = e.LastPlayTime,
CreateTime = e.CreateTime,
MissionType = e.MissionType,
};
private static PlayedTogetherEntryDto ToWire(PlayedTogetherEntry e) => new()
{
ViewerId = e.ViewerId,
Name = e.Name,
CountryCode = e.CountryCode,
Rank = e.Rank,
EmblemId = e.EmblemId,
DegreeId = e.DegreeId,
LastPlayTime = e.LastPlayTime,
PlayedTime = e.PlayedTime,
FriendStatus = e.FriendStatus,
FriendApplyId = e.FriendApplyId,
PlayedMode = e.PlayedMode,
BattleType = e.BattleType,
DeckFormat = e.DeckFormat,
TwoPickType = e.TwoPickType,
};
}

View File

@@ -1,30 +1,30 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Mapping;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// Tutorial-scoped gift endpoints. We do NOT implement a generic gift system here —
/// only the /tutorial/gift_top and /tutorial/gift_receive aliases needed for the
/// step 31 → 41 reward flow. A full gift inbox is future work; if/when needed,
/// add /gift/top and /gift/receive_gift aliases to this controller.
/// Persistent gift inbox. /gift/top + /tutorial/gift_top are pure URL aliases over the
/// same ViewerPresent query; /gift/receive_gift + /tutorial/gift_receive share a single
/// ReceiveImpl whose only divergence is the route-gated tutorial-state bump.
///
/// Tutorial gifts are seeded as real ViewerPresent rows during /tool/signup
/// (see ViewerRepository.RegisterAnonymousViewer) — this controller carries no static
/// gift catalog.
/// </summary>
public class GiftController : SVSimController
{
/// <summary>The hardcoded tutorial gift bundle every fresh viewer sees at step 31.</summary>
public static readonly IReadOnlyList<PresentDto> TutorialGifts = new[]
{
new PresentDto { PresentId = "71478626", RewardType = "1", RewardDetailId = "0", RewardCount = "400", Message = "For completing the tutorial" },
new PresentDto { PresentId = "71478627", RewardType = "9", RewardDetailId = "0", RewardCount = "100", Message = "For completing the tutorial" },
new PresentDto { PresentId = "71478628", RewardType = "4", RewardDetailId = "1", RewardCount = "3", Message = "For completing the tutorial", ItemType = 1 },
new PresentDto { PresentId = "71478629", RewardType = "4", RewardDetailId = "80001", RewardCount = "40", Message = "For completing the tutorial", ItemType = 2 },
new PresentDto { PresentId = "71478630", RewardType = "4", RewardDetailId = "90001", RewardCount = "1", Message = "For completing the tutorial", ItemType = 2 },
};
private const int PageSize = 30;
private const int GiftReceiveTutorialStep = 41;
private readonly SVSimDbContext _db;
private readonly IInventoryService _inv;
@@ -35,180 +35,168 @@ public class GiftController : SVSimController
_inv = inv;
}
[HttpPost("/gift/top")]
[HttpPost("/tutorial/gift_top")]
public async Task<ActionResult<GiftTopResponse>> TutorialGiftTop([FromBody] GiftTopRequest request)
public async Task<ActionResult<GiftTopResponse>> Top([FromBody] GiftTopRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var claimedList = await _db.ViewerClaimedTutorialGifts
.Where(g => g.ViewerId == viewerId)
.Select(g => g.PresentId)
.ToListAsync();
var claimed = new HashSet<string>(claimedList);
var nowString = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
var presents = TutorialGifts
.Where(p => !claimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList();
var history = TutorialGifts
.Where(p => claimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList();
var (unclaimed, history) = await ReadTopWindowAsync(viewerId, request.Page);
return new GiftTopResponse
{
PresentList = presents,
PresentHistoryList = history,
LimitOverPresentList = new(),
PresentList = unclaimed.Select(PresentMapper.ToWire).ToList(),
PresentHistoryList = history.Select(PresentMapper.ToWire).ToList(),
LimitOverPresentList = new(), // expiration sweep deferred — always [] for now
};
}
[HttpPost("/gift/receive_gift")]
public Task<ActionResult<GiftReceiveResponse>> Receive([FromBody] GiftReceiveRequest r)
=> ReceiveImpl(r, advanceTutorial: false);
[HttpPost("/tutorial/gift_receive")]
public async Task<ActionResult<GiftReceiveResponse>> TutorialGiftReceive([FromBody] GiftReceiveRequest request)
public Task<ActionResult<GiftReceiveResponse>> TutorialReceive([FromBody] GiftReceiveRequest r)
=> ReceiveImpl(r, advanceTutorial: true);
private async Task<ActionResult<GiftReceiveResponse>> ReceiveImpl(
GiftReceiveRequest request, bool advanceTutorial)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var requestedIds = request.PresentIdArray.ToHashSet();
var requested = request.PresentIdArray.ToHashSet();
var state = request.State; // 1 = MAIL_READ (claim), 3 = MAIL_DELETE
// Resolve which of the requested ids are still claimable for this viewer before opening tx.
var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts
.Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId))
.Select(g => g.PresentId)
// Pull only currently-Unclaimed rows matching the request — already-Claimed /
// Deleted / Expired rows are silently ignored (idempotent retry semantics).
var targets = await _db.ViewerPresents
.Where(p => p.ViewerId == viewerId
&& p.Status == PresentStatus.Unclaimed
&& requested.Contains(p.PresentId))
.ToListAsync();
var alreadyClaimed = new HashSet<string>(alreadyClaimedList);
var toClaim = TutorialGifts
.Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId))
.ToList();
// Open inventory tx with MissionData loaded for tutorial-step advance.
await using var tx = await _inv.BeginAsync(viewerId, configure:
cfg => cfg.WithInclude(v => v.MissionData));
// Apply grants via tx. Collect post-state per (type, detailId) for reward_list.
// Each GrantAsync returns a list of GrantedReward with post-state totals; for currencies
// only one entry is returned; for cards the cascade may return more entries (card + cosmetics).
// reward_list must carry post-state totals (client does direct assignment).
var rewardListEntries = new List<GiftRewardListEntry>();
foreach (var p in toClaim)
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg =>
{
var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType));
var granted = await tx.GrantAsync(goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount));
// Use the first granted entry's post-state for the top-level gift reward_list entry.
// Gift rewards are currencies and items only (no cards in TutorialGifts), so granted
// always has exactly one element. The post-state total is already correct from tx.
if (granted.Count > 0)
cfg.Source = GrantSource.AdminGrant;
cfg.WithInclude(v => v.MissionData);
});
var rewardListEntries = new List<GiftRewardListEntry>();
var now = DateTime.UtcNow;
foreach (var p in targets)
{
if (state == 1)
{
rewardListEntries.Add(new GiftRewardListEntry
var granted = await tx.GrantAsync(
WireRewardTypeToUserGoodsType(p.RewardType),
p.RewardDetailId,
(int)p.RewardCount);
// reward_list carries POST-STATE TOTALS (client does direct assignment).
// See project_wire_reward_list_post_state. GrantAsync already returns post-state.
if (granted.Count > 0)
{
RewardType = p.RewardType,
RewardId = p.RewardDetailId,
RewardNum = granted[0].RewardNum.ToString(System.Globalization.CultureInfo.InvariantCulture),
});
rewardListEntries.Add(new GiftRewardListEntry
{
RewardType = p.RewardType.ToString(CultureInfo.InvariantCulture),
RewardId = p.RewardDetailId.ToString(CultureInfo.InvariantCulture),
RewardNum = granted[0].RewardNum.ToString(CultureInfo.InvariantCulture),
});
}
p.Status = PresentStatus.Claimed;
p.ClaimedAt = now;
}
else if (state == 3)
{
// MAIL_DELETE: no grant, no reward_list entry, no history. Tombstone the
// row so re-deletes are idempotent under the same WHERE-Unclaimed filter.
p.Status = PresentStatus.Deleted;
p.ClaimedAt = now; // overload as "decided-at" — tombstone never reaches wire
}
}
// Advance tutorial state from 31 → 41 as a side-effect of this claim (no separate
// /tutorial/update → 41 call appears in the prod capture). Preserve max — don't downgrade
// viewers who are already past step 41.
const int GiftReceiveTutorialStep = 41;
if (tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
{
// Tutorial step advance — route-gated, no Source/state checks. Preserve-max so
// replays don't downgrade viewers already past 41.
if (advanceTutorial && tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
tx.Viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
}
// Persist claim receipts inside the same tx.
var now = DateTime.UtcNow;
foreach (var p in toClaim)
{
_db.ViewerClaimedTutorialGifts.Add(new SVSim.Database.Models.ViewerClaimedTutorialGift
{
ViewerId = viewerId,
PresentId = p.PresentId,
ClaimedAt = now,
});
}
await tx.CommitAsync();
await tx.CommitAsync(); // throws DbUpdateConcurrencyException on RowVersion conflict
var nowString = now.ToString("yyyy-MM-dd HH:mm:ss");
var allClaimedList = await _db.ViewerClaimedTutorialGifts
.Where(g => g.ViewerId == viewerId)
.Select(g => g.PresentId)
.ToListAsync();
var allClaimed = new HashSet<string>(allClaimedList);
// Rebuild the inbox window (page 1) — the client wipes its local lists and rebuilds
// from these.
var (unclaimed, history) = await ReadTopWindowAsync(viewerId, page: 1);
// Derive presentList/historyList up front so IsUnreceivedPresent can read the count
// without re-filtering. unclaimedPresents are the gifts still on offer after this call;
// claimedPresents are everything the viewer has ever received (this call + prior calls).
var unclaimedPresents = TutorialGifts
.Where(p => !allClaimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList();
var claimedPresents = TutorialGifts
.Where(p => allClaimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList();
// is_unreceived_present drives the home-screen inbox badge — must be the DB count
// post-commit, NOT hardcoded false (hiding the badge after partial claims).
var stillUnclaimed = await _db.ViewerPresents
.AnyAsync(p => p.ViewerId == viewerId && p.Status == PresentStatus.Unclaimed);
return new GiftReceiveResponse
{
CardList = new(),
// Echo only the ids actually granted by THIS call. Building this from `requestedIds`
// would falsely confirm a re-grant on idempotent retries: the client would re-show
// the "received N gifts" popup and direct-assign the same post-state totals it already
// applied, double-toasting the user. Sort ascending to match the prod-capture order.
ReceivedIds = toClaim
.Select(p => p.PresentId)
.OrderBy(x => x)
CardList = new(), // capture is []; reward_list carries the grants
// Echo only ids actually transitioned by THIS call — NOT requested ids, which
// would re-fire the "received N gifts" popup on replay.
ReceivedIds = targets
.Select(t => t.PresentId)
.OrderBy(x => x, StringComparer.Ordinal)
.ToList(),
// Same idempotency contract: only the gifts granted in THIS call belong in the
// per-reward summary list. The client uses this to drive the +N popups.
TotalReceiveCountList = toClaim
.Select(p => new TotalReceiveCountDto
// Per-gift summary for the "+N received" popup. Empty on state=3.
TotalReceiveCountList = (state == 1 ? targets : Enumerable.Empty<ViewerPresent>())
.Select(t => new TotalReceiveCountDto
{
RewardType = int.Parse(p.RewardType),
RewardDetailId = long.Parse(p.RewardDetailId),
RewardCount = long.Parse(p.RewardCount),
ItemType = p.ItemType ?? 0,
IsUsable = true,
RewardType = t.RewardType,
RewardDetailId = t.RewardDetailId,
RewardCount = t.RewardCount,
ItemType = t.ItemType ?? 0,
IsUsable = true,
}).ToList(),
PresentList = unclaimedPresents,
PresentHistoryList = claimedPresents,
// True when there are still unclaimed gifts on offer — drives the inbox badge state.
// Hardcoding false hid the badge after partial claims even though present_list still
// carried unclaimed entries.
IsUnreceivedPresent = unclaimedPresents.Count > 0,
// reward_list entries carry POST-STATE TOTALS (from tx.GrantAsync).
// See project memory: project_wire_reward_list_post_state.
// Iterate toClaim so idempotent re-receive doesn't re-emit post-state entries.
PresentList = unclaimed.Select(PresentMapper.ToWire).ToList(),
PresentHistoryList = history.Select(PresentMapper.ToWire).ToList(),
IsUnreceivedPresent = stillUnclaimed,
RewardList = rewardListEntries,
// Echo the persisted state, not a hardcoded 41. The state may already be past 41
// for replay/edge-case calls (the Math.Max-preserve block above keeps it stable);
// emitting 41 anyway would surface a regressed step to the client and desync the
// tutorial-state machine.
// Echo persisted state, not a hardcoded 41 — preserve-max above keeps it stable.
TutorialStep = tx.Viewer.MissionData.TutorialState,
};
}
private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch
/// <summary>
/// Gift wire's <c>reward_type</c> is a literal <see cref="UserGoodsType"/> integer — the
/// client's <c>Wizard/RewardBase.cs:245</c> casts it directly to <c>UserGoods.Type</c>.
/// Mirror that cast, validated against <see cref="GiftRewardTypes.IsSupported(int)"/>.
/// </summary>
private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType)
{
1 => UserGoodsType.Crystal,
4 => UserGoodsType.Item,
9 => UserGoodsType.Rupy,
_ => throw new InvalidOperationException($"Unmapped gift wire reward_type {wireType}"),
};
if (!GiftRewardTypes.IsSupported(wireType))
throw new InvalidOperationException($"Unsupported gift reward_type {wireType}");
return (UserGoodsType)wireType;
}
private static PresentDto Clone(PresentDto p, string createTime) => new()
private async Task<(List<ViewerPresent> Unclaimed, List<ViewerPresent> History)> ReadTopWindowAsync(
long viewerId, int page)
{
PresentId = p.PresentId,
RewardType = p.RewardType,
RewardDetailId = p.RewardDetailId,
RewardCount = p.RewardCount,
ConditionNumber = p.ConditionNumber,
PresentLimitType = p.PresentLimitType,
RewardLimitTime = p.RewardLimitTime,
CreateTime = createTime,
ItemType = p.ItemType,
Message = p.Message,
};
int pageOneIndexed = Math.Max(1, page);
int skip = (pageOneIndexed - 1) * PageSize;
// Unclaimed: chronological (oldest first — capture order matches this).
var unclaimed = await _db.ViewerPresents
.Where(p => p.ViewerId == viewerId && p.Status == PresentStatus.Unclaimed)
.OrderBy(p => p.CreatedAt).ThenBy(p => p.Id)
.Skip(skip).Take(PageSize)
.ToListAsync();
// History: most-recent-first (standard inbox UX).
var history = await _db.ViewerPresents
.Where(p => p.ViewerId == viewerId && p.Status == PresentStatus.Claimed)
.OrderByDescending(p => p.ClaimedAt).ThenByDescending(p => p.Id)
.Skip(skip).Take(PageSize)
.ToListAsync();
return (unclaimed, history);
}
}

View File

@@ -0,0 +1,47 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.ItemAcquireHistory;
namespace SVSim.EmulatedEntrypoint.Controllers;
[Route("item_acquire_history")]
public sealed class ItemAcquireHistoryController : SVSimController
{
private const string WireDateFormat = "yyyy-MM-dd HH:mm:ss";
private readonly SVSimDbContext _db;
public ItemAcquireHistoryController(SVSimDbContext db) => _db = db;
[HttpPost("info")]
public async Task<ActionResult<ItemAcquireHistoryInfoResponse>> Info(
[FromBody] ItemAcquireHistoryInfoRequest _,
CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var rows = await _db.ViewerAcquireHistory
.Where(h => h.ViewerId == viewerId)
.OrderByDescending(h => h.AcquireTime)
.ThenByDescending(h => h.Id)
.Take(InventoryHistoryConfig.RetentionRowsPerViewer)
.AsNoTracking()
.ToListAsync(ct);
return new ItemAcquireHistoryInfoResponse
{
Histories = rows.Select(h => new ItemAcquireHistoryEntryDto
{
RewardType = h.RewardType.ToString(),
RewardDetailId = h.RewardDetailId.ToString(),
RewardCount = h.RewardCount.ToString(),
AcquireType = h.AcquireType.ToString(),
AcquireTime = h.AcquireTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
Message = h.Message,
}).ToList(),
};
}
}

View File

@@ -114,7 +114,7 @@ public class ItemPurchaseController : SVSimController
if (rest <= 0)
return BadRequest(new { error = "sold_out" });
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.ItemPurchase);
// Debit the require side via the tx.
var debit = await tx.TryDebitAsync(

View File

@@ -177,7 +177,7 @@ public class LeaderSkinController : SVSimController
if (!product.IsEnabled || product.Series is not { IsEnabled: true })
return BadRequest(new { error = "product_not_available" });
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.LeaderSkinBuy);
// Already-purchased = viewer owns the leader_skin this product grants.
if (tx.OwnsCosmetic(CosmeticType.Skin, product.LeaderSkinId))
@@ -230,7 +230,7 @@ public class LeaderSkinController : SVSimController
if (!series.IsEnabled || series.SetSalesStatus == 0)
return BadRequest(new { error = "set_sale_not_active" });
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.LeaderSkinBuy);
if (tx.IsFreeplay)
return BadRequest(new { error = "already_purchased" });
@@ -286,7 +286,7 @@ public class LeaderSkinController : SVSimController
if (existingClaim is not null)
return new LeaderSkinBuyResponse { RewardList = new() };
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.LeaderSkinBuy);
// Must own every skin in the series to claim the bonus.
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => tx.OwnsCosmetic(CosmeticType.Skin, p.LeaderSkinId));

View File

@@ -192,7 +192,9 @@ public class LoadController : SVSimController
.Select(d => new UserDeck(d)).ToList()
},
UserCards = allCardsAsOwned.Select(card => new UserCard(card)).ToList(),
UserClasses = viewer.Classes.Select(vc => new UserClass(vc)).ToList(),
UserClasses = viewer.Classes.Select(vc => new UserClass(
vc,
viewer.LeaderSkins.Where(s => s.ClassId == vc.Class.Id).Select(s => s.Id).ToList())).ToList(),
Sleeves = cosmetics.SleeveIds.Select(id => new SleeveIdentifier { SleeveId = id }).ToList(),
UserEmblems = cosmetics.EmblemIds.Select(id => new EmblemIdentifier { EmblemId = id }).ToList(),
UserDegrees = cosmetics.DegreeIds.Select(id => new DegreeIdentifier { DegreeId = id }).ToList(),

View File

@@ -11,6 +11,7 @@ using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -24,14 +25,17 @@ public class MyPageController : SVSimController
private readonly IGlobalsRepository _globalsRepository;
private readonly IGameConfigService _config;
private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns;
private readonly IHomeDialogSessionTracker _homeDialogTracker;
public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns)
IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns,
IHomeDialogSessionTracker homeDialogTracker)
{
_viewerRepository = viewerRepository;
_globalsRepository = globalsRepository;
_config = config;
_arenaTwoPickRuns = arenaTwoPickRuns;
_homeDialogTracker = homeDialogTracker;
}
[HttpPost("index")]
@@ -59,6 +63,17 @@ public class MyPageController : SVSimController
var masterPointPeriod = await _globalsRepository.GetCurrentMasterPointPeriod();
var bannerEntries = await _globalsRepository.GetBanners();
var specialDeckFormats = await _globalsRepository.GetActiveSpecialDeckFormats();
var activeHomeDialogs = await _globalsRepository.GetActiveHomeDialogsAsync(DateTime.UtcNow);
var homeDialogList = new List<Models.Dtos.Common.HomeDialog>();
foreach (var entry in activeHomeDialogs)
{
if (_homeDialogTracker.TryReserve(viewer.ShortUdid, entry.Id))
{
homeDialogList.Add(BuildHomeDialog(entry));
break; // Client only reads [0]; emit at most one per call.
}
}
// Remaining stubs are tagged TODO(mypage-stub) — see docs/api-spec/endpoints/post-login/mypage-index.md.
return new MyPageIndexResponse
@@ -97,9 +112,17 @@ public class MyPageController : SVSimController
Quest = new Quest(), // TODO(mypage-stub): active Quest event + viewer flags
MasterPointRankingPeriod = BuildMasterPointRankingPeriod(masterPointPeriod),
PreReleaseStatus = 0, // TODO(mypage-stub): derive from PreReleaseInfo
UserMyPageInfo = new UserMyPageInfo // TODO(mypage-stub): viewer mypage BG selection
UserMyPageInfo = new UserMyPageInfo
{
UserMyPageSetting = new MyPageBgSetting(),
UserMyPageSetting = new MyPageBgSetting
{
MyPageId = viewer.MyPageBgId.ToString(),
SelectType = viewer.MyPageBgSelectType.ToString(),
MyPageIdList = viewer.MyPageBgRotation
.OrderBy(r => r.Slot)
.Select(r => r.BgId.ToString())
.ToList(),
},
},
BasicPuzzle = new Models.Dtos.Common.BadgeFlag { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
@@ -110,6 +133,7 @@ public class MyPageController : SVSimController
// out is_hide=1 tutorial packs (the legendary starter 99047) via PackConfig.EnableBuyPack.
// Populate from viewer.Items so the client's dict stays in sync with the DB.
UserItemList = viewer.Items.Select(i => new UserItem(i)).ToList(),
HomeDialogList = homeDialogList,
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,
@@ -268,6 +292,32 @@ public class MyPageController : SVSimController
};
}
/// <summary>
/// Deserializes the jsonb button_list column into wire-shape DTOs. Truncates >3 buttons —
/// the client's switch in MyPageHomeDialog.InitializeButtonAction only handles 0/1/2/3,
/// extras would be silently ignored anyway; doing it server-side keeps the wire honest.
/// </summary>
private static Models.Dtos.Common.HomeDialog BuildHomeDialog(HomeDialogEntry row)
{
List<Models.Dtos.Common.HomeDialogButtonDto> buttons = new();
if (!string.IsNullOrEmpty(row.ButtonListJson) && row.ButtonListJson != "[]")
{
buttons = JsonSerializer.Deserialize<List<Models.Dtos.Common.HomeDialogButtonDto>>(
row.ButtonListJson, JsonbReadOptions.Instance) ?? new();
}
if (buttons.Count > 3)
{
buttons = buttons.Take(3).ToList();
}
return new Models.Dtos.Common.HomeDialog
{
Type = row.Type?.ToString(CultureInfo.InvariantCulture),
TitleTextId = row.TitleTextId,
Image = row.Image,
ButtonList = buttons,
};
}
private static BannerInfo BuildBannerInfo(BannerEntry row)
{
List<string> imagePaths = new();

View File

@@ -92,10 +92,19 @@ public class PackController : SVSimController
.Select(b => new { b.PackId, b.Points })
.ToDictionaryAsync(x => x.PackId, x => x.Points);
// Per-viewer free-pack claim records, keyed by campaign id. Drives the
// "drop the type_detail=10 child once today's quota is spent" filter in ToDto.
// Plain projection — no owned-entity tracking needed (mirrors the items query above).
var freeClaimsByCampaignId = await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.FreePackClaims)
.Select(c => new { c.FreeGachaCampaignId, c.LastClaimedAt, c.ClaimCount })
.ToDictionaryAsync(x => x.FreeGachaCampaignId, x => (x.LastClaimedAt, x.ClaimCount));
return new PackInfoResponse
{
PackConfigList = packs
.Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId))
.Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId, freeClaimsByCampaignId))
.ToList(),
};
}
@@ -104,14 +113,32 @@ public class PackController : SVSimController
PackConfigEntry p,
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
IReadOnlyDictionary<long, int> ownedItemsByItemId,
IReadOnlyDictionary<int, int> gachaPointBalancesByPackId)
IReadOnlyDictionary<int, int> gachaPointBalancesByPackId,
IReadOnlyDictionary<int, (DateTime LastClaimedAt, int ClaimCount)> freeClaimsByCampaignId)
{
int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0;
// Drop type_detail=10 (FREE_PACKS) children whose daily quota for THIS viewer is spent.
// Mirrors prod behavior: post-claim /pack/info simply omits the free child from
// child_gacha_info (verified in traffic_event_crate_free_pack.ndjson lines 28→32).
// Today's claim count >= DailyFreeGachaCount and same UTC date => hide.
var today = DateTime.UtcNow.Date;
bool ChildAvailable(PackChildGachaEntry c)
{
if (c.TypeDetail != CardPackType.FreePacks) return true;
if (c.FreeGachaCampaignId is not int campaignId) return true;
if (!freeClaimsByCampaignId.TryGetValue(campaignId, out var claim)) return true;
if (claim.LastClaimedAt.Date != today) return true;
int dailyCap = c.DailyFreeGachaCount > 0 ? c.DailyFreeGachaCount : 1;
return claim.ClaimCount < dailyCap;
}
var visibleChildren = p.ChildGachas.Where(ChildAvailable).ToList();
// Ticket-only pack: every child is TICKET (4) or TICKET_MULTI (5). These are
// gifted-currency packs (tutorial starter, throwback) that don't participate in
// gacha-point accrual or exchange, even if GachaPointConfig is set in seed.
bool isTicketOnly = p.ChildGachas.All(c => c.TypeDetail == 4 || c.TypeDetail == 5);
bool isTicketOnly = visibleChildren.All(c =>
c.TypeDetail == CardPackType.Ticket || c.TypeDetail == CardPackType.TicketMulti);
PackGachaPointDto? gachaPointDto = null;
if (p.GachaPointConfig is not null && !isTicketOnly)
@@ -145,10 +172,10 @@ public class PackController : SVSimController
DialogTitle = b.DialogTitle,
}).ToList(),
GachaDetail = p.GachaDetail,
ChildGachaInfo = p.ChildGachas.Select(c => new PackChildGachaDto
ChildGachaInfo = visibleChildren.Select(c => new PackChildGachaDto
{
GachaId = c.GachaId,
TypeDetail = c.TypeDetail,
TypeDetail = (int)c.TypeDetail,
Cost = c.Cost,
Count = c.CardCount,
ItemId = c.ItemId?.ToString(CultureInfo.InvariantCulture),
@@ -164,6 +191,14 @@ public class PackController : SVSimController
: 0,
IsDailySingle = c.IsDailySingle,
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
CampaignName = c.CampaignName,
PurchaseLimitCount = c.PurchaseLimitCount > 0
? c.PurchaseLimitCount.ToString(CultureInfo.InvariantCulture)
: null,
DailyFreeGachaCount = c.DailyFreeGachaCount > 0
? c.DailyFreeGachaCount.ToString(CultureInfo.InvariantCulture)
: null,
FreeGachaCampaignId = c.FreeGachaCampaignId,
}).ToList(),
OpenCount = openCount,
OpenCountLimit = p.OpenCountLimit,
@@ -204,9 +239,12 @@ public class PackController : SVSimController
// Open inventory tx with extra includes for GachaPointBalances + GachaPointReceived
// (needed by TryExchangeAsync to validate balance and already-received guard).
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg
.WithInclude(v => v.GachaPointBalances)
.WithInclude(v => v.GachaPointReceived));
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg =>
{
cfg.Source = GrantSource.GachaPointExchange;
cfg.WithInclude(v => v.GachaPointBalances);
cfg.WithInclude(v => v.GachaPointReceived);
});
// Use odds_gacha_id (the seasonal pack id) — that's where the balance / received marker
// live. Mirrors the GetGachaPointRewards fix.
@@ -264,21 +302,28 @@ public class PackController : SVSimController
// when buying a RUPY_MULTI (type_detail=7) child. The gacha_id alone disambiguates the
// child; gacha_type validation against child.TypeDetail would falsely reject every buy.
// Supported type_details on the normal path:
// 1 CRYSTAL / 2 CRYSTAL_MULTI -> spend crystals
// 6 RUPY / 7 RUPY_MULTI -> spend rupees
// 3 DAILY -> spend rupees, once per UTC day
// 4 TICKET / 5 TICKET_MULTI -> consume child.ItemId from OwnedItemEntry
// Skin-overload types (8/9/13) and free-pack overlays (10/11/12) need extra
// selection / banner plumbing — kept 501 until the relevant flows land.
if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7))
// Supported on the normal path: Crystal / CrystalMulti -> spend crystals; Rupy /
// RupyMulti -> spend rupees; Daily -> spend rupees, once per UTC day; Ticket /
// TicketMulti -> consume child.ItemId from OwnedItemEntry; FreePacks -> no debit,
// gated by per-campaign daily quota.
// CrystalSpecial / CrystalSelectSkin / CrystalAcquireSkinCardPack and the
// FreePackWithSkin / RotationStarterPack overlays need extra selection / banner
// plumbing — kept 501 until the relevant flows land.
if (!isTutorialPath && child.TypeDetail is not (
CardPackType.Crystal or CardPackType.CrystalMulti or CardPackType.Daily or
CardPackType.Ticket or CardPackType.TicketMulti or CardPackType.Rupy or
CardPackType.RupyMulti or CardPackType.FreePacks))
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
// Load viewer via InventoryService transaction with extra includes for pack-open needs.
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg
.WithInclude(v => v.PackOpenCounts)
.WithInclude(v => v.GachaPointBalances)
.WithInclude(v => v.MissionData));
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg =>
{
cfg.Source = GrantSource.PackOpen;
cfg.WithInclude(v => v.PackOpenCounts);
cfg.WithInclude(v => v.GachaPointBalances);
cfg.WithInclude(v => v.MissionData);
cfg.WithInclude(v => v.FreePackClaims);
});
var viewer = tx.Viewer;
// Tutorial alias is only valid pre-END. After state>=100 the viewer has already
@@ -296,23 +341,23 @@ public class PackController : SVSimController
{
switch (child.TypeDetail)
{
case 1: // CRYSTAL (single)
case 2: // CRYSTAL_MULTI (10-pack)
case CardPackType.Crystal:
case CardPackType.CrystalMulti:
{
long cost = (long)child.Cost * packNumber;
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
break;
}
case 6: // RUPY (single)
case 7: // RUPY_MULTI (10-pack)
case CardPackType.Rupy:
case CardPackType.RupyMulti:
{
long cost = (long)child.Cost * packNumber;
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
case 3: // DAILY single — once per UTC day
case CardPackType.Daily:
{
// TODO(daily-reset): no project-wide daily-reset convention exists yet. Using UTC
// midnight; revisit when the global reset boundary is settled.
@@ -326,8 +371,8 @@ public class PackController : SVSimController
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
case 4: // TICKET (single)
case 5: // TICKET_MULTI (10-pack)
case CardPackType.Ticket:
case CardPackType.TicketMulti:
{
if (child.ItemId is not long ticketItemId)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
@@ -337,6 +382,42 @@ public class PackController : SVSimController
if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" });
break;
}
case CardPackType.FreePacks:
{
if (child.FreeGachaCampaignId is not int campaignId)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "free_pack_missing_campaign_id" });
int dailyCap = child.DailyFreeGachaCount > 0 ? child.DailyFreeGachaCount : 1;
var today = DateTime.UtcNow.Date;
var existing = viewer.FreePackClaims.FirstOrDefault(c => c.FreeGachaCampaignId == campaignId);
if (existing is not null && existing.LastClaimedAt.Date == today && existing.ClaimCount >= dailyCap)
return BadRequest(new { error = "free_pack_already_claimed_today" });
// pack_number is forced to 1 — free-pack metadata never authorizes multi-opens.
// The capture shows pack_number=1 even when daily_free_gacha_count=1 == daily quota.
packNumber = 1;
if (existing is null)
{
viewer.FreePackClaims.Add(new ViewerFreePackClaim
{
FreeGachaCampaignId = campaignId,
ClaimCount = 1,
LastClaimedAt = DateTime.UtcNow,
});
}
else if (existing.LastClaimedAt.Date != today)
{
existing.ClaimCount = 1;
existing.LastClaimedAt = DateTime.UtcNow;
}
else
{
existing.ClaimCount++;
existing.LastClaimedAt = DateTime.UtcNow;
}
break;
}
}
}
@@ -346,7 +427,7 @@ public class PackController : SVSimController
if (!isTutorialPath)
{
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
if (child.TypeDetail == 3)
if (child.TypeDetail == CardPackType.Daily)
{
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
}

View File

@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Profile;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /profile/* — viewer-scoped profile read endpoint. Surfaces total rank-match wins
/// and the per-class roster (level, exp, leader-skin selection).
/// </summary>
[Route("profile")]
public sealed class ProfileController : SVSimController
{
private readonly IViewerRepository _viewerRepository;
public ProfileController(IViewerRepository viewerRepository) =>
_viewerRepository = viewerRepository;
[HttpPost("index")]
public async Task<ActionResult<ProfileIndexResponse>> Index(
[FromBody] ProfileIndexRequest _,
CancellationToken ct)
{
var shortUdidClaim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ShortUdidClaim)?.Value;
if (shortUdidClaim is null || !long.TryParse(shortUdidClaim, out long shortUdid))
return Unauthorized();
var viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
if (viewer is null) return NotFound();
var skinsByClass = viewer.LeaderSkins
.Where(s => s.ClassId.HasValue)
.GroupBy(s => s.ClassId!.Value)
.ToDictionary(g => g.Key, g => (IReadOnlyCollection<int>)g.Select(s => s.Id).ToList());
var classes = viewer.Classes
.Select(vc => new UserClass(
vc,
skinsByClass.GetValueOrDefault(vc.Class.Id, Array.Empty<int>())))
.ToList();
return new ProfileIndexResponse
{
// TODO: when rank-match results are tracked, compute from viewer's rank history.
UserRankMatchTotalWin = 0,
UserClassList = classes,
};
}
}

View File

@@ -174,7 +174,7 @@ public class PuzzleController : SVSimController
if (fresh.Count > 0)
{
await using var tx = await _inv.BeginAsync(viewerId);
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg.Source = GrantSource.PuzzleReward);
foreach (var status in fresh)
{

View File

@@ -3,7 +3,10 @@ using Microsoft.AspNetCore.Mvc;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Sessions;
using SVSim.Database.Enums;
using SVSim.Database.Services.Friend;
using SVSim.Database.Services.Replay;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Matching;
using SVSim.EmulatedEntrypoint.Models.Dtos.RankBattle;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
@@ -27,6 +30,9 @@ public sealed class RankBattleController : ControllerBase
private readonly IBattleSessionStore _sessionStore;
private readonly IMatchContextBuilder _ctxBuilder;
private readonly IBotRoster _botRoster;
private readonly IBattleContextStore _battleContextStore;
private readonly IBattleHistoryWriter _historyWriter;
private readonly IPlayedTogetherWriter _playedTogetherWriter;
private readonly ILogger<RankBattleController> _log;
public RankBattleController(
@@ -34,12 +40,18 @@ public sealed class RankBattleController : ControllerBase
IBattleSessionStore sessionStore,
IMatchContextBuilder ctxBuilder,
IBotRoster botRoster,
IBattleContextStore battleContextStore,
IBattleHistoryWriter historyWriter,
IPlayedTogetherWriter playedTogetherWriter,
ILogger<RankBattleController> log)
{
_resolver = resolver;
_sessionStore = sessionStore;
_ctxBuilder = ctxBuilder;
_botRoster = botRoster;
_battleContextStore = battleContextStore;
_historyWriter = historyWriter;
_playedTogetherWriter = playedTogetherWriter;
_log = log;
}
@@ -79,9 +91,29 @@ public sealed class RankBattleController : ControllerBase
[HttpPost("/unlimited_rank_battle/finish")]
[HttpPost("/ai_rotation_rank_battle/finish")]
[HttpPost("/ai_unlimited_rank_battle/finish")]
public IActionResult Finish([FromBody] RankBattleFinishRequestDto req)
public async Task<IActionResult> Finish([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
{
if (!TryGetViewerId(out var _)) return Unauthorized();
if (!TryGetViewerId(out var vid)) return Unauthorized();
var ctx = _battleContextStore.TakeFor(vid);
bool isWin = req.BattleResult == 1;
await _historyWriter.RecordAsync(vid, ctx, isWin, ct);
// Played-together only fires for human PvP. AI bots have OpponentViewerId=0.
if (ctx is { OpponentViewerId: > 0 })
{
await _playedTogetherWriter.RecordAsync(
vid,
ctx.OpponentViewerId,
new BattleParticipationContext(
PlayedMode: 0,
BattleType: ctx.BattleType,
DeckFormat: ctx.DeckFormat,
TwoPickType: ctx.TwoPickType),
ct);
}
return Ok(new RankBattleFinishResponseDto
{
BattleResult = req.BattleResult,
@@ -165,6 +197,33 @@ public sealed class RankBattleController : ControllerBase
var bot = await _botRoster.PickAsync(selfCtx, pending.BattleId, ct);
var seed = Random.Shared.Next();
// Stash battle context for the upcoming /finish so the replay-history hook can
// compose a ViewerBattleHistory row. See docs/superpowers/specs/2026-06-10-replay-info-design.md.
if (long.TryParse(pending.BattleId, out var battleIdLong))
{
_battleContextStore.Set(vid, new BattleContext(
BattleId: battleIdLong,
// Wire battle_type: 2 = rank battle (per docs/api-spec/common/types.ts.md
// #battle-types). AI variant shares the rank-battle wire id.
BattleType: 2,
DeckFormat: format.ToApi(), // wire-int via existing converter
TwoPickType: 0,
SelfClassId: (int)selfCtx.ClassId, // CardClass enum
SelfSubClassId: 0,
SelfCharaId: int.TryParse(selfCtx.CharaId, out var ch) ? ch : 0, // CharaId is string on MatchContext
SelfRotationId: "0",
OpponentViewerId: 0, // AI bot — not a real viewer
OpponentName: bot.UserName,
OpponentClassId: bot.ClassId, // int on AIBotProfile
OpponentSubClassId: 0,
OpponentCharaId: bot.CharaId, // int on AIBotProfile
OpponentCountryCode: bot.CountryCode,
OpponentEmblemId: bot.EmblemId, // int → long widen
OpponentDegreeId: bot.DegreeId, // int → long widen
OpponentRotationId: "0",
BattleStartTime: DateTime.UtcNow));
}
// Per spec, ai-start.md TODO: turnState semantics unclear. Default 0 (player first).
return Ok(new AiBattleStartResponseDto
{

View File

@@ -0,0 +1,86 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.EmulatedEntrypoint.Models.Dtos.Ranking;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /ranking/* — Rankings menu. Stub: the period picker renders a real
/// deterministic monthly schedule, but every leaderboard returns an empty
/// `ranking: []`. See docs/superpowers/specs/2026-06-10-ranking-stubs-design.md.
/// </summary>
[Route("ranking")]
public sealed class RankingController : SVSimController
{
[HttpPost("get_viewable_ranking_period_list")]
public IActionResult GetViewablePeriodList([FromBody] BaseRequest req)
{
if (!TryGetViewerId(out _)) return Unauthorized();
var now = DateTime.UtcNow;
return Ok(new PeriodListResponseDto
{
RankMatch = ToBase(RankingPeriodSchedule.GenerateFor(RankingPeriodSchedule.Family.RankMatch, now)),
MasterPoint = ToMasterPoint(RankingPeriodSchedule.GenerateFor(RankingPeriodSchedule.Family.MasterPoint, now)),
TwoPick = ToTwoPick(RankingPeriodSchedule.GenerateFor(RankingPeriodSchedule.Family.TwoPick, now)),
Sealed = ToBase(RankingPeriodSchedule.GenerateFor(RankingPeriodSchedule.Family.Sealed, now)),
// Crossover arrays stay empty — captured prod returned [] for both.
});
}
[HttpPost("master_point_rotation_info")]
public IActionResult MasterPointRotation([FromBody] MasterPointInfoRequestDto req)
=> RankingFor(RankingPeriodSchedule.Family.MasterPoint, req.PeriodId);
[HttpPost("master_point_unlimited_info")]
public IActionResult MasterPointUnlimited([FromBody] MasterPointInfoRequestDto req)
=> RankingFor(RankingPeriodSchedule.Family.MasterPoint, req.PeriodId);
[HttpPost("rank_match_class_win_rotation_info")]
public IActionResult RankMatchClassWinRotation([FromBody] ClassWinInfoRequestDto req)
=> RankingFor(RankingPeriodSchedule.Family.RankMatch, req.PeriodId);
[HttpPost("rank_match_class_win_unlimited_info")]
public IActionResult RankMatchClassWinUnlimited([FromBody] ClassWinInfoRequestDto req)
=> RankingFor(RankingPeriodSchedule.Family.RankMatch, req.PeriodId);
[HttpPost("two_pick_win_info")]
public IActionResult TwoPickWin([FromBody] TwoPickWinInfoRequestDto req)
=> RankingFor(RankingPeriodSchedule.Family.TwoPick, req.PeriodId);
private IActionResult RankingFor(RankingPeriodSchedule.Family family, int periodId)
{
if (!TryGetViewerId(out _)) return Unauthorized();
var entry = RankingPeriodSchedule.TryFindById(family, periodId, DateTime.UtcNow);
var periodDto = entry is null
? new PeriodEntryDto { Id = periodId.ToString() }
: new PeriodEntryDto
{
Id = entry.Id,
PeriodNum = entry.PeriodNum,
BeginTime = entry.BeginTime,
EndTime = entry.EndTime,
};
return Ok(new MonthlyRankingResponseDto { Period = periodDto, Ranking = new() });
}
private static List<PeriodEntryDto> ToBase(IReadOnlyList<PeriodEntry> src)
=> src.Select(e => new PeriodEntryDto
{
Id = e.Id, PeriodNum = e.PeriodNum, BeginTime = e.BeginTime, EndTime = e.EndTime,
}).ToList();
private static List<MasterPointPeriodEntryDto> ToMasterPoint(IReadOnlyList<PeriodEntry> src)
=> src.Select(e => new MasterPointPeriodEntryDto
{
Id = e.Id, PeriodNum = e.PeriodNum, BeginTime = e.BeginTime, EndTime = e.EndTime,
NecessaryScore = "0",
}).ToList();
private static List<TwoPickPeriodEntryDto> ToTwoPick(IReadOnlyList<PeriodEntry> src)
=> src.Select(e => new TwoPickPeriodEntryDto
{
Id = e.Id, PeriodNum = e.PeriodNum, BeginTime = e.BeginTime, EndTime = e.EndTime,
Type = "2", Over460 = "1",
}).ToList();
}

View File

@@ -0,0 +1,69 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Services.Replay;
using SVSim.EmulatedEntrypoint.Models.Dtos.Replay;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// Replay menu — recent-battles list + per-battle detail stub.
/// /replay/info returns up to 50 rows newest-first from ViewerBattleHistories.
/// /replay/detail returns 400 (result_code=99) — local cache is the canonical
/// playback source; this endpoint is only hit on cache miss, and we don't store
/// replay payloads. The client (ReplayDialogContent.GoReplay) aborts the scene
/// transition cleanly on non-success.
/// </summary>
[Route("replay")]
public sealed class ReplayController : SVSimController
{
private const string TimeFormat = "yyyy-MM-dd HH:mm:ss";
private readonly IReplayHistoryReader _reader;
public ReplayController(IReplayHistoryReader reader) => _reader = reader;
[HttpPost("info")]
public async Task<IActionResult> Info([FromBody] BaseRequest _, CancellationToken ct)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
var rows = await _reader.GetRecentAsync(vid, take: 50, ct);
var resp = new ReplayInfoResponseDto
{
ReplayList = rows.Select(MapToWire).ToList(),
};
return Ok(resp);
}
[HttpPost("detail")]
public IActionResult Detail([FromBody] ReplayDetailRequestDto req)
{
if (!TryGetViewerId(out _)) return Unauthorized();
return BadRequest(new { result_code = 99 });
}
private static ReplayInfoItemDto MapToWire(ReplayHistoryEntry e) => new()
{
BattleType = e.BattleType.ToString(CultureInfo.InvariantCulture),
TwoPickType = e.TwoPickType.ToString(CultureInfo.InvariantCulture),
DeckFormat = e.DeckFormat.ToString(CultureInfo.InvariantCulture),
BattleId = e.BattleId.ToString(CultureInfo.InvariantCulture),
IsLimitTurn = e.IsLimitTurn.ToString(CultureInfo.InvariantCulture),
OpponentName = e.OpponentName,
ClassId = e.SelfClassId.ToString(CultureInfo.InvariantCulture),
OpponentClassId = e.OpponentClassId.ToString(CultureInfo.InvariantCulture),
SubClassId = e.SelfSubClassId.ToString(CultureInfo.InvariantCulture),
OpponentSubClassId = e.OpponentSubClassId.ToString(CultureInfo.InvariantCulture),
RotationId = e.SelfRotationId,
OpponentRotationId = e.OpponentRotationId,
OpponentCountryCode = e.OpponentCountryCode,
CharaId = e.SelfCharaId.ToString(CultureInfo.InvariantCulture),
OpponentCharaId = e.OpponentCharaId.ToString(CultureInfo.InvariantCulture),
OpponentEmblemId = e.OpponentEmblemId.ToString(CultureInfo.InvariantCulture),
OpponentDegreeId = e.OpponentDegreeId.ToString(CultureInfo.InvariantCulture),
IsWin = e.IsWin ? "1" : "0",
BattleStartTime = e.BattleStartTime.ToString(TimeFormat, CultureInfo.InvariantCulture),
CreateTime = e.CreateTime.ToString(TimeFormat, CultureInfo.InvariantCulture),
};
}

View File

@@ -111,7 +111,7 @@ public class SleeveController : SVSimController
if (product.SeriesId != request.SeriesId)
return BadRequest(new { error = "series_product_mismatch" });
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.SleeveBuy);
if (tx.IsFreeplay)
return BadRequest(new { error = "already_purchased" });

View File

@@ -124,7 +124,7 @@ public class SpotCardExchangeController : SVSimController
return BadRequest(new { error = "pre_release_limit_reached" });
}
await using var tx = await _inv.BeginAsync(viewerId);
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg.Source = GrantSource.GachaPointExchange);
var rewardList = new List<RewardListEntry>();

View File

@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Models.Dtos.UserMyPage;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /user_mypage/* — viewer-scoped MyPage configuration writes. Separate from the
/// <c>/mypage/*</c> family because the wire URL family is distinct.
/// </summary>
[Route("user_mypage")]
public sealed class UserMyPageController : SVSimController
{
private readonly SVSimDbContext _db;
public UserMyPageController(SVSimDbContext db) => _db = db;
[HttpPost("update")]
public async Task<ActionResult<UserMyPageUpdateResponse>> Update(
[FromBody] UserMyPageUpdateRequest request,
CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var viewer = await _db.Viewers
.Include(v => v.MyPageBgRotation)
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
if (viewer is null) return NotFound();
viewer.MyPageBgSelectType = request.SelectType;
viewer.MyPageBgId = ParseIdOrZero(request.MyPageId);
// Clear() on a loaded OwnsMany marks every tracked entry as Deleted; SaveChangesAsync
// issues DELETEs for all old slots before inserting the new ones.
viewer.MyPageBgRotation.Clear();
for (int slot = 0; slot < request.MyPageIdList.Count; slot++)
{
viewer.MyPageBgRotation.Add(new MyPageBgRotationEntry
{
Slot = slot,
BgId = ParseIdOrZero(request.MyPageIdList[slot]),
});
}
await _db.SaveChangesAsync(ct);
return new UserMyPageUpdateResponse();
}
private static int ParseIdOrZero(string s) =>
int.TryParse(s, out var n) ? n : 0;
}

View File

@@ -0,0 +1,30 @@
using System.Globalization;
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
namespace SVSim.EmulatedEntrypoint.Mapping;
internal static class PresentMapper
{
/// <summary>
/// Project a ViewerPresent row onto the wire DTO. Field-by-field stringification matches
/// the prod capture at data_dumps/captures/traffic_event_crate_free_pack.ndjson:
/// - present_id, reward_type, reward_detail_id, reward_count, condition_number,
/// present_limit_type — STRINGS on the wire.
/// - reward_limit_time, item_type — INTS on the wire.
/// - create_time — "yyyy-MM-dd HH:mm:ss" string, gift's row-creation time (NOT now()).
/// </summary>
public static PresentDto ToWire(ViewerPresent row) => new()
{
PresentId = row.PresentId,
RewardType = row.RewardType.ToString(CultureInfo.InvariantCulture),
RewardDetailId = row.RewardDetailId.ToString(CultureInfo.InvariantCulture),
RewardCount = row.RewardCount.ToString(CultureInfo.InvariantCulture),
ConditionNumber = row.ConditionNumber.ToString(CultureInfo.InvariantCulture),
PresentLimitType = row.PresentLimitType.ToString(CultureInfo.InvariantCulture),
RewardLimitTime = (int)row.RewardLimitTime,
CreateTime = row.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
ItemType = row.ItemType,
Message = row.Message,
};
}

View File

@@ -123,17 +123,30 @@ public class ShadowverseTranslationMiddleware : IMiddleware
throw;
}
// Peek the decrypted msgpack as a raw dict to extract the auth tuple BEFORE the typed
// DTO deserialize drops anything the action's DTO doesn't model. Stash the result in
// HttpContext.Items so SteamSessionAuthenticationHandler can read it without depending
// on the DTO shape — that's the whole point of the decoupling, see
// docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md. Failures
// here are non-fatal: the auth handler will surface a 401 with a more specific reason
// (missing ticket vs corrupt body) than we could from middleware.
if (!skipEncryption)
{
TryStashAuthFields(context, decryptedBytes);
}
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.
// body to anything. Fail loud with a specific message rather than NREing below on
// .ParameterType. Authed actions can declare any DTO shape (auth fields are already
// stashed via TryStashAuthFields above); they just need ONE parameter so the binder
// has somewhere to put the rewritten JSON body.
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.");
"middleware needs at least one to bind the decrypted body. Add a request DTO " +
"parameter — even an empty one (see ProfileIndexRequest for the minimal shape).");
}
Type requestType = firstParam.ParameterType;
object? data;
@@ -271,6 +284,54 @@ public class ShadowverseTranslationMiddleware : IMiddleware
context.Response.Body = originalResponsebody;
}
/// <summary>
/// Pulls <c>viewer_id</c> / <c>steam_id</c> / <c>steam_session_ticket</c> out of the
/// decrypted msgpack body and stashes them in <c>HttpContext.Items[AuthFields.ContextKey]</c>.
/// Lets the Steam handler read the auth tuple from a separate channel so action DTOs no
/// longer need to inherit <c>BaseRequest</c> just so the handler can find the ticket.
/// Failures (corrupt body, non-map root, missing keys) are silent on purpose: the auth
/// handler will surface a more specific 401 reason than we can here.
/// </summary>
private static void TryStashAuthFields(HttpContext context, byte[] decryptedBytes)
{
try
{
var raw = MessagePackSerializer.Deserialize<Dictionary<object, object?>>(
decryptedBytes,
MessagePackSerializerOptions.Standard.WithResolver(ContractlessStandardResolver.Instance));
if (raw is null) return;
context.Items[Security.SteamSessionAuthentication.AuthFields.ContextKey] =
new Security.SteamSessionAuthentication.AuthFields
{
ViewerId = TryGetString(raw, "viewer_id"),
SteamId = TryGetUlong(raw, "steam_id"),
SteamSessionTicket = TryGetString(raw, "steam_session_ticket"),
};
}
catch
{
// Malformed body — auth handler will fail with its own diagnostic.
}
}
private static string? TryGetString(Dictionary<object, object?> raw, string key) =>
raw.TryGetValue(key, out var v) ? v as string : null;
private static ulong TryGetUlong(Dictionary<object, object?> raw, string key)
{
if (!raw.TryGetValue(key, out var v) || v is null) return 0;
return v switch
{
ulong u => u,
long l => unchecked((ulong)l),
int i => unchecked((ulong)(long)i),
uint ui => ui,
string s => ulong.TryParse(s, out var parsed) ? parsed : 0,
_ => 0,
};
}
/// <summary>
/// Walks a parsed JSON tree into the plain CLR shape MessagePack-CSharp's contractless
/// resolver understands: objects → <c>Dictionary&lt;string, object?&gt;</c>, arrays →

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Campaign;
/// <summary>
/// Body of <c>POST /campaign/regist_serial_code</c>. Client task:
/// <c>MyPageCodeInputTask</c> (Shadowverse_Code_2026-05-23/Wizard/MyPageCodeInputTask.cs).
/// </summary>
[MessagePackObject]
public sealed class RegisterSerialCodeRequest
{
/// <summary>User-typed serial code. Case-sensitive on the server.</summary>
[JsonPropertyName("serial_code")]
[Key("serial_code")]
public string SerialCode { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Campaign;
/// <summary>
/// Success response shape. Failure path uses an anonymous <c>{ result_code = 4202 }</c>
/// (mirroring AchievementController/MissionController) and bypasses this DTO.
/// </summary>
[MessagePackObject]
public sealed class RegisterSerialCodeResponse
{
[JsonPropertyName("is_complete")]
[Key("is_complete")]
public bool IsComplete { get; set; }
}

View File

@@ -0,0 +1,37 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
/// <summary>
/// One entry in /mypage/index data.home_dialog_list. Client parser
/// (Wizard/MyPageHomeDialogData.cs) only reads [0]; up to 3 buttons supported
/// (switch on 0/1/2/3 in MyPageHomeDialog.cs).
/// </summary>
[MessagePackObject]
public class HomeDialog
{
/// <summary>Wire "type" — prod sends "1"; client parser ignores it. Stringly-typed.
/// Null is omitted by the global WhenWritingNull policy.</summary>
[JsonPropertyName("type")] [Key("type")] public string? Type { get; set; }
/// <summary>Localization key resolved client-side via Data.SystemText.Get.</summary>
[JsonPropertyName("title_text_id")] [Key("title_text_id")] public string TitleTextId { get; set; } = string.Empty;
/// <summary>Asset name resolved via ResourcesManager.AssetLoadPathType.UiDownLoad.</summary>
[JsonPropertyName("image")] [Key("image")] public string Image { get; set; } = string.Empty;
[JsonPropertyName("button_list")] [Key("button_list")] public List<HomeDialogButtonDto> ButtonList { get; set; } = new();
}
[MessagePackObject]
public class HomeDialogButtonDto
{
[JsonPropertyName("button_text_id")] [Key("button_text_id")] public string ButtonTextId { get; set; } = string.Empty;
/// <summary>Scene id consumed by MyPageBannerBase.SceneChangeBySetting (e.g. "card_pack", "mission").</summary>
[JsonPropertyName("scene")] [Key("scene")] public string Scene { get; set; } = string.Empty;
/// <summary>Contextual id passed to the scene (e.g. parent_gacha_id "80032"). Stringly-typed on the wire.</summary>
[JsonPropertyName("status")] [Key("status")] public string Status { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,55 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
/// <summary>
/// Prod sends most numeric-looking fields as STRINGS on the gift endpoints (present_id,
/// reward_type, reward_detail_id, reward_count, condition_number, present_limit_type).
/// item_type is an INT. We mirror the prod shape exactly. See the capture at
/// data_dumps/captures/traffic_event_crate_free_pack.ndjson, /gift/top response (line 18).
/// </summary>
[MessagePackObject]
public class PresentDto
{
[JsonPropertyName("present_id")]
[Key("present_id")]
public string PresentId { get; set; } = string.Empty;
[JsonPropertyName("reward_type")]
[Key("reward_type")]
public string RewardType { get; set; } = string.Empty;
[JsonPropertyName("reward_detail_id")]
[Key("reward_detail_id")]
public string RewardDetailId { get; set; } = string.Empty;
[JsonPropertyName("reward_count")]
[Key("reward_count")]
public string RewardCount { get; set; } = string.Empty;
[JsonPropertyName("condition_number")]
[Key("condition_number")]
public string ConditionNumber { get; set; } = "0";
[JsonPropertyName("present_limit_type")]
[Key("present_limit_type")]
public string PresentLimitType { get; set; } = "0";
[JsonPropertyName("reward_limit_time")]
[Key("reward_limit_time")]
public int RewardLimitTime { get; set; }
[JsonPropertyName("create_time")]
[Key("create_time")]
public string CreateTime { get; set; } = string.Empty;
/// <summary>Only present on item/pack-ticket entries (reward_type=4); omit on currency entries.</summary>
[JsonPropertyName("item_type")]
[Key("item_type")]
public int? ItemType { get; set; }
[JsonPropertyName("message")]
[Key("message")]
public string Message { get; set; } = string.Empty;
}

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