Commit Graph

66 Commits

Author SHA1 Message Date
gamer147
8e41739bde feat(mission): wire class_level_up emits from every XP-granting controller
Every _xp.GrantAsync callsite now conditionally emits class_level_up:{class}
when the returned BattleXpGrantResult.LeveledUp is true. Six callsites total:

- PracticeController.Finish
- RankBattleController.FinishInternal
- FreeBattleController.Finish
- ArenaTwoPickBattleController.Finish (LeveledUp + ClassId routed via
  BattleFinishResultDto — controller-side signals, not on wire)
- ArenaColosseumBattleController (missed in the original wire-up survey;
  colosseum-battle XP now also fires the level-up event when applicable)
- StoryController.Finish (LeveledUp + ClassId routed via new
  StoryFinishOutcome wrapper, mirroring the TK2 RunFinishOutcome pattern)

Story's IStoryService.FinishAsync now returns StoryFinishOutcome instead of
FinishResponse directly. Test callsites updated to unwrap .Response.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 13:31:04 -04:00
gamer147
94622a526d refactor(arena-tk2): FinishAsync returns RunFinishOutcome with WasFullClear signal
Introduces RunFinishOutcome — a semantic record that wraps the wire
FinishResponseDto with the controller-side WasFullClear signal used to fire
the challenge_full_clear mission event. The wire response isn't affected
(controller unwraps the .Response field before returning to the client);
RetireAsync stays returning FinishResponseDto because retire cannot full-clear.

Full-clear detection compares run.WinCount against ResolveMaxBattleCountAsync
rather than hard-coding 5, so a future TK2 rules-config change tracks
automatically. New test covers the full-clear signal explicitly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 13:17:23 -04:00
gamer147
06bf9eb615 import_viewer: response skip-count fields for missions/achievements/story 2026-06-23 19:24:59 -04:00
gamer147
e6e3f919a6 fix(dto): align daily_login_bonus DTOs with prod wire shape
- numeric fields ship as quoted strings (campaign_id, img, effect_id,
  reward_type, reward_detail_id, reward_number)
- DailyLoginBonus.Campaign is a List<>, not a single object
- LoginBonusCampaign.Img replaces Image (string, asset-key)
- add IsOneDayMultiRewards (SpecialData-only on client, byte-faithful)
2026-06-13 15:59:42 -04:00
gamer147
f707fb2ffb refactor: drop unused DailyLoginBonuses table + importer
The captured shape ({"1":[], "3":[], "4":[]}) is the empty-period
wire echo; the client parser (LoadDetail.cs:553) only reads normal/total/
campaign. Table, entity, seed, and importer were never wired to a code path.

Replaced by config-section catalog + service in the follow-up commits.
2026-06-13 15:32:33 -04:00
gamer147
43a3665775 refactor(arena-colosseum): dedup colosseum_info source-of-truth
/mypage/index data.colosseum_info and /arena_colosseum/{top,get_fee_info}
now both project through ColosseumLobbyInfoBuilder.Build(ColosseumSeasonConfig).
One source of truth for the home-tab gating and the lobby reads — admins
flip the season on by writing to GameConfigs (per the runbook), no second
table to keep in sync.

* New ColosseumLobbyInfoBuilder — static projection from ColosseumSeasonConfig
  to the wire ColosseumLobbyInfo. Replaces ArenaColosseumController's private
  BuildColosseumInfo and MyPageController's BuildColosseumInfo(ColosseumConfig).
* MyPageController reads ColosseumSeasonConfig via IGameConfigService; the
  IGlobalsRepository.GetCurrentColosseum() call goes away.
* MyPageIndexResponse.ColosseumInfo retyped to the new ColosseumLobbyInfo.
* ColosseumSalesPeriodInfo relocated into the ArenaColosseum namespace
  alongside the rest of the family DTOs.
* Drops: Colosseums DB table (migration DropColosseumsTable), ColosseumConfig
  entity, the captured-shape Models.Dtos.ColosseumInfo DTO, ColosseumSeed,
  MyPageGlobalsImporter.ImportColosseumAsync + seed file colosseum.json,
  IGlobalsRepository.GetCurrentColosseum + impl, the two related tests.

Net: home-screen Grand Prix tab now hidden by default because the season
ships with IsColosseumPeriod=false. Full suite: 1345/1345 (was 1347 — the
2 dropped tests covered the now-dead surface).
2026-06-13 13:31:03 -04:00
gamer147
d126649ad4 feat(arena-colosseum): 2-pick + curated deck sources (phase 3)
Closes the family. arena-colosseum 10/16 → 15/16 zero stubs (the 16th —
finish_load — is dead per project_dead_battle_endpoints).

* 2-Pick draft lift onto ArenaColosseumController:
  - get_candidate_classes samples from ArenaTwoPickConfig.AllowedClassIds
    and persists the slate onto the run.
  - class_choose accepts class_id XOR chaos_id; both populate run.ClassId,
    chaos branch stores ChaosId for replay.
  - get_candidate_cards is the idempotent draft-resume snapshot.
  - card_choose appends both cards from the picked pair, advances turn 1..15.
  - Pool override: ArenaTwoPickCardPoolService gets a non-breaking
    GeneratePickSetsForTurn(..., poolCardSetIds) overload; Colosseum routes
    pass ColosseumSeasonConfig.PoolCardSetIds (falls back to challenge →
    rotation when empty).
* Curated-deck schema: ColosseumHofDeck / ColosseumWindFallDeck /
  ColosseumAvatarDeck — three identical tables (separate per per-pool
  operational lifecycle), unique on DeckNo. Migration AddColosseumCuratedDecks.
* IColosseumCuratedDeck interface + a generic ColosseumCuratedDeckImporterBase<T>
  with three concrete subclasses (HOF / WindFall / Avatar), registered in
  Bootstrap. Seed files ship empty.
* 6 curated endpoints on ArenaColosseumController: get_{hof|windfall|avatar}_deck_list
  return BARE-ARRAY data per spec; register_{hof|windfall|avatar}_deck share
  one generic RegisterCuratedAsync<T> dispatcher. Cross-pool register
  rejected via per-pool lookup. Curated register has no is_published flag
  (constructed-only) — clears the run's flag for state consistency.
* Tests: 5 draft HTTP tests + 11 curated-deck HTTP tests (4 parameterized
  3 ways across HOF/WindFall/Avatar + a cross-pool isolation test + an
  is_published clear test). Existing TwoPick service tests updated for the
  new pool overload. Full suite: 1347/1347.

Phase 3 ship gate met. Branch ready for merge.
2026-06-13 12:49:57 -04:00
gamer147
cbee0f9a50 feat(arena-colosseum): playable bracket + rank-match promotion (phase 2)
Closes 4 more arena-colosseum spec shapes (6/16 → 10/16): the dual-prefix
battle URLs (colosseum_battle/* and colosseum_rank_battle/*) for both
do_matching and per-match finish, plus the bracket-end /finish + /retire
pair on /arena_colosseum.

* ColosseumProgressionService: pure-logic decisions for win-threshold
  advancement, 3008 promotion trigger, and per-round reward bundles.
  Extends ColosseumRoundsConfig with FinishRewards/RetireRewards per
  round + a top-level ChampionRewards list.
* MatchContextBuilder.BuildForColosseumAsync: lifts the registered deck
  via IDeckRepository (NOT viewer-graph) per project_ef_nav_include_pitfall.
* ArenaColosseumBattleController: dual route prefixes off one controller
  via explicit absolute [HttpPost("/...")] routes — does NOT extend
  SVSimController's [Route("[controller]")]. Same pattern as FreeBattle.
  URL-vs-IsRankMatching mismatch returns 400 with a phase-mismatch error;
  no-deck triggers the standard 3001 matchmaking-illegal state. 3008
  flips run.IsRankMatching exactly once via the progression service.
* /arena_colosseum/finish + /retire share FinishResponse (rewards +
  reward_list + colosseum_status). Champion finish appends ChampionRewards
  and surfaces colosseum_status.is_champion = true. Both delete the run
  row after granting via IInventoryTransaction.GrantAsync.
* DI: ModePolicy entries for "colosseum_battle" and "colosseum_rank_battle".
* Tests: 8 progression-service unit tests (pure logic), 5 battle-controller
  HTTP tests, 5 bracket-terminate HTTP tests, 2 full-bracket E2E walks
  (champion path + retire mid-round). Full suite: 1331/1331.
2026-06-13 12:32:56 -04:00
gamer147
110867358c feat(arena-colosseum): lobby + constructed entry (phase 1)
Closes 5 of arena-colosseum's 16 spec shapes (1/16 → 6/16). Lobby reads
(/top, /get_fee_info, /event_info) render an empty "no event scheduled"
payload by default; /entry + /register_deck activate via admin-flipped
ColosseumSeason + ColosseumRounds config sections.

* Schema: ViewerArenaColosseumRun standalone table (unique on ViewerId)
  with jsonb run-state columns mirroring ViewerArenaTwoPickRun.
* Config sections: ColosseumSeasonConfig (event-level), ColosseumRoundsConfig
  (the 3-round bracket). Empty defaults — IsColosseumPeriod=false.
* Migration AddArenaColosseumRun (DDL only; seed rows come from the section
  ShippedDefaults via EnsureSeedDataAsync).
* DTOs: ColosseumLobbyInfo (round-level, /top + /get_fee_info), ColosseumEventInfo
  (event-level, /event_info), ColosseumOwnStatus (shared status block),
  ColosseumEntryRef, ColosseumFeeList, ColosseumUserDeck, ColosseumBattleResults,
  ColosseumRoundDetail + ColosseumGroupRow. EventInfoResponse uses explicit
  [JsonPropertyName("1"|"2"|"3")] for the string-keyed rounds shape per spec.
* Controller: ArenaColosseumController replaces the 1-action stub with
  the five lifecycle endpoints. /top emits leader_skin_id even when 0
  (project_wire_null_policy override).
* Tests: 11 controller-level tests + 6 config round-trip tests covering
  empty-season payloads, run-seeded /top round-trip, crystal/rupy entry
  debits, now_round_id mismatch rejection, deck_no_list round-trip + reject.
2026-06-13 12:16:22 -04:00
gamer147
43dee9d00b feat(card): /card/create_foil_card Seer's Globe conversion (1 orb + 1 base → 1 foil) 2026-06-13 09:58:55 -04:00
gamer147
921297227e feat(emblem): /emblem/emblem_list returns owned ids 2026-06-13 08:14:36 -04:00
gamer147
68741b2980 feat(degree): /degree/degree_list returns owned ids 2026-06-13 08:11:13 -04:00
gamer147
f879c05dca feat(sleeve): /sleeve/sleeve_list returns owned ids 2026-06-12 23:17:10 -04:00
gamer147
43e4c06a2c feat(mypage): /mypage/finish_battle (returns check=0 — no in-flight state yet) 2026-06-12 23:08:42 -04:00
gamer147
b4a279ef0c feat(load-index): emit data.card_master_hash on mismatch (tier-1 freshness gate) 2026-06-12 12:27:40 -04:00
gamer147
dc39ed4146 feat(card-master): add request/response DTOs for /immutable_data/card_master 2026-06-12 12:05:14 -04:00
gamer147
bf51dabcff refactor(dtos): promote PresentDto to Common/ 2026-06-08 20:35:42 -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
8112b3f81f feat(arena-tk2): split do_matching success into 3007 owner / 3004 joiner
Mirrors prod's TK2 wire flow: the first arriver (parked, picks up cached
pair on a later poll) gets matching_state 3007 (SUCCEEDED_OWNER); the
second arriver (whose poll triggered the pair) gets 3004 (SUCCEEDED).

Observationally inert in the public matching code path today — the
client's Matching class writes isOwner from the response into a field
that nothing in TK2/ranked reads. Matching_Room (private rooms) DOES
read it but from a separate code path that doesn't consult our response.
We send the split anyway for prod fidelity and to leave room for future
flows (rematch UI, etc.) that might start consuming it.

TryPairAsync now returns PairUpResult(Match, IsOwner) instead of bare
PendingMatch?, so the controller can decide owner vs joiner without
re-deriving it.

Also documents on DoMatchingResponseDto why we omit prod's `room_id`
field (not in the client's DoMatchingDetail model; private-room flows
get their room id from a different API and don't consult this response).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:24:13 -04:00
gamer147
225c20daeb feat(arena-tk2): PvP pair-up trigger via /do_matching, ?scripted=1 opt-in
Solo pollers park (3001 RETRY); two concurrent pollers pair and both
receive 3004 + same BattleId. Cache hits on the first arriver's next
poll. ?scripted=1 retains today's solo Scripted path for dev work.
Response DTO's BattleId/NodeServerUrl become nullable so 3001 omits
them on the wire (WhenWritingNull policy drops them).

ASP.NET's default bool binder rejects "1" as a value, so the scripted
opt-in is bound as string? and parsed permissively (accepts "1" and
"true"/"True"/etc.) rather than relying on built-in bool binding.
2026-06-01 22:14:04 -04:00
gamer147
9776873073 fix(arena-tk2): include card_master_id in do_matching success response
The decompiled client's DoMatchingBase.SettingCardMasterId calls
jsonData["card_master_id"].ToInt() with no Keys.Contains guard when
matching_state ∈ {3004, 3007, 3011}. Omitting the field crashes the
client with KeyNotFoundException at Cute.NetworkManager+Connect.

Add CardMasterId to DoMatchingResponseDto with a default value of 1
(matching the /load/index response and prod captures). Extend the
controller test to assert the field is present.

Caught during the v1 smoke walk-through; full client log line:
  [Error: Unity Log] KeyNotFoundException: The given key was not
  present in the dictionary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:52:57 -04:00
gamer147
ff51c33b6c feat(arena-tk2): do_matching mints battle via IMatchingBridge, returns 3004 2026-05-31 22:53:20 -04:00
gamer147
ac2f31103d fix(arena): match prod get_challenge_info wire shape; stub ranking_history
Prod /arena/get_challenge_info capture (Season 26):
- reward_step_info.reward_step_list is a Dict<string,string>
  ({"5":"5","10":"10","15":"15"}), not the List<int> I'd assumed
- max_reward_step is stringified

The previous stub would have parsed at the client (LitJson tolerates the
shape via indexed iteration), but cleaning to match prod exactly.

Also stubs /arena/get_challenge_ranking_history (new endpoint observed
in the same capture). Prod ships {two_pick: [], sealed: []} with no
history populated — empty lists match. Routing smoke added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:21:44 -04:00
gamer147
1af56b4ec4 fix(tk2): per-viewer is_join in arena_info + stub /arena/get_challenge_info
Bug 1 ("pay to enter again after restart"):
arena_info[0].is_join shipped from the static ArenaSeasonConfig seed,
so /load/index and /mypage/index always emitted false regardless of
viewer state. The client uses is_join to choose between the "Pay to
enter" and "Resume run" dialogs (Wizard/ChallengeEntry.cs:165 + the
ArenaEntryBase._isJoinFunc pivot). Without a per-viewer override every
cold start after a partial run looked like "no run" and the player got
charged again.

LoadController + MyPageController now compute is_join from
ViewerArenaTwoPickRuns presence. MyPageController grew an
IArenaTwoPickRunRepository dep (LoadController already had _db).

Bug 2: /arena/get_challenge_info 404. Stubbed via a new
ArenaController + DTO pair. Returns the season seed's begin/end_time
+ name where available; placeholder zeros for win history. All 6 keys
required by ChallangeHistoryInfoTask.Parse are present (unconditional
JsonData lookups).

Routing smoke added for /arena/get_challenge_info.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:13:11 -04:00
gamer147
1e2e18e828 fix(tk2): rewards array uses ReceivedReward shape (reward_detail_id/item_type/is_usable)
The /retire and /finish responses carry two reward arrays with DIFFERENT
key schemas:

  rewards[]      → ReceivedReward(JsonData) parser
                   {reward_type, reward_detail_id, item_type, reward_count?, is_usable}
  reward_list[]  → PlayerStaticData.UpdateHaveUserGoodsNumByJsonData
                   {reward_type, reward_id, reward_num}

We were emitting both with reward_list's schema, so the client threw
KeyNotFoundException on `data["reward_detail_id"]` while parsing each
delta entry — observed live as the retire-screen failure.

- New TwoPickRewardReceivedDto mirrors the existing Achievement/
  TotalReceiveCountDto shape.
- FinishResponseDto.Rewards switched from List<RewardEntryDto>
  to List<TwoPickRewardReceivedDto>.
- GrantRunRewardsAndDeleteAsync pre-loads ItemEntry.Type for any
  Item-typed reward so item_type ships correctly (0 for currencies).
- Existing tests renamed RewardNum→RewardCount on the deltas list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 12:56:05 -04:00
gamer147
f8ca4a0ae9 feat(http): stub /arena_colosseum/get_fee_info (is_colosseum_period:false) 2026-05-31 11:58:18 -04:00
gamer147
09b8c49743 feat(http): ArenaTwoPickBattleController (do_matching stub + finish)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:27:02 -04:00
gamer147
ba49852c42 feat(svc): IArenaTwoPickService + response DTOs + GetTopAsync
6 response DTOs, IArenaTwoPickService interface + ArenaTwoPickException,
ArenaTwoPickService skeleton with GetTopAsync implemented and stubs for
Tasks 13-15. 3 NUnit tests for GetTopAsync all pass. DI: AddScoped.
2026-05-31 10:51:41 -04:00
gamer147
71b3c3e19f feat(import): import owned card collection with unknown-card skip
Extends POST /admin/import_viewer to accept owned_cards (snapshot full-replace).
Unknown card_ids are skipped, counted in skipped_card_count on the response, and
logged server-side. Count is clamped to OwnedCardEntry.MaxCopies (3). Also injects
ILogger into AdminController and adds Cards/Items/Decks to the viewer reload graph.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:22:44 -04:00
gamer147
2d675aa35d feat(practice): serve default/trial/leader-skin lists on practice/deck_list
practice/deck_list returns the same wire shape as /deck/info (the client parses
both via DeckGroupListData), but only ever sent user decks — so a fresh account
saw no default decks and couldn't start a practice match.

Extract the /deck/info hydration into a shared IDeckListBuilder used by
/deck/info, /deck/my_list, and /practice/deck_list. Practice passes
padEmptySlots:false (deck *select*, not builder) — matches the prod practice
capture, which returns real decks unpadded plus the 8 per-class default decks
and per-class leader-skin settings. Retire the near-duplicate
PracticeDeckListResponse DTO in favor of the shared DeckListResponse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:01:36 -04:00
gamer147
bd2eaa9e97 refactor(deck): re-type /deck/info trial_deck_list to List<TrialDeck>?
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:54:53 -04:00
gamer147
ef1af8259e feat(pack): gacha-point endpoint DTOs 2026-05-28 22:56:33 -04:00
gamer147
b5e33c15f6 fix(mypage): populate user_item_list from viewer.Items
MyPageTask.Parse (Wizard/MyPageTask.cs:155-163) does
`_userItemDict.Clear();` the moment `user_item_list` is present in
the response body — not when it's non-empty — then re-populates
from the wire. Our /mypage/index was emitting [] by default (the
field initializer on the DTO), which wiped the inventory that
/load/index had just populated.

Downstream consequence: the client's PackChildGachaInfo.CostGoodsCount
reads from _userItemDict, so a wiped dict makes every ticket-cost
pack report CostGoodsCount=0, PackConfig.EnableBuyPack returns false,
and is_hide=1 packs (including the tutorial legendary starter 99047)
disappear from the rotation pack list — even though the gift bundle
just granted the ticket and the DB row exists. The tutorial then
auto-selects whatever non-tutorial pack happens to be at index 0 of
the filtered list, the user can't afford it, and the flow is stuck.

Fix:

- MyPageController.Index now sets UserItemList from viewer.Items
  (already loaded by GetViewerByShortUdid's home-screen graph).
- DTO docstring rewritten to call out the presence-sensitive semantics
  and the load-bearing path through PackConfig.EnableBuyPack, so the
  next developer doesn't get the "empty is fine" hint the old comment
  implied.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:03:50 -04:00
gamer147
6819e65160 feat(tutorial): /tutorial/pack_open emits tutorial_step=100, advances viewer state
Stack [HttpPost("/tutorial/pack_open")] alias on PackController.Open. Detect
isTutorialPath via HttpContext.Request.Path; gate the type_detail rejection,
currency switch, open-count tracking, and currency reward_list entries behind
!isTutorialPath so the starter legendary pack (99047/990047, type_detail=5)
bypasses the purchasable-pack code path. After grant, set MissionData.TutorialState=100
and emit tutorial_step=100 in PackOpenResponse — this is the sole END transition,
per live-traffic capture. Add pack 99047 to test-fixture packs.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:55:08 -04:00
gamer147
f6f9216162 feat(tutorial): add /tutorial/gift_receive — grant + receipt + idempotent re-claim
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:22:37 -04:00
gamer147
2034034c1b feat(tutorial): add /tutorial/gift_top with hardcoded starter present list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:02:54 -04:00
gamer147
bc9ffe1d31 feat(tutorial): add /tutorial/update — echo step + persist to viewer
POST /tutorial/update echoes tutorial_step back and saves it to
Viewer.MissionData.TutorialState. is_skip=1 is handled server-side
by honoring whatever tutorial_step value the client sends (client
already sends 100 when skipping). Adds TutorialUpdateRequest DTO,
TutorialUpdateResponse DTO, injects SVSimDbContext into
TutorialController, and adds GetViewerTutorialStateAsync helper to
SVSimTestFactory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:47:09 -04:00
gamer147
36dd25826b fix(deck-builder): wire key is cardID/phantomCardID, not snake_case
Client's LitJson serializer emits the C# property name verbatim — the
SetParameter param classes in Wizard/GenerateDeckCodeTask.cs use cardID /
phantomCardID, and the matching Parse() reads jsonData["cardID"]. Snake-case
keys bound to empty in msgpack deserialize, the controller saw 0 cards, and
returned INVALID_DECK — surfaced as a blank deck code in the in-game UI.

Repro lived in data_dumps/traffic.ndjson #19-20. Existing tests pass through
the same JsonPropertyName on both serialize and deserialize, so they happily
round-tripped any consistent key — adding a wire-shape regression test that
posts the literal client JSON would be the right way to catch this class of
bug in the future (out of scope here).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:25:17 -04:00
gamer147
5aac24d2b9 feat(deck-builder): /deck_code mint + /deck resolve with 3-min in-memory TTL
Adds the portal pair (shadowverse-portal.com deck-builder endpoints) as
anonymous routes on the app server. The translation middleware learns a new
[NoWireEncryption] attribute that skips both AES calls but keeps the rest of
the msgpack + base64 + envelope pipeline intact, matching prod's portal wire
profile observed in data_dumps/traffic_prod_deckcode.ndjson.

Storage is a 3-minute IMemoryCache — codes are anonymous-global, 4-char
lowercase alphanumeric (matches the shortest prod sample). Foil bit is
stripped on mint to match prod's normalize-on-encode behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:11:21 -04:00
gamer147
1ee31c1689 controller(card): POST /card/protect 2026-05-28 01:56:47 -04:00
gamer147
442399b268 dto(card): CardCreate request and response 2026-05-28 01:25:28 -04:00
gamer147
7ef5f03eb3 feat(spot-card-exchange): /spot_card_exchange/{top,exchange} + SpotPoints currency
Final shop family. Schema additions:
- ViewerCurrency.SpotPoints (ulong) — new currency column on Viewers.
- SpotCardExchangeEntry — catalog (distinct from the pre-existing
  SpotCardEntry, which is the /load/index rental-cost concept).
- ViewerSpotCardExchange — standalone composite-PK table tracking
  (viewer, card, exchanged_at, is_pre_release_snapshot). Standalone
  avoids cartesian-explode on viewer-graph reads.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:16:11 -04:00
gamer147
7abdfe27cb review(bp): drop fragile cast, thread CT, internalize cache reset
- IndexResponse.BattlePassLevelInfo widened to IReadOnlyDictionary<string,BattlePassLevel>?
  so any IReadOnlyDictionary impl (FrozenDictionary, wrapper, etc.) serializes correctly
  instead of silently null-ing via a failed as-cast
- LoadController.Index now takes CancellationToken ct and threads it to GetLevelCurveAsync
  instead of CancellationToken.None
- BattlePassRepository.ResetLevelCurveCache changed from public to internal; added
  InternalsVisibleTo("SVSim.UnitTests") to SVSim.Database.csproj (was absent)
2026-05-26 23:01:26 -04:00
gamer147
141f34f817 chore(bootstrap): refresh stale GlobalsImporter references in docs/test names 2026-05-26 16:44:54 -04:00
gamer147
9090086a47 Class leader fixes 2026-05-26 10:01:37 -04:00
gamer147
b6966ece6e Prebuilt deck purchasing and fixes 2026-05-26 09:16:21 -04:00