Compare commits

...

42 Commits

Author SHA1 Message Date
gamer147
4b5c3b905e test(admin): update MissionMeta round-trip test to new wire types
Two test callsites in AdminControllerTests still constructed ImportMissionMeta
with the pre-fix types (bool HasReceivedPickTwoMission, long MissionChangeTime),
which compiled locally against the stale test binaries under --no-build but
tripped CS0029 in CI on a clean build. Switch to wire-shape values (int 1,
"yyyy-MM-dd HH:mm:ss" string) and adjust the round-trip assertion to compare
against the parsed-UTC DateTime rather than the Unix conversion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 19:51:31 -04:00
gamer147
eecad64655 build(entrypoint): cross-platform Steamworks-lib copy target
The old `<Exec Command="COPY ...">` PostBuild step only worked on Windows,
blocking CI on ubuntu-latest. Replaced with two MSBuild `<Copy>` targets:
one AfterTargets="Build" (preserves the existing build-output behavior),
one AfterTargets="Publish" (new — publish output now includes the libs
automatically, so downstream release packaging doesn't need a shell copy
workaround).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 19:44:59 -04:00
gamer147
d815bfa994 fix(admin): parse mission_change_time as wire datetime string
Real /load/index captures ship mission_meta.mission_change_time as a
"yyyy-MM-dd HH:mm:ss" string (see FriendService.DefaultMissionChangeTime for the
canonical game format) but ImportViewerRequest typed it as long? Unix seconds. The
FromUnixTimeSeconds branch had never been reachable with a real capture, and any
loader dump carrying mission_meta 400d the whole request. Change the DTO field to
string? and parse in the controller with DateTime.TryParseExact + invariant culture
(AssumeUniversal | AdjustToUniversal); unparseable values skip cleanly instead of
poisoning the whole viewer import.

Also fix is_received_two_pick_mission: DTO's JsonPropertyName was
has_received_pick_two_mission (never matched the wire key), and the wire ships "1"
/"0" strings not booleans. Rename to the actual wire key, retype to int?, controller
normalizes `hrptm != 0` before assigning to the bool DB property.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 18:25:28 -04:00
gamer147
c8e4bda6af feat(admin): shared-secret auth for /admin/import_viewer + Swagger authorize
Anonymous /admin/import_viewer was safe locally but exposed viewer-import surface
whenever the server was reachable off-box. Gate it on a shared secret carried in
X-Admin-Secret, sourced from Admin:ImportSecret in appsettings (override via
ADMIN__IMPORTSECRET env var). Fails closed: an unconfigured deployment 401s every
request (with a logged warning) instead of leaving the endpoint open.

Implementation is a plain IAuthorizationFilter attribute (RequireAdminSecret) that
runs before model binding — short-circuits the ImportViewerRequest deserialize on
unauthorized calls. Compare uses CryptographicOperations.FixedTimeEquals to avoid
timing signal. A companion Swagger IOperationFilter registers an ApiKey security
definition and attaches the requirement ONLY to actions carrying the attribute, so
the Swagger UI shows an Authorize dialog for admin endpoints without decorating
ordinary game routes with a padlock.

Tests: existing 27 AdminController tests routed through a new
SVSimTestFactory.CreateAdminClient() helper that bakes the header from
appsettings.Testing.json; added two negative-path tests
(missing_secret_header, wrong_secret) — 29/29 passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 18:24:59 -04:00
gamer147
7a13e99df7 fix(guild): emit is_friend / is_friend_apply in /guild/info members
GuildMemberInfo.cs:48-49 re-reads json["is_friend"] / json["is_friend_apply"]
UNGUARDED on top of the base-class UserInfoBase's guarded read. Our DTO had
them as int? with global WhenWritingNull → keys omitted → LitJson threw
KeyNotFoundException, crashing the client right after guild create.

Flip the fields to non-nullable int with JsonIgnoreCondition.Never so they
always ship, and wire a batched IFriendService.GetFriendRelationsAsync
lookup so the values are actually populated (single Contains-batched query
per relation table).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 16:18:22 -04:00
gamer147
1bdcd8397b config(battle-node): require NodeServerUrl from appsettings, no fallback
Move the localhost default off BattleNodeOptions and out of Program.cs
into an appsettings.json "BattleNode" section, and make AddBattleNode
throw at startup if the value is missing or blank. Deployments now must
set the URL explicitly, and dev keeps working via the checked-in value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 15:10:23 -04:00
gamer147
6b0b467f31 config(default-grants): zero out shipped Crystals/Rupees/Ether
Tutorial gifts now cover the starter pack + starter deck flow (crystals
+ ether + tickets granted via ViewerPresents on signup), so the 50k/50k/50k
default deposit was double-dipping. Zero the ShippedDefaults so a fresh
viewer's currency reflects exactly what they've earned.

Also update the two tests that asserted the old 50k baseline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 14:54:43 -04:00
gamer147
05d143d2e8 fix(mypage): populate unread_present_count from ViewerPresents
The home-screen crate button reads this count via MyPageTask (parsed
into Data.MyPage.data.unread_mail_count) and MyPageItemHome.cs:148
routes it into SetUnreadGiftCount to render the "N" bubble on the
gift button. It was hardcoded 0, so tutorial gifts (5 presents sitting
in the viewer's inbox after signup) showed no badge until the crate
was opened.

Add IViewerRepository.CountUnclaimedPresentsAsync and call it from
MyPageController.Index. Regression test seeds a viewer with tutorial
presents and asserts unread_present_count > 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 14:44:52 -04:00
gamer147
c087f9b08f fix(pack): disable stale rotation-specific pack rows
Ten packs in the seed were still being served after the June rotation
took over, causing the client's Throwback tab to include Ultimate
Colosseum and other May-era sets:

- 10028-10032: vintage variants of the June-rotation sets. Prod hides
  these while their 16xxx counterparts are the active current-rotation
  offering.
- 16011-16015: current-rotation variants for the May rotation
  (Altersphere - Colosseum). Prod dropped them when June kicked in.

Flag all ten off via is_enabled=false. Diffed against
traffic_prod_allstars_freepack.ndjson (2026-06-03 prod capture),
seed now returns exactly the 35 packs prod served on that date.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 14:29:54 -04:00
gamer147
7309488748 fix(pack): route /tutorial/pack_open through the normal path
The tutorial pack-open alias hardcoded parent_gacha_id=99047 (the May 2026
throwback pair), so once the June rotation replaced it with 99032 the
tutorial's final pack-open 400ed. Fix the identity gate by removing it:
/tutorial/pack_open is now a plain alias for /pack/open with a single
epilogue that advances TutorialState to 100 and emits tutorial_step=100.

The tutorial gift's TICKET_MULTI item naturally scopes what the alias
can open — the normal path's ticket-debit branch already consumes it,
so no separate tutorial-path bypass is needed. Also drop the July-1
expiry on packs 80032/99032 so the current throwback pair stays visible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 14:21:52 -04:00
gamer147
9813f82edc feat(mission): wire rank_achieved emit at RankBattleController
RankBattleController.FinishInternal now emits rank_achieved + tier-qualified
variant (via MissionEventKeys.Rank.AchievedAll) whenever
rankResult.TierAdvanced fires. Closes the last catalog wire-up gap in the
Tier 1+2 scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 13:35:23 -04:00
gamer147
e9c13ae5f4 feat(rank-progress): RankProgressResult carries TierAdvanced signal
RankProgressResult gains a TierAdvanced bool (defaulted false so the 7-arg
positional-constructor sites don't break). Set true iff the grant is a win
AND RankTier.Name resolves both pre-grant and post-grant to distinct strings —
i.e. a promotion crossed a tier boundary.

Explicitly guards on isWin so a demotion (in principle possible after a loss
below a tier floor, though the current floor rules prevent it) never fires
the achievement backward.

Three new tests: no-cross win, beginner→d cross, loss stays false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 13:32:45 -04:00
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
a2048a4d54 feat(battle-xp): BattleXpGrantResult carries LeveledUp signal
BattleXpGrantResult gains a LeveledUp bool set true iff at least one curve
threshold was crossed during the grant (comparing pre-Level to post-Level after
the level-up loop). Callers will gate class_level_up mission emits on this flag
rather than caching pre-state themselves.

No behavior change for existing callers — they read GetXp/TotalXp/Level today
and never check LeveledUp yet. Tests cover: unchanged level → false, single
threshold → true, multi-threshold within one grant → still true, unknown class
guardrail → false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 13:23:34 -04:00
gamer147
42b0fd3d6e feat(mission): wire Tier 1 emits — rank/free/challenge match events
Adds IMissionProgressService to RankBattle, FreeBattle, ArenaTwoPickBattle, and
ArenaTwoPick controllers. Each finish handler emits the appropriate
MissionEventKeys builder result:

- RankBattle.Finish (win) → Ranked.WinAll(classId)
  Includes AI-rank routes; on a private server AI wins count the same.
- FreeBattle.Finish (win) → Free.WinAll() (aggregates only)
- ArenaTwoPickBattle.Finish → Challenge.MatchPlayAll or MatchWinAll
  Always fires challenge_play; wins additionally fire challenge_win and
  the ranked/arena/daily aggregates.
- ArenaTwoPick.Finish (5-win full clear) → Challenge.FullClearAll
  Gated on RunFinishOutcome.WasFullClear from the previous commit.

This closes six of the eight remaining catalog wire-up gaps; class_level_up
and rank_achieved follow in Tier 2 (need pre-state detection).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 13:22:02 -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
f3c8b46469 feat(mission): add RankTier + Class/Ranked/Free/Challenge/ClassLevel/Rank builders
MissionEventKeys grows six new nested static classes covering the wire-up
gap for the counter families that Practice/Story left uncovered. Each mirrors
the established (Practice/Story) shape: named accessors for individual keys,
`{Family}.WinAll(...)` / `AchievedAll(...)` etc. as convenience arrays that
callers pass straight to RecordEventAsync.

RankTier lives at SVSim.Database/Enums/RankTier.cs since the concept (rank_id
→ tier bucket) is data-shaped, not emit-specific, and RankProgressService
will need it internally for tier-crossing detection. Ranked/ClassLevel/Rank
builders in MissionEventKeys compose Class.Name and RankTier.Name.

12 new registry tests cover the family shapes plus tier boundary edges
(rank 4/5, 24/25, 25/26).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 13:15:11 -04:00
gamer147
0f6c8d5e41 feat(bootstrap): validate seed event_type against MissionEventKeys registry
Achievement, mission, and BP monthly mission importers now throw at bootstrap
if any seed row references an event_type that doesn't start with a registered
top-level prefix. Prevents drift: a typo in the seed JSON (or a prefix removed
from code without updating the seed) fails loudly at startup instead of
silently producing counters no emitter writes to.

Verified all 12 top-level prefixes currently in the seed JSON pass validation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 12:59:48 -04:00
gamer147
652011ea74 refactor(controllers): migrate practice/story/item_purchase to MissionEventKeys
PracticeController.Finish now uses MissionEventKeys.Practice.WinAll — the
counter keys emitted match the catalog's named form (practice_win:elite:arisa
etc.), closing the numeric-vs-named mismatch that had all practice-elite
achievements silently at zero. Also removes the "bridging is a follow-up" TODO.

StoryController.Finish uses MissionEventKeys.Story.ChapterFinishAll for the
same shape. ItemPurchaseController.CounterKey routes through
MissionEventKeys.ItemPurchase so the "item_purchase:" prefix has one owner.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 12:59:34 -04:00
gamer147
fa89c32110 refactor(calendar): expose AllTime via GameCalendarPeriods, not the concrete service
Move the "all-time" constant out of GameCalendarService (concrete) into a new
static class GameCalendarPeriods. Consumers using IGameCalendarService no
longer have to reach through the concrete class to name the lifetime bucket.

Migrates 5 callsites (mission progress/assembler, admin, item purchase, tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 12:59:24 -04:00
gamer147
e1b38d5649 feat(mission): add MissionEventKeys registry with Practice numeric→named mapping
Central registry of every string that can appear as ViewerEventCounter.EventKey
or catalog EventType. All 12 top-level prefixes from the achievement/mission
seed JSON are constants; Practice.WinAll and Story.ChapterFinishAll are
hierarchical builders that emit multi-level counter keys per event.

Practice.WinAll translates the wire (difficulty:int, enemy_class_id:int) tuple
into the catalog-facing named form (elite/elite2/elite3 : arisa/erika/...).
Wire difficulty 4/6/7 map to elite/elite2/elite3 (verified via
practicetext.json + practice-opponents.json + practice_ai_setting.csv). Class
1-8 map to the leader names in CardClass ordering. Wire values outside the
known set skip that hierarchical level but still advance the top-level counter.

MissionEventKeys.IsRegistered validates that a string starts with a registered
top-level prefix — for seed-importer drift checks in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 12:59:15 -04:00
gamer147
6e6856db4f refactor(calendar): delete JstPeriod, update doc comments
All JstPeriod callsites have moved to IGameCalendarService — delete the
static class and its tests. Updates the doc comments on Viewer.cs
(LastLoginBonusClaimedAt) and ViewerEventCounter.cs to reference the
new UTC / config-driven boundary instead of the old "JST 02:00".

GameConfigurationJsonb config-section snapshot test picks up "GameCalendar".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 12:00:18 -04:00
gamer147
448153d592 refactor(admin,item-purchase): migrate to IGameCalendarService
Swaps 6 JstPeriod.* callsites across AdminController (mission counter
resolution for the /admin catalog) and ItemPurchaseController (monthly
reset bucket key + all-time period).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 11:52:53 -04:00
gamer147
2b0cd516f0 refactor(mission): migrate MissionProgressService + MissionAssembler to IGameCalendarService
Swaps 6 JstPeriod.* callsites in mission code to IGameCalendarService.
Also drops the manual `jstNow = now.ToOffset(+9).AddHours(-2)` shift in
MissionAssembler — BP monthly missions now key off UTC calendar month, and
the start/end wire timestamps become UTC-midnight month boundaries. This
matches the plan's all-UTC discipline; wire fidelity for BP monthlies
is not shipped yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 11:51:27 -04:00
gamer147
70ec063c0f refactor(login-bonus): migrate LoginBonusService to IGameCalendarService
IsDue() collapses from the JST DayKey comparison boilerplate to a single
_calendar.ResetReady(viewer.LastLoginBonusClaimedAt) call.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 11:49:54 -04:00
gamer147
a6b2c47b98 refactor(pack): migrate PackController to IGameCalendarService
Swaps all four DateTime.UtcNow.Date same-day checks in PackController for
_calendar.ResetReady() — daily-single open, /pack/info daily-single gate,
FreePacks /pack/info visibility, FreePacks /pack/open quota. Removes the
TODO(daily-reset) comments; ToDto promoted from static to instance so it
can see _calendar.

Also fills in GameCalendarConfig.ShippedDefaults() (required by the
[ConfigSection] discovery in SVSimDbContext.EnsureSeedDataAsync — bootstrap
throws without it).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 11:49:17 -04:00
gamer147
90d420d97a feat(calendar): add IGameCalendarService — UTC reset-boundary primitive
New config-driven service that replaces JstPeriod. All timestamps are UTC;
one config value (GameCalendarConfig.DailyResetUtcHour, default 0 = midnight
UTC) drives both ResetReady checks and DayKey/WeekKey/MonthKey bucket keys.
Because both derive from the same MostRecentBoundary helper, they can never
disagree.

Prod parity is a config flip: set DailyResetUtcHour=17 in GameConfigs to get
the 02:00 JST boundary. No JST-flavored math anywhere in the code.

JstPeriod callsite migration + deletion follow in subsequent commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 11:46:12 -04:00
gamer147
81fe842c15 fix(pack): daily half-off single spends crystals and gates re-purchase
The DAILY child gacha (type_detail=3, is_daily_single=true) is the once-per-day
half-off crystal single-pack. Two bugs:

1. /pack/open Daily case was calling TrySpendAsync(Rupee) — decompiled client
   (GachaUI.cs:1046 CheckBuyPackWithCrystal, :1187 SetClystalConfirmDialog)
   confirms it's a crystal purchase. Freeplay mode masked the mismatch in
   testing. Swapped to Crystal + insufficient_crystals error.

2. /pack/info kept emitting is_daily_single=true after the viewer's first
   claim, so the client re-showed the half-off button and a second click
   400'd with daily_free_already_claimed. Added a per-parent-pack gate that
   flips is_daily_single false when LastDailyFreeAt.Date == today. The child
   entry itself stays so the CRYSTAL_MULTI full-price button still activates.

TODO(daily-reset) comments now reference docs/plans/daily-reset-service.md
(in the outer repo) — the current UTC-midnight comparison will migrate to
the JST 02:00 reset boundary via that plan.

Updated the existing daily test to use cost=50 and assert
Crystals: 200 -> 150 + Rupees untouched (previously used cost=0 so the
currency choice was undetectable). Added two /pack/info gate tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 11:23:51 -04:00
gamer147
4ce1cd172a feat(rank): AI-start SelfInfo carries viewer rank progress
/ai_*_rank_battle/start now reads ViewerRankProgress for the target format
and populates SelfInfo.rank / battlePoint / isMasterRank / masterPoint so
the pre-battle screen matches the viewer's real state. Read-only path uses
IRankProgressService.GetAsync (added in this commit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 10:25:29 -04:00
gamer147
7ff8a7bd92 feat(rank): wire RankProgressService into RankBattleController.Finish
Replaces the phase-3 zero stub. Real progression math now lands in the
response body (rank / after_battle_point / after_master_point /
battle_point / master_point). Format is authored by URL (not derived from
BattleContext) so /finish still works when hit without a prior
/do_matching — mirrors the client's URL-per-format Wizard/RankBattleFinishTask
dispatch (decompile lines 12-35).

3 existing tests updated with real progression assertions; 3 new tests
cover D-tier floor loss, format separation, and AI-variant persistence.
Full solution suite: 1583/1583 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 10:21:12 -04:00
gamer147
f18cfb58b8 feat(rank): viewer repo LoadForRankProgressAsync
Split-query load of Classes (for class XP) + RankProgress (for rank grant)
so RankBattleController.Finish uses a single viewer read. Test factory's
ReferenceDataImporter.ImportAllAsync already seeds RankInfo — no test
helper needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 10:16:10 -04:00
gamer147
4e87957306 feat(rank): RankProgressService — pure +100/-50 tier-floored math
Reads RankInfoEntry.LowerLimitPoint from ranks.csv (via IGlobalsRepository)
for main-tier floors — no hardcoded thresholds. Master (Point=50000) and
Grand Master (MP-based, floor 5000) branch handles the post-Master path.
GetAsync is a read-only snapshot used later by AI-start's SelfInfo.

13 unit tests cover first-win, loss floors (0, Beginner→D, D→D0), win
crossing 50000, MP awards at Master, GM promotion at MP≥5000, GM floor
protecting against Master demotion, format separation, Crossover throw.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 10:15:34 -04:00
gamer147
9f97837211 feat(rank): add ViewerRankProgress owned entity + migration
One row per (viewer, format) tracking accumulated Point + MasterPoint.
Unique index on (ViewerId, Format) enforces one row per format per viewer
(mirrors owned-collection precedent per project_owned_collection_unique_index).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 10:13:50 -04:00
gamer147
94ed6734c7 tune(battle-xp): raise defaults to XpPerWin=200 / XpPerLoss=50
Doubles the shipped defaults across all modes without touching per-mode
overrides. Curve interaction against classexp.csv (L1=50, L2=150):
- Win (200 XP): crosses both L1 + L2 → land at L3, Exp=0
- Loss (50 XP): exactly meets L1 → land at L2, Exp=0

Updates every affected test assertion (Practice, Rank, Free, TK2,
Colosseum, Story) for the new level/exp math.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 09:34:01 -04:00
gamer147
8199a95356 feat(battle-xp): wire story first-clear /finish through IBattleXpService
- StoryConfig.ClassXpPerClear retired; equivalent knob moves to
  BattleXpConfig.StoryXpPerClear (falls back to XpPerWin when null).
- StoryService.FinishAsync now grants class XP via IBattleXpService with
  BattleXpMode.Story on the firstClear && isPlayShape branch. Persists
  post-grant Level/Exp to Viewer.Classes.
- Wire fields (get_class_experience, class_level) stringified as before.
- StoryConfig kept as an empty [ConfigSection] for future story-specific
  knobs. StoryServiceTests updated to reflect the new default path
  (XpPerWin=100 crosses classexp.csv L1=50 → L2 with 50 carry).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 09:06:47 -04:00
gamer147
a03bff3ea7 feat(battle-xp): wire colosseum /finish through IBattleXpService, extend response DTO
- ColosseumBattleFinishResponseDto gains get_class_experience,
  class_experience, class_level. Client-safe: same BattleFinishResponsProcessing
  handles all three keys as ints (decomp verified).
- ArenaColosseumBattleController.FinishInternal grants win/loss XP via
  IBattleXpService with BattleXpMode.Colosseum for both /colosseum_battle/finish
  and /colosseum_rank_battle/finish URLs.
- Existing win test extended: XP wire assertions + persistence check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 09:02:25 -04:00
gamer147
91ec14fc7e feat(battle-xp): wire free-battle /finish through IBattleXpService
- FreeBattleController.Finish (rotation + unlimited URLs) now grants
  win/loss XP via IBattleXpService with BattleXpMode.Free.
- Preserves the strict field subset (no rank/master fields).
- Tests updated: win asserts XP+level-up; new loss test on rotation URL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 09:00:29 -04:00
gamer147
1e9693e56d feat(battle-xp): wire rank + AI-rank /finish through IBattleXpService
- RankBattleController.Finish (covers all 4 URLs: rotation, unlimited,
  ai_rotation, ai_unlimited) now grants win/loss XP via IBattleXpService
  with BattleXpMode.Rank. Uses req.ClassId from wire.
- Tests updated: AI-variant win asserts new XP values; consistency-loss
  asserts loss XP. Added persistence test on /unlimited_rank_battle/finish.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 08:59:01 -04:00
gamer147
c3da6079b2 feat(battle-xp): wire /practice/finish through IBattleXpService
- Practice finish now grants win/loss XP via IBattleXpService.GrantAsync
  with BattleXpMode.Practice. Response fields (get_class_experience,
  class_experience, class_level) reflect post-grant totals.
- Uses req.ClassId from wire; no BattleContext lookup needed.
- Test asserts default XpPerWin=100 crosses classexp.csv L1=50 threshold
  → Level=2 + Exp=50 carry, persists to Viewer.Classes.
- Added loss-XP test asserting default XpPerLoss=25 stays under threshold.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 08:56:59 -04:00
gamer147
f85f221bfa refactor(battle-xp): retire ArenaTwoPickConfig.ClassXpPerBattle for shared BattleXpService
- ArenaTwoPickService now delegates class-XP grants to IBattleXpService
  with BattleXpMode.ArenaTwoPick. Inline GrantClassXp/ResolveClassLevel
  helpers deleted.
- ClassXpPerBattle field removed from ArenaTwoPickConfig; TK2 XP now
  configurable via ArenaTwoPickXpPerWin/XpPerLoss overrides or the global
  BattleXpConfig defaults (100/25).
- Updated 5 test files (ArenaTwoPickService callers) for the new
  constructor arg. Added loss-XP assertion to Finish tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 08:54:52 -04:00
gamer147
81771bb0a0 feat(battle-xp): IBattleXpService with per-mode overrides and auto level-up
- IBattleXpService.GrantAsync mutates viewer.Classes in place; caller SaveChanges
- Resolves win/loss amount from per-mode override or global XpPerWin/XpPerLoss
- Story mode ignores isWin, uses StoryXpPerClear ?? XpPerWin
- Level-up loop consults curve[row.Level].NecessaryExp; carries overflow
- Saturates at max curve level (excess piles in Exp)
- Unknown classId → no-op returning (0, 0, 1), logs Warning
- 10 unit tests (Moq-backed curve stub); DI registered next to InventoryService
- GameConfigurationJsonbTests baseline updated (19 sections)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 08:51:43 -04:00
gamer147
4541378885 feat(battle-xp): config section + viewer loader for class XP grants
Adds BattleXpConfig ([ConfigSection("BattleXp")]) with global
XpPerWin/XpPerLoss plus per-mode nullable overrides. Adds BattleXpMode
enum and IViewerRepository.LoadForBattleXpGrantAsync — focused tracked
load with viewer.Classes + Class nav ref included, for the service
about to be introduced.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 08:44:11 -04:00
86 changed files with 8338 additions and 594 deletions

View File

@@ -1,13 +1,25 @@
namespace SVSim.BattleNode.Bridge; namespace SVSim.BattleNode.Bridge;
/// <summary> /// <summary>
/// DI-injected options for the battle node. NodeServerUrl matches the prod /// DI-injected options for the battle node.
/// do_matching wire format: <c>host:port/socket.io/</c>, no scheme prefix.
/// BestHTTP's SocketManager parses it as the Socket.IO v2 endpoint URL.
/// </summary> /// </summary>
public sealed class BattleNodeOptions public sealed class BattleNodeOptions
{ {
public string NodeServerUrl { get; set; } = "localhost:5148/socket.io/"; /// <summary>
/// The Socket.IO v2 endpoint URL echoed back to the client on <c>/*/do_matching</c>
/// success (matching_state 3004/3007/3011). Matches the prod do_matching wire format:
/// <c>host[:port]/socket.io/</c> — no scheme prefix, must end with <c>/socket.io/</c>.
/// The client's BestHTTP <c>SocketManager</c> parses this string directly; a leading
/// <c>http://</c>/<c>https://</c> or a missing trailing slash will make the client
/// fail to connect. Host may be an IP, hostname, or FQDN.
/// <para>
/// Deployment-time value — no hardcoded fallback. Must be provided via the
/// <c>"BattleNode:NodeServerUrl"</c> key in <c>appsettings.json</c> (or an
/// equivalently-named env var); <see cref="Hosting.BattleNodeExtensions.AddBattleNode"/>
/// validates presence at startup and throws if empty.
/// </para>
/// </summary>
public string NodeServerUrl { get; set; } = "";
/// <summary> /// <summary>
/// How long the first arriver's WS waits for a partner before disconnecting. /// How long the first arriver's WS waits for a partner before disconnecting.

View File

@@ -18,15 +18,26 @@ public static class BattleNodeExtensions
/// instance the WebSocket handler constructs on connect. /// instance the WebSocket handler constructs on connect.
/// </summary> /// </summary>
/// <param name="configure"> /// <param name="configure">
/// Optional callback to override <see cref="BattleNodeOptions"/> defaults. The default /// Callback to populate <see cref="BattleNodeOptions"/>. Must set
/// <c>NodeServerUrl</c> assumes the EmulatedEntrypoint host on /// <see cref="BattleNodeOptions.NodeServerUrl"/> — there is no hardcoded fallback; the
/// <c>http://localhost:5148</c> and shares the port for the Socket.IO endpoint. Override /// value is a deployment concern (localhost during dev, a real host[:port]/socket.io/
/// when the node runs on a different port/host or behind a reverse proxy. /// when the node runs behind a reverse proxy or on a separate box). Startup throws
/// <see cref="InvalidOperationException"/> if it's still empty after the callback runs.
/// </param> /// </param>
/// <exception cref="InvalidOperationException">
/// <see cref="BattleNodeOptions.NodeServerUrl"/> is null, empty, or whitespace after
/// <paramref name="configure"/> runs.
/// </exception>
public static IServiceCollection AddBattleNode(this IServiceCollection services, Action<BattleNodeOptions>? configure = null) public static IServiceCollection AddBattleNode(this IServiceCollection services, Action<BattleNodeOptions>? configure = null)
{ {
var options = new BattleNodeOptions(); var options = new BattleNodeOptions();
configure?.Invoke(options); configure?.Invoke(options);
if (string.IsNullOrWhiteSpace(options.NodeServerUrl))
{
throw new InvalidOperationException(
"BattleNode:NodeServerUrl is not configured. Set it in appsettings.json under " +
"the \"BattleNode\" section (format: \"host[:port]/socket.io/\", no scheme prefix).");
}
services.AddSingleton(options); services.AddSingleton(options);
services.AddSingleton<IBattleSessionStore, InMemoryBattleSessionStore>(); services.AddSingleton<IBattleSessionStore, InMemoryBattleSessionStore>();
services.AddSingleton<IMatchingBridge, MatchingBridge>(); services.AddSingleton<IMatchingBridge, MatchingBridge>();

View File

@@ -2381,6 +2381,7 @@
"is_pre_release": false, "is_pre_release": false,
"open_count_limit": 0, "open_count_limit": 0,
"sales_period_time": null, "sales_period_time": null,
"is_enabled": false,
"gacha_point": { "gacha_point": {
"exchangeable_point": 400, "exchangeable_point": 400,
"increase_gacha_point": 1 "increase_gacha_point": 1
@@ -2472,6 +2473,7 @@
"is_pre_release": false, "is_pre_release": false,
"open_count_limit": 0, "open_count_limit": 0,
"sales_period_time": null, "sales_period_time": null,
"is_enabled": false,
"gacha_point": { "gacha_point": {
"exchangeable_point": 400, "exchangeable_point": 400,
"increase_gacha_point": 1 "increase_gacha_point": 1
@@ -2563,6 +2565,7 @@
"is_pre_release": false, "is_pre_release": false,
"open_count_limit": 0, "open_count_limit": 0,
"sales_period_time": null, "sales_period_time": null,
"is_enabled": false,
"gacha_point": { "gacha_point": {
"exchangeable_point": 400, "exchangeable_point": 400,
"increase_gacha_point": 1 "increase_gacha_point": 1
@@ -2654,6 +2657,7 @@
"is_pre_release": false, "is_pre_release": false,
"open_count_limit": 0, "open_count_limit": 0,
"sales_period_time": null, "sales_period_time": null,
"is_enabled": false,
"gacha_point": { "gacha_point": {
"exchangeable_point": 400, "exchangeable_point": 400,
"increase_gacha_point": 1 "increase_gacha_point": 1
@@ -2745,6 +2749,7 @@
"is_pre_release": false, "is_pre_release": false,
"open_count_limit": 0, "open_count_limit": 0,
"sales_period_time": null, "sales_period_time": null,
"is_enabled": false,
"gacha_point": { "gacha_point": {
"exchangeable_point": 400, "exchangeable_point": 400,
"increase_gacha_point": 1 "increase_gacha_point": 1
@@ -2836,6 +2841,7 @@
"is_pre_release": false, "is_pre_release": false,
"open_count_limit": 0, "open_count_limit": 0,
"sales_period_time": null, "sales_period_time": null,
"is_enabled": false,
"gacha_point": { "gacha_point": {
"exchangeable_point": 400, "exchangeable_point": 400,
"increase_gacha_point": 1 "increase_gacha_point": 1
@@ -2923,6 +2929,7 @@
"is_pre_release": false, "is_pre_release": false,
"open_count_limit": 0, "open_count_limit": 0,
"sales_period_time": null, "sales_period_time": null,
"is_enabled": false,
"gacha_point": { "gacha_point": {
"exchangeable_point": 400, "exchangeable_point": 400,
"increase_gacha_point": 1 "increase_gacha_point": 1
@@ -3010,6 +3017,7 @@
"is_pre_release": false, "is_pre_release": false,
"open_count_limit": 0, "open_count_limit": 0,
"sales_period_time": null, "sales_period_time": null,
"is_enabled": false,
"gacha_point": { "gacha_point": {
"exchangeable_point": 400, "exchangeable_point": 400,
"increase_gacha_point": 1 "increase_gacha_point": 1
@@ -3097,6 +3105,7 @@
"is_pre_release": false, "is_pre_release": false,
"open_count_limit": 0, "open_count_limit": 0,
"sales_period_time": null, "sales_period_time": null,
"is_enabled": false,
"gacha_point": { "gacha_point": {
"exchangeable_point": 400, "exchangeable_point": 400,
"increase_gacha_point": 1 "increase_gacha_point": 1
@@ -3184,6 +3193,7 @@
"is_pre_release": false, "is_pre_release": false,
"open_count_limit": 0, "open_count_limit": 0,
"sales_period_time": null, "sales_period_time": null,
"is_enabled": false,
"gacha_point": { "gacha_point": {
"exchangeable_point": 400, "exchangeable_point": 400,
"increase_gacha_point": 1 "increase_gacha_point": 1
@@ -3715,7 +3725,7 @@
"pack_category": 1, "pack_category": 1,
"poster_type": 0, "poster_type": 0,
"commence_date": "2026-06-01 02:00:00", "commence_date": "2026-06-01 02:00:00",
"complete_date": "2026-07-01 01:59:59", "complete_date": "2030-12-31 23:59:59",
"sleeve_id": 5090001, "sleeve_id": 5090001,
"special_sleeve_id": 0, "special_sleeve_id": 0,
"override_draw_effect_pack_id": 80001, "override_draw_effect_pack_id": 80001,
@@ -3725,7 +3735,7 @@
"is_new": false, "is_new": false,
"is_pre_release": false, "is_pre_release": false,
"open_count_limit": 0, "open_count_limit": 0,
"sales_period_time": "2026-07-01 01:59:59", "sales_period_time": "2030-12-31 23:59:59",
"gacha_point": null, "gacha_point": null,
"child_gachas": [ "child_gachas": [
{ {
@@ -3846,7 +3856,7 @@
"pack_category": 1, "pack_category": 1,
"poster_type": 0, "poster_type": 0,
"commence_date": "2026-06-01 02:00:00", "commence_date": "2026-06-01 02:00:00",
"complete_date": "2026-07-01 01:59:59", "complete_date": "2030-12-31 23:59:59",
"sleeve_id": 5090001, "sleeve_id": 5090001,
"special_sleeve_id": 0, "special_sleeve_id": 0,
"override_draw_effect_pack_id": 90001, "override_draw_effect_pack_id": 90001,
@@ -3856,7 +3866,7 @@
"is_new": false, "is_new": false,
"is_pre_release": false, "is_pre_release": false,
"open_count_limit": 0, "open_count_limit": 0,
"sales_period_time": "2026-07-01 01:59:59", "sales_period_time": "2030-12-31 23:59:59",
"gacha_point": null, "gacha_point": null,
"child_gachas": [ "child_gachas": [
{ {

View File

@@ -3,6 +3,7 @@ using SVSim.Bootstrap.Models.Seed;
using SVSim.Database; using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Services;
namespace SVSim.Bootstrap.Importers; namespace SVSim.Bootstrap.Importers;
@@ -24,6 +25,22 @@ public class AchievementCatalogImporter
var existing = await context.AchievementCatalog var existing = await context.AchievementCatalog
.ToDictionaryAsync(e => (e.AchievementType, e.Level)); .ToDictionaryAsync(e => (e.AchievementType, e.Level));
// Fail fast on drift between seed event_type values and the code-side registry.
// Missing prefixes silently produce counters no emitter ever writes to; typos likewise.
var unregistered = seed
.Where(s => s.EventType is not null && !MissionEventKeys.IsRegistered(s.EventType))
.Select(s => s.EventType!)
.Distinct()
.OrderBy(x => x)
.ToList();
if (unregistered.Count > 0)
{
throw new InvalidOperationException(
"[AchievementCatalogImporter] seed rows reference unregistered event_type(s): "
+ string.Join(", ", unregistered)
+ ". Add the top-level prefix to MissionEventKeys.RegisteredPrefixes, or fix the seed.");
}
int created = 0, updated = 0; int created = 0, updated = 0;
var unmappedTypes = new HashSet<int>(); var unmappedTypes = new HashSet<int>();
foreach (var s in seed) foreach (var s in seed)

View File

@@ -3,6 +3,7 @@ using SVSim.Bootstrap.Models.Seed;
using SVSim.Database; using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Services;
namespace SVSim.Bootstrap.Importers; namespace SVSim.Bootstrap.Importers;
@@ -22,6 +23,21 @@ public class BattlePassMonthlyMissionImporter
return 0; return 0;
} }
// Fail fast on drift between seed event_type values and the code-side registry.
var unregistered = seed
.Where(s => s.EventType is not null && !MissionEventKeys.IsRegistered(s.EventType))
.Select(s => s.EventType!)
.Distinct()
.OrderBy(x => x)
.ToList();
if (unregistered.Count > 0)
{
throw new InvalidOperationException(
"[BattlePassMonthlyMissionImporter] seed rows reference unregistered event_type(s): "
+ string.Join(", ", unregistered)
+ ". Add the top-level prefix to MissionEventKeys.RegisteredPrefixes, or fix the seed.");
}
var existing = await context.BattlePassMonthlyMissions var existing = await context.BattlePassMonthlyMissions
.ToDictionaryAsync(e => (e.Year, e.Month, e.OrderNum)); .ToDictionaryAsync(e => (e.Year, e.Month, e.OrderNum));
int created = 0, updated = 0; int created = 0, updated = 0;

View File

@@ -3,6 +3,7 @@ using SVSim.Bootstrap.Models.Seed;
using SVSim.Database; using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Services;
namespace SVSim.Bootstrap.Importers; namespace SVSim.Bootstrap.Importers;
@@ -21,6 +22,21 @@ public class MissionCatalogImporter
return 0; return 0;
} }
// Fail fast on drift between seed event_type values and the code-side registry.
var unregistered = seed
.Where(s => s.EventType is not null && !MissionEventKeys.IsRegistered(s.EventType))
.Select(s => s.EventType!)
.Distinct()
.OrderBy(x => x)
.ToList();
if (unregistered.Count > 0)
{
throw new InvalidOperationException(
"[MissionCatalogImporter] seed rows reference unregistered event_type(s): "
+ string.Join(", ", unregistered)
+ ". Add the top-level prefix to MissionEventKeys.RegisteredPrefixes, or fix the seed.");
}
var existing = await context.MissionCatalog.ToDictionaryAsync(e => e.Id); var existing = await context.MissionCatalog.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0; int created = 0, updated = 0;
var unmapped = new List<int>(); var unmapped = new List<int>();

View File

@@ -0,0 +1,36 @@
namespace SVSim.Database.Enums;
/// <summary>
/// Rank-tier bucket names used by mission/achievement catalog rows keyed as
/// <c>rank_achieved:{tier}</c>. Boundaries pinned from ranks.csv:
/// <list type="bullet">
/// <item>1-4 = Beginner 0-3</item>
/// <item>5-8 = D0-D3</item>
/// <item>9-12 = C0-C3</item>
/// <item>13-16 = B0-B3</item>
/// <item>17-20 = A0-A3</item>
/// <item>21-24 = AA0-AA3</item>
/// <item>25 = Master</item>
/// <item>26-29 = Grand Master (G026-G029)</item>
/// </list>
/// The current prod catalog has no <c>rank_achieved:grand_master</c> rows, but the
/// emit is still faithful — a grand-master promotion advances the top-level
/// <c>rank_achieved</c> counter and the tier-qualified one (which no catalog row
/// reads yet, but stays consistent if one lands later).
/// </summary>
public static class RankTier
{
/// <summary>Wire rank_id → catalog-facing tier name. Null iff <paramref name="rankId"/> is out of range.</summary>
public static string? Name(int rankId) => rankId switch
{
>= 1 and <= 4 => "beginner",
>= 5 and <= 8 => "d",
>= 9 and <= 12 => "c",
>= 13 and <= 16 => "b",
>= 17 and <= 20 => "a",
>= 21 and <= 24 => "aa",
25 => "master",
>= 26 and <= 29 => "grand_master",
_ => null,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddViewerRankProgress : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ViewerRankProgress",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Format = table.Column<int>(type: "integer", nullable: false),
Point = table.Column<int>(type: "integer", nullable: false),
MasterPoint = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerRankProgress", x => new { x.ViewerId, x.Id });
table.ForeignKey(
name: "FK_ViewerRankProgress_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ViewerRankProgress_ViewerId_Format",
table: "ViewerRankProgress",
columns: new[] { "ViewerId", "Format" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ViewerRankProgress_ViewerId_Format",
table: "ViewerRankProgress");
migrationBuilder.DropTable(
name: "ViewerRankProgress");
}
}
}

View File

@@ -4837,6 +4837,34 @@ namespace SVSim.Database.Migrations
.HasForeignKey("ViewerId"); .HasForeignKey("ViewerId");
}); });
b.OwnsMany("SVSim.Database.Models.ViewerRankProgress", "RankProgress", b1 =>
{
b1.Property<long>("ViewerId")
.HasColumnType("bigint");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Format")
.HasColumnType("integer");
b1.Property<int>("MasterPoint")
.HasColumnType("integer");
b1.Property<int>("Point")
.HasColumnType("integer");
b1.HasKey("ViewerId", "Id");
b1.ToTable("ViewerRankProgress");
b1.WithOwner()
.HasForeignKey("ViewerId");
});
b.Navigation("BuildDeckPurchases"); b.Navigation("BuildDeckPurchases");
b.Navigation("Cards"); b.Navigation("Cards");
@@ -4868,6 +4896,8 @@ namespace SVSim.Database.Migrations
b.Navigation("PackStarterClasses"); b.Navigation("PackStarterClasses");
b.Navigation("RankProgress");
b.Navigation("SocialAccountConnections"); b.Navigation("SocialAccountConnections");
}); });

View File

@@ -10,7 +10,6 @@ public class ArenaTwoPickConfig
{ {
public int RewardScheduleId { get; set; } = 1; public int RewardScheduleId { get; set; } = 1;
public int ChallengeId { get; set; } = 1; public int ChallengeId { get; set; } = 1;
public int ClassXpPerBattle { get; set; } = 100;
public int SpotPointsPerBattle { get; set; } = 10; public int SpotPointsPerBattle { get; set; } = 10;
public double LegendaryRate { get; set; } = 0.06; public double LegendaryRate { get; set; } = 0.06;

View File

@@ -0,0 +1,28 @@
namespace SVSim.Database.Models.Config;
/// <summary>
/// Class XP awarded on battle finish. <see cref="XpPerWin"/> / <see cref="XpPerLoss"/>
/// are the global defaults; each per-mode nullable slot overrides them for that mode
/// when set. Story is clear-only (no loss variant) — <see cref="StoryXpPerClear"/>
/// falls back to <see cref="XpPerWin"/> when null.
/// </summary>
[ConfigSection("BattleXp")]
public class BattleXpConfig
{
public int XpPerWin { get; set; } = 200;
public int XpPerLoss { get; set; } = 50;
public int? PracticeXpPerWin { get; set; }
public int? PracticeXpPerLoss { get; set; }
public int? RankXpPerWin { get; set; }
public int? RankXpPerLoss { get; set; }
public int? FreeXpPerWin { get; set; }
public int? FreeXpPerLoss { get; set; }
public int? ArenaTwoPickXpPerWin { get; set; }
public int? ArenaTwoPickXpPerLoss { get; set; }
public int? ColosseumXpPerWin { get; set; }
public int? ColosseumXpPerLoss { get; set; }
public int? StoryXpPerClear { get; set; }
public static BattleXpConfig ShippedDefaults() => new();
}

View File

@@ -4,9 +4,9 @@ namespace SVSim.Database.Models.Config;
[ConfigSection("DefaultGrants")] [ConfigSection("DefaultGrants")]
public class DefaultGrantsConfig public class DefaultGrantsConfig
{ {
public ulong Crystals { get; set; } = 50000; public ulong Crystals { get; set; } = 0;
public ulong Rupees { get; set; } = 50000; public ulong Rupees { get; set; } = 0;
public ulong Ether { get; set; } = 50000; public ulong Ether { get; set; } = 0;
public static DefaultGrantsConfig ShippedDefaults() => new(); public static DefaultGrantsConfig ShippedDefaults() => new();
} }

View File

@@ -0,0 +1,20 @@
namespace SVSim.Database.Models.Config;
/// <summary>
/// Reset boundaries for time-windowed game state (daily missions, daily-single packs,
/// login bonuses). All timestamps in the codebase are UTC; this config only shifts
/// where the day boundary falls.
/// </summary>
[ConfigSection("GameCalendar")]
public class GameCalendarConfig
{
/// <summary>
/// UTC hour (0-23) at which the daily reset occurs. Default 0 = midnight UTC.
/// Prod parity: 17 (= 02:00 JST). Changing the value at runtime is safe for the
/// ResetReady check but retroactively misaligns any DB rows keyed by DayKey /
/// WeekKey / MonthKey — treat as a startup constant in production.
/// </summary>
public int DailyResetUtcHour { get; set; } = 0;
public static GameCalendarConfig ShippedDefaults() => new();
}

View File

@@ -1,9 +1,13 @@
namespace SVSim.Database.Models.Config; namespace SVSim.Database.Models.Config;
/// <summary>
/// Story-family placeholder config section. Class XP per clear moved to
/// <see cref="BattleXpConfig.StoryXpPerClear"/> (BattleXpMode.Story) as part of the
/// unified per-mode XP surface. Kept as an empty section so future story-specific
/// knobs (dialogue speed, auto-skip, etc.) have a home.
/// </summary>
[ConfigSection("Story")] [ConfigSection("Story")]
public class StoryConfig public class StoryConfig
{ {
public int ClassXpPerClear { get; set; } = 200;
public static StoryConfig ShippedDefaults() => new(); public static StoryConfig ShippedDefaults() => new();
} }

View File

@@ -35,7 +35,7 @@ public class Viewer : BaseEntity<long>
/// <summary> /// <summary>
/// UTC instant of the most recent daily-login-bonus claim. Null = never claimed. /// UTC instant of the most recent daily-login-bonus claim. Null = never claimed.
/// Compared against JST 02:00 day boundary via JstPeriod.DayKey to decide eligibility. /// Compared against the daily reset boundary via IGameCalendarService.ResetReady.
/// </summary> /// </summary>
public DateTime? LastLoginBonusClaimedAt { get; set; } public DateTime? LastLoginBonusClaimedAt { get; set; }
@@ -87,6 +87,8 @@ public class Viewer : BaseEntity<long>
public List<ViewerFreePackClaim> FreePackClaims { get; set; } = new List<ViewerFreePackClaim>(); public List<ViewerFreePackClaim> FreePackClaims { get; set; } = new List<ViewerFreePackClaim>();
public List<ViewerRankProgress> RankProgress { get; set; } = new List<ViewerRankProgress>();
public List<MyPageBgRotationEntry> MyPageBgRotation { get; set; } = new List<MyPageBgRotationEntry>(); public List<MyPageBgRotationEntry> MyPageBgRotation { get; set; } = new List<MyPageBgRotationEntry>();
public List<ViewerGachaPointBalance> GachaPointBalances { get; set; } = new List<ViewerGachaPointBalance>(); public List<ViewerGachaPointBalance> GachaPointBalances { get; set; } = new List<ViewerGachaPointBalance>();

View File

@@ -3,7 +3,8 @@ namespace SVSim.Database.Models;
/// <summary> /// <summary>
/// Per-viewer "how many times has this happened" counter. Composite PK /// Per-viewer "how many times has this happened" counter. Composite PK
/// (ViewerId, EventKey, Period). Period strings: "all-time", "month:YYYY-MM", /// (ViewerId, EventKey, Period). Period strings: "all-time", "month:YYYY-MM",
/// "week:YYYY-W##", "day:YYYY-MM-DD" — all JST-anchored with 02:00 day-boundary. /// "week:YYYY-W##", "day:YYYY-MM-DD" — all UTC, with the reset-window boundary
/// set by <c>GameCalendarConfig.DailyResetUtcHour</c>.
/// Single source of truth for total_count / done_number on every wire shape. /// Single source of truth for total_count / done_number on every wire shape.
/// </summary> /// </summary>
public class ViewerEventCounter public class ViewerEventCounter

View File

@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
/// <summary>
/// One row per (viewer, format) tracking accumulated rank points and master points.
/// Point is 0-based cumulative; the current rank_id is derived at read time from Point
/// (and MasterPoint once past 50000). Owned collection on <see cref="Viewer"/>.
/// </summary>
[Owned]
public class ViewerRankProgress
{
public Format Format { get; set; }
public int Point { get; set; }
public int MasterPoint { get; set; }
}

View File

@@ -51,6 +51,22 @@ public interface IViewerRepository
/// </summary> /// </summary>
Task<Models.Viewer?> LoadForMatchContextAsync(long viewerId); Task<Models.Viewer?> LoadForMatchContextAsync(long viewerId);
/// <summary>
/// Focused load for class-XP grants: viewer with owned <c>Classes</c> collection and
/// each <c>ViewerClassData.Class</c> nav ref included. Tracked (not AsNoTracking) so
/// the caller can mutate <c>Exp</c>/<c>Level</c> and <c>SaveChangesAsync</c>. Returns
/// null if the viewer does not exist.
/// </summary>
Task<Models.Viewer?> LoadForBattleXpGrantAsync(long viewerId, CancellationToken ct = default);
/// <summary>
/// Load a viewer with the joins <see cref="Controllers.RankBattleController"/>'s <c>Finish</c>
/// needs: Classes (for the class-XP grant) + RankProgress (for the rank grant). Split-query
/// per <c>project_ef_split_query</c> to avoid the cartesian explosion when both nav
/// collections are populated on the same viewer.
/// </summary>
Task<Models.Viewer?> LoadForRankProgressAsync(long viewerId, CancellationToken ct = default);
/// <summary>Sets Viewer.GuildId to <paramref name="guildId"/>. No-op if the viewer does not exist.</summary> /// <summary>Sets Viewer.GuildId to <paramref name="guildId"/>. No-op if the viewer does not exist.</summary>
Task SetGuildIdAsync(long viewerId, int guildId, CancellationToken ct = default); Task SetGuildIdAsync(long viewerId, int guildId, CancellationToken ct = default);
@@ -88,4 +104,12 @@ public interface IViewerRepository
/// Batch-loads owned guild-emblem ids for a viewer. Used by <c>/guild/emblem_list</c>. /// Batch-loads owned guild-emblem ids for a viewer. Used by <c>/guild/emblem_list</c>.
/// </summary> /// </summary>
Task<List<long>> GetEmblemListAsync(long viewerId, CancellationToken ct = default); Task<List<long>> GetEmblemListAsync(long viewerId, CancellationToken ct = default);
/// <summary>
/// Counts the viewer's unclaimed <see cref="Models.ViewerPresent"/> rows. Drives
/// <c>/mypage/index.unread_present_count</c>, which the client casts to
/// <c>Data.MyPage.data.unread_mail_count</c> to render the home-screen crate badge
/// (MyPageItemHome.cs:148 → SetUnreadGiftCount).
/// </summary>
Task<int> CountUnclaimedPresentsAsync(long viewerId, CancellationToken ct = default);
} }

View File

@@ -273,6 +273,24 @@ public class ViewerRepository : IViewerRepository
.FirstOrDefaultAsync(v => v.Id == viewerId); .FirstOrDefaultAsync(v => v.Id == viewerId);
} }
public Task<Models.Viewer?> LoadForBattleXpGrantAsync(long viewerId, CancellationToken ct = default)
{
return _dbContext.Set<Models.Viewer>()
.Include(v => v.Classes)
.ThenInclude(c => c.Class)
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
}
public Task<Models.Viewer?> LoadForRankProgressAsync(long viewerId, CancellationToken ct = default)
{
return _dbContext.Set<Models.Viewer>()
.Include(v => v.Classes)
.ThenInclude(c => c.Class)
.Include(v => v.RankProgress)
.AsSplitQuery()
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
}
public async Task SetGuildIdAsync(long viewerId, int guildId, CancellationToken ct = default) public async Task SetGuildIdAsync(long viewerId, int guildId, CancellationToken ct = default)
{ {
var viewer = await _dbContext.Set<Models.Viewer>() var viewer = await _dbContext.Set<Models.Viewer>()
@@ -378,6 +396,13 @@ public class ViewerRepository : IViewerRepository
return viewer?.Emblems.Select(e => (long)e.Id).ToList() ?? new List<long>(); return viewer?.Emblems.Select(e => (long)e.Id).ToList() ?? new List<long>();
} }
public Task<int> CountUnclaimedPresentsAsync(long viewerId, CancellationToken ct = default)
{
return _dbContext.Set<ViewerPresent>()
.Where(p => p.ViewerId == viewerId && p.Status == PresentStatus.Unclaimed)
.CountAsync(ct);
}
private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1) private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1)
{ {
Models.Viewer viewer = new Models.Viewer Models.Viewer viewer = new Models.Viewer

View File

@@ -0,0 +1,11 @@
namespace SVSim.Database.Services.BattleXp;
public enum BattleXpMode
{
Practice,
Rank,
Free,
ArenaTwoPick,
Colosseum,
Story,
}

View File

@@ -0,0 +1,80 @@
using Microsoft.Extensions.Logging;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Globals;
namespace SVSim.Database.Services.BattleXp;
public sealed class BattleXpService : IBattleXpService
{
private readonly IGlobalsRepository _globals;
private readonly IGameConfigService _config;
private readonly ILogger<BattleXpService> _log;
// Curve is immutable per boot; cache the first fetch.
private List<ClassExpEntry>? _cachedCurve;
public BattleXpService(IGlobalsRepository globals, IGameConfigService config, ILogger<BattleXpService> log)
{
_globals = globals;
_config = config;
_log = log;
}
public async Task<BattleXpGrantResult> GrantAsync(
Viewer viewer, int classId, bool isWin, BattleXpMode mode, CancellationToken ct = default)
{
var row = viewer.Classes.FirstOrDefault(c => c.Class.Id == classId);
if (row is null)
{
_log.LogWarning(
"BattleXpService: viewer {ViewerId} has no ViewerClassData for classId {ClassId}; skipping grant.",
viewer.Id, classId);
return new BattleXpGrantResult(0, 0, 1, LeveledUp: false);
}
int amount = ResolveAmount(mode, isWin);
row.Exp += amount;
var curve = _cachedCurve ??= await _globals.GetClassExpCurve();
// curve[level] semantics: XP required WHILE AT that level to reach the next
// (matching classexp.csv seed values and LoadController's client-facing shape).
var byLevel = curve.ToDictionary(e => e.Id, e => e.NecessaryExp);
int maxLevel = curve.Count == 0 ? row.Level : curve.Max(e => e.Id);
int startingLevel = row.Level;
while (row.Level < maxLevel
&& byLevel.TryGetValue(row.Level, out var needed)
&& row.Exp >= needed)
{
row.Exp -= needed;
row.Level += 1;
}
return new BattleXpGrantResult(amount, row.Exp, row.Level, LeveledUp: row.Level > startingLevel);
}
private int ResolveAmount(BattleXpMode mode, bool isWin)
{
var cfg = _config.Get<BattleXpConfig>();
if (mode == BattleXpMode.Story)
return cfg.StoryXpPerClear ?? cfg.XpPerWin;
int? overrideVal = (mode, isWin) switch
{
(BattleXpMode.Practice, true) => cfg.PracticeXpPerWin,
(BattleXpMode.Practice, false) => cfg.PracticeXpPerLoss,
(BattleXpMode.Rank, true) => cfg.RankXpPerWin,
(BattleXpMode.Rank, false) => cfg.RankXpPerLoss,
(BattleXpMode.Free, true) => cfg.FreeXpPerWin,
(BattleXpMode.Free, false) => cfg.FreeXpPerLoss,
(BattleXpMode.ArenaTwoPick, true) => cfg.ArenaTwoPickXpPerWin,
(BattleXpMode.ArenaTwoPick, false) => cfg.ArenaTwoPickXpPerLoss,
(BattleXpMode.Colosseum, true) => cfg.ColosseumXpPerWin,
(BattleXpMode.Colosseum, false) => cfg.ColosseumXpPerLoss,
_ => null,
};
return overrideVal ?? (isWin ? cfg.XpPerWin : cfg.XpPerLoss);
}
}

View File

@@ -0,0 +1,39 @@
using SVSim.Database.Models;
namespace SVSim.Database.Services.BattleXp;
/// <summary>
/// Amounts returned to callers after a class-XP grant. <see cref="TotalXp"/> and
/// <see cref="Level"/> are POST-grant, POST-level-up (matching the wire shape's
/// <c>class_experience</c> + <c>class_level</c> post-state semantics).
/// <see cref="LeveledUp"/> is <c>true</c> iff at least one level threshold was
/// crossed during this grant — callers gate <c>class_level_up</c> mission emits
/// on this flag rather than caching pre-state themselves.
/// </summary>
public sealed record BattleXpGrantResult(int GetXp, int TotalXp, int Level, bool LeveledUp);
public interface IBattleXpService
{
/// <summary>
/// Grants class XP for a battle finish. Caller supplies a viewer loaded via
/// <see cref="Repositories.Viewer.IViewerRepository.LoadForBattleXpGrantAsync"/>
/// (or equivalent, with <c>.Include(v =&gt; v.Classes).ThenInclude(c =&gt; c.Class)</c>).
/// Caller <c>SaveChangesAsync</c> after this returns.
/// <para>
/// Amount resolution: mode-specific config override if non-null, else global
/// <c>XpPerWin</c>/<c>XpPerLoss</c>. Story ignores <paramref name="isWin"/> (always
/// treated as a clear).
/// </para>
/// <para>
/// Level-up: loops on <c>ClassExpEntry</c> curve; <c>row.Exp</c> stores level-relative
/// XP and carries overflow after each level-up. Saturates at curve max level (excess
/// piles in <c>Exp</c>).
/// </para>
/// <para>
/// Guardrail: viewer has no <see cref="ViewerClassData"/> row for
/// <paramref name="classId"/> → returns <c>(0, 0, 1)</c>, no mutation, logs Warning.
/// </para>
/// </summary>
Task<BattleXpGrantResult> GrantAsync(
Viewer viewer, int classId, bool isWin, BattleXpMode mode, CancellationToken ct = default);
}

View File

@@ -82,3 +82,9 @@ public sealed record BattleParticipationContext(
int BattleType, int BattleType,
int DeckFormat, int DeckFormat,
int TwoPickType); int TwoPickType);
/// <summary>
/// Caller-relative friend flags for another viewer. Used to badge rows in guild-member,
/// invite-candidate, and join-request lists on the wire (<c>is_friend</c>, <c>is_friend_apply</c>).
/// </summary>
public sealed record FriendRelation(bool IsFriend, bool HasOutgoingApply);

View File

@@ -313,6 +313,36 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
} }
public async Task<IReadOnlyDictionary<long, FriendRelation>> GetFriendRelationsAsync(
long viewerId, IReadOnlyList<long> otherViewerIds, CancellationToken ct)
{
if (otherViewerIds.Count == 0)
return new Dictionary<long, FriendRelation>();
var idSet = otherViewerIds.Where(id => id != viewerId).Distinct().ToList();
var friendSet = idSet.Count == 0
? new HashSet<long>()
: (await _db.ViewerFriends.AsNoTracking()
.Where(f => f.OwnerViewerId == viewerId && idSet.Contains(f.FriendViewerId))
.Select(f => f.FriendViewerId)
.ToListAsync(ct)).ToHashSet();
var applySet = idSet.Count == 0
? new HashSet<long>()
: (await _db.ViewerFriendApplies.AsNoTracking()
.Where(a => a.FromViewerId == viewerId && idSet.Contains(a.ToViewerId))
.Select(a => a.ToViewerId)
.ToListAsync(ct)).ToHashSet();
var result = new Dictionary<long, FriendRelation>(otherViewerIds.Count);
foreach (var id in otherViewerIds)
{
result[id] = id == viewerId
? new FriendRelation(false, false)
: new FriendRelation(friendSet.Contains(id), applySet.Contains(id));
}
return result;
}
// --- helpers --- // --- helpers ---
private sealed record ViewerProjection( private sealed record ViewerProjection(

View File

@@ -27,4 +27,12 @@ public interface IFriendService
/// <summary>Deletes both directions of the friendship (A→B and B→A).</summary> /// <summary>Deletes both directions of the friendship (A→B and B→A).</summary>
Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct); Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct);
/// <summary>
/// Batched caller-relative friend-relation lookup. For each id in <paramref name="otherViewerIds"/>
/// returns whether the caller is already friends with them and/or has an outgoing pending apply
/// to them. Self and unknown ids resolve to <c>(false, false)</c>.
/// </summary>
Task<IReadOnlyDictionary<long, FriendRelation>> GetFriendRelationsAsync(
long viewerId, IReadOnlyList<long> otherViewerIds, CancellationToken ct);
} }

View File

@@ -0,0 +1,253 @@
using SVSim.Database.Enums;
namespace SVSim.Database.Services;
/// <summary>
/// Registry of every string that can appear as <c>ViewerEventCounter.EventKey</c> or
/// mission/achievement <c>EventType</c>. Two goals:
///
/// <list type="number">
/// <item>Give emitters (controllers) a single named entry point per event family so no
/// controller inlines the string form.</item>
/// <item>Give the catalog seed importers a validation set — every <c>event_type</c> in a
/// seed row must start with a prefix registered here, else the importer throws at bootstrap.
/// Prevents silent drift between catalog data and emitter code (e.g. a mission that
/// references <c>practice_wln</c> and never advances).</item>
/// </list>
///
/// Format convention: colon-hierarchical, most-general first. Callers emit multiple levels
/// so a single event increments every level of counter the catalog might reference.
/// </summary>
public static class MissionEventKeys
{
// ---- Top-level catalog prefixes (12) — 1:1 with seed JSON event_type strings ----
public const string PracticeWin = "practice_win";
public const string RankedWin = "ranked_win";
public const string RankedOrArenaWin = "ranked_or_arena_win";
public const string DailyMatchWin = "daily_match_win";
public const string StoryChapterFinish = "story_chapter_finish";
public const string ClassLevelUp = "class_level_up";
public const string RankAchieved = "rank_achieved";
public const string ChallengePlay = "challenge_play";
public const string ChallengeWin = "challenge_win";
public const string ChallengeFullClear = "challenge_full_clear";
public const string PlayFollowers = "play_followers";
public const string PrivateMatchDistinctOpponent = "private_match_distinct_opponent";
// ---- Item purchase (per-catalog-entry) ----
public const string ItemPurchasePrefix = "item_purchase";
public static string ItemPurchase(int catalogId) => $"{ItemPurchasePrefix}:{catalogId}";
// ---- Practice hierarchical builders ----
public static class Practice
{
/// <summary>
/// Wire <c>difficulty</c> → tier name used in catalog rows. Values 4/6/7 correspond to
/// Elite / Elite 2 / Elite 3 respectively (verified via
/// practicetext.json + practice-opponents.json + practice_ai_setting.csv cross-reference).
/// Other CSV difficulty values (0, 2, 3, 5, 101-109) have no achievement catalog rows —
/// null return means "emit only the top-level counter."
/// </summary>
public static string? TierName(int wireDifficulty) => wireDifficulty switch
{
4 => "elite",
6 => "elite2",
7 => "elite3",
_ => null,
};
/// <summary>
/// Wire <c>enemy_class_id</c> → leader name used in catalog rows. Class ordering
/// matches the <c>CardClass</c> enum (1=Forestcraft/Arisa, ..., 8=Portalcraft/Yuwan).
/// </summary>
public static string? LeaderName(int enemyClassId) => enemyClassId switch
{
1 => "arisa",
2 => "erika",
3 => "isabelle",
4 => "rowen",
5 => "luna",
6 => "urias",
7 => "eris",
8 => "yuwan",
_ => null,
};
/// <summary>
/// Emits <c>practice_win</c> plus any hierarchical variants whose parts resolve. If the
/// wire values don't resolve to a known tier or leader, that level is skipped — the
/// lifetime counter still advances.
/// </summary>
public static IReadOnlyList<string> WinAll(int wireDifficulty, int enemyClassId)
{
var tier = TierName(wireDifficulty);
var leader = LeaderName(enemyClassId);
var list = new List<string>(3) { PracticeWin };
if (tier is not null)
list.Add($"{PracticeWin}:{tier}");
if (tier is not null && leader is not null)
list.Add($"{PracticeWin}:{tier}:{leader}");
return list;
}
}
// ---- Story hierarchical builders ----
public static class Story
{
/// <summary>
/// Emits <c>story_chapter_finish</c> plus <c>:{family}</c> plus <c>:{family}:{storyId}</c>.
/// <paramref name="family"/> is the low-cardinality family label (<c>main</c>,
/// <c>limited</c>, <c>event</c>, ...) resolved from <c>StoryApiType</c> upstream.
/// </summary>
public static IReadOnlyList<string> ChapterFinishAll(string family, long storyId) => new[]
{
StoryChapterFinish,
$"{StoryChapterFinish}:{family}",
$"{StoryChapterFinish}:{family}:{storyId}",
};
}
// ---- Class-name mapping (shared by Ranked and ClassLevel families) ----
public static class Class
{
/// <summary>
/// Wire <c>class_id</c> 1-8 → catalog-facing craft name. Ordering matches the
/// <see cref="SVSim.BattleNode.Bridge.CardClass"/> enum (1=Forestcraft ... 8=Portalcraft).
/// Duplicated as a string switch rather than reused from the enum's <c>ToString()</c>
/// because the catalog uses lowercase — an accidental case change would silently break
/// counter alignment.
/// </summary>
public static string? Name(int classId) => classId switch
{
1 => "forestcraft",
2 => "swordcraft",
3 => "runecraft",
4 => "dragoncraft",
5 => "shadowcraft",
6 => "bloodcraft",
7 => "havencraft",
8 => "portalcraft",
_ => null,
};
}
// ---- Ranked / Free / Challenge hierarchical builders ----
public static class Ranked
{
/// <summary>
/// Rank-battle win: emits <c>ranked_win</c>, the class-qualified variant, and the two
/// aggregate keys (<c>ranked_or_arena_win</c>, <c>daily_match_win</c>) that every
/// ranked/arena win advances.
/// </summary>
public static IReadOnlyList<string> WinAll(int classId)
{
var list = new List<string>(4)
{
RankedWin,
RankedOrArenaWin,
DailyMatchWin,
};
if (Class.Name(classId) is { } name)
list.Add($"{RankedWin}:{name}");
return list;
}
}
public static class Free
{
/// <summary>
/// Unranked/free-battle win: only the two aggregates. There is no <c>free_win</c>
/// catalog prefix — free battles just count toward "any match" mission lines.
/// </summary>
public static IReadOnlyList<string> WinAll() => new[] { RankedOrArenaWin, DailyMatchWin };
}
public static class Challenge
{
/// <summary>Any TK2 match finish (win OR loss). Advances <c>challenge_play</c>.</summary>
public static IReadOnlyList<string> MatchPlayAll() => new[] { ChallengePlay };
/// <summary>
/// TK2 match win: fires both <c>challenge_win</c> and <c>challenge_play</c> (a win is
/// also a play) plus the two aggregates. Keeping <c>challenge_play</c> in this list
/// makes the invariant "always increment play, additionally increment win" obvious.
/// </summary>
public static IReadOnlyList<string> MatchWinAll() => new[]
{
ChallengeWin, ChallengePlay, RankedOrArenaWin, DailyMatchWin,
};
/// <summary>TK2 run ended with all 5 wins — advances <c>challenge_full_clear</c>.</summary>
public static IReadOnlyList<string> FullClearAll() => new[] { ChallengeFullClear };
}
// ---- ClassLevel / Rank hierarchical builders ----
public static class ClassLevel
{
/// <summary>
/// Class went up at least one level in this battle. Callsite must gate on
/// <c>BattleXpGrantResult.LeveledUp</c> — this method doesn't check.
/// </summary>
public static IReadOnlyList<string> UpAll(int classId)
{
var list = new List<string>(2) { ClassLevelUp };
if (Class.Name(classId) is { } name)
list.Add($"{ClassLevelUp}:{name}");
return list;
}
}
public static class Rank
{
/// <summary>
/// Viewer's rank crossed into a new tier. Callsite must gate on
/// <c>RankProgressResult.TierAdvanced</c>. Tier name comes from
/// <see cref="RankTier.Name(int)"/>.
/// </summary>
public static IReadOnlyList<string> AchievedAll(int rankId)
{
var list = new List<string>(2) { RankAchieved };
if (RankTier.Name(rankId) is { } name)
list.Add($"{RankAchieved}:{name}");
return list;
}
}
// ---- Seed-import validation ----
private static readonly IReadOnlySet<string> _registeredPrefixes = new HashSet<string>
{
PracticeWin, RankedWin, RankedOrArenaWin, DailyMatchWin, StoryChapterFinish,
ClassLevelUp, RankAchieved, ChallengePlay, ChallengeWin, ChallengeFullClear,
PlayFollowers, PrivateMatchDistinctOpponent,
ItemPurchasePrefix,
};
/// <summary>
/// True iff <paramref name="eventType"/> is a registered top-level prefix or a hierarchical
/// extension of one (<c>prefix</c> alone, or <c>prefix:qualifier</c>). Called by the
/// achievement/mission catalog importers to catch drift between seed data and code.
/// </summary>
public static bool IsRegistered(string eventType)
{
foreach (var prefix in _registeredPrefixes)
{
if (eventType == prefix) return true;
if (eventType.Length > prefix.Length + 1
&& eventType[prefix.Length] == ':'
&& eventType.AsSpan(0, prefix.Length).SequenceEqual(prefix))
return true;
}
return false;
}
/// <summary>Exposed for test assertions; do NOT mutate.</summary>
public static IReadOnlySet<string> RegisteredPrefixes => _registeredPrefixes;
}

View File

@@ -0,0 +1,55 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Database.Services.RankProgress;
/// <summary>
/// Wire-shape result of a single rank-battle finish grant. The wire-mapped fields
/// (<see cref="Rank"/>, <see cref="AfterBattlePoint"/>, <see cref="AfterMasterPoint"/>,
/// <see cref="BattlePoint"/>, <see cref="MasterPoint"/>, <see cref="IsMasterRank"/>,
/// <see cref="IsGrandMasterRank"/>) map 1:1 to <c>RankBattleFinishResponseDto</c>
/// keys the client reads via GetValueOrDefault in Wizard/RankBattleFinishTask.cs:57-63.
/// <see cref="TierAdvanced"/> is a controller-side signal for the rank_achieved mission
/// emit and is not serialized to the wire.
/// </summary>
/// <param name="Rank">rank_id post-grant (1..29).</param>
/// <param name="AfterBattlePoint">Point post-grant (accumulated).</param>
/// <param name="AfterMasterPoint">MasterPoint post-grant (accumulated).</param>
/// <param name="BattlePoint">Signed Point delta (+100/-50/0).</param>
/// <param name="MasterPoint">Signed MasterPoint delta.</param>
/// <param name="IsMasterRank">True iff Rank == 25.</param>
/// <param name="IsGrandMasterRank">True iff Rank &gt;= 26.</param>
/// <param name="TierAdvanced">
/// True iff this grant crossed the viewer into a higher <see cref="RankTier"/> bucket
/// than they held pre-grant. Callsites gate rank_achieved mission emits on this flag.
/// </param>
public sealed record RankProgressResult(
int Rank,
int AfterBattlePoint,
int AfterMasterPoint,
int BattlePoint,
int MasterPoint,
bool IsMasterRank,
bool IsGrandMasterRank,
bool TierAdvanced = false);
public interface IRankProgressService
{
/// <summary>
/// Applies a +100 (win) or -50 (loss) delta to the viewer's rank progression in the
/// given format, respecting tier floors from <c>ranks.csv</c>'s LowerLimitPoint column.
/// Creates a ViewerRankProgress row for (viewer, format) if none exists. Caller must
/// have loaded the viewer with <c>.Include(v =&gt; v.RankProgress)</c> and must call
/// <c>SaveChangesAsync</c> after this returns.
/// </summary>
/// <param name="format">Must be Format.Rotation or Format.Unlimited.</param>
Task<RankProgressResult> GrantAsync(
Viewer viewer, Format format, bool isWin, CancellationToken ct = default);
/// <summary>
/// Read-only current progression snapshot for (viewer, format). Returns a zero-value
/// result if no row exists. Does not mutate the viewer or hit the DB.
/// </summary>
Task<RankProgressResult> GetAsync(
Viewer viewer, Format format, CancellationToken ct = default);
}

View File

@@ -0,0 +1,164 @@
using Microsoft.Extensions.Logging;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals;
namespace SVSim.Database.Services.RankProgress;
public sealed class RankProgressService : IRankProgressService
{
// Simple constants for v1. Promote to a [ConfigSection("RankProgress")] if per-mode
// tuning is needed later (mirror BattleXpConfig's shape).
public const int PointsPerWin = 100;
public const int PointsPerLoss = 50;
private const int MasterRankId = 25;
private const int FirstGrandMasterRankId = 26;
private readonly IGlobalsRepository _globals;
private readonly ILogger<RankProgressService> _log;
private Dictionary<int, RankInfoEntry>? _byId;
public RankProgressService(IGlobalsRepository globals, ILogger<RankProgressService> log)
{
_globals = globals;
_log = log;
}
public async Task<RankProgressResult> GrantAsync(
Viewer viewer, Format format, bool isWin, CancellationToken ct = default)
{
EnsureSupportedFormat(format);
var byId = await LoadRanksAsync();
var row = viewer.RankProgress.FirstOrDefault(p => p.Format == format);
if (row is null)
{
row = new ViewerRankProgress { Format = format, Point = 0, MasterPoint = 0 };
viewer.RankProgress.Add(row);
}
// Snapshot pre-tier for the TierAdvanced signal — compared to post-tier below.
string? preTier = RankTier.Name(CurrentRankId(row, byId));
int deltaPoint = 0;
int deltaMp = 0;
int masterFloor = byId[MasterRankId].LowerLimitPoint; // 50000
if (isWin)
{
if (row.Point < masterFloor)
{
deltaPoint = PointsPerWin;
row.Point += PointsPerWin;
}
else
{
deltaMp = PointsPerWin;
row.MasterPoint += PointsPerWin;
}
}
else
{
if (row.MasterPoint > 0)
{
// Once you're at Grand Master (rank_id >= 26), MP can never drop back to
// Master. Floor = the Master→GM0 threshold from ranks.csv (5000).
int rankBeforeDemotion = CurrentRankId(row, byId);
int mpFloor = rankBeforeDemotion >= FirstGrandMasterRankId
? byId[MasterRankId].AccumulateMasterPoint
: 0;
int newMp = Math.Max(row.MasterPoint - PointsPerLoss, mpFloor);
deltaMp = newMp - row.MasterPoint;
row.MasterPoint = newMp;
}
else
{
int currentRank = CurrentRankId(row, byId);
int floor = byId[currentRank].LowerLimitPoint;
int newPoint = Math.Max(row.Point - PointsPerLoss, floor);
deltaPoint = newPoint - row.Point;
row.Point = newPoint;
}
}
int finalRank = CurrentRankId(row, byId);
string? postTier = RankTier.Name(finalRank);
// Tier "advanced" only on a promotion (pre != post AND both resolve). Demotion by
// point loss would technically change the tier string too, but rank_achieved is
// an achievement — it doesn't fire on going backward.
bool tierAdvanced = isWin && preTier != postTier && postTier is not null && preTier is not null;
return new RankProgressResult(
Rank: finalRank,
AfterBattlePoint: row.Point,
AfterMasterPoint: row.MasterPoint,
BattlePoint: deltaPoint,
MasterPoint: deltaMp,
IsMasterRank: finalRank == MasterRankId,
IsGrandMasterRank: finalRank >= FirstGrandMasterRankId,
TierAdvanced: tierAdvanced);
}
public async Task<RankProgressResult> GetAsync(
Viewer viewer, Format format, CancellationToken ct = default)
{
var byId = await LoadRanksAsync();
var row = viewer.RankProgress.FirstOrDefault(p => p.Format == format)
?? new ViewerRankProgress { Format = format };
int rank = CurrentRankId(row, byId);
return new RankProgressResult(
Rank: rank,
AfterBattlePoint: row.Point,
AfterMasterPoint: row.MasterPoint,
BattlePoint: 0,
MasterPoint: 0,
IsMasterRank: rank == MasterRankId,
IsGrandMasterRank: rank >= FirstGrandMasterRankId);
}
private static void EnsureSupportedFormat(Format format)
{
if (format != Format.Rotation && format != Format.Unlimited)
throw new ArgumentOutOfRangeException(nameof(format),
$"RankProgressService supports only Rotation/Unlimited, got {format}.");
}
/// <summary>
/// Highest rank_id whose entry conditions the viewer meets.
/// - MP &gt;= previous rank's AccumulateMasterPoint threshold → GM tier (26..29)
/// - Point &gt;= 50000 (MasterRankId.LowerLimitPoint) → 25 (Master)
/// - Otherwise: smallest rank_id in 1..24 where Point &lt; AccumulatePoint.
/// </summary>
private static int CurrentRankId(ViewerRankProgress row, Dictionary<int, RankInfoEntry> byId)
{
for (int gm = 29; gm >= FirstGrandMasterRankId; gm--)
{
int threshold = byId[gm - 1].AccumulateMasterPoint;
if (threshold > 0 && row.MasterPoint >= threshold)
return gm;
}
if (row.Point >= byId[MasterRankId].LowerLimitPoint)
return MasterRankId;
for (int r = 1; r <= 24; r++)
{
if (row.Point < byId[r].AccumulatePoint) return r;
}
return 24; // guardrail; shouldn't be reachable.
}
private async Task<Dictionary<int, RankInfoEntry>> LoadRanksAsync()
{
if (_byId is not null) return _byId;
var rows = await _globals.GetRankInfo();
if (rows.Count == 0)
{
_log.LogWarning("RankProgressService: RankInfo table is empty; grant will no-op.");
}
_byId = rows.ToDictionary(r => r.Id);
return _byId;
}
}

View File

@@ -0,0 +1,17 @@
namespace SVSim.EmulatedEntrypoint.Configuration;
/// <summary>
/// Config for the /admin/* util endpoints. Bound from the "Admin" section of appsettings.
/// </summary>
public class AdminOptions
{
public const string SectionName = "Admin";
/// <summary>
/// Shared secret required in the <c>X-Admin-Secret</c> header on protected admin endpoints
/// (see <see cref="Infrastructure.RequireAdminSecretAttribute"/>). Empty / whitespace means
/// the endpoint is disabled — the filter fails closed so an unconfigured deployment never
/// exposes the admin surface.
/// </summary>
public string ImportSecret { get; set; } = string.Empty;
}

View File

@@ -16,21 +16,25 @@ using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers; namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary> /// <summary>
/// Util endpoints for bootstrapping the dev environment. Anonymous-allowed today — security /// Util endpoints for bootstrapping the dev environment. Actions are gated by
/// audit pending (don't expose these to the public internet). /// <see cref="RequireAdminSecretAttribute"/>: callers must send the shared secret from
/// <c>Admin:ImportSecret</c> in the <c>X-Admin-Secret</c> header. Missing/blank config
/// disables the endpoint (fail-closed).
/// </summary> /// </summary>
public class AdminController : SVSimController public class AdminController : SVSimController
{ {
private readonly IViewerRepository _viewerRepository; private readonly IViewerRepository _viewerRepository;
private readonly SVSimDbContext _dbContext; private readonly SVSimDbContext _dbContext;
private readonly ILogger<AdminController> _logger; private readonly ILogger<AdminController> _logger;
private readonly IGameCalendarService _calendar;
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext, public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext,
ILogger<AdminController> logger) ILogger<AdminController> logger, IGameCalendarService calendar)
{ {
_viewerRepository = viewerRepository; _viewerRepository = viewerRepository;
_dbContext = dbContext; _dbContext = dbContext;
_logger = logger; _logger = logger;
_calendar = calendar;
} }
/// <summary> /// <summary>
@@ -39,6 +43,7 @@ public class AdminController : SVSimController
/// Only essential fields are imported today — extend as needed. /// Only essential fields are imported today — extend as needed.
/// </summary> /// </summary>
[AllowAnonymous] [AllowAnonymous]
[RequireAdminSecret]
[HttpPost("import_viewer")] [HttpPost("import_viewer")]
public async Task<ActionResult<ImportViewerResponse>> ImportViewer(ImportViewerRequest request) public async Task<ActionResult<ImportViewerResponse>> ImportViewer(ImportViewerRequest request)
{ {
@@ -262,11 +267,17 @@ public class AdminController : SVSimController
{ {
viewer.MissionData ??= new ViewerMissionData(); viewer.MissionData ??= new ViewerMissionData();
if (meta.HasReceivedPickTwoMission is { } hrptm) if (meta.HasReceivedPickTwoMission is { } hrptm)
viewer.MissionData.HasReceivedPickTwoMission = hrptm; viewer.MissionData.HasReceivedPickTwoMission = hrptm != 0;
if (meta.MissionReceiveType is { } mrt) if (meta.MissionReceiveType is { } mrt)
viewer.MissionData.MissionReceiveType = mrt; viewer.MissionData.MissionReceiveType = mrt;
if (meta.MissionChangeTime is { } mct) if (meta.MissionChangeTime is { } mct
viewer.MissionData.MissionChangeTime = DateTimeOffset.FromUnixTimeSeconds(mct).UtcDateTime; && DateTime.TryParseExact(mct, "yyyy-MM-dd HH:mm:ss",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out var mctParsed))
{
viewer.MissionData.MissionChangeTime = mctParsed;
}
} }
// Pass B: Missions + ViewerEventCounter // Pass B: Missions + ViewerEventCounter
@@ -610,13 +621,13 @@ public class AdminController : SVSimController
} }
// TODO: unify with MissionAssembler.cs — same logic duplicated here. // TODO: unify with MissionAssembler.cs — same logic duplicated here.
private static (string EventKey, string Period)? ResolveMissionCounter(MissionCatalogEntry catalog, DateTimeOffset nowUtc) private (string EventKey, string Period)? ResolveMissionCounter(MissionCatalogEntry catalog, DateTimeOffset nowUtc)
{ {
if (string.IsNullOrEmpty(catalog.EventType)) return null; if (string.IsNullOrEmpty(catalog.EventType)) return null;
var period = catalog.LotType switch var period = catalog.LotType switch
{ {
6 => JstPeriod.DayKey(nowUtc), 6 => _calendar.DayKey(nowUtc),
2 => JstPeriod.WeekKey(nowUtc), 2 => _calendar.WeekKey(nowUtc),
_ => null _ => null
}; };
if (period is null) return null; if (period is null) return null;
@@ -627,7 +638,7 @@ public class AdminController : SVSimController
private static (string EventKey, string Period)? ResolveAchievementCounter(AchievementCatalogEntry catalog) private static (string EventKey, string Period)? ResolveAchievementCounter(AchievementCatalogEntry catalog)
{ {
if (string.IsNullOrEmpty(catalog.EventType)) return null; if (string.IsNullOrEmpty(catalog.EventType)) return null;
return (catalog.EventType!, JstPeriod.AllTime); return (catalog.EventType!, GameCalendarPeriods.AllTime);
} }
/// <summary> /// <summary>

View File

@@ -2,7 +2,10 @@ using System.Text.Json;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Bridge;
using SVSim.Database;
using SVSim.Database.Repositories.Viewer; using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.Database.Services.BattleXp;
using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Matching; using SVSim.EmulatedEntrypoint.Matching;
using SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum; using SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum;
@@ -36,6 +39,10 @@ public sealed class ArenaColosseumBattleController : ControllerBase
private readonly IMatchingResolver _resolver; private readonly IMatchingResolver _resolver;
private readonly IArenaColosseumRunRepository _runs; private readonly IArenaColosseumRunRepository _runs;
private readonly IColosseumProgressionService _progression; private readonly IColosseumProgressionService _progression;
private readonly IViewerRepository _viewers;
private readonly IBattleXpService _xp;
private readonly IMissionProgressService _missionProgress;
private readonly SVSimDbContext _db;
private readonly ILogger<ArenaColosseumBattleController> _log; private readonly ILogger<ArenaColosseumBattleController> _log;
public ArenaColosseumBattleController( public ArenaColosseumBattleController(
@@ -43,12 +50,20 @@ public sealed class ArenaColosseumBattleController : ControllerBase
IMatchingResolver resolver, IMatchingResolver resolver,
IArenaColosseumRunRepository runs, IArenaColosseumRunRepository runs,
IColosseumProgressionService progression, IColosseumProgressionService progression,
IViewerRepository viewers,
IBattleXpService xp,
IMissionProgressService missionProgress,
SVSimDbContext db,
ILogger<ArenaColosseumBattleController> log) ILogger<ArenaColosseumBattleController> log)
{ {
_ctxBuilder = ctxBuilder; _ctxBuilder = ctxBuilder;
_resolver = resolver; _resolver = resolver;
_runs = runs; _runs = runs;
_progression = progression; _progression = progression;
_viewers = viewers;
_xp = xp;
_missionProgress = missionProgress;
_db = db;
_log = log; _log = log;
} }
@@ -162,10 +177,35 @@ public sealed class ArenaColosseumBattleController : ControllerBase
await _runs.UpsertAsync(run); await _runs.UpsertAsync(run);
} }
int gainXp = 0, totalXp = 0, level = 1;
bool leveledUp = false;
var viewer = await _viewers.LoadForBattleXpGrantAsync(vid, ct);
if (viewer is not null)
{
var xp = await _xp.GrantAsync(viewer, req.ClassId, req.BattleResult == 1, BattleXpMode.Colosseum, ct);
await _db.SaveChangesAsync(ct);
gainXp = xp.GetXp;
totalXp = xp.TotalXp;
level = xp.Level == 0 ? 1 : xp.Level;
leveledUp = xp.LeveledUp;
}
if (leveledUp)
{
await _missionProgress.RecordEventAsync(
vid, MissionEventKeys.ClassLevel.UpAll(req.ClassId), ct: ct);
}
// result_code 3502 ("battle already finished") is the idempotent-retry tolerance per // result_code 3502 ("battle already finished") is the idempotent-retry tolerance per
// FinishTaskBase.IsEffectiveErrorCode. Server emits standard data; the translation // FinishTaskBase.IsEffectiveErrorCode. Server emits standard data; the translation
// middleware sets the wire result_code via data_headers from the response envelope. // middleware sets the wire result_code via data_headers from the response envelope.
return Ok(new ColosseumBattleFinishResponseDto { BattleResult = req.BattleResult }); return Ok(new ColosseumBattleFinishResponseDto
{
BattleResult = req.BattleResult,
GetClassExperience = gainXp,
ClassExperience = totalXp,
ClassLevel = level,
});
} }
private static List<int> ParseIntList(string json) => private static List<int> ParseIntList(string json) =>

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Bridge;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Services;
using SVSim.Database.Services.Friend; using SVSim.Database.Services.Friend;
using SVSim.Database.Services.Replay; using SVSim.Database.Services.Replay;
using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Extensions;
@@ -20,6 +21,7 @@ public class ArenaTwoPickBattleController : SVSimController
private readonly IBattleContextStore _battleContextStore; private readonly IBattleContextStore _battleContextStore;
private readonly IBattleHistoryWriter _historyWriter; private readonly IBattleHistoryWriter _historyWriter;
private readonly IPlayedTogetherWriter _playedTogetherWriter; private readonly IPlayedTogetherWriter _playedTogetherWriter;
private readonly IMissionProgressService _missionProgress;
public ArenaTwoPickBattleController( public ArenaTwoPickBattleController(
IArenaTwoPickService svc, IArenaTwoPickService svc,
@@ -27,7 +29,8 @@ public class ArenaTwoPickBattleController : SVSimController
IMatchingResolver resolver, IMatchingResolver resolver,
IBattleContextStore battleContextStore, IBattleContextStore battleContextStore,
IBattleHistoryWriter historyWriter, IBattleHistoryWriter historyWriter,
IPlayedTogetherWriter playedTogetherWriter) IPlayedTogetherWriter playedTogetherWriter,
IMissionProgressService missionProgress)
{ {
_svc = svc; _svc = svc;
_matchContextBuilder = matchContextBuilder; _matchContextBuilder = matchContextBuilder;
@@ -35,6 +38,7 @@ public class ArenaTwoPickBattleController : SVSimController
_battleContextStore = battleContextStore; _battleContextStore = battleContextStore;
_historyWriter = historyWriter; _historyWriter = historyWriter;
_playedTogetherWriter = playedTogetherWriter; _playedTogetherWriter = playedTogetherWriter;
_missionProgress = missionProgress;
} }
[HttpPost("do_matching")] [HttpPost("do_matching")]
@@ -117,6 +121,19 @@ public class ArenaTwoPickBattleController : SVSimController
} }
var result = await _svc.RecordBattleResultAsync(vid, isWin); var result = await _svc.RecordBattleResultAsync(vid, isWin);
// Mission counters — TK2 matches always advance challenge_play, wins additionally
// advance challenge_win + the ranked/arena/daily aggregates.
await _missionProgress.RecordEventAsync(
vid,
isWin ? MissionEventKeys.Challenge.MatchWinAll() : MissionEventKeys.Challenge.MatchPlayAll(),
ct: ct);
if (result.LeveledUp)
{
await _missionProgress.RecordEventAsync(
vid, MissionEventKeys.ClassLevel.UpAll(result.ClassId), ct: ct);
}
return Ok(new BattleFinishResponseDto return Ok(new BattleFinishResponseDto
{ {
BattleResult = result.BattleResult, BattleResult = result.BattleResult,

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
@@ -8,7 +9,13 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class ArenaTwoPickController : SVSimController public class ArenaTwoPickController : SVSimController
{ {
private readonly IArenaTwoPickService _svc; private readonly IArenaTwoPickService _svc;
public ArenaTwoPickController(IArenaTwoPickService svc) => _svc = svc; private readonly IMissionProgressService _missionProgress;
public ArenaTwoPickController(IArenaTwoPickService svc, IMissionProgressService missionProgress)
{
_svc = svc;
_missionProgress = missionProgress;
}
[HttpPost("top")] [HttpPost("top")]
public async Task<IActionResult> Top([FromBody] TopRequest _) public async Task<IActionResult> Top([FromBody] TopRequest _)
@@ -46,10 +53,19 @@ public class ArenaTwoPickController : SVSimController
} }
[HttpPost("finish")] [HttpPost("finish")]
public async Task<IActionResult> Finish([FromBody] FinishRequest _) public async Task<IActionResult> Finish([FromBody] FinishRequest _, CancellationToken ct = default)
{ {
if (!TryGetViewerId(out var vid)) return Unauthorized(); if (!TryGetViewerId(out var vid)) return Unauthorized();
return await GuardAsync(() => _svc.FinishAsync(vid)); return await GuardAsync(async () =>
{
var outcome = await _svc.FinishAsync(vid);
if (outcome.WasFullClear)
{
await _missionProgress.RecordEventAsync(
vid, MissionEventKeys.Challenge.FullClearAll(), ct: ct);
}
return outcome.Response;
});
} }
private async Task<IActionResult> GuardAsync<T>(Func<Task<T>> action) private async Task<IActionResult> GuardAsync<T>(Func<Task<T>> action)

View File

@@ -1,7 +1,11 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Bridge;
using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.Database.Services.BattleXp;
using SVSim.Database.Services.Friend; using SVSim.Database.Services.Friend;
using SVSim.Database.Services.Replay; using SVSim.Database.Services.Replay;
using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Constants;
@@ -32,6 +36,10 @@ public sealed class FreeBattleController : ControllerBase
private readonly IBattleContextStore _battleContextStore; private readonly IBattleContextStore _battleContextStore;
private readonly IBattleHistoryWriter _historyWriter; private readonly IBattleHistoryWriter _historyWriter;
private readonly IPlayedTogetherWriter _playedTogetherWriter; private readonly IPlayedTogetherWriter _playedTogetherWriter;
private readonly IViewerRepository _viewers;
private readonly IBattleXpService _xp;
private readonly IMissionProgressService _missionProgress;
private readonly SVSimDbContext _db;
private readonly ILogger<FreeBattleController> _log; private readonly ILogger<FreeBattleController> _log;
public FreeBattleController( public FreeBattleController(
@@ -40,6 +48,10 @@ public sealed class FreeBattleController : ControllerBase
IBattleContextStore battleContextStore, IBattleContextStore battleContextStore,
IBattleHistoryWriter historyWriter, IBattleHistoryWriter historyWriter,
IPlayedTogetherWriter playedTogetherWriter, IPlayedTogetherWriter playedTogetherWriter,
IViewerRepository viewers,
IBattleXpService xp,
IMissionProgressService missionProgress,
SVSimDbContext db,
ILogger<FreeBattleController> log) ILogger<FreeBattleController> log)
{ {
_resolver = resolver; _resolver = resolver;
@@ -47,6 +59,10 @@ public sealed class FreeBattleController : ControllerBase
_battleContextStore = battleContextStore; _battleContextStore = battleContextStore;
_historyWriter = historyWriter; _historyWriter = historyWriter;
_playedTogetherWriter = playedTogetherWriter; _playedTogetherWriter = playedTogetherWriter;
_viewers = viewers;
_xp = xp;
_missionProgress = missionProgress;
_db = db;
_log = log; _log = log;
} }
@@ -97,10 +113,37 @@ public sealed class FreeBattleController : ControllerBase
ct); ct);
} }
int gainXp = 0, totalXp = 0, level = 1;
bool leveledUp = false;
var viewer = await _viewers.LoadForBattleXpGrantAsync(vid, ct);
if (viewer is not null)
{
var xp = await _xp.GrantAsync(viewer, req.ClassId, isWin, BattleXpMode.Free, ct);
await _db.SaveChangesAsync(ct);
gainXp = xp.GetXp;
totalXp = xp.TotalXp;
level = xp.Level == 0 ? 1 : xp.Level;
leveledUp = xp.LeveledUp;
}
// Mission counters — unranked wins feed ranked_or_arena_win + daily_match_win.
if (isWin)
{
await _missionProgress.RecordEventAsync(
vid, MissionEventKeys.Free.WinAll(), ct: ct);
}
if (leveledUp)
{
await _missionProgress.RecordEventAsync(
vid, MissionEventKeys.ClassLevel.UpAll(req.ClassId), ct: ct);
}
return Ok(new FreeBattleFinishResponseDto return Ok(new FreeBattleFinishResponseDto
{ {
BattleResult = req.BattleResult, BattleResult = req.BattleResult,
// All other fields default to 0 (ClassLevel defaults to 1). GetClassExperience = gainXp,
ClassExperience = totalXp,
ClassLevel = level,
}); });
} }

View File

@@ -396,17 +396,19 @@ public sealed class GuildController : SVSimController
var viewerIds = members.Select(m => m.ViewerId).ToList(); var viewerIds = members.Select(m => m.ViewerId).ToList();
var profiles = await _viewers.LoadGuildProfileBatchAsync(viewerIds, ct); var profiles = await _viewers.LoadGuildProfileBatchAsync(viewerIds, ct);
var relations = await _friends.GetFriendRelationsAsync(callerViewerId, viewerIds, ct);
var result = new List<GuildMemberInfoDto>(members.Count); var result = new List<GuildMemberInfoDto>(members.Count);
foreach (var m in members) foreach (var m in members)
{ {
profiles.TryGetValue(m.ViewerId, out var p); profiles.TryGetValue(m.ViewerId, out var p);
result.Add(ToMemberDto(m, p)); relations.TryGetValue(m.ViewerId, out var r);
result.Add(ToMemberDto(m, p, r));
} }
return result; return result;
} }
private static GuildMemberInfoDto ToMemberDto(GuildMember member, GuildMemberProfile? profile) => new() private static GuildMemberInfoDto ToMemberDto(GuildMember member, GuildMemberProfile? profile, FriendRelation? relation) => new()
{ {
ViewerId = member.ViewerId, ViewerId = member.ViewerId,
Name = profile?.Name ?? "", Name = profile?.Name ?? "",
@@ -414,6 +416,8 @@ public sealed class GuildController : SVSimController
CountryCode = profile?.CountryCode ?? "", CountryCode = profile?.CountryCode ?? "",
Rank = profile?.Rank ?? 1, Rank = profile?.Rank ?? 1,
DegreeId = profile?.DegreeId ?? 0, DegreeId = profile?.DegreeId ?? 0,
IsFriend = relation?.IsFriend == true ? 1 : 0,
IsFriendApply = relation?.HasOutgoingApply == true ? 1 : 0,
IsOfficialMarkDisplayed = profile?.IsOfficialMarkDisplayed == true ? 1 : 0, IsOfficialMarkDisplayed = profile?.IsOfficialMarkDisplayed == true ? 1 : 0,
Role = (int)member.Role, Role = (int)member.Role,
}; };

View File

@@ -24,12 +24,14 @@ public class ItemPurchaseController : SVSimController
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly IInventoryService _inv; private readonly IInventoryService _inv;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly IGameCalendarService _calendar;
public ItemPurchaseController(SVSimDbContext db, IInventoryService inv, TimeProvider time) public ItemPurchaseController(SVSimDbContext db, IInventoryService inv, TimeProvider time, IGameCalendarService calendar)
{ {
_db = db; _db = db;
_inv = inv; _inv = inv;
_time = time; _time = time;
_calendar = calendar;
} }
[HttpPost("info")] [HttpPost("info")]
@@ -43,7 +45,7 @@ public class ItemPurchaseController : SVSimController
.ToListAsync(); .ToListAsync();
var now = _time.GetUtcNow(); var now = _time.GetUtcNow();
var monthKey = JstPeriod.MonthKey(now); var monthKey = _calendar.MonthKey(now);
var keys = catalog.Select(c => CounterKey(c.Id)).ToList(); var keys = catalog.Select(c => CounterKey(c.Id)).ToList();
var counters = await _db.ViewerEventCounters var counters = await _db.ViewerEventCounters
.Where(c => c.ViewerId == viewerId && keys.Contains(c.EventKey)) .Where(c => c.ViewerId == viewerId && keys.Contains(c.EventKey))
@@ -104,7 +106,7 @@ public class ItemPurchaseController : SVSimController
return BadRequest(new { error = "unknown_purchase" }); return BadRequest(new { error = "unknown_purchase" });
var now = _time.GetUtcNow(); var now = _time.GetUtcNow();
var period = entry.IsMonthlyReset ? JstPeriod.MonthKey(now) : JstPeriod.AllTime; var period = entry.IsMonthlyReset ? _calendar.MonthKey(now) : GameCalendarPeriods.AllTime;
var key = CounterKey(entry.Id); var key = CounterKey(entry.Id);
var counter = await _db.ViewerEventCounters var counter = await _db.ViewerEventCounters
@@ -158,11 +160,11 @@ public class ItemPurchaseController : SVSimController
_ => "debit_type_not_supported", _ => "debit_type_not_supported",
}; };
private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}"; private static string CounterKey(int purchaseId) => MissionEventKeys.ItemPurchase(purchaseId);
private static int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey) private static int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey)
{ {
var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime; var period = entry.IsMonthlyReset ? monthKey : GameCalendarPeriods.AllTime;
return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0; return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0;
} }
} }

View File

@@ -100,7 +100,7 @@ public class MyPageController : SVSimController
CanGiveDailyLoginBonus = _loginBonus.IsDue(viewer), CanGiveDailyLoginBonus = _loginBonus.IsDue(viewer),
UnreceivedMissionRewardCount = 0, // TODO(mypage-stub): viewer mission progress UnreceivedMissionRewardCount = 0, // TODO(mypage-stub): viewer mission progress
ReceiveFriendApplyCount = 0, // TODO(mypage-stub): viewer friend-request inbox ReceiveFriendApplyCount = 0, // TODO(mypage-stub): viewer friend-request inbox
UnreadPresentCount = 0, // TODO(mypage-stub): viewer presents/mail UnreadPresentCount = await _viewerRepository.CountUnclaimedPresentsAsync(viewer.Id, HttpContext.RequestAborted),
FriendBattleInviteCount = 0, // TODO(mypage-stub): viewer room-invite count FriendBattleInviteCount = 0, // TODO(mypage-stub): viewer room-invite count
GuildNotification = await BuildGuildNotificationAsync(viewer.Id, HttpContext.RequestAborted), GuildNotification = await BuildGuildNotificationAsync(viewer.Id, HttpContext.RequestAborted),
LastAnnounceId = 0, // TODO(mypage-stub): globals announcement metadata LastAnnounceId = 0, // TODO(mypage-stub): globals announcement metadata

View File

@@ -34,6 +34,7 @@ public class PackController : SVSimController
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly IInventoryService _inv; private readonly IInventoryService _inv;
private readonly IGachaPointService _gachaPoint; private readonly IGachaPointService _gachaPoint;
private readonly IGameCalendarService _calendar;
public PackController( public PackController(
IPackRepository packs, IPackRepository packs,
@@ -43,7 +44,8 @@ public class PackController : SVSimController
IRandom rng, IRandom rng,
SVSimDbContext db, SVSimDbContext db,
IInventoryService inv, IInventoryService inv,
IGachaPointService gachaPoint) IGachaPointService gachaPoint,
IGameCalendarService calendar)
{ {
_packs = packs; _packs = packs;
_opener = opener; _opener = opener;
@@ -53,6 +55,7 @@ public class PackController : SVSimController
_db = db; _db = db;
_inv = inv; _inv = inv;
_gachaPoint = gachaPoint; _gachaPoint = gachaPoint;
_calendar = calendar;
} }
[HttpPost("info")] [HttpPost("info")]
@@ -120,7 +123,7 @@ public class PackController : SVSimController
}; };
} }
private static PackConfigDto ToDto( private PackConfigDto ToDto(
PackConfigEntry p, PackConfigEntry p,
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts, IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
IReadOnlyDictionary<long, int> ownedItemsByItemId, IReadOnlyDictionary<long, int> ownedItemsByItemId,
@@ -130,17 +133,25 @@ public class PackController : SVSimController
{ {
int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0; int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0;
// Suppress the daily-single half-off flag once the viewer has claimed this parent
// pack's DAILY child today. The client's UI is entirely wire-driven — it renders the
// half-off button iff `is_daily_single: true` (GachaPackAreaLayout.cs:420,
// PackInfoTask.cs:143 → PackChildGachaInfo.IsDailySingle). Without this gate the button
// stays visible after the first successful open and a second click 400s with
// `daily_free_already_claimed`. Keep the child entry itself so the CRYSTAL_MULTI
// full-price button still activates.
bool dailyClaimedToday = !_calendar.ResetReady(oc?.LastDailyFreeAt);
// Drop type_detail=10 (FREE_PACKS) children whose daily quota for THIS viewer is spent. // 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 // 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). // child_gacha_info (verified in traffic_event_crate_free_pack.ndjson lines 28→32).
// Today's claim count >= DailyFreeGachaCount and same UTC date => hide. // Reset happens at the daily boundary — new day resets ClaimCount effectively.
var today = DateTime.UtcNow.Date;
bool ChildAvailable(PackChildGachaEntry c) bool ChildAvailable(PackChildGachaEntry c)
{ {
if (c.TypeDetail != CardPackType.FreePacks) return true; if (c.TypeDetail != CardPackType.FreePacks) return true;
if (c.FreeGachaCampaignId is not int campaignId) return true; if (c.FreeGachaCampaignId is not int campaignId) return true;
if (!freeClaimsByCampaignId.TryGetValue(campaignId, out var claim)) return true; if (!freeClaimsByCampaignId.TryGetValue(campaignId, out var claim)) return true;
if (claim.LastClaimedAt.Date != today) return true; if (_calendar.ResetReady(claim.LastClaimedAt)) return true;
int dailyCap = c.DailyFreeGachaCount > 0 ? c.DailyFreeGachaCount : 1; int dailyCap = c.DailyFreeGachaCount > 0 ? c.DailyFreeGachaCount : 1;
return claim.ClaimCount < dailyCap; return claim.ClaimCount < dailyCap;
} }
@@ -201,7 +212,7 @@ public class PackController : SVSimController
ItemNumber = c.ItemId is long iid && ownedItemsByItemId.TryGetValue(iid, out var ownedCount) ItemNumber = c.ItemId is long iid && ownedItemsByItemId.TryGetValue(iid, out var ownedCount)
? ownedCount ? ownedCount
: 0, : 0,
IsDailySingle = c.IsDailySingle, IsDailySingle = c.IsDailySingle && !(c.TypeDetail == CardPackType.Daily && dailyClaimedToday),
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint.ToString(CultureInfo.InvariantCulture), OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
CampaignName = c.CampaignName, CampaignName = c.CampaignName,
PurchaseLimitCount = c.PurchaseLimitCount > 0 PurchaseLimitCount = c.PurchaseLimitCount > 0
@@ -329,16 +340,14 @@ public class PackController : SVSimController
{ {
if (!TryGetViewerId(out long viewerId)) return Unauthorized(); if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// /tutorial/pack_open is a plain alias for /pack/open — the client uses it during the
// tutorial's final "open the starter legendary pack" step. The only wire-level
// difference is that the response carries tutorial_step=100 so the client transitions
// out of the tutorial. Currency/ticket debits, open-count tracking, and pack identity
// all flow through the normal path — the tutorial gift's tickets naturally constrain
// which packs are openable via this alias to the current throwback starter pair.
bool isTutorialPath = HttpContext.Request.Path.StartsWithSegments("/tutorial/pack_open"); bool isTutorialPath = HttpContext.Request.Path.StartsWithSegments("/tutorial/pack_open");
// The tutorial alias bypasses the currency / type_detail / open-count guards because
// the legendary starter pack (99047) is a free server-grant during the 41→100 tutorial
// transition. Constrain the alias to that one pack so the bypass isn't a free draw on
// ANY pack the client supplies a parent_gacha_id for.
const int StarterParentGachaId = 99047;
if (isTutorialPath && request.ParentGachaId != StarterParentGachaId)
return BadRequest(new { error = "tutorial_path_only_for_starter_pack" });
// Skin-card overload not implemented; rotation-starter (class_id) IS supported below. // Skin-card overload not implemented; rotation-starter (class_id) IS supported below.
if (request.TargetCardId.HasValue) if (request.TargetCardId.HasValue)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "skin_overload_not_implemented" }); return StatusCode(StatusCodes.Status501NotImplemented, new { error = "skin_overload_not_implemented" });
@@ -391,14 +400,13 @@ public class PackController : SVSimController
// when buying a RUPY_MULTI (type_detail=7) child. The gacha_id alone disambiguates the // 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. // child; gacha_type validation against child.TypeDetail would falsely reject every buy.
// Supported on the normal path: Crystal / CrystalMulti -> spend crystals; Rupy / // Supported: Crystal / CrystalMulti -> spend crystals; Rupy / RupyMulti -> spend rupees;
// RupyMulti -> spend rupees; Daily -> spend rupees, once per UTC day; Ticket / // Daily -> spend rupees, once per UTC day; Ticket / TicketMulti -> consume child.ItemId
// TicketMulti -> consume child.ItemId from OwnedItemEntry; FreePacks -> no debit, // from OwnedItemEntry; FreePacks -> no debit, gated by per-campaign daily quota.
// gated by per-campaign daily quota.
// CrystalSpecial / CrystalSelectSkin / CrystalAcquireSkinCardPack and the // CrystalSpecial / CrystalSelectSkin / CrystalAcquireSkinCardPack and the
// FreePackWithSkin / RotationStarterPack overlays need extra selection / banner // FreePackWithSkin / RotationStarterPack overlays need extra selection / banner
// plumbing — kept 501 until the relevant flows land. // plumbing — kept 501 until the relevant flows land.
if (!isTutorialPath && child.TypeDetail is not ( if (child.TypeDetail is not (
CardPackType.Crystal or CardPackType.CrystalMulti or CardPackType.Daily or CardPackType.Crystal or CardPackType.CrystalMulti or CardPackType.Daily or
CardPackType.Ticket or CardPackType.TicketMulti or CardPackType.Rupy or CardPackType.Ticket or CardPackType.TicketMulti or CardPackType.Rupy or
CardPackType.RupyMulti or CardPackType.FreePacks)) CardPackType.RupyMulti or CardPackType.FreePacks))
@@ -415,19 +423,13 @@ public class PackController : SVSimController
}); });
var viewer = tx.Viewer; var viewer = tx.Viewer;
// Tutorial alias is only valid pre-END. After state>=100 the viewer has already
// completed the tutorial — re-running the path would re-consume the ticket they
// chose to keep, and (without the max-preserve write below) could regress a higher
// state value. Mirrors the 31<41 guard in GiftController.TutorialGiftReceive.
const int TutorialEndStep = 100; const int TutorialEndStep = 100;
if (isTutorialPath && viewer.MissionData.TutorialState >= TutorialEndStep)
return BadRequest(new { error = "tutorial_already_complete" });
int packNumber = Math.Max(1, request.PackNumber); int packNumber = Math.Max(1, request.PackNumber);
// Currency check + deduction (skipped for tutorial path — starter pack is free) // Currency check + deduction. TICKET_MULTI is the mechanism the tutorial alias rides
if (!isTutorialPath) // on: the tutorial gift grants the starter ticket, and this branch consumes it, so no
{ // separate tutorial-path bypass is needed.
switch (child.TypeDetail) switch (child.TypeDetail)
{ {
case CardPackType.Crystal: case CardPackType.Crystal:
@@ -448,16 +450,15 @@ public class PackController : SVSimController
} }
case CardPackType.Daily: case CardPackType.Daily:
{ {
// TODO(daily-reset): no project-wide daily-reset convention exists yet. Using UTC // DAILY is the once-per-day half-off crystal single-pack (GachaUI.cs:1046 →
// midnight; revisit when the global reset boundary is settled. // CheckBuyPackWithCrystal, decompile-confirmed). Currency is Crystal, NOT Rupee.
var now = DateTime.UtcNow;
var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id); var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id);
if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date) if (!_calendar.ResetReady(existing?.LastDailyFreeAt))
return BadRequest(new { error = "daily_free_already_claimed" }); return BadRequest(new { error = "daily_free_already_claimed" });
long cost = (long)child.Cost * packNumber; long cost = (long)child.Cost * packNumber;
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost); var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
break; break;
} }
case CardPackType.Ticket: case CardPackType.Ticket:
@@ -477,9 +478,9 @@ public class PackController : SVSimController
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "free_pack_missing_campaign_id" }); return StatusCode(StatusCodes.Status501NotImplemented, new { error = "free_pack_missing_campaign_id" });
int dailyCap = child.DailyFreeGachaCount > 0 ? child.DailyFreeGachaCount : 1; int dailyCap = child.DailyFreeGachaCount > 0 ? child.DailyFreeGachaCount : 1;
var today = DateTime.UtcNow.Date;
var existing = viewer.FreePackClaims.FirstOrDefault(c => c.FreeGachaCampaignId == campaignId); var existing = viewer.FreePackClaims.FirstOrDefault(c => c.FreeGachaCampaignId == campaignId);
if (existing is not null && existing.LastClaimedAt.Date == today && existing.ClaimCount >= dailyCap) bool resetSinceLastClaim = existing is null || _calendar.ResetReady(existing.LastClaimedAt);
if (existing is not null && !resetSinceLastClaim && existing.ClaimCount >= dailyCap)
return BadRequest(new { error = "free_pack_already_claimed_today" }); return BadRequest(new { error = "free_pack_already_claimed_today" });
// pack_number is forced to 1 — free-pack metadata never authorizes multi-opens. // pack_number is forced to 1 — free-pack metadata never authorizes multi-opens.
@@ -495,7 +496,7 @@ public class PackController : SVSimController
LastClaimedAt = DateTime.UtcNow, LastClaimedAt = DateTime.UtcNow,
}); });
} }
else if (existing.LastClaimedAt.Date != today) else if (resetSinceLastClaim)
{ {
existing.ClaimCount = 1; existing.ClaimCount = 1;
existing.LastClaimedAt = DateTime.UtcNow; existing.LastClaimedAt = DateTime.UtcNow;
@@ -508,19 +509,12 @@ public class PackController : SVSimController
break; break;
} }
} }
}
// Increment open count + mark daily-free timestamp where relevant.
// Tutorial path skips these — the starter pack is a one-time free grant, not a
// purchasable/trackable open.
if (!isTutorialPath)
{
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber); await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
if (child.TypeDetail == CardPackType.Daily) if (child.TypeDetail == CardPackType.Daily)
{ {
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow); await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
} }
}
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open). // Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
int drawCount = child.IsDailySingle ? 1 : packNumber; int drawCount = child.IsDailySingle ? 1 : packNumber;
@@ -552,42 +546,21 @@ public class PackController : SVSimController
foreach (var grp in draw.Cards.GroupBy(c => c.CardId)) foreach (var grp in draw.Cards.GroupBy(c => c.CardId))
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count()); await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count());
// Accrue gacha points (skip tutorial path — the starter pack isn't a real open).
if (!isTutorialPath)
{
_gachaPoint.Accrue(viewer, pack, child, drawCount); _gachaPoint.Accrue(viewer, pack, child, drawCount);
}
// Tutorial path consumes the granted ticket (same item_id used to gate display) so the // Tutorial alias epilogue: advance TutorialState to END (max-preserve so a higher
// pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still // sentinel is never regressed) and emit tutorial_step=100 on the wire. The client's
// shows item_number=1 after the tutorial pack-open, the client lets the user re-click // PackOpenTask.Parse runs _userTutorial.Update on the response — this is the END
// it, and the second click hits /pack/open (not /tutorial/pack_open) — which 501s on // signal that transitions the client out of the tutorial.
// type_detail=5 (TICKET_MULTI is out of scope for the normal path). Emitting the
// post-state count in reward_list direct-assigns the client's _userItemDict so the
// UI also goes stale-safe immediately (client does direct assignment per
// project_wire_reward_list_post_state memory).
int? responseTutorialStep = null; int? responseTutorialStep = null;
if (isTutorialPath) if (isTutorialPath)
{ {
if (child.ItemId is long tutorialTicketItemId)
{
int ticketsToConsume = packNumber;
var debit = await tx.TryDebitAsync(UserGoodsType.Item, tutorialTicketItemId, ticketsToConsume);
// Silently accept if the viewer doesn't have the ticket (already consumed or never granted)
_ = debit;
}
// Max-preserve: never regress the persisted state, even though Gate B already
// rejected state>=100 above. Belt-and-braces against a future caller that
// bypasses Gate B (refactor, new alias, etc.). Wire still emits 100 — that's
// the tutorial-END signal the client expects.
if (viewer.MissionData.TutorialState < TutorialEndStep) if (viewer.MissionData.TutorialState < TutorialEndStep)
viewer.MissionData.TutorialState = TutorialEndStep; viewer.MissionData.TutorialState = TutorialEndStep;
responseTutorialStep = TutorialEndStep; responseTutorialStep = TutorialEndStep;
} }
// CommitAsync saves all mutations and produces reward_list with currency-collision resolved. // CommitAsync saves all mutations and produces reward_list with currency-collision resolved.
// Tutorial path never calls TrySpendAsync so no currency op is in the log — correct.
var result = await tx.CommitAsync(HttpContext.RequestAborted); var result = await tx.CommitAsync(HttpContext.RequestAborted);
var rewardList = result.RewardList.ToRewardList(); var rewardList = result.RewardList.ToRewardList();

View File

@@ -1,7 +1,11 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.Database.Services.BattleXp;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
@@ -16,15 +20,24 @@ public class PracticeController : SVSimController
private readonly IGlobalsRepository _globalsRepository; private readonly IGlobalsRepository _globalsRepository;
private readonly IMissionProgressService _missionProgress; private readonly IMissionProgressService _missionProgress;
private readonly IDeckListBuilder _deckListBuilder; private readonly IDeckListBuilder _deckListBuilder;
private readonly IViewerRepository _viewers;
private readonly IBattleXpService _xp;
private readonly SVSimDbContext _db;
public PracticeController( public PracticeController(
IGlobalsRepository globalsRepository, IGlobalsRepository globalsRepository,
IMissionProgressService missionProgress, IMissionProgressService missionProgress,
IDeckListBuilder deckListBuilder) IDeckListBuilder deckListBuilder,
IViewerRepository viewers,
IBattleXpService xp,
SVSimDbContext db)
{ {
_globalsRepository = globalsRepository; _globalsRepository = globalsRepository;
_missionProgress = missionProgress; _missionProgress = missionProgress;
_deckListBuilder = deckListBuilder; _deckListBuilder = deckListBuilder;
_viewers = viewers;
_xp = xp;
_db = db;
} }
/// <summary> /// <summary>
@@ -79,31 +92,59 @@ public class PracticeController : SVSimController
} }
/// <summary> /// <summary>
/// /practice/finish — accept the recovery_data blob without validation; return zero /// /practice/finish — accept the recovery_data blob without validation; grant class XP
/// XP / no rewards. Class XP bookkeeping is deferred until a per-class XP store exists. /// (win or loss) via <see cref="IBattleXpService"/> and return the post-state totals.
/// </summary> /// </summary>
[HttpPost("finish")] [HttpPost("finish")]
public async Task<PracticeFinishResponse> Finish(PracticeFinishRequest request) public async Task<PracticeFinishResponse> Finish(PracticeFinishRequest request)
{ {
// Mission/achievement progress hook. Catalog rows for practice_win achievements use bool isWin = request.IsWin == 1;
// opponent NAMES (e.g. "practice_win:elite:arisa") — we only have numeric class_id /
// difficulty here, so we emit numeric forms. Bridging numeric→name to match captured if (!TryGetViewerId(out long viewerId))
// catalog rows is a follow-up; the infrastructure is in place.
if (request.IsWin == 1 && TryGetViewerId(out long viewerId))
{ {
await _missionProgress.RecordEventAsync(viewerId, new[] return new PracticeFinishResponse
{ {
"practice_win", ClassLevel = 1,
$"practice_win:{request.Difficulty}", AchievedInfo = new Dictionary<string, object>(),
$"practice_win:{request.Difficulty}:{request.EnemyClassId}", RewardList = new List<Models.Dtos.Common.Reward>(),
}); };
}
// Mission/achievement progress hook. Wire values (difficulty int, enemy_class_id int)
// are mapped to catalog-facing names by MissionEventKeys.Practice — the catalog
// authors keys like "practice_win:elite:arisa", so the emitter must match. Wire
// difficulties outside 4/6/7 (elite/elite2/elite3) only advance the top-level counter.
if (isWin)
{
await _missionProgress.RecordEventAsync(
viewerId,
MissionEventKeys.Practice.WinAll(request.Difficulty, request.EnemyClassId));
}
int gainXp = 0, totalXp = 0, level = 1;
bool leveledUp = false;
var viewer = await _viewers.LoadForBattleXpGrantAsync(viewerId);
if (viewer is not null)
{
var xp = await _xp.GrantAsync(viewer, request.ClassId, isWin, BattleXpMode.Practice);
await _db.SaveChangesAsync();
gainXp = xp.GetXp;
totalXp = xp.TotalXp;
level = xp.Level == 0 ? 1 : xp.Level;
leveledUp = xp.LeveledUp;
}
if (leveledUp)
{
await _missionProgress.RecordEventAsync(
viewerId, MissionEventKeys.ClassLevel.UpAll(request.ClassId));
} }
return new PracticeFinishResponse return new PracticeFinishResponse
{ {
GetClassExperience = 0, GetClassExperience = gainXp,
ClassExperience = 0, ClassExperience = totalXp,
ClassLevel = 1, ClassLevel = level,
AchievedInfo = new Dictionary<string, object>(), AchievedInfo = new Dictionary<string, object>(),
RewardList = new List<Models.Dtos.Common.Reward>() RewardList = new List<Models.Dtos.Common.Reward>()
}; };

View File

@@ -2,8 +2,13 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions;
using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.Database.Services.BattleXp;
using SVSim.Database.Services.Friend; using SVSim.Database.Services.Friend;
using SVSim.Database.Services.RankProgress;
using SVSim.Database.Services.Replay; using SVSim.Database.Services.Replay;
using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Extensions;
@@ -33,6 +38,11 @@ public sealed class RankBattleController : ControllerBase
private readonly IBattleContextStore _battleContextStore; private readonly IBattleContextStore _battleContextStore;
private readonly IBattleHistoryWriter _historyWriter; private readonly IBattleHistoryWriter _historyWriter;
private readonly IPlayedTogetherWriter _playedTogetherWriter; private readonly IPlayedTogetherWriter _playedTogetherWriter;
private readonly IViewerRepository _viewers;
private readonly IBattleXpService _xp;
private readonly IRankProgressService _rankProgress;
private readonly IMissionProgressService _missionProgress;
private readonly SVSimDbContext _db;
private readonly ILogger<RankBattleController> _log; private readonly ILogger<RankBattleController> _log;
public RankBattleController( public RankBattleController(
@@ -43,6 +53,11 @@ public sealed class RankBattleController : ControllerBase
IBattleContextStore battleContextStore, IBattleContextStore battleContextStore,
IBattleHistoryWriter historyWriter, IBattleHistoryWriter historyWriter,
IPlayedTogetherWriter playedTogetherWriter, IPlayedTogetherWriter playedTogetherWriter,
IViewerRepository viewers,
IBattleXpService xp,
IRankProgressService rankProgress,
IMissionProgressService missionProgress,
SVSimDbContext db,
ILogger<RankBattleController> log) ILogger<RankBattleController> log)
{ {
_resolver = resolver; _resolver = resolver;
@@ -52,6 +67,11 @@ public sealed class RankBattleController : ControllerBase
_battleContextStore = battleContextStore; _battleContextStore = battleContextStore;
_historyWriter = historyWriter; _historyWriter = historyWriter;
_playedTogetherWriter = playedTogetherWriter; _playedTogetherWriter = playedTogetherWriter;
_viewers = viewers;
_xp = xp;
_rankProgress = rankProgress;
_missionProgress = missionProgress;
_db = db;
_log = log; _log = log;
} }
@@ -81,17 +101,35 @@ public sealed class RankBattleController : ControllerBase
public Task<IActionResult> AiStartUnlimited([FromBody] BaseRequest _, CancellationToken ct) public Task<IActionResult> AiStartUnlimited([FromBody] BaseRequest _, CancellationToken ct)
=> AiStartInternal(Format.Unlimited, ct); => AiStartInternal(Format.Unlimited, ct);
// Finish routes route by URL to the shared handler with the format baked in — the URL
// is authoritative (mirrors DoMatchingRotation/DoMatchingUnlimited). Deriving format
// from the BattleContext is fragile: the client may reach /finish without a prior
// /do_matching (e.g. reconnect, replay-of-log flows), and we still need rank
// progression to land in the right format bucket. Client-side, RankBattleFinishTask
// picks the URL by (Data.CurrentFormat, IsAINetwork) — see decompile lines 12-35.
[HttpPost("/rotation_rank_battle/finish")]
public Task<IActionResult> FinishRotation([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
=> FinishInternal(Format.Rotation, req, ct);
[HttpPost("/unlimited_rank_battle/finish")]
public Task<IActionResult> FinishUnlimited([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
=> FinishInternal(Format.Unlimited, req, ct);
[HttpPost("/ai_rotation_rank_battle/finish")]
public Task<IActionResult> AiFinishRotation([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
=> FinishInternal(Format.Rotation, req, ct);
[HttpPost("/ai_unlimited_rank_battle/finish")]
public Task<IActionResult> AiFinishUnlimited([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
=> FinishInternal(Format.Unlimited, req, ct);
/// <summary> /// <summary>
/// Shared finish handler — RankBattleFinishTask parses the same wire shape for /// Shared finish handler — RankBattleFinishTask parses the same wire shape for
/// all four URLs and routes server-side by URL (vs IsAINetwork flag in the client). /// all four URLs. Grants class XP + rank progression (+100 win / -50 loss,
/// Stubbed for Phase 3: echo battle_result, emit zeros elsewhere. Real rank /// tier-floored). Format is baked into the route.
/// progression math is a separate spec.
/// </summary> /// </summary>
[HttpPost("/rotation_rank_battle/finish")] private async Task<IActionResult> FinishInternal(Format format, RankBattleFinishRequestDto req, CancellationToken ct)
[HttpPost("/unlimited_rank_battle/finish")]
[HttpPost("/ai_rotation_rank_battle/finish")]
[HttpPost("/ai_unlimited_rank_battle/finish")]
public async Task<IActionResult> Finish([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
{ {
if (!TryGetViewerId(out var vid)) return Unauthorized(); if (!TryGetViewerId(out var vid)) return Unauthorized();
@@ -114,10 +152,49 @@ public sealed class RankBattleController : ControllerBase
ct); ct);
} }
int gainXp = 0, totalXp = 0, level = 1;
bool leveledUp = false;
var rankResult = new RankProgressResult(0, 0, 0, 0, 0, false, false);
var viewer = await _viewers.LoadForRankProgressAsync(vid, ct);
if (viewer is not null)
{
var xp = await _xp.GrantAsync(viewer, req.ClassId, isWin, BattleXpMode.Rank, ct);
rankResult = await _rankProgress.GrantAsync(viewer, format, isWin, ct);
await _db.SaveChangesAsync(ct);
gainXp = xp.GetXp;
totalXp = xp.TotalXp;
level = xp.Level == 0 ? 1 : xp.Level;
leveledUp = xp.LeveledUp;
}
// Mission counters — AI-rank routes emit the same keys as human-rank on a private server.
if (isWin)
{
await _missionProgress.RecordEventAsync(
vid, MissionEventKeys.Ranked.WinAll(req.ClassId), ct: ct);
}
if (leveledUp)
{
await _missionProgress.RecordEventAsync(
vid, MissionEventKeys.ClassLevel.UpAll(req.ClassId), ct: ct);
}
if (rankResult.TierAdvanced)
{
await _missionProgress.RecordEventAsync(
vid, MissionEventKeys.Rank.AchievedAll(rankResult.Rank), ct: ct);
}
return Ok(new RankBattleFinishResponseDto return Ok(new RankBattleFinishResponseDto
{ {
BattleResult = req.BattleResult, BattleResult = req.BattleResult,
// All other fields default to 0 in the DTO (ClassLevel defaults to 1). GetClassExperience = gainXp,
ClassExperience = totalXp,
ClassLevel = level,
Rank = rankResult.Rank,
AfterBattlePoint = rankResult.AfterBattlePoint,
AfterMasterPoint = rankResult.AfterMasterPoint,
BattlePoint = rankResult.BattlePoint,
MasterPoint = rankResult.MasterPoint,
}); });
} }
@@ -230,6 +307,13 @@ public sealed class RankBattleController : ControllerBase
var bot = await _botRoster.PickAsync(selfCtx, pending.BattleId, ct); var bot = await _botRoster.PickAsync(selfCtx, pending.BattleId, ct);
var seed = Random.Shared.Next(); var seed = Random.Shared.Next();
// Read the viewer's rank progression for this format so the pre-battle screen
// shows the current tier/point rather than "Beginner 0 with 0 pts."
var selfViewer = await _viewers.LoadForRankProgressAsync(vid, ct);
var selfRank = selfViewer is null
? new RankProgressResult(1, 0, 0, 0, 0, false, false)
: await _rankProgress.GetAsync(selfViewer, format, ct);
// Stash battle context for the upcoming /finish so the replay-history hook can // 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. // compose a ViewerBattleHistory row. See docs/superpowers/specs/2026-06-10-replay-info-design.md.
if (long.TryParse(pending.BattleId, out var battleIdLong)) if (long.TryParse(pending.BattleId, out var battleIdLong))
@@ -273,12 +357,12 @@ public sealed class RankBattleController : ControllerBase
IsOfficial = selfCtx.IsOfficial, IsOfficial = selfCtx.IsOfficial,
OppoId = bot.AiId, OppoId = bot.AiId,
Seed = seed, Seed = seed,
Rank = 0, Rank = selfRank.Rank,
BattlePoint = 0, BattlePoint = selfRank.AfterBattlePoint,
ClassId = (int)selfCtx.ClassId, ClassId = (int)selfCtx.ClassId,
CharaId = int.TryParse(selfCtx.CharaId, out var chId) ? chId : -1, CharaId = int.TryParse(selfCtx.CharaId, out var chId) ? chId : -1,
IsMasterRank = 0, IsMasterRank = selfRank.IsMasterRank ? 1 : 0,
MasterPoint = 0, MasterPoint = selfRank.AfterMasterPoint,
}, },
OppoInfo = new AiBattlePlayerInfo OppoInfo = new AiBattlePlayerInfo
{ {

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Entities.Story; using SVSim.Database.Entities.Story;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos.Story; using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
@@ -70,7 +71,7 @@ public class StoryController : SVSimController
public async Task<ActionResult<FinishResponse>> Finish(FinishRequest req) public async Task<ActionResult<FinishResponse>> Finish(FinishRequest req)
{ {
if (!TryGetViewerId(out long vid)) return Unauthorized(); if (!TryGetViewerId(out long vid)) return Unauthorized();
var result = await _service.FinishAsync(ResolveApiType(), req, vid); var outcome = await _service.FinishAsync(ResolveApiType(), req, vid);
// Emit story-chapter-finish events for mission/achievement progress. // Emit story-chapter-finish events for mission/achievement progress.
var apiType = ResolveApiType(); var apiType = ResolveApiType();
@@ -83,15 +84,16 @@ public class StoryController : SVSimController
}; };
if (prefix is not null && req.StoryId != 0) if (prefix is not null && req.StoryId != 0)
{ {
await _missionProgress.RecordEventAsync(vid, new[] await _missionProgress.RecordEventAsync(
vid, MissionEventKeys.Story.ChapterFinishAll(prefix, req.StoryId));
}
if (outcome.LeveledUp && outcome.ClassId.HasValue)
{ {
"story_chapter_finish", await _missionProgress.RecordEventAsync(
$"story_chapter_finish:{prefix}", vid, MissionEventKeys.ClassLevel.UpAll(outcome.ClassId.Value));
$"story_chapter_finish:{prefix}:{req.StoryId}",
});
} }
return result; return outcome.Response;
} }
[HttpPost("/main_story/all_finish")] [HttpPost("/main_story/all_finish")]

View File

@@ -0,0 +1,39 @@
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace SVSim.EmulatedEntrypoint.Infrastructure;
/// <summary>
/// Attaches the <see cref="RequireAdminSecretAttribute.HeaderName"/> security requirement to
/// Swagger operations whose action carries <see cref="RequireAdminSecretAttribute"/>. The
/// matching <see cref="OpenApiSecurityScheme"/> is registered by <c>Program.cs</c> under the
/// same scheme id (<see cref="SchemeId"/>) so the Swagger UI shows an Authorize dialog and,
/// once populated, sends the header on gated endpoints only.
/// </summary>
public sealed class AdminSecretOperationFilter : IOperationFilter
{
public const string SchemeId = "AdminSecret";
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var hasAttribute = context.MethodInfo.GetCustomAttributes(true)
.OfType<RequireAdminSecretAttribute>().Any()
|| (context.MethodInfo.DeclaringType?.GetCustomAttributes(true)
.OfType<RequireAdminSecretAttribute>().Any() ?? false);
if (!hasAttribute) return;
operation.Security ??= new List<OpenApiSecurityRequirement>();
operation.Security.Add(new OpenApiSecurityRequirement
{
[new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = SchemeId,
}
}] = Array.Empty<string>()
});
}
}

View File

@@ -0,0 +1,57 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SVSim.EmulatedEntrypoint.Configuration;
namespace SVSim.EmulatedEntrypoint.Infrastructure;
/// <summary>
/// Gates a controller or action on a shared secret carried in the <c>X-Admin-Secret</c> header,
/// compared against <see cref="AdminOptions.ImportSecret"/>. Runs as an authorization filter so
/// unauthorized requests short-circuit before model binding.
///
/// Fail-closed: if the configured secret is null/empty the endpoint is treated as disabled and
/// every request gets a 401 (with a warning logged once per request). This means a deployment
/// that forgets to set the secret leaves the endpoint locked, not open.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)]
public sealed class RequireAdminSecretAttribute : Attribute, IAuthorizationFilter
{
public const string HeaderName = "X-Admin-Secret";
public void OnAuthorization(AuthorizationFilterContext context)
{
var services = context.HttpContext.RequestServices;
var options = services.GetRequiredService<IOptions<AdminOptions>>().Value;
var logger = services.GetRequiredService<ILogger<RequireAdminSecretAttribute>>();
if (string.IsNullOrWhiteSpace(options.ImportSecret))
{
logger.LogWarning(
"Rejecting request to {Path}: Admin:ImportSecret is not configured. " +
"Set it in appsettings (or the NPGSQL_ADMIN__IMPORTSECRET env var) to enable the endpoint.",
context.HttpContext.Request.Path);
context.Result = new UnauthorizedResult();
return;
}
if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out var provided)
|| provided.Count == 0
|| string.IsNullOrEmpty(provided[0]))
{
context.Result = new UnauthorizedResult();
return;
}
var providedBytes = Encoding.UTF8.GetBytes(provided[0]!);
var expectedBytes = Encoding.UTF8.GetBytes(options.ImportSecret);
if (!CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes))
{
context.Result = new UnauthorizedResult();
}
}
}

View File

@@ -46,4 +46,13 @@ public sealed class ColosseumBattleFinishResponseDto
{ {
[JsonPropertyName("battle_result")] [Key("battle_result")] [JsonPropertyName("battle_result")] [Key("battle_result")]
public int BattleResult { get; set; } public int BattleResult { get; set; }
[JsonPropertyName("get_class_experience")] [Key("get_class_experience")]
public int GetClassExperience { get; set; }
[JsonPropertyName("class_experience")] [Key("class_experience")]
public int ClassExperience { get; set; }
[JsonPropertyName("class_level")] [Key("class_level")]
public int ClassLevel { get; set; } = 1;
} }

View File

@@ -30,13 +30,21 @@ public class GuildMemberInfoDto
[JsonPropertyName("degree_id"), Key("degree_id"), JsonConverter(typeof(StringifiedIntConverter))] [JsonPropertyName("degree_id"), Key("degree_id"), JsonConverter(typeof(StringifiedIntConverter))]
public int DegreeId { get; set; } public int DegreeId { get; set; }
/// <summary>Optional — omit when the caller has no friend relationship with this member.</summary> /// <summary>
[JsonPropertyName("is_friend"), Key("is_friend")] /// Client reads unconditionally in GuildMemberInfo.cs:48 (json["is_friend"].ToBoolean()) — always emit.
public int? IsFriend { get; set; } /// Base UserInfoBase guards this, but GuildMemberInfo re-reads it on top, unguarded. 0 = not a friend.
/// </summary>
[JsonPropertyName("is_friend"), Key("is_friend"),
JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public int IsFriend { get; set; }
/// <summary>Optional — omit when no pending friend apply.</summary> /// <summary>
[JsonPropertyName("is_friend_apply"), Key("is_friend_apply")] /// Client reads unconditionally in GuildMemberInfo.cs:49 (json["is_friend_apply"].ToBoolean()) — always emit.
public int? IsFriendApply { get; set; } /// 0 = no pending friend apply.
/// </summary>
[JsonPropertyName("is_friend_apply"), Key("is_friend_apply"),
JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public int IsFriendApply { get; set; }
/// <summary> /// <summary>
/// Whether the official-account badge is shown for this member. /// Whether the official-account badge is shown for this member.

View File

@@ -124,12 +124,18 @@ public class ImportStoryProgress
public class ImportMissionMeta public class ImportMissionMeta
{ {
[JsonPropertyName("has_received_pick_two_mission")] // Wire key is `is_received_two_pick_mission` (loader-captured verbatim from /load/index
public bool? HasReceivedPickTwoMission { get; set; } // user_info); the field ships as a string "1"/"0" not a bool. Keep the DTO type as int?
// so binding matches; the controller normalizes to bool.
[JsonPropertyName("is_received_two_pick_mission")]
public int? HasReceivedPickTwoMission { get; set; }
[JsonPropertyName("mission_receive_type")] [JsonPropertyName("mission_receive_type")]
public int? MissionReceiveType { get; set; } public int? MissionReceiveType { get; set; }
// Wire ships this as a "yyyy-MM-dd HH:mm:ss" string, NOT a Unix long (see
// FriendService.DefaultMissionChangeTime for the canonical game format). Controller
// parses via DateTime.TryParseExact with invariant culture.
[JsonPropertyName("mission_change_time")] [JsonPropertyName("mission_change_time")]
public long? MissionChangeTime { get; set; } public string? MissionChangeTime { get; set; }
} }

View File

@@ -4,6 +4,8 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
/// What the battle controller needs from the service to build the standard battle-finish /// What the battle controller needs from the service to build the standard battle-finish
/// envelope (class XP delta + post-state, spot points before/add/after, battle_result echo). /// envelope (class XP delta + post-state, spot points before/add/after, battle_result echo).
/// Achieved-info is built by the controller from IMissionAssembler. /// Achieved-info is built by the controller from IMissionAssembler.
/// <see cref="LeveledUp"/> and <see cref="ClassId"/> are controller-side signals for the
/// class_level_up mission emit and are not serialized to the wire.
/// </summary> /// </summary>
public class BattleFinishResultDto public class BattleFinishResultDto
{ {
@@ -14,4 +16,6 @@ public class BattleFinishResultDto
public int BeforeSpotPoint { get; set; } public int BeforeSpotPoint { get; set; }
public int AddSpotPoint { get; set; } public int AddSpotPoint { get; set; }
public int AfterSpotPoint { get; set; } public int AfterSpotPoint { get; set; }
public bool LeveledUp { get; set; }
public int ClassId { get; set; }
} }

View File

@@ -0,0 +1,9 @@
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
/// <summary>
/// What <see cref="Services.IArenaTwoPickService.FinishAsync(long)"/> returns to
/// <c>ArenaTwoPickController.Finish</c>. The wire response is <see cref="Response"/>;
/// <see cref="WasFullClear"/> is a controller-side signal for the
/// <c>challenge_full_clear</c> mission emit and is never serialized.
/// </summary>
public sealed record RunFinishOutcome(FinishResponseDto Response, bool WasFullClear);

View File

@@ -0,0 +1,10 @@
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
/// <summary>
/// What <c>IStoryService.FinishAsync</c> returns to <c>StoryController.Finish</c>.
/// <see cref="Response"/> is the wire payload; <see cref="LeveledUp"/> and
/// <see cref="ClassId"/> are controller-side signals for the class_level_up mission emit
/// and are never serialized. <see cref="ClassId"/> is nullable because skip-shape finishes
/// (no class chosen) don't grant XP and therefore can't level anything up.
/// </summary>
public sealed record StoryFinishOutcome(FinishResponse Response, bool LeveledUp, int? ClassId);

View File

@@ -15,6 +15,7 @@ using SVSim.Database.Services.Friend;
using SVSim.Database.Services.Replay; using SVSim.Database.Services.Replay;
using SVSim.EmulatedEntrypoint.Configuration; using SVSim.EmulatedEntrypoint.Configuration;
using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Matching; using SVSim.EmulatedEntrypoint.Matching;
using SVSim.EmulatedEntrypoint.Middlewares; using SVSim.EmulatedEntrypoint.Middlewares;
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication; using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
@@ -64,6 +65,19 @@ public class Program
// Disambiguate same-named DTOs across families (e.g. Story.StartRequest vs // Disambiguate same-named DTOs across families (e.g. Story.StartRequest vs
// BasicPuzzle.StartRequest) by qualifying schema ids with the full type name. // BasicPuzzle.StartRequest) by qualifying schema ids with the full type name.
c.CustomSchemaIds(t => t.FullName?.Replace("+", ".")); c.CustomSchemaIds(t => t.FullName?.Replace("+", "."));
// Register the X-Admin-Secret shared-secret header so the Swagger UI shows an
// Authorize dialog for it. AdminSecretOperationFilter then attaches the requirement
// only to endpoints that carry [RequireAdminSecret], keeping the padlock off
// ordinary game endpoints.
c.AddSecurityDefinition(AdminSecretOperationFilter.SchemeId, new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Name = RequireAdminSecretAttribute.HeaderName,
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey,
Description = "Shared secret for /admin/* endpoints (matches Admin:ImportSecret in appsettings).",
});
c.OperationFilter<AdminSecretOperationFilter>();
}); });
builder.Services.AddHttpLogging(opt => builder.Services.AddHttpLogging(opt =>
{ {
@@ -71,6 +85,7 @@ public class Program
}); });
builder.Services.Configure<DeckOptions>(builder.Configuration.GetSection(DeckOptions.SectionName)); builder.Services.Configure<DeckOptions>(builder.Configuration.GetSection(DeckOptions.SectionName));
builder.Services.Configure<AdminOptions>(builder.Configuration.GetSection(AdminOptions.SectionName));
#region Database Services #region Database Services
@@ -99,6 +114,10 @@ public class Program
builder.Services.AddScoped<IGachaPointService, GachaPointService>(); builder.Services.AddScoped<IGachaPointService, GachaPointService>();
builder.Services.AddScoped<SVSim.Database.Services.Inventory.IInventoryService, builder.Services.AddScoped<SVSim.Database.Services.Inventory.IInventoryService,
SVSim.Database.Services.Inventory.InventoryService>(); SVSim.Database.Services.Inventory.InventoryService>();
builder.Services.AddScoped<SVSim.Database.Services.BattleXp.IBattleXpService,
SVSim.Database.Services.BattleXp.BattleXpService>();
builder.Services.AddScoped<SVSim.Database.Services.RankProgress.IRankProgressService,
SVSim.Database.Services.RankProgress.RankProgressService>();
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IBattlePassRepository, builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IBattlePassRepository,
SVSim.Database.Repositories.BattlePass.BattlePassRepository>(); SVSim.Database.Repositories.BattlePass.BattlePassRepository>();
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IViewerBattlePassRepository, builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IViewerBattlePassRepository,
@@ -123,6 +142,7 @@ public class Program
builder.Services.AddScoped<IMatchContextBuilder, MatchContextBuilder>(); builder.Services.AddScoped<IMatchContextBuilder, MatchContextBuilder>();
builder.Services.AddScoped<IStoryService, StoryService>(); builder.Services.AddScoped<IStoryService, StoryService>();
builder.Services.AddScoped<ILoginBonusService, LoginBonusService>(); builder.Services.AddScoped<ILoginBonusService, LoginBonusService>();
builder.Services.AddScoped<IGameCalendarService, GameCalendarService>();
builder.Services.AddScoped<IDeckListBuilder, DeckListBuilder>(); builder.Services.AddScoped<IDeckListBuilder, DeckListBuilder>();
builder.Services.AddSingleton<IRandom, SystemRandom>(); builder.Services.AddSingleton<IRandom, SystemRandom>();
builder.Services.AddSingleton<PuzzleMissionEvaluator>(); builder.Services.AddSingleton<PuzzleMissionEvaluator>();
@@ -160,11 +180,10 @@ public class Program
builder.Services.AddBattleNode(opt => builder.Services.AddBattleNode(opt =>
{ {
// Matches the prod do_matching wire format: host:port/socket.io/, no scheme prefix. // Every field on BattleNodeOptions is populated from the "BattleNode" section in
// BestHTTP's SocketManager parses this as the Socket.IO v2 endpoint URL. // appsettings*.json. NodeServerUrl has no hardcoded fallback — AddBattleNode
opt.NodeServerUrl = "localhost:5148/socket.io/"; // throws at startup if it's missing/blank. See BattleNodeOptions.NodeServerUrl
// Any field in BattleNodeOptions can be overridden via the "BattleNode" section // for the required wire format.
// in appsettings*.json — see appsettings.Development.json for DiagnosticLogging.
builder.Configuration.GetSection("BattleNode").Bind(opt); builder.Configuration.GetSection("BattleNode").Bind(opt);
}); });
// In-process FCFS pair-up for TK2 PvP /do_matching, plus rank-battle's AI-fallback // In-process FCFS pair-up for TK2 PvP /do_matching, plus rank-battle's AI-fallback

View File

@@ -63,8 +63,17 @@
</None> </None>
</ItemGroup> </ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent"> <!-- Copy the Steamworks native libs into the build AND publish output. Was previously
<Exec Command="COPY &quot;$(ProjectDir)\lib\*&quot; &quot;$(TargetDir)&quot;" /> an `<Exec Command="COPY ...">` that only ran on Windows; the MSBuild `<Copy>` task
is cross-platform, so CI can build on ubuntu-latest without a shell fallback. -->
<ItemGroup>
<SteamworksNativeLibs Include="$(ProjectDir)lib\**\*" />
</ItemGroup>
<Target Name="CopySteamworksLibsToOutput" AfterTargets="Build">
<Copy SourceFiles="@(SteamworksNativeLibs)" DestinationFolder="$(TargetDir)" SkipUnchangedFiles="true" />
</Target>
<Target Name="CopySteamworksLibsToPublish" AfterTargets="Publish">
<Copy SourceFiles="@(SteamworksNativeLibs)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" />
</Target> </Target>
</Project> </Project>

View File

@@ -6,6 +6,7 @@ using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer; using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.BattleXp;
using SVSim.Database.Services.Inventory; using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick; using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
@@ -20,6 +21,7 @@ public class ArenaTwoPickService : IArenaTwoPickService
private readonly IGameConfigService _config; private readonly IGameConfigService _config;
private readonly IViewerRepository _viewers; private readonly IViewerRepository _viewers;
private readonly IInventoryService _inv; private readonly IInventoryService _inv;
private readonly IBattleXpService _xp;
private readonly IRandom _rng; private readonly IRandom _rng;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
@@ -30,11 +32,12 @@ public class ArenaTwoPickService : IArenaTwoPickService
IGameConfigService config, IGameConfigService config,
IViewerRepository viewers, IViewerRepository viewers,
IInventoryService inv, IInventoryService inv,
IBattleXpService xp,
IRandom rng, IRandom rng,
SVSimDbContext db) SVSimDbContext db)
{ {
_runs = runs; _rewards = rewards; _pool = pool; _config = config; _runs = runs; _rewards = rewards; _pool = pool; _config = config;
_viewers = viewers; _inv = inv; _rng = rng; _db = db; _viewers = viewers; _inv = inv; _xp = xp; _rng = rng; _db = db;
} }
public async Task<TopResponseDto> GetTopAsync(long viewerId) public async Task<TopResponseDto> GetTopAsync(long viewerId)
@@ -278,11 +281,27 @@ public class ArenaTwoPickService : IArenaTwoPickService
}).ToList(), }).ToList(),
}; };
} }
public Task<FinishResponseDto> RetireAsync(long viewerId) => GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: false); public async Task<FinishResponseDto> RetireAsync(long viewerId)
{
var (response, _) = await GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: false);
return response;
}
public Task<FinishResponseDto> FinishAsync(long viewerId) => GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: true); public async Task<RunFinishOutcome> FinishAsync(long viewerId)
{
var (response, winCount) = await GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: true);
// TK2 is 5 battles per run; full clear == 5 wins == 0 losses. Reference the reward
// catalog's max WinCount rather than a magic 5 so a future rules-config change tracks.
int maxBattles = await ResolveMaxBattleCountAsync();
return new RunFinishOutcome(response, WasFullClear: winCount >= maxBattles);
}
private async Task<FinishResponseDto> GrantRunRewardsAndDeleteAsync(long viewerId, bool requireComplete) /// <summary>
/// Grants rewards for the given run tier and deletes the run row. Returns the wire
/// response DTO plus the run's final WinCount — the latter feeds
/// <see cref="FinishAsync"/>'s full-clear detection but isn't exposed on the wire.
/// </summary>
private async Task<(FinishResponseDto Response, int WinCount)> GrantRunRewardsAndDeleteAsync(long viewerId, bool requireComplete)
{ {
var run = await _runs.GetByViewerIdAsync(viewerId) var run = await _runs.GetByViewerIdAsync(viewerId)
?? throw new ArenaTwoPickException("arena_two_pick_no_active_run"); ?? throw new ArenaTwoPickException("arena_two_pick_no_active_run");
@@ -342,8 +361,9 @@ public class ArenaTwoPickService : IArenaTwoPickService
.Select(g => new RewardEntryDto { RewardType = (int)g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) .Select(g => new RewardEntryDto { RewardType = (int)g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(); .ToList();
int winCountAtFinish = run.WinCount;
await _runs.DeleteAsync(viewerId); await _runs.DeleteAsync(viewerId);
return new FinishResponseDto { Rewards = deltas, RewardList = postStates }; return (new FinishResponseDto { Rewards = deltas, RewardList = postStates }, winCountAtFinish);
} }
private static SVSim.Database.Models.ArenaTwoPickReward WeightedPick( private static SVSim.Database.Models.ArenaTwoPickReward WeightedPick(
@@ -375,8 +395,7 @@ public class ArenaTwoPickService : IArenaTwoPickService
var viewer = await LoadViewerForGrantsAsync(viewerId); var viewer = await LoadViewerForGrantsAsync(viewerId);
int before = (int)(viewer.Currency?.SpotPoints ?? 0); int before = (int)(viewer.Currency?.SpotPoints ?? 0);
int newClassXp = GrantClassXp(viewer, run.ClassId, aCfg.ClassXpPerBattle); var xp = await _xp.GrantAsync(viewer, run.ClassId, isWin, BattleXpMode.ArenaTwoPick);
int classLevel = ResolveClassLevel(viewer, run.ClassId);
viewer.Currency!.SpotPoints += (ulong)aCfg.SpotPointsPerBattle; viewer.Currency!.SpotPoints += (ulong)aCfg.SpotPointsPerBattle;
int after = (int)viewer.Currency.SpotPoints; int after = (int)viewer.Currency.SpotPoints;
@@ -385,29 +404,17 @@ public class ArenaTwoPickService : IArenaTwoPickService
return new BattleFinishResultDto return new BattleFinishResultDto
{ {
BattleResult = isWin ? 1 : 0, BattleResult = isWin ? 1 : 0,
GetClassExperience = aCfg.ClassXpPerBattle, GetClassExperience = xp.GetXp,
ClassExperience = newClassXp, ClassExperience = xp.TotalXp,
ClassLevel = classLevel, ClassLevel = xp.Level,
BeforeSpotPoint = before, BeforeSpotPoint = before,
AddSpotPoint = aCfg.SpotPointsPerBattle, AddSpotPoint = aCfg.SpotPointsPerBattle,
AfterSpotPoint = after, AfterSpotPoint = after,
LeveledUp = xp.LeveledUp,
ClassId = run.ClassId,
}; };
} }
private static int GrantClassXp(SVSim.Database.Models.Viewer viewer, int classId, int xp)
{
var row = viewer.Classes.FirstOrDefault(c => c.Class.Id == classId);
if (row is null) return 0;
row.Exp += xp;
return row.Exp;
}
private static int ResolveClassLevel(SVSim.Database.Models.Viewer viewer, int classId)
{
var row = viewer.Classes.FirstOrDefault(c => c.Class.Id == classId);
return row is null ? 1 : row.Level;
}
// --- projection helpers (kept internal so test subclasses could exercise if needed) --- // --- projection helpers (kept internal so test subclasses could exercise if needed) ---
internal static EntryInfoDto ProjectEntryInfo(ViewerArenaTwoPickRun run, long viewerId) => new() internal static EntryInfoDto ProjectEntryInfo(ViewerArenaTwoPickRun run, long viewerId) => new()

View File

@@ -0,0 +1,92 @@
using System.Globalization;
using SVSim.Database.Models.Config;
using SVSim.Database.Services;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Reset-boundary primitive. All timestamps are UTC. The boundary hour is a config
/// value (<see cref="GameCalendarConfig.DailyResetUtcHour"/>); everything else — daily
/// reset checks, bucket keys for <c>ViewerEventCounters</c> — derives from it.
/// Because bucket keys and <see cref="ResetReady"/> share the same
/// <see cref="MostRecentBoundary"/> math, they can never disagree.
/// </summary>
public interface IGameCalendarService
{
/// <summary>
/// True if a reset boundary has passed since <paramref name="lastCheckpointUtc"/>.
/// Null → true (never checked in).
/// </summary>
bool ResetReady(DateTime? lastCheckpointUtc);
/// <summary>Bucket key for the current daily reset window: <c>"day:yyyy-MM-dd"</c>.</summary>
string DayKey(DateTimeOffset when);
/// <summary>Bucket key for the ISO week containing the current daily window's start.</summary>
string WeekKey(DateTimeOffset when);
/// <summary>Bucket key for the calendar month containing the current daily window's start.</summary>
string MonthKey(DateTimeOffset when);
/// <summary>Returns [day, week, month, all-time] for the given instant.</summary>
IReadOnlyList<string> AllPeriods(DateTimeOffset when);
}
public static class GameCalendarPeriods
{
/// <summary>The lifetime bucket string — same constant regardless of reset boundary config.</summary>
public const string AllTime = "all-time";
}
public class GameCalendarService : IGameCalendarService
{
private readonly IGameConfigService _config;
public GameCalendarService(IGameConfigService config)
{
_config = config;
}
public bool ResetReady(DateTime? lastCheckpointUtc)
{
if (lastCheckpointUtc is null) return true;
var now = DateTime.UtcNow;
var boundary = MostRecentBoundary(now);
return DateTime.SpecifyKind(lastCheckpointUtc.Value, DateTimeKind.Utc) < boundary;
}
public string DayKey(DateTimeOffset when)
{
var d = WindowStartDate(when);
return $"day:{d:yyyy-MM-dd}";
}
public string WeekKey(DateTimeOffset when)
{
var d = WindowStartDate(when);
var iso = ISOWeek.GetWeekOfYear(d);
var year = ISOWeek.GetYear(d);
return $"week:{year:D4}-W{iso:D2}";
}
public string MonthKey(DateTimeOffset when)
{
var d = WindowStartDate(when);
return $"month:{d:yyyy-MM}";
}
public IReadOnlyList<string> AllPeriods(DateTimeOffset when) => new[]
{
DayKey(when), WeekKey(when), MonthKey(when), GameCalendarPeriods.AllTime,
};
private DateTime MostRecentBoundary(DateTime nowUtc)
{
int hour = _config.Get<GameCalendarConfig>().DailyResetUtcHour;
var todayBoundary = nowUtc.Date.AddHours(hour);
return nowUtc >= todayBoundary ? todayBoundary : todayBoundary.AddDays(-1);
}
private DateTime WindowStartDate(DateTimeOffset when) =>
MostRecentBoundary(when.UtcDateTime);
}

View File

@@ -9,7 +9,12 @@ public interface IArenaTwoPickService
Task<ClassChooseResponseDto> ChooseClassAsync(long viewerId, int classId); Task<ClassChooseResponseDto> ChooseClassAsync(long viewerId, int classId);
Task<CardChooseResponseDto> ChooseCardAsync(long viewerId, long selectedId); Task<CardChooseResponseDto> ChooseCardAsync(long viewerId, long selectedId);
Task<FinishResponseDto> RetireAsync(long viewerId); Task<FinishResponseDto> RetireAsync(long viewerId);
Task<FinishResponseDto> FinishAsync(long viewerId); /// <summary>
/// Ends a completed TK2 run (5 battles played) and grants the reward tier for
/// <c>run.WinCount</c>. Returns both the wire-shape rewards and a controller-side
/// signal for mission emit (<see cref="RunFinishOutcome.WasFullClear"/>).
/// </summary>
Task<RunFinishOutcome> FinishAsync(long viewerId);
Task<BattleFinishResultDto> RecordBattleResultAsync(long viewerId, bool isWin); Task<BattleFinishResultDto> RecordBattleResultAsync(long viewerId, bool isWin);
} }

View File

@@ -10,6 +10,6 @@ public interface IStoryService
Task<InfoResponse> GetInfoAsync(StoryApiType apiType, int sectionId, int? charaId, long viewerId); Task<InfoResponse> GetInfoAsync(StoryApiType apiType, int sectionId, int? charaId, long viewerId);
Task<GetDeckListResponse> GetDeckListAsync(StoryApiType apiType, int storyId, long viewerId); Task<GetDeckListResponse> GetDeckListAsync(StoryApiType apiType, int storyId, long viewerId);
Task<StartResponse> StartAsync(StoryApiType apiType, int[] storyIds, long viewerId); Task<StartResponse> StartAsync(StoryApiType apiType, int[] storyIds, long viewerId);
Task<FinishResponse> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId); Task<StoryFinishOutcome> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId);
Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId); Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId);
} }

View File

@@ -1,53 +0,0 @@
using System.Globalization;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// JST-anchored period bucketing for ViewerEventCounters. Day/week/month boundaries are
/// 02:00 JST (matching the real-game daily reset). Pure functions, no dependencies.
/// </summary>
public static class JstPeriod
{
private static readonly TimeSpan Jst = TimeSpan.FromHours(9);
private const string DayPrefix = "day:";
private const string WeekPrefix = "week:";
private const string MonthPrefix = "month:";
public const string AllTime = "all-time";
/// <summary>
/// Converts the given instant to a JST-anchored "effective date" by:
/// 1. Shifting to JST (+09:00)
/// 2. Subtracting 2 hours so anything before 02:00 JST belongs to the previous day
/// </summary>
private static DateTime EffectiveJstDate(DateTimeOffset utcOrAny)
{
var jst = utcOrAny.ToOffset(Jst);
return jst.AddHours(-2).Date;
}
public static string DayKey(DateTimeOffset when)
{
var d = EffectiveJstDate(when);
return $"{DayPrefix}{d:yyyy-MM-dd}";
}
public static string WeekKey(DateTimeOffset when)
{
var d = EffectiveJstDate(when);
var iso = ISOWeek.GetWeekOfYear(d);
var year = ISOWeek.GetYear(d);
return $"{WeekPrefix}{year:D4}-W{iso:D2}";
}
public static string MonthKey(DateTimeOffset when)
{
var d = EffectiveJstDate(when);
return $"{MonthPrefix}{d:yyyy-MM}";
}
/// <summary>Returns [day, week, month, all-time] keys for the given instant.</summary>
public static IReadOnlyList<string> AllPeriods(DateTimeOffset when) => new[]
{
DayKey(when), WeekKey(when), MonthKey(when), AllTime,
};
}

View File

@@ -11,21 +11,17 @@ public class LoginBonusService : ILoginBonusService
{ {
private readonly IGameConfigService _config; private readonly IGameConfigService _config;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly IGameCalendarService _calendar;
public LoginBonusService(IGameConfigService config, TimeProvider time) public LoginBonusService(IGameConfigService config, TimeProvider time, IGameCalendarService calendar)
{ {
_config = config; _config = config;
_time = time; _time = time;
_calendar = calendar;
} }
public bool IsDue(Viewer viewer) public bool IsDue(Viewer viewer) =>
{ _calendar.ResetReady(viewer.LastLoginBonusClaimedAt);
if (viewer.LastLoginBonusClaimedAt is null) return true;
var now = _time.GetUtcNow();
var lastClaim = new DateTimeOffset(
DateTime.SpecifyKind(viewer.LastLoginBonusClaimedAt.Value, DateTimeKind.Utc));
return JstPeriod.DayKey(lastClaim) != JstPeriod.DayKey(now);
}
public async Task<DailyLoginBonus?> GrantIfDueAsync(IInventoryTransaction tx, CancellationToken ct = default) public async Task<DailyLoginBonus?> GrantIfDueAsync(IInventoryTransaction tx, CancellationToken ct = default)
{ {

View File

@@ -9,15 +9,18 @@ public sealed class MissionAssembler : IMissionAssembler
private readonly IMissionCatalogRepository _catalog; private readonly IMissionCatalogRepository _catalog;
private readonly IViewerMissionRepository _viewerRepo; private readonly IViewerMissionRepository _viewerRepo;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly IGameCalendarService _calendar;
public MissionAssembler( public MissionAssembler(
IMissionCatalogRepository catalog, IMissionCatalogRepository catalog,
IViewerMissionRepository viewerRepo, IViewerMissionRepository viewerRepo,
TimeProvider time) TimeProvider time,
IGameCalendarService calendar)
{ {
_catalog = catalog; _catalog = catalog;
_viewerRepo = viewerRepo; _viewerRepo = viewerRepo;
_time = time; _time = time;
_calendar = calendar;
} }
public async Task<MissionInfoDataDto> BuildAsync(Viewer viewer, CancellationToken ct = default) public async Task<MissionInfoDataDto> BuildAsync(Viewer viewer, CancellationToken ct = default)
@@ -54,15 +57,14 @@ public sealed class MissionAssembler : IMissionAssembler
if (c.EventType is not null) counterEventKeys.Add(c.EventType); if (c.EventType is not null) counterEventKeys.Add(c.EventType);
} }
// BP monthly missions for current JST month. // BP monthly missions for the current calendar month (UTC).
var jstNow = now.ToOffset(TimeSpan.FromHours(9)).AddHours(-2); var monthlyMissions = await _catalog.GetMonthlyMissionsAsync(now.Year, now.Month, ct);
var monthlyMissions = await _catalog.GetMonthlyMissionsAsync(jstNow.Year, jstNow.Month, ct);
foreach (var mm in monthlyMissions) foreach (var mm in monthlyMissions)
{ {
if (mm.EventType is not null) counterEventKeys.Add(mm.EventType); if (mm.EventType is not null) counterEventKeys.Add(mm.EventType);
} }
var periods = new[] { JstPeriod.DayKey(now), JstPeriod.WeekKey(now), JstPeriod.MonthKey(now), JstPeriod.AllTime }; var periods = _calendar.AllPeriods(now);
var counters = counterEventKeys.Count == 0 var counters = counterEventKeys.Count == 0
? new List<ViewerEventCounter>() ? new List<ViewerEventCounter>()
: await _viewerRepo.GetCountersAsync(viewer.Id, counterEventKeys.ToList(), periods, ct); : await _viewerRepo.GetCountersAsync(viewer.Id, counterEventKeys.ToList(), periods, ct);
@@ -78,7 +80,7 @@ public sealed class MissionAssembler : IMissionAssembler
int total = 0; int total = 0;
if (cat.EventType is not null) if (cat.EventType is not null)
{ {
string period = cat.LotType == 6 ? JstPeriod.DayKey(now) : JstPeriod.WeekKey(now); string period = cat.LotType == 6 ? _calendar.DayKey(now) : _calendar.WeekKey(now);
total = GetCounter(cat.EventType, period); total = GetCounter(cat.EventType, period);
} }
dto.UserMissionList.Add(new UserMissionDto dto.UserMissionList.Add(new UserMissionDto
@@ -105,7 +107,7 @@ public sealed class MissionAssembler : IMissionAssembler
foreach (var a in viewerAchievements.OrderBy(a => a.AchievementType)) foreach (var a in viewerAchievements.OrderBy(a => a.AchievementType))
{ {
if (!achievementCatalogByKey.TryGetValue((a.AchievementType, a.Level), out var catalog)) continue; if (!achievementCatalogByKey.TryGetValue((a.AchievementType, a.Level), out var catalog)) continue;
int total = catalog.EventType is null ? 0 : GetCounter(catalog.EventType, JstPeriod.AllTime); int total = catalog.EventType is null ? 0 : GetCounter(catalog.EventType, GameCalendarPeriods.AllTime);
int maxLevel = maxLevelByType.TryGetValue(a.AchievementType, out var ml) ? ml : a.Level; int maxLevel = maxLevelByType.TryGetValue(a.AchievementType, out var ml) ? ml : a.Level;
dto.UserAchievementList.Add(new UserAchievementDto dto.UserAchievementList.Add(new UserAchievementDto
{ {
@@ -142,7 +144,7 @@ public sealed class MissionAssembler : IMissionAssembler
// BP monthly missions block — omit when no rows for current month. // BP monthly missions block — omit when no rows for current month.
if (monthlyMissions.Count > 0) if (monthlyMissions.Count > 0)
{ {
var startUtc = new DateTimeOffset(jstNow.Year, jstNow.Month, 1, 2, 0, 0, TimeSpan.FromHours(9)); var startUtc = new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero);
var endUtc = startUtc.AddMonths(1).AddSeconds(-1); var endUtc = startUtc.AddMonths(1).AddSeconds(-1);
dto.BattlePassMonthlyMission = new BPMonthlyMissionsDto dto.BattlePassMonthlyMission = new BPMonthlyMissionsDto
{ {
@@ -151,7 +153,7 @@ public sealed class MissionAssembler : IMissionAssembler
MissionList = monthlyMissions.Select(mm => MissionList = monthlyMissions.Select(mm =>
{ {
int done = mm.EventType is null ? 0 int done = mm.EventType is null ? 0
: GetCounter(mm.EventType, JstPeriod.MonthKey(now)); : GetCounter(mm.EventType, _calendar.MonthKey(now));
var entry = new BPMonthlyMissionDto var entry = new BPMonthlyMissionDto
{ {
Name = mm.Name, Name = mm.Name,

View File

@@ -9,24 +9,27 @@ public sealed class MissionProgressService : IMissionProgressService
private readonly IMissionCatalogRepository _catalog; private readonly IMissionCatalogRepository _catalog;
private readonly IViewerMissionRepository _viewerRepo; private readonly IViewerMissionRepository _viewerRepo;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly IGameCalendarService _calendar;
public MissionProgressService( public MissionProgressService(
SVSimDbContext db, SVSimDbContext db,
IMissionCatalogRepository catalog, IMissionCatalogRepository catalog,
IViewerMissionRepository viewerRepo, IViewerMissionRepository viewerRepo,
TimeProvider time) TimeProvider time,
IGameCalendarService calendar)
{ {
_db = db; _db = db;
_catalog = catalog; _catalog = catalog;
_viewerRepo = viewerRepo; _viewerRepo = viewerRepo;
_time = time; _time = time;
_calendar = calendar;
} }
public async Task RecordEventAsync(long viewerId, IReadOnlyList<string> eventKeys, int delta = 1, CancellationToken ct = default) public async Task RecordEventAsync(long viewerId, IReadOnlyList<string> eventKeys, int delta = 1, CancellationToken ct = default)
{ {
if (eventKeys.Count == 0) return; if (eventKeys.Count == 0) return;
var now = _time.GetUtcNow(); var now = _time.GetUtcNow();
var periods = JstPeriod.AllPeriods(now); var periods = _calendar.AllPeriods(now);
// 1. Increment counters for every (key, period). // 1. Increment counters for every (key, period).
foreach (var key in eventKeys) foreach (var key in eventKeys)
@@ -50,7 +53,7 @@ public sealed class MissionProgressService : IMissionProgressService
var atLevel = catalogRows.FirstOrDefault(r => r.Level == viewerRow.Level); var atLevel = catalogRows.FirstOrDefault(r => r.Level == viewerRow.Level);
if (atLevel is null || atLevel.EventType is null) continue; if (atLevel is null || atLevel.EventType is null) continue;
var count = await _viewerRepo.GetCounterAsync(viewerId, atLevel.EventType, JstPeriod.AllTime, ct); var count = await _viewerRepo.GetCounterAsync(viewerId, atLevel.EventType, GameCalendarPeriods.AllTime, ct);
if (count >= atLevel.RequireNumber && viewerRow.AchievementStatus == 0) if (count >= atLevel.RequireNumber && viewerRow.AchievementStatus == 0)
{ {
viewerRow.AchievementStatus = 1; viewerRow.AchievementStatus = 1;

View File

@@ -7,7 +7,9 @@ using SVSim.Database.Enums;
using SVSim.Database.Models.Config; using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.Deck;
using SVSim.Database.Repositories.BuildDeck; using SVSim.Database.Repositories.BuildDeck;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.BattleXp;
using SVSim.Database.Services.Inventory; using SVSim.Database.Services.Inventory;
using SVSim.Database.Repositories.Story; using SVSim.Database.Repositories.Story;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
@@ -25,6 +27,8 @@ public class StoryService : IStoryService
private readonly IGameConfigService _configService; private readonly IGameConfigService _configService;
private readonly IDeckRepository _deckRepository; private readonly IDeckRepository _deckRepository;
private readonly IBuildDeckRepository _buildDecks; private readonly IBuildDeckRepository _buildDecks;
private readonly IViewerRepository _viewers;
private readonly IBattleXpService _xp;
private readonly ILogger<StoryService> _logger; private readonly ILogger<StoryService> _logger;
public StoryService( public StoryService(
@@ -35,6 +39,8 @@ public class StoryService : IStoryService
IGameConfigService configService, IGameConfigService configService,
IDeckRepository deckRepository, IDeckRepository deckRepository,
IBuildDeckRepository buildDecks, IBuildDeckRepository buildDecks,
IViewerRepository viewers,
IBattleXpService xp,
ILogger<StoryService> logger) ILogger<StoryService> logger)
{ {
_master = master; _master = master;
@@ -44,6 +50,8 @@ public class StoryService : IStoryService
_configService = configService; _configService = configService;
_deckRepository = deckRepository; _deckRepository = deckRepository;
_buildDecks = buildDecks; _buildDecks = buildDecks;
_viewers = viewers;
_xp = xp;
_logger = logger; _logger = logger;
} }
@@ -478,7 +486,7 @@ public class StoryService : IStoryService
resp["mission_parameter"] = Array.Empty<object>(); resp["mission_parameter"] = Array.Empty<object>();
return resp; return resp;
} }
public async Task<FinishResponse> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId) public async Task<StoryFinishOutcome> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId)
{ {
var chapter = await _master.GetChapterByIdAsync(req.StoryId); var chapter = await _master.GetChapterByIdAsync(req.StoryId);
if (chapter is null) if (chapter is null)
@@ -489,9 +497,9 @@ public class StoryService : IStoryService
// StorySubChapter lookup and record progress at the sub's id with isFinish+isSkipped // StorySubChapter lookup and record progress at the sub's id with isFinish+isSkipped
// both true (sub-chapters are always narrative-only — no battle settings on the wire). // both true (sub-chapters are always narrative-only — no battle settings on the wire).
var sub = await _master.FindSubChapterByStoryIdAsync(req.StoryId); var sub = await _master.FindSubChapterByStoryIdAsync(req.StoryId);
if (sub is null) return new FinishResponse(); if (sub is null) return new StoryFinishOutcome(new FinishResponse(), LeveledUp: false, ClassId: null);
await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: true, isSkipped: true); await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: true, isSkipped: true);
return new FinishResponse(); return new StoryFinishOutcome(new FinishResponse(), LeveledUp: false, ClassId: null);
} }
var progress = (await _viewer.GetProgressForChaptersAsync(viewerId, new[] { req.StoryId })) var progress = (await _viewer.GetProgressForChaptersAsync(viewerId, new[] { req.StoryId }))
@@ -573,18 +581,25 @@ public class StoryService : IStoryService
resp.StoryRewardList.AddRange(storyRewardDeltas); resp.StoryRewardList.AddRange(storyRewardDeltas);
} }
bool leveledUp = false;
if (firstClear && isPlayShape) if (firstClear && isPlayShape)
{ {
// XP grant requires a class_id (only sent on play-shape). No-battle chapters // XP grant requires a class_id (only sent on play-shape). No-battle chapters
// have no class context — prod returns get_class_experience=0 for them. // have no class context — prod returns get_class_experience=0 for them.
var xp = _configService.Get<StoryConfig>().ClassXpPerClear; var xpViewer = await _viewers.LoadForBattleXpGrantAsync(viewerId);
resp.GetClassExperience = xp.ToString(); if (xpViewer is not null && req.ClassId.HasValue)
// class_experience / class_level updates would consult the viewer's per-class XP {
// table — placeholder zeros; wire to viewer.Classes[class_id] when that path exists. var xp = await _xp.GrantAsync(xpViewer, req.ClassId.Value, isWin: true, BattleXpMode.Story);
resp.ClassExperience = 0; await _db.SaveChangesAsync();
resp.ClassLevel = "0"; resp.GetClassExperience = xp.GetXp.ToString();
resp.ClassExperience = xp.TotalXp;
resp.ClassLevel = xp.Level.ToString();
leveledUp = xp.LeveledUp;
} }
} }
return new StoryFinishOutcome(resp, LeveledUp: leveledUp, ClassId: req.ClassId);
}
else else
{ {
// Skip-shape: optionally unlock a branch child if selection_chapter_id is set. // Skip-shape: optionally unlock a branch child if selection_chapter_id is set.
@@ -598,7 +613,7 @@ public class StoryService : IStoryService
await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: null, isSkipped: true); await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: null, isSkipped: true);
} }
return resp; return new StoryFinishOutcome(resp, LeveledUp: false, ClassId: null);
} }
public async Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId) public async Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId)
{ {

View File

@@ -6,5 +6,10 @@
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Warning", "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Warning",
"Microsoft.EntityFrameworkCore": "Warning" "Microsoft.EntityFrameworkCore": "Warning"
} }
},
"Admin": {
// Fixed secret so AdminControllerTests can send X-Admin-Secret without going through
// a per-test config knob. Tests read it back via AdminControllerTests.TestSecret.
"ImportSecret": "test-admin-secret"
} }
} }

View File

@@ -27,5 +27,20 @@
"Deck": { "Deck": {
"MaxDeckSlots": 36 "MaxDeckSlots": 36
}, },
"Admin": {
// Shared secret required in the X-Admin-Secret header on /admin/* util endpoints
// (see RequireAdminSecretAttribute). Leaving this blank disables the endpoint entirely
// — fail-closed so an unconfigured deployment never exposes admin surface. Override with
// the ADMIN__IMPORTSECRET env var.
"ImportSecret": "replaceme"
},
"BattleNode": {
// Socket.IO v2 endpoint URL echoed to the client on /*/do_matching success.
// Wire format: "host[:port]/socket.io/" — no scheme prefix, trailing slash REQUIRED.
// Host may be an IP, hostname, or FQDN. BestHTTP's SocketManager parses this string
// directly; a leading http:// or https:// will make the client fail to connect.
// Startup throws if this value is missing or blank; there is no hardcoded fallback.
"NodeServerUrl": "localhost:5148/socket.io/"
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View File

@@ -100,7 +100,7 @@ public class AchievementControllerTests
var grant = rewardList[0]; var grant = rewardList[0];
Assert.That(grant.GetProperty("reward_type").GetInt32(), Is.EqualTo(9)); Assert.That(grant.GetProperty("reward_type").GetInt32(), Is.EqualTo(9));
// For currency grants, reward_num is the POST-STATE TOTAL (per project convention, // For currency grants, reward_num is the POST-STATE TOTAL (per project convention,
// matches /pack/open behavior). Default-seeded viewer has 50000 rupees → 50100 after grant. // matches /pack/open behavior). Default-seeded viewer starts at 0 rupees → 100 after grant.
Assert.That(grant.GetProperty("reward_num").GetInt32(), Is.GreaterThanOrEqualTo(100), Assert.That(grant.GetProperty("reward_num").GetInt32(), Is.GreaterThanOrEqualTo(100),
"reward_num is post-state total for currencies, must be at least the granted amount"); "reward_num is post-state total for currencies, must be at least the granted amount");
} }

View File

@@ -14,10 +14,12 @@ using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers; namespace SVSim.UnitTests.Controllers;
/// <summary> /// <summary>
/// End-to-end coverage for <c>/admin/import_viewer</c>. The endpoint is [AllowAnonymous] so /// End-to-end coverage for <c>/admin/import_viewer</c>. The endpoint is [AllowAnonymous] +
/// these tests don't need to seed a viewer first; the fresh-user path exercises the just-fixed /// [RequireAdminSecret], so tests reach it through <see cref="SVSimTestFactory.CreateAdminClient"/>
/// nav-graph NRE inside <c>ViewerRepository.RegisterViewer</c>, and the existing-user path /// which bakes in the <c>X-Admin-Secret</c> header from <c>appsettings.Testing.json</c>. The
/// exercises the owned-type lookup used to dedupe by Steam id. /// fresh-user path exercises the nav-graph NRE fix inside <c>ViewerRepository.RegisterViewer</c>;
/// the existing-user path exercises the owned-type Steam-id lookup. Negative-path tests at the
/// bottom of the file cover the header gate itself.
/// </summary> /// </summary>
public class AdminControllerTests public class AdminControllerTests
{ {
@@ -27,7 +29,7 @@ public class AdminControllerTests
public async Task ImportViewer_fresh_user_creates_viewer_and_returns_ids() public async Task ImportViewer_fresh_user_creates_viewer_and_returns_ids()
{ {
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
using var client = factory.CreateClient(); using var client = factory.CreateAdminClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{ {
@@ -71,7 +73,7 @@ public class AdminControllerTests
const ulong steamId = 76_561_198_555_666_777UL; const ulong steamId = 76_561_198_555_666_777UL;
long seededId = await factory.SeedViewerAsync(steamId: steamId, displayName: "Original Name"); long seededId = await factory.SeedViewerAsync(steamId: steamId, displayName: "Original Name");
using var client = factory.CreateClient(); using var client = factory.CreateAdminClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{ {
SteamId = steamId, SteamId = steamId,
@@ -107,7 +109,7 @@ public class AdminControllerTests
public async Task ImportViewer_missing_steam_id_returns_400() public async Task ImportViewer_missing_steam_id_returns_400()
{ {
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
using var client = factory.CreateClient(); using var client = factory.CreateAdminClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{ {
@@ -122,7 +124,7 @@ public class AdminControllerTests
public async Task ImportViewer_imports_owned_cards_and_skips_unknown() public async Task ImportViewer_imports_owned_cards_and_skips_unknown()
{ {
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
using var client = factory.CreateClient(); using var client = factory.CreateAdminClient();
// 10001001 is in the minimal test card set; 99999999 is not. // 10001001 is in the minimal test card set; 99999999 is not.
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -156,7 +158,7 @@ public class AdminControllerTests
public async Task ImportViewer_clamps_card_count_to_max_copies() public async Task ImportViewer_clamps_card_count_to_max_copies()
{ {
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
using var client = factory.CreateClient(); using var client = factory.CreateAdminClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{ {
@@ -181,7 +183,7 @@ public class AdminControllerTests
long viewerId = await factory.SeedViewerAsync(steamId: steamId); long viewerId = await factory.SeedViewerAsync(steamId: steamId);
await factory.SeedOwnedCardAsync(viewerId, 10001001L, count: 3); await factory.SeedOwnedCardAsync(viewerId, 10001001L, count: 3);
using var client = factory.CreateClient(); using var client = factory.CreateAdminClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{ {
SteamId = steamId, SteamId = steamId,
@@ -207,7 +209,7 @@ public class AdminControllerTests
// Registers the ItemEntry master row (70001) and gives an initial owned count to be replaced. // Registers the ItemEntry master row (70001) and gives an initial owned count to be replaced.
await factory.SeedOwnedItemAsync(viewerId, itemId: 70001, count: 1); await factory.SeedOwnedItemAsync(viewerId, itemId: 70001, count: 1);
using var client = factory.CreateClient(); using var client = factory.CreateAdminClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{ {
SteamId = steamId, SteamId = steamId,
@@ -242,7 +244,7 @@ public class AdminControllerTests
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id; leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
} }
using var client = factory.CreateClient(); using var client = factory.CreateAdminClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{ {
SteamId = steamId, SteamId = steamId,
@@ -294,7 +296,7 @@ public class AdminControllerTests
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id; leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
} }
using var client = factory.CreateClient(); using var client = factory.CreateAdminClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{ {
SteamId = steamId, SteamId = steamId,
@@ -329,7 +331,7 @@ public class AdminControllerTests
public async Task ImportViewer_fresh_user_has_no_decks_when_none_imported() public async Task ImportViewer_fresh_user_has_no_decks_when_none_imported()
{ {
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
using var client = factory.CreateClient(); using var client = factory.CreateAdminClient();
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{ {
@@ -380,7 +382,7 @@ public class AdminControllerTests
} }
"""; """;
using var client = factory.CreateClient(); using var client = factory.CreateAdminClient();
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/admin/import_viewer", content); var response = await client.PostAsync("/admin/import_viewer", content);
@@ -438,7 +440,7 @@ public class AdminControllerTests
} }
"""; """;
using var client = factory.CreateClient(); using var client = factory.CreateAdminClient();
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/admin/import_viewer", content); var response = await client.PostAsync("/admin/import_viewer", content);
@@ -457,7 +459,7 @@ public class AdminControllerTests
public async Task ImportViewer_MissionMeta_RoundTrips() public async Task ImportViewer_MissionMeta_RoundTrips()
{ {
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000001UL; ulong steamId = 70000000000000001UL;
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -465,9 +467,9 @@ public class AdminControllerTests
SteamId = steamId, SteamId = steamId,
MissionMeta = new ImportMissionMeta MissionMeta = new ImportMissionMeta
{ {
HasReceivedPickTwoMission = true, HasReceivedPickTwoMission = 1, // wire "1"/"0"; controller normalizes to bool
MissionReceiveType = 2, MissionReceiveType = 2,
MissionChangeTime = 1_700_000_000L MissionChangeTime = "2023-11-14 22:13:20" // wire "yyyy-MM-dd HH:mm:ss"; parsed as UTC
} }
}); });
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
@@ -482,9 +484,10 @@ public class AdminControllerTests
Assert.That(viewer.MissionData, Is.Not.Null); Assert.That(viewer.MissionData, Is.Not.Null);
Assert.That(viewer.MissionData!.HasReceivedPickTwoMission, Is.True); Assert.That(viewer.MissionData!.HasReceivedPickTwoMission, Is.True);
Assert.That(viewer.MissionData.MissionReceiveType, Is.EqualTo(2)); Assert.That(viewer.MissionData.MissionReceiveType, Is.EqualTo(2));
// MissionChangeTime is DateTime in storage; verify it survives the round-trip from unix seconds. // MissionChangeTime is DateTime in storage; the datetime string parses as UTC
// (AssumeUniversal | AdjustToUniversal in AdminController.Pass A).
Assert.That(viewer.MissionData.MissionChangeTime, Assert.That(viewer.MissionData.MissionChangeTime,
Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000L).UtcDateTime)); Is.EqualTo(new DateTime(2023, 11, 14, 22, 13, 20, DateTimeKind.Utc)));
} }
[Test] [Test]
@@ -501,7 +504,7 @@ public class AdminControllerTests
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000002UL; ulong steamId = 70000000000000002UL;
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -532,7 +535,7 @@ public class AdminControllerTests
public async Task ImportViewer_Missions_UnknownMissionIdSkipped() public async Task ImportViewer_Missions_UnknownMissionIdSkipped()
{ {
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000003UL; ulong steamId = 70000000000000003UL;
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -566,7 +569,7 @@ public class AdminControllerTests
}); });
await seedDb.SaveChangesAsync(); await seedDb.SaveChangesAsync();
} }
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000004UL; ulong steamId = 70000000000000004UL;
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -604,7 +607,7 @@ public class AdminControllerTests
new MissionCatalogEntry { Id = 9004, LotType = 2, EventType = "battle_win_total", EventArg = null }); new MissionCatalogEntry { Id = 9004, LotType = 2, EventType = "battle_win_total", EventArg = null });
await seedDb.SaveChangesAsync(); await seedDb.SaveChangesAsync();
} }
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000005UL; ulong steamId = 70000000000000005UL;
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -651,7 +654,7 @@ public class AdminControllerTests
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000007UL; ulong steamId = 70000000000000007UL;
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -686,7 +689,7 @@ public class AdminControllerTests
public async Task ImportViewer_Achievements_UnknownTypeSkipped() public async Task ImportViewer_Achievements_UnknownTypeSkipped()
{ {
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000008UL; ulong steamId = 70000000000000008UL;
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -723,7 +726,7 @@ public class AdminControllerTests
new AchievementCatalogEntry { AchievementType = 602, Level = 1, EventType = "battle_win_total", EventArg = null }); new AchievementCatalogEntry { AchievementType = 602, Level = 1, EventType = "battle_win_total", EventArg = null });
await seedDb.SaveChangesAsync(); await seedDb.SaveChangesAsync();
} }
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000009UL; ulong steamId = 70000000000000009UL;
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -773,7 +776,7 @@ public class AdminControllerTests
}); });
await seedDb.SaveChangesAsync(); await seedDb.SaveChangesAsync();
} }
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000006UL; ulong steamId = 70000000000000006UL;
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -810,7 +813,7 @@ public class AdminControllerTests
db.StoryWorlds.Add(new StoryWorld { Id = 20_000_005 }); // Event db.StoryWorlds.Add(new StoryWorld { Id = 20_000_005 }); // Event
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000007UL; ulong steamId = 70000000000000007UL;
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -849,7 +852,7 @@ public class AdminControllerTests
db.StoryWorlds.Add(new StoryWorld { Id = 11 }); // sub_chapter (own row) db.StoryWorlds.Add(new StoryWorld { Id = 11 }); // sub_chapter (own row)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000008UL; ulong steamId = 70000000000000008UL;
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -877,7 +880,7 @@ public class AdminControllerTests
public async Task ImportViewer_StoryProgress_UnknownStoryIdSkipped() public async Task ImportViewer_StoryProgress_UnknownStoryIdSkipped()
{ {
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000009UL; ulong steamId = 70000000000000009UL;
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
@@ -913,7 +916,7 @@ public class AdminControllerTests
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
const ulong steamId = 70000000000000020UL; const ulong steamId = 70000000000000020UL;
// First POST: both missions present (explicit slots to avoid the (ViewerId,Slot) unique index). // First POST: both missions present (explicit slots to avoid the (ViewerId,Slot) unique index).
@@ -980,7 +983,7 @@ public class AdminControllerTests
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
const ulong steamId = 70000000000000021UL; const ulong steamId = 70000000000000021UL;
// First POST: both achievements present. // First POST: both achievements present.
@@ -1043,13 +1046,13 @@ public class AdminControllerTests
db.StoryWorlds.Add(new StoryWorld { Id = 50 }); db.StoryWorlds.Add(new StoryWorld { Id = 50 });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
var client = factory.CreateClient(); var client = factory.CreateAdminClient();
ulong steamId = 70000000000000010UL; ulong steamId = 70000000000000010UL;
var req = new ImportViewerRequest var req = new ImportViewerRequest
{ {
SteamId = steamId, SteamId = steamId,
MissionMeta = new ImportMissionMeta { HasReceivedPickTwoMission = true, MissionReceiveType = 1, MissionChangeTime = 1L }, MissionMeta = new ImportMissionMeta { HasReceivedPickTwoMission = 1, MissionReceiveType = 1, MissionChangeTime = "2023-01-01 00:00:00" },
Missions = new List<ImportMission> { new() { MissionId = 8001, MissionStatus = 1, TotalCount = 5 } }, Missions = new List<ImportMission> { new() { MissionId = 8001, MissionStatus = 1, TotalCount = 5 } },
Achievements = new List<ImportAchievement> { new() { AchievementType = 800, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 9 } }, Achievements = new List<ImportAchievement> { new() { AchievementType = 800, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 9 } },
StoryProgress = new List<ImportStoryProgress> { new() { StoryApiType = 1, StoryId = 50, IsFinish = true } } StoryProgress = new List<ImportStoryProgress> { new() { StoryApiType = 1, StoryId = 50, IsFinish = true } }
@@ -1069,4 +1072,35 @@ public class AdminControllerTests
Assert.That(verifyDb.ViewerStoryProgress.Count(p => p.ViewerId == viewerId), Is.EqualTo(1)); Assert.That(verifyDb.ViewerStoryProgress.Count(p => p.ViewerId == viewerId), Is.EqualTo(1));
Assert.That(verifyDb.ViewerEventCounters.Count(c => c.ViewerId == viewerId), Is.EqualTo(2)); Assert.That(verifyDb.ViewerEventCounters.Count(c => c.ViewerId == viewerId), Is.EqualTo(2));
} }
// --- X-Admin-Secret gate ---
[Test]
public async Task ImportViewer_without_secret_header_returns_401()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient(); // no X-Admin-Secret header
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = 76_561_198_999_999_001UL
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
[Test]
public async Task ImportViewer_with_wrong_secret_returns_401()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Admin-Secret", "not-the-real-secret");
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = 76_561_198_999_999_002UL
});
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
} }

View File

@@ -118,6 +118,13 @@ public class ArenaColosseumBattleControllerTests
var resp = await client.PostAsync("/colosseum_battle/finish", JsonContent.Create(req)); var resp = await client.PostAsync("/colosseum_battle/finish", JsonContent.Create(req));
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
// XP wire assertions
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
// XpPerWin=200; classexp.csv L1=50, L2=150 → 200 XP crosses both: L3, Exp=0.
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(200));
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
Assert.That(doc.RootElement.GetProperty("class_level").GetInt32(), Is.EqualTo(3));
using var scope = factory.Services.CreateScope(); using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>(); var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var run = await db.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid); var run = await db.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid);
@@ -125,6 +132,13 @@ public class ArenaColosseumBattleControllerTests
Assert.That(run.LossCount, Is.EqualTo(0)); Assert.That(run.LossCount, Is.EqualTo(0));
Assert.That(run.BattleCountThisRound, Is.EqualTo(1)); Assert.That(run.BattleCountThisRound, Is.EqualTo(1));
Assert.That(run.ResultListJson, Is.EqualTo("[1]")); Assert.That(run.ResultListJson, Is.EqualTo("[1]"));
// Persisted class XP on viewer
var v = await db.Viewers.Include(x => x.Classes).ThenInclude(c => c.Class)
.FirstAsync(x => x.Id == vid);
var cls1 = v.Classes.Single(c => c.Class.Id == 1);
Assert.That(cls1.Level, Is.EqualTo(3));
Assert.That(cls1.Exp, Is.EqualTo(0));
} }
[Test] [Test]

View File

@@ -114,22 +114,24 @@ public class FreeBattleControllerTests
} }
[Test] [Test]
public async Task Finish_emits_battle_result_echo_and_four_field_minimum() public async Task Finish_win_grants_class_xp_with_strict_field_subset()
{ {
await using var factory = new SVSimTestFactory(); await using var factory = new SVSimTestFactory();
var viewerId = await factory.SeedViewerAsync(); var viewerId = await factory.SeedViewerAsync();
var client = factory.CreateAuthenticatedClient(viewerId); var client = factory.CreateAuthenticatedClient(viewerId);
var resp = await client.PostAsJsonAsync("/unlimited_free_battle/finish", FinishBody(battleResult: 1)); var resp = await client.PostAsJsonAsync(
"/unlimited_free_battle/finish", FinishBody(battleResult: 1, classId: 1));
Assert.That(resp.IsSuccessStatusCode, Is.True); Assert.That(resp.IsSuccessStatusCode, Is.True);
var raw = await resp.Content.ReadAsStringAsync(); var raw = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(raw); using var doc = JsonDocument.Parse(raw);
var data = doc.RootElement; var data = doc.RootElement;
Assert.That(data.GetProperty("battle_result").GetInt32(), Is.EqualTo(1)); Assert.That(data.GetProperty("battle_result").GetInt32(), Is.EqualTo(1));
Assert.That(data.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(0)); // XpPerWin=200; classexp.csv L1=50, L2=150 → 200 XP crosses both: L3, Exp=0.
Assert.That(data.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(200));
Assert.That(data.GetProperty("class_experience").GetInt32(), Is.EqualTo(0)); Assert.That(data.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
Assert.That(data.GetProperty("class_level").GetInt32(), Is.EqualTo(1)); Assert.That(data.GetProperty("class_level").GetInt32(), Is.EqualTo(3));
// Strict subset — no rank fields. The client doesn't read them on free-battle // Strict subset — no rank fields. The client doesn't read them on free-battle
// finish; emitting them would be wire-format pollution. // finish; emitting them would be wire-format pollution.
@@ -140,16 +142,21 @@ public class FreeBattleControllerTests
} }
[Test] [Test]
public async Task Finish_with_consistency_result_echoes_2_on_rotation_url() public async Task Finish_loss_grants_loss_xp_on_rotation_url()
{ {
await using var factory = new SVSimTestFactory(); await using var factory = new SVSimTestFactory();
var viewerId = await factory.SeedViewerAsync(); var viewerId = await factory.SeedViewerAsync();
var client = factory.CreateAuthenticatedClient(viewerId); var client = factory.CreateAuthenticatedClient(viewerId);
var resp = await client.PostAsJsonAsync("/rotation_free_battle/finish", FinishBody(battleResult: 2)); var resp = await client.PostAsJsonAsync(
"/rotation_free_battle/finish", FinishBody(battleResult: 2, classId: 1));
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
Assert.That(doc.RootElement.GetProperty("battle_result").GetInt32(), Is.EqualTo(2)); Assert.That(doc.RootElement.GetProperty("battle_result").GetInt32(), Is.EqualTo(2));
// XpPerLoss=50 exactly meets L1 threshold → L2, Exp=0.
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(50));
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
Assert.That(doc.RootElement.GetProperty("class_level").GetInt32(), Is.EqualTo(2));
} }
[Test] [Test]

View File

@@ -54,4 +54,27 @@ public class MyPageControllerTests
var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.That(root.GetProperty("can_give_daily_login_bonus").GetBoolean(), Is.False); Assert.That(root.GetProperty("can_give_daily_login_bonus").GetBoolean(), Is.False);
} }
[Test]
public async Task MyPage_unread_present_count_reflects_unclaimed_viewer_presents()
{
// Drives the home-screen crate badge — MyPageTask parses `unread_present_count` into
// Data.MyPage.data.unread_mail_count, which MyPageItemHome.SetUnreadGiftCount reads to
// show the "N" bubble on the gift button. Stubbed to 0 would hide it even when the
// tutorial gift has 5 rewards sitting unclaimed.
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedTutorialPresentsAsync(viewerId);
using var client = factory.CreateAuthenticatedClient(viewerId);
var resp = await client.PostAsync("/mypage/index",
new StringContent(MyPageRequestJson, Encoding.UTF8, "application/json"));
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), await resp.Content.ReadAsStringAsync());
var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
int count = root.GetProperty("unread_present_count").GetInt32();
Assert.That(count, Is.GreaterThan(0),
"unread_present_count must be the viewer's live Unclaimed ViewerPresent count — a stub 0 hides the crate badge.");
}
} }

View File

@@ -299,4 +299,87 @@ public class PackControllerInfoTests
Assert.That(children.Count, Is.EqualTo(1), "Only the ticket child should remain after today's free claim"); Assert.That(children.Count, Is.EqualTo(1), "Only the ticket child should remain after today's free claim");
Assert.That(children[0].GetProperty("type_detail").GetInt32(), Is.EqualTo(5)); Assert.That(children[0].GetProperty("type_detail").GetInt32(), Is.EqualTo(5));
} }
[Test]
public async Task Info_emits_is_daily_single_true_before_todays_claim()
{
// Client's DAILY-branch UI (GachaPackAreaLayout.cs:420) renders the half-off button
// iff the wire ships `is_daily_single: true`. Pre-claim it must be truthy.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Packs.Add(new PackConfigEntry
{
Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "daily test",
ChildGachas =
{
new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 50, CardCount = 8, IsDailySingle = true },
new PackChildGachaEntry { GachaId = 100002, TypeDetail = CardPackType.CrystalMulti, Cost = 100, CardCount = 8 },
},
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var daily = doc.RootElement.GetProperty("pack_config_list")[0]
.GetProperty("child_gacha_info").EnumerateArray()
.Single(c => c.GetProperty("type_detail").GetInt32() == 3);
Assert.That(daily.TryGetProperty("is_daily_single", out var flag), Is.True,
"is_daily_single must be present when claimable");
Assert.That(flag.GetBoolean(), Is.True);
}
[Test]
public async Task Info_suppresses_is_daily_single_after_todays_claim()
{
// Bug repro: after a successful DAILY open, /pack/info kept emitting is_daily_single=true,
// so the client re-showed the half-off button. Clicking it again 400'd with
// daily_free_already_claimed. Post-claim the flag must be false (or omitted) so the
// client's DAILY-branch renders the full-price crystal button instead. The child entry
// itself stays so CRYSTAL_MULTI still activates.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Packs.Add(new PackConfigEntry
{
Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "daily test",
ChildGachas =
{
new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 50, CardCount = 8, IsDailySingle = true },
new PackChildGachaEntry { GachaId = 100002, TypeDetail = CardPackType.CrystalMulti, Cost = 100, CardCount = 8 },
},
});
var v = await db.Viewers.Include(x => x.PackOpenCounts).FirstAsync(x => x.Id == viewerId);
v.PackOpenCounts.Add(new ViewerPackOpenCount { PackId = 10001, OpenCount = 1, LastDailyFreeAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var children = doc.RootElement.GetProperty("pack_config_list")[0]
.GetProperty("child_gacha_info").EnumerateArray().ToList();
Assert.That(children.Count, Is.EqualTo(2), "child entries stay; only the daily-single flag flips");
var daily = children.Single(c => c.GetProperty("type_detail").GetInt32() == 3);
// With WhenWritingDefault, the wire omits the key entirely when false — either shape is
// fine (PackInfoTask.cs:143-150 defaults to false on missing key).
bool value = daily.TryGetProperty("is_daily_single", out var flag) && flag.GetBoolean();
Assert.That(value, Is.False,
"is_daily_single must be false/absent post-claim so the client stops offering the half-off button");
}
} }

View File

@@ -399,8 +399,10 @@ public class PackControllerOpenTests
} }
[Test] [Test]
public async Task Open_daily_marks_last_daily_free_at_and_rejects_second_attempt() public async Task Open_daily_debits_crystals_and_rejects_second_attempt()
{ {
// DAILY is the once-per-day half-off CRYSTAL single-pack (GachaUI.cs:1046 →
// CheckBuyPackWithCrystal). Cost=50 crystals; second same-day attempt 400s.
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(); long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope()) using (var scope = factory.Services.CreateScope())
@@ -412,8 +414,11 @@ public class PackControllerOpenTests
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None, Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "daily test", GachaType = 1, GachaDetail = "daily test",
ChildGachas = { new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 0, CardCount = 1, IsDailySingle = true } }, ChildGachas = { new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 50, CardCount = 1, IsDailySingle = true } },
}); });
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.Currency.Crystals = 200;
v.Currency.Rupees = 200;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
@@ -424,9 +429,17 @@ public class PackControllerOpenTests
var first = await client.PostAsync("/pack/open", JsonBody(json)); var first = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK), await first.Content.ReadAsStringAsync()); Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK), await first.Content.ReadAsStringAsync());
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
Assert.That(v.Currency.Crystals, Is.EqualTo(150UL), "200 starting - 50 daily-single crystal cost");
Assert.That(v.Currency.Rupees, Is.EqualTo(200UL), "Rupees must not be touched by a DAILY (crystal) open");
}
var second = await client.PostAsync("/pack/open", JsonBody(json)); var second = await client.PostAsync("/pack/open", JsonBody(json));
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
"Second daily-free open the same UTC day should be rejected."); "Second daily-single open the same UTC day should be rejected.");
} }
// ---------------- Regression tests for wire-shape quirks ---------------- // ---------------- Regression tests for wire-shape quirks ----------------
@@ -714,71 +727,6 @@ public class PackControllerOpenTests
Assert.That(v.Currency.Rupees, Is.EqualTo(0UL), "freeplay must not deduct real DB balance"); Assert.That(v.Currency.Rupees, Is.EqualTo(0UL), "freeplay must not deduct real DB balance");
} }
[Test]
public async Task TutorialPackOpen_does_not_accrue_gacha_points()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
// Seed the starter pack 99047 with a GachaPointConfig set — the tutorial-path skip
// must hold even when the pack is technically point-eligible.
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Classes.Add(new ClassEntry { Id = 0, Name = "Neutral" });
var set = new ShadowverseCardSetEntry { Id = 99047, IsInRotation = true };
db.CardSets.Add(set);
for (int i = 0; i < 30; i++)
{
set.Cards.Add(new ShadowverseCardEntry
{
Id = 99047_1010 + i, Name = $"c{i}",
Rarity = (Rarity)((i % 4) + 1),
Class = db.Classes.Local.First(), IsFoil = false,
});
}
db.Items.Add(new ItemEntry { Id = 90001, Name = "starter-ticket" });
db.Packs.Add(new PackConfigEntry
{
Id = 99047, BasePackId = 99047, PackCategory = PackCategory.LegendCardPack,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1,
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
ChildGachas =
{
new PackChildGachaEntry
{
GachaId = 990475, TypeDetail = CardPackType.TicketMulti, Cost = 0, CardCount = 8,
ItemId = 90001,
},
},
});
var viewer = await db.Viewers
.Include(v => v.Items).ThenInclude(i => i.Item)
.FirstAsync(v => v.Id == viewerId);
viewer.Items.Add(new OwnedItemEntry { Item = db.Items.Local.First(), Count = 1, Viewer = viewer });
viewer.MissionData.TutorialState = 41; // pre-END so the tutorial path is allowed
await db.SaveChangesAsync();
}
// Install a draw table for 99047 pointing at the 30 seeded card stubs.
var seededCardIds = Enumerable.Range(0, 30).Select(i => (long)(99047_1010 + i)).ToArray();
await factory.SeedPackDrawTableAsync(99047, seededCardIds);
using var client = factory.CreateAuthenticatedClient(viewerId);
var body = new StringContent(
"""{"parent_gacha_id":99047,"gacha_id":990475,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""",
System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/tutorial/pack_open", body);
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await response.Content.ReadAsStringAsync());
using var scope2 = factory.Services.CreateScope();
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db2.Viewers
.Include(x => x.GachaPointBalances)
.FirstAsync(x => x.Id == viewerId);
Assert.That(v.GachaPointBalances, Is.Empty, "tutorial path must not accrue gacha points");
}
// ---------------- Free pack (type_detail=10) ---------------- // ---------------- Free pack (type_detail=10) ----------------
/// <summary> /// <summary>

View File

@@ -181,69 +181,43 @@ public class PackControllerTests
"Regular /pack/open must never emit tutorial_step — only /tutorial/pack_open does."); "Regular /pack/open must never emit tutorial_step — only /tutorial/pack_open does.");
} }
[Test]
public async Task TutorialPackOpen_rejects_non_starter_parent_gacha_id()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
using var client = factory.CreateAuthenticatedClient(viewerId);
// Pick any non-99047 parent_gacha_id seeded by SeedGlobalsAsync (10032 is the most
// recent crystal-multi pack in the catalog). The alias must reject it BadRequest.
var requestJson = """{"parent_gacha_id":10032,"gacha_id":100320,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/tutorial/pack_open",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
"Tutorial alias must only accept the starter pack (99047); otherwise any authenticated " +
"viewer can draw any pack for free via the currency-bypass tutorial path.");
// State must NOT have advanced.
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(41),
"Rejected requests leave TutorialState untouched.");
}
[Test]
public async Task TutorialPackOpen_rejects_completed_viewer()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 100);
await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket");
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/tutorial/pack_open",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
"Tutorial alias must reject viewers past the tutorial-end gate (state>=100); the path " +
"would otherwise re-clobber state and consume a ticket the viewer kept post-tutorial.");
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(1),
"Rejected requests do not consume tickets.");
}
[Test] [Test]
public async Task TutorialPackOpen_does_not_downgrade_state_past_100() public async Task TutorialPackOpen_does_not_downgrade_state_past_100()
{ {
// This is the max-preserve check. A future state > 100 (e.g., a post-tutorial training // Max-preserve: a state > 100 (e.g., a post-tutorial training sentinel) must not be
// sentinel) must not be clobbered down to 100. Today nothing in prod sets state above 100, // clobbered down to 100. Nothing in prod sets state above 100 today, so synthesize
// so synthesize the case directly. // the case directly. The viewer must have the starter ticket + card pool seeded so
// the path reaches the tutorial epilogue (rather than 400ing before the state write).
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync(); await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 200); long viewerId = await factory.SeedViewerAsync(tutorialState: 200);
using var client = factory.CreateAuthenticatedClient(viewerId); await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket");
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.CardSets.Add(new ShadowverseCardSetEntry
{
Id = 90001, Name = "TutorialStarterSet", IsInRotation = true, IsBasic = false,
Cards =
[
new ShadowverseCardEntry { Id = 90001001L, Name = "StarterCard1", Rarity = Rarity.Bronze },
new ShadowverseCardEntry { Id = 90001002L, Name = "StarterCard2", Rarity = Rarity.Gold },
new ShadowverseCardEntry { Id = 90001003L, Name = "StarterCard3", Rarity = Rarity.Legendary },
],
});
await db.SaveChangesAsync();
}
await factory.SeedPackDrawTableAsync(99047, 90001001L, 90001002L, 90001003L);
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/tutorial/pack_open", var response = await client.PostAsync("/tutorial/pack_open",
new StringContent(requestJson, Encoding.UTF8, "application/json")); new StringContent(requestJson, Encoding.UTF8, "application/json"));
// Either the request is rejected (because state>=100, see Gate B above), OR — if the var body = await response.Content.ReadAsStringAsync();
// implementation reads the gate differently — at minimum the persisted state must not Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
// regress. Encode the load-bearing invariant: state never goes backwards.
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.GreaterThanOrEqualTo(200), Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.GreaterThanOrEqualTo(200),
"TutorialState must not regress regardless of the alias's accept/reject decision."); "TutorialState must not regress when the alias fires against a viewer past END.");
} }
} }

View File

@@ -197,7 +197,7 @@ public class PracticeControllerTests
} }
[Test] [Test]
public async Task Finish_accepts_any_recovery_data_returns_zero_xp() public async Task Finish_win_grants_class_xp_and_persists_level_up()
{ {
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(); long viewerId = await factory.SeedViewerAsync();
@@ -218,8 +218,44 @@ public class PracticeControllerTests
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body); using var doc = JsonDocument.Parse(body);
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(0)); // BattleXpConfig.XpPerWin default = 200. classexp.csv seeds L1=50, L2=150 → 200 XP
// crosses L1 (spends 50, level→2, exp=150) then L2 (spends 150, level→3, exp=0).
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(200));
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0)); Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
Assert.That(doc.RootElement.GetProperty("class_level").GetInt32(), Is.EqualTo(3));
Assert.That(doc.RootElement.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0)); Assert.That(doc.RootElement.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0));
// Persistence check — reload viewer and confirm the ViewerClassData row moved.
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.Classes).ThenInclude(c => c.Class)
.FirstAsync(x => x.Id == viewerId);
var cls1 = v.Classes.Single(c => c.Class.Id == 1);
Assert.That(cls1.Level, Is.EqualTo(3));
Assert.That(cls1.Exp, Is.EqualTo(0));
}
[Test]
public async Task Finish_loss_grants_default_loss_xp()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var finishJson =
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"is_win":0,"evolve_count":0,"total_turn":3,"enemy_class_id":3,"difficulty":1,"deck_format":1,"class_id":1,"recovery_data":"{}"}""";
var response = await client.PostAsync("/practice/finish",
new StringContent(finishJson, Encoding.UTF8, "application/json"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
// BattleXpConfig.XpPerLoss default = 50. classexp.csv L1=50 → 50 XP exactly meets
// the threshold: Level=2, Exp=0.
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(50));
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
Assert.That(doc.RootElement.GetProperty("class_level").GetInt32(), Is.EqualTo(2));
} }
} }

View File

@@ -1,10 +1,13 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework; using NUnit.Framework;
using SVSim.BattleNode.Bridge; using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions;
using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure; using SVSim.UnitTests.Infrastructure;
@@ -231,21 +234,30 @@ public class RankBattleControllerTests
} }
[Test] [Test]
public async Task Finish_emits_stubbed_zeros_with_battle_result_echo() public async Task Finish_grants_win_xp_via_ai_variant()
{ {
await using var factory = new SVSimTestFactory(); await using var factory = new SVSimTestFactory();
var viewerId = await factory.SeedViewerAsync(); var viewerId = await factory.SeedViewerAsync();
var client = factory.CreateAuthenticatedClient(viewerId); var client = factory.CreateAuthenticatedClient(viewerId);
var resp = await client.PostAsJsonAsync("/ai_rotation_rank_battle/finish", FinishBody(battleResult: 1)); var resp = await client.PostAsJsonAsync(
"/ai_rotation_rank_battle/finish", FinishBody(battleResult: 1, classId: 1));
Assert.That(resp.IsSuccessStatusCode, Is.True); Assert.That(resp.IsSuccessStatusCode, Is.True);
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
var data = doc.RootElement; var data = doc.RootElement;
Assert.That(data.GetProperty("battle_result").GetInt32(), Is.EqualTo(1)); Assert.That(data.GetProperty("battle_result").GetInt32(), Is.EqualTo(1));
Assert.That(data.GetProperty("rank").GetInt32(), Is.EqualTo(0)); // First-ever win: +100 → Beginner 1 (rank_id=2), Point=100.
Assert.That(data.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(0)); Assert.That(data.GetProperty("rank").GetInt32(), Is.EqualTo(2));
Assert.That(data.GetProperty("class_level").GetInt32(), Is.EqualTo(1)); Assert.That(data.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(100));
Assert.That(data.GetProperty("battle_point").GetInt32(), Is.EqualTo(100));
Assert.That(data.GetProperty("after_master_point").GetInt32(), Is.EqualTo(0));
Assert.That(data.GetProperty("master_point").GetInt32(), Is.EqualTo(0));
// BattleXpConfig.XpPerWin=200; classexp.csv L1=50, L2=150 → 200 XP crosses both
// thresholds: land at L3 with 0.
Assert.That(data.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(200));
Assert.That(data.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
Assert.That(data.GetProperty("class_level").GetInt32(), Is.EqualTo(3));
} }
[Test] [Test]
@@ -255,9 +267,139 @@ public class RankBattleControllerTests
var viewerId = await factory.SeedViewerAsync(); var viewerId = await factory.SeedViewerAsync();
var client = factory.CreateAuthenticatedClient(viewerId); var client = factory.CreateAuthenticatedClient(viewerId);
var resp = await client.PostAsJsonAsync("/rotation_rank_battle/finish", FinishBody(battleResult: 2)); var resp = await client.PostAsJsonAsync("/rotation_rank_battle/finish", FinishBody(battleResult: 2, classId: 1));
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
Assert.That(doc.RootElement.GetProperty("battle_result").GetInt32(), Is.EqualTo(2)); Assert.That(doc.RootElement.GetProperty("battle_result").GetInt32(), Is.EqualTo(2));
// battle_result==2 is loss-shaped for rank too: fresh viewer at 0 pts stays at
// Beginner 0 (tier floor 0). No point change; +/-0 emitted.
Assert.That(doc.RootElement.GetProperty("rank").GetInt32(), Is.EqualTo(1));
Assert.That(doc.RootElement.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(0));
Assert.That(doc.RootElement.GetProperty("battle_point").GetInt32(), Is.EqualTo(0));
// battle_result==2 is loss-shaped: XpPerLoss=50 exactly meets L1 threshold → L2, Exp=0.
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(50));
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
Assert.That(doc.RootElement.GetProperty("class_level").GetInt32(), Is.EqualTo(2));
}
[Test]
public async Task Finish_persists_class_xp_to_viewer()
{
await using var factory = new SVSimTestFactory();
var viewerId = await factory.SeedViewerAsync();
var client = factory.CreateAuthenticatedClient(viewerId);
await client.PostAsJsonAsync(
"/unlimited_rank_battle/finish", FinishBody(battleResult: 1, classId: 3));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.Classes).ThenInclude(c => c.Class)
.Include(x => x.RankProgress)
.FirstAsync(x => x.Id == viewerId);
var cls3 = v.Classes.Single(c => c.Class.Id == 3);
Assert.That(cls3.Level, Is.EqualTo(3));
Assert.That(cls3.Exp, Is.EqualTo(0));
// Rank progress persisted under Unlimited with +100.
var rp = v.RankProgress.Single(r => r.Format == Format.Unlimited);
Assert.That(rp.Point, Is.EqualTo(100));
Assert.That(rp.MasterPoint, Is.EqualTo(0));
}
[Test]
public async Task Finish_loss_at_D_tier_stays_at_D_floor()
{
await using var factory = new SVSimTestFactory();
var viewerId = await factory.SeedViewerAsync();
// Preload viewer with Point=1200 (D0 entry) in Rotation.
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.RankProgress).FirstAsync(x => x.Id == viewerId);
v.RankProgress.Add(new ViewerRankProgress
{
Format = Format.Rotation, Point = 1200, MasterPoint = 0,
});
await db.SaveChangesAsync();
}
var client = factory.CreateAuthenticatedClient(viewerId);
var resp = await client.PostAsJsonAsync(
"/rotation_rank_battle/finish", FinishBody(battleResult: 0, classId: 1));
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
var data = doc.RootElement;
Assert.That(data.GetProperty("rank").GetInt32(), Is.EqualTo(5)); // still D0
Assert.That(data.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(1200)); // floored
Assert.That(data.GetProperty("battle_point").GetInt32(), Is.EqualTo(0)); // no drop
}
[Test]
public async Task Finish_rotation_and_unlimited_progress_independently()
{
await using var factory = new SVSimTestFactory();
var viewerId = await factory.SeedViewerAsync();
var client = factory.CreateAuthenticatedClient(viewerId);
await client.PostAsJsonAsync("/rotation_rank_battle/finish", FinishBody(battleResult: 1, classId: 1));
await client.PostAsJsonAsync("/rotation_rank_battle/finish", FinishBody(battleResult: 1, classId: 1));
await client.PostAsJsonAsync("/unlimited_rank_battle/finish", FinishBody(battleResult: 1, classId: 1));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.RankProgress).FirstAsync(x => x.Id == viewerId);
Assert.That(v.RankProgress.Single(r => r.Format == Format.Rotation).Point, Is.EqualTo(200));
Assert.That(v.RankProgress.Single(r => r.Format == Format.Unlimited).Point, Is.EqualTo(100));
}
[Test]
public async Task Finish_ai_variant_persists_progress_same_as_human_variant()
{
await using var factory = new SVSimTestFactory();
var viewerId = await factory.SeedViewerAsync();
var client = factory.CreateAuthenticatedClient(viewerId);
await client.PostAsJsonAsync("/ai_unlimited_rank_battle/finish", FinishBody(battleResult: 1, classId: 1));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.RankProgress).FirstAsync(x => x.Id == viewerId);
Assert.That(v.RankProgress.Single(r => r.Format == Format.Unlimited).Point, Is.EqualTo(100));
}
[Test]
public async Task AiStart_SelfInfo_carries_viewer_rank_progress()
{
await using var factory = new SVSimTestFactory();
var viewerId = await factory.SeedViewerAsync();
await factory.SeedGlobalsAsync();
await factory.SeedDeckAsync(viewerId, Format.Unlimited, 1);
// Preload Point=200 (Beginner 2 = rank_id 3) on Unlimited.
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.RankProgress).FirstAsync(x => x.Id == viewerId);
v.RankProgress.Add(new ViewerRankProgress
{
Format = Format.Unlimited, Point = 200, MasterPoint = 0,
});
await db.SaveChangesAsync();
}
await RegisterBotBattleAsync(factory, viewerId, Format.Unlimited, deckNo: 1);
var client = factory.CreateAuthenticatedClient(viewerId);
var resp = await client.PostAsJsonAsync("/ai_unlimited_rank_battle/start", EmptyAuthedBody);
Assert.That(resp.IsSuccessStatusCode, Is.True);
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
// AiBattlePlayerInfo wire keys are camelCase (self_info/oppo_info aside);
// see AiBattleStartResponseDto.cs class comment.
var self = doc.RootElement.GetProperty("self_info");
Assert.That(self.GetProperty("rank").GetInt32(), Is.EqualTo(3)); // 200 → Beginner 2
Assert.That(self.GetProperty("battlePoint").GetInt32(), Is.EqualTo(200));
Assert.That(self.GetProperty("isMasterRank").GetInt32(), Is.EqualTo(0));
Assert.That(self.GetProperty("masterPoint").GetInt32(), Is.EqualTo(0));
} }
} }

View File

@@ -397,6 +397,20 @@ internal class SVSimTestFactory : WebApplicationFactory<Program>
return client; return client;
} }
/// <summary>
/// Shared secret baked into <c>appsettings.Testing.json</c> for <c>/admin/*</c> gating. Kept
/// here so tests can construct requests with or without a valid header via the same source.
/// </summary>
public const string AdminSecret = "test-admin-secret";
/// <summary>Convenience: bake the X-Admin-Secret header into a fresh client.</summary>
public HttpClient CreateAdminClient()
{
var client = CreateClient();
client.DefaultRequestHeaders.Add("X-Admin-Secret", AdminSecret);
return client;
}
/// <summary> /// <summary>
/// Inserts a deck for the viewer via the real <see cref="IDeckRepository.UpsertDeck"/> /// Inserts a deck for the viewer via the real <see cref="IDeckRepository.UpsertDeck"/>
/// path. Picks the first seeded class/sleeve/leader-skin from the master tables; tests /// path. Picks the first seeded class/sleeve/leader-skin from the master tables; tests

View File

@@ -25,16 +25,16 @@ public class GameConfigurationJsonbTests
var rows = await db.GameConfigs.AsNoTracking().ToListAsync(); var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
var byName = rows.ToDictionary(r => r.SectionName); var byName = rows.ToDictionary(r => r.SectionName);
// One row per [ConfigSection]-marked POCO (18 sections today: Player, DefaultGrants, // One row per [ConfigSection]-marked POCO (19 sections today: Player, DefaultGrants,
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig, // DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig,
// Freeplay, ArenaTwoPick, Matching, CardMasterConfig, ColosseumSeason, ColosseumRounds, // Freeplay, ArenaTwoPick, Matching, CardMasterConfig, ColosseumSeason, ColosseumRounds,
// LoginBonus, Guild, SkipTutorial). // LoginBonus, Guild, SkipTutorial, BattleXp).
Assert.That(byName.Keys, Is.EquivalentTo(new[] Assert.That(byName.Keys, Is.EquivalentTo(new[]
{ {
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates", "Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
"MyRotationSchedule", "Story", "ResourceConfig", "Freeplay", "ArenaTwoPick", "Matching", "MyRotationSchedule", "Story", "ResourceConfig", "Freeplay", "ArenaTwoPick", "Matching",
"CardMasterConfig", "ColosseumSeason", "ColosseumRounds", "LoginBonus", "Guild", "CardMasterConfig", "ColosseumSeason", "ColosseumRounds", "LoginBonus", "Guild",
"SkipTutorial", "SkipTutorial", "BattleXp", "GameCalendar",
})); }));
var resources = JsonSerializer.Deserialize<ResourceConfig>(byName["ResourceConfig"].ValueJson)!; var resources = JsonSerializer.Deserialize<ResourceConfig>(byName["ResourceConfig"].ValueJson)!;
@@ -55,9 +55,9 @@ public class GameConfigurationJsonbTests
Assert.That(slot8!.Silver, Is.EqualTo(0.7692).Within(1e-9)); Assert.That(slot8!.Silver, Is.EqualTo(0.7692).Within(1e-9));
var grants = JsonSerializer.Deserialize<DefaultGrantsConfig>(byName["DefaultGrants"].ValueJson)!; var grants = JsonSerializer.Deserialize<DefaultGrantsConfig>(byName["DefaultGrants"].ValueJson)!;
Assert.That(grants.Crystals, Is.EqualTo(50000UL)); Assert.That(grants.Crystals, Is.EqualTo(0UL));
Assert.That(grants.Rupees, Is.EqualTo(50000UL)); Assert.That(grants.Rupees, Is.EqualTo(0UL));
Assert.That(grants.Ether, Is.EqualTo(50000UL)); Assert.That(grants.Ether, Is.EqualTo(0UL));
var player = JsonSerializer.Deserialize<PlayerConfig>(byName["Player"].ValueJson)!; var player = JsonSerializer.Deserialize<PlayerConfig>(byName["Player"].ValueJson)!;
Assert.That(player.MaxFriends, Is.EqualTo(20)); Assert.That(player.MaxFriends, Is.EqualTo(20));

View File

@@ -64,6 +64,7 @@ public class ArenaTwoPickServiceDraftTests
scope.ServiceProvider.GetRequiredService<IGameConfigService>(), scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
scope.ServiceProvider.GetRequiredService<IViewerRepository>(), scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
scope.ServiceProvider.GetRequiredService<IInventoryService>(), scope.ServiceProvider.GetRequiredService<IInventoryService>(),
scope.ServiceProvider.GetRequiredService<SVSim.Database.Services.BattleXp.IBattleXpService>(),
new SystemRandom(seed: 1), new SystemRandom(seed: 1),
db); db);

View File

@@ -59,6 +59,7 @@ public class ArenaTwoPickServiceEntryTests
config, config,
scope.ServiceProvider.GetRequiredService<IViewerRepository>(), scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
inv, inv,
scope.ServiceProvider.GetRequiredService<SVSim.Database.Services.BattleXp.IBattleXpService>(),
new SystemRandom(seed: 1234), new SystemRandom(seed: 1234),
db); db);

View File

@@ -80,6 +80,7 @@ public class ArenaTwoPickServiceFinishTests
scope.ServiceProvider.GetRequiredService<IGameConfigService>(), scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
scope.ServiceProvider.GetRequiredService<IViewerRepository>(), scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
scope.ServiceProvider.GetRequiredService<IInventoryService>(), scope.ServiceProvider.GetRequiredService<IInventoryService>(),
scope.ServiceProvider.GetRequiredService<SVSim.Database.Services.BattleXp.IBattleXpService>(),
new SystemRandom(seed: 1), new SystemRandom(seed: 1),
db); db);
@@ -122,11 +123,24 @@ public class ArenaTwoPickServiceFinishTests
var (db, svc, vid) = await SetupWithRunAsync(winCount: 0, lossCount: 5); var (db, svc, vid) = await SetupWithRunAsync(winCount: 0, lossCount: 5);
await using var _ = db; await using var _ = db;
var dto = await svc.FinishAsync(vid); var outcome = await svc.FinishAsync(vid);
Assert.That(dto.Rewards.Single(r => r.RewardType == 9).RewardCount, Is.EqualTo(100)); Assert.That(outcome.Response.Rewards.Single(r => r.RewardType == 9).RewardCount, Is.EqualTo(100));
Assert.That(outcome.WasFullClear, Is.False, "0W 5L is not a full clear");
Assert.That(await db.ViewerArenaTwoPickRuns.AnyAsync(), Is.False); Assert.That(await db.ViewerArenaTwoPickRuns.AnyAsync(), Is.False);
} }
[Test]
public async Task FinishAsync_signals_full_clear_when_run_ended_with_5_wins()
{
// Full clear = 5W 0L (max battles = 5). Controller consumes this to fire the
// challenge_full_clear mission event.
var (db, svc, vid) = await SetupWithRunAsync(winCount: 5, lossCount: 0);
await using var _ = db;
var outcome = await svc.FinishAsync(vid);
Assert.That(outcome.WasFullClear, Is.True);
}
[Test] [Test]
public async Task RecordBattleResultAsync_win_increments_win_and_grants_xp_and_spot_points() public async Task RecordBattleResultAsync_win_increments_win_and_grants_xp_and_spot_points()
{ {
@@ -136,13 +150,30 @@ public class ArenaTwoPickServiceFinishTests
var result = await svc.RecordBattleResultAsync(vid, isWin: true); var result = await svc.RecordBattleResultAsync(vid, isWin: true);
Assert.That(result.BattleResult, Is.EqualTo(1)); Assert.That(result.BattleResult, Is.EqualTo(1));
Assert.That(result.GetClassExperience, Is.EqualTo(100)); Assert.That(result.GetClassExperience, Is.EqualTo(200),
"Default BattleXpConfig.XpPerWin");
Assert.That(result.AddSpotPoint, Is.EqualTo(10)); Assert.That(result.AddSpotPoint, Is.EqualTo(10));
var run = await db.ViewerArenaTwoPickRuns.FirstAsync(r => r.ViewerId == vid); var run = await db.ViewerArenaTwoPickRuns.FirstAsync(r => r.ViewerId == vid);
Assert.That(run.WinCount, Is.EqualTo(2)); Assert.That(run.WinCount, Is.EqualTo(2));
Assert.That(JsonSerializer.Deserialize<List<bool>>(run.ResultListJson)!.Count, Is.EqualTo(2)); Assert.That(JsonSerializer.Deserialize<List<bool>>(run.ResultListJson)!.Count, Is.EqualTo(2));
} }
[Test]
public async Task RecordBattleResultAsync_loss_grants_loss_xp()
{
var (db, svc, vid) = await SetupWithRunAsync(winCount: 0, lossCount: 0);
await using var _ = db;
var result = await svc.RecordBattleResultAsync(vid, isWin: false);
Assert.That(result.BattleResult, Is.EqualTo(0));
Assert.That(result.GetClassExperience, Is.EqualTo(50),
"Default BattleXpConfig.XpPerLoss");
Assert.That(result.ClassExperience, Is.EqualTo(0),
"Fresh viewer with Exp=0, +50 loss XP exactly meets curve[1]=50 → L2, Exp=0.");
Assert.That(result.ClassLevel, Is.EqualTo(2));
}
[Test] [Test]
public async Task RecordBattleResultAsync_increments_loss_without_terminating() public async Task RecordBattleResultAsync_increments_loss_without_terminating()
{ {

View File

@@ -82,6 +82,6 @@ public class ArenaTwoPickServiceTopTests
{ {
// GetTopAsync only uses _runs — every other dep can be null! because the test path // GetTopAsync only uses _runs — every other dep can be null! because the test path
// never touches them. // never touches them.
return new ArenaTwoPickService(runRepo, null!, null!, null!, null!, null!, null!, db); return new ArenaTwoPickService(runRepo, null!, null!, null!, null!, null!, null!, null!, db);
} }
} }

View File

@@ -90,6 +90,7 @@ public class ArenaTwoPickServiceWeightedRewardsTests
scope.ServiceProvider.GetRequiredService<IGameConfigService>(), scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
scope.ServiceProvider.GetRequiredService<IViewerRepository>(), scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
scope.ServiceProvider.GetRequiredService<IInventoryService>(), scope.ServiceProvider.GetRequiredService<IInventoryService>(),
scope.ServiceProvider.GetRequiredService<SVSim.Database.Services.BattleXp.IBattleXpService>(),
rng, rng,
db); db);

View File

@@ -0,0 +1,233 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Services;
using SVSim.Database.Services.BattleXp;
namespace SVSim.UnitTests.Services;
public class BattleXpServiceTests
{
private sealed class FakeConfig : IGameConfigService
{
private readonly BattleXpConfig _cfg;
public FakeConfig(BattleXpConfig cfg) { _cfg = cfg; }
public T Get<T>() where T : class, new() => (T)(object)_cfg;
}
private static IGlobalsRepository CurveRepo(params (int Level, int NecessaryExp)[] rows)
{
var mock = new Mock<IGlobalsRepository>(MockBehavior.Strict);
mock.Setup(x => x.GetClassExpCurve()).ReturnsAsync(
rows.Select(r => new ClassExpEntry { Id = r.Level, NecessaryExp = r.NecessaryExp }).ToList());
return mock.Object;
}
private static Viewer NewViewerWithClass(int classId, int level = 1, int exp = 0)
{
var cls = new ClassEntry { Id = classId, Name = $"Class{classId}" };
return new Viewer
{
Id = 1,
DisplayName = "v",
Classes = { new ViewerClassData { Class = cls, Level = level, Exp = exp } },
};
}
private static BattleXpService NewService(BattleXpConfig cfg, params (int Level, int NecessaryExp)[] curve)
{
var rows = curve.Length > 0
? curve
: new[] { (1, 100), (2, 150), (3, 250) };
return new BattleXpService(CurveRepo(rows), new FakeConfig(cfg), NullLogger<BattleXpService>.Instance);
}
[Test]
public async Task Win_uses_XpPerWin_and_accumulates_exp()
{
var svc = NewService(new BattleXpConfig { XpPerWin = 40, XpPerLoss = 5 });
var v = NewViewerWithClass(1);
var r = await svc.GrantAsync(v, classId: 1, isWin: true, BattleXpMode.Rank);
Assert.That(r.GetXp, Is.EqualTo(40));
Assert.That(r.TotalXp, Is.EqualTo(40));
Assert.That(r.Level, Is.EqualTo(1));
Assert.That(v.Classes.Single().Exp, Is.EqualTo(40));
Assert.That(v.Classes.Single().Level, Is.EqualTo(1));
}
[Test]
public async Task Loss_uses_XpPerLoss()
{
var svc = NewService(new BattleXpConfig { XpPerWin = 40, XpPerLoss = 5 });
var v = NewViewerWithClass(1);
var r = await svc.GrantAsync(v, classId: 1, isWin: false, BattleXpMode.Rank);
Assert.That(r.GetXp, Is.EqualTo(5));
Assert.That(r.TotalXp, Is.EqualTo(5));
}
[Test]
public async Task Per_mode_override_wins_over_global()
{
var svc = NewService(new BattleXpConfig
{
XpPerWin = 40, XpPerLoss = 5,
PracticeXpPerWin = 200, PracticeXpPerLoss = 20,
});
var vw = NewViewerWithClass(1);
var win = await svc.GrantAsync(vw, 1, isWin: true, BattleXpMode.Practice);
Assert.That(win.GetXp, Is.EqualTo(200));
var vl = NewViewerWithClass(1);
var loss = await svc.GrantAsync(vl, 1, isWin: false, BattleXpMode.Practice);
Assert.That(loss.GetXp, Is.EqualTo(20));
}
[Test]
public async Task Rank_mode_falls_back_to_global_when_override_null()
{
var svc = NewService(new BattleXpConfig
{
XpPerWin = 40, XpPerLoss = 5,
PracticeXpPerWin = 200, // Rank has no override — must fall back.
});
var v = NewViewerWithClass(1);
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
Assert.That(r.GetXp, Is.EqualTo(40));
}
[Test]
public async Task Story_uses_StoryXpPerClear_when_set_ignoring_isWin()
{
var svc = NewService(new BattleXpConfig
{
XpPerWin = 40, XpPerLoss = 5,
StoryXpPerClear = 300,
});
var v = NewViewerWithClass(1);
var r = await svc.GrantAsync(v, 1, isWin: false, BattleXpMode.Story);
Assert.That(r.GetXp, Is.EqualTo(300));
}
[Test]
public async Task Story_falls_back_to_XpPerWin_when_StoryXpPerClear_null()
{
var svc = NewService(new BattleXpConfig { XpPerWin = 40, XpPerLoss = 5 });
var v = NewViewerWithClass(1);
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Story);
Assert.That(r.GetXp, Is.EqualTo(40));
}
[Test]
public async Task Single_grant_crossing_one_threshold_bumps_level_and_carries_overflow()
{
// Curve: L1 needs 100 to reach L2. Grant 130 → level 2, Exp 30 carry.
var svc = NewService(new BattleXpConfig { XpPerWin = 130 });
var v = NewViewerWithClass(1);
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
Assert.That(r.Level, Is.EqualTo(2));
Assert.That(r.TotalXp, Is.EqualTo(30));
Assert.That(v.Classes.Single().Level, Is.EqualTo(2));
Assert.That(v.Classes.Single().Exp, Is.EqualTo(30));
}
[Test]
public async Task Single_grant_crossing_multiple_thresholds_bumps_multiple_levels()
{
// Curve: L1=100, L2=150, L3=250.
// Grant 500 → L1(100)→L2 (400 left) → L2(150)→L3 (250 left) → stop at maxLevel=3.
// Final: Level=3, Exp=250.
var svc = NewService(new BattleXpConfig { XpPerWin = 500 });
var v = NewViewerWithClass(1);
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
Assert.That(r.Level, Is.EqualTo(3));
Assert.That(r.TotalXp, Is.EqualTo(250));
}
[Test]
public async Task Saturates_at_max_curve_level_with_excess_in_exp()
{
// Max curve level = 3. Grant enough to overshoot far.
var svc = NewService(new BattleXpConfig { XpPerWin = 10_000 });
var v = NewViewerWithClass(1);
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
Assert.That(r.Level, Is.EqualTo(3));
// 10000 - 100 (L1→L2) - 150 (L2→L3) = 9750 sitting in Exp at L3.
Assert.That(r.TotalXp, Is.EqualTo(9750));
}
[Test]
public async Task Unknown_classId_returns_zero_and_does_not_mutate_viewer()
{
var svc = NewService(new BattleXpConfig { XpPerWin = 40 });
var v = NewViewerWithClass(classId: 1);
var r = await svc.GrantAsync(v, classId: 99, isWin: true, BattleXpMode.Rank);
Assert.That(r.GetXp, Is.EqualTo(0));
Assert.That(r.TotalXp, Is.EqualTo(0));
Assert.That(r.Level, Is.EqualTo(1));
Assert.That(r.LeveledUp, Is.False);
Assert.That(v.Classes.Single().Exp, Is.EqualTo(0),
"Class 1's row must not have been touched.");
}
// ---- LeveledUp signal ----
[Test]
public async Task LeveledUp_is_false_when_grant_stays_within_same_level()
{
// L1 requires 100 to reach L2; grant only 40.
var svc = NewService(new BattleXpConfig { XpPerWin = 40 });
var v = NewViewerWithClass(1);
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
Assert.That(r.Level, Is.EqualTo(1));
Assert.That(r.LeveledUp, Is.False);
}
[Test]
public async Task LeveledUp_is_true_when_grant_crosses_a_threshold()
{
// L1=100 → grant 130 → L2 with 30 carry.
var svc = NewService(new BattleXpConfig { XpPerWin = 130 });
var v = NewViewerWithClass(1);
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
Assert.That(r.Level, Is.EqualTo(2));
Assert.That(r.LeveledUp, Is.True);
}
[Test]
public async Task LeveledUp_stays_true_across_multiple_bumps_in_one_grant()
{
// Grant crosses L1→L2→L3 in one go.
var svc = NewService(new BattleXpConfig { XpPerWin = 500 });
var v = NewViewerWithClass(1);
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
Assert.That(r.Level, Is.EqualTo(3));
Assert.That(r.LeveledUp, Is.True);
}
}

View File

@@ -0,0 +1,128 @@
using SVSim.Database.Models.Config;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.UnitTests.Services;
public class GameCalendarServiceTests
{
private sealed class FakeConfig : IGameConfigService
{
private readonly int _hour;
public FakeConfig(int hour) { _hour = hour; }
public T Get<T>() where T : class, new() =>
(T)(object)new GameCalendarConfig { DailyResetUtcHour = _hour };
}
private static GameCalendarService NewService(int resetHour) =>
new(new FakeConfig(resetHour));
private static DateTimeOffset Utc(int y, int mo, int d, int h, int m, int s) =>
new(y, mo, d, h, m, s, TimeSpan.Zero);
// ------------ ResetReady ------------
[Test]
public void ResetReady_returns_true_when_lastCheckpoint_is_null()
{
var svc = NewService(0);
Assert.That(svc.ResetReady(null), Is.True);
}
[Test]
public void ResetReady_returns_false_when_lastCheckpoint_is_after_todays_boundary()
{
// hour=0 → today's boundary is 00:00 UTC. lastCheckpoint at 00:00:01 UTC today
// is AFTER the boundary → not yet time to reset.
var svc = NewService(0);
var last = DateTime.UtcNow.Date.AddSeconds(1);
Assert.That(svc.ResetReady(last), Is.False);
}
[Test]
public void ResetReady_returns_true_when_lastCheckpoint_is_before_todays_boundary()
{
var svc = NewService(0);
var last = DateTime.UtcNow.Date.AddSeconds(-1); // yesterday, 1s before midnight
Assert.That(svc.ResetReady(last), Is.True);
}
[Test]
public void ResetReady_respects_config_reset_hour()
{
// hour=17. lastCheckpoint at 16:00 UTC today = before today's boundary → reset ready.
// (unless we're currently before 17:00 UTC — in which case today's boundary is
// "tomorrow-in-the-future" and MostRecent is yesterday 17:00; last at 16:00 today
// is AFTER yesterday's 17:00 → NOT ready. Test both sides explicitly rather than
// relying on wall clock.)
var svc = NewService(17);
var now = DateTime.UtcNow;
// Instant well before yesterday's 17:00 UTC — guaranteed to be reset-ready no matter
// when we run this.
var stale = now.AddDays(-2);
Assert.That(svc.ResetReady(stale), Is.True, "48h ago is always reset-ready");
}
// ------------ DayKey ------------
[Test]
public void DayKey_hour0_uses_utc_calendar_day()
{
var svc = NewService(0);
Assert.That(svc.DayKey(Utc(2026, 6, 1, 0, 0, 0)), Is.EqualTo("day:2026-06-01"));
Assert.That(svc.DayKey(Utc(2026, 6, 1, 23, 59, 59)), Is.EqualTo("day:2026-06-01"));
Assert.That(svc.DayKey(Utc(2026, 6, 2, 0, 0, 0)), Is.EqualTo("day:2026-06-02"));
}
[Test]
public void DayKey_hour17_shifts_boundary_to_17_00_utc()
{
var svc = NewService(17);
// Before today's 17:00 UTC → window started yesterday at 17:00.
Assert.That(svc.DayKey(Utc(2026, 6, 1, 16, 59, 59)), Is.EqualTo("day:2026-05-31"));
// 17:00 UTC exactly → new window starts.
Assert.That(svc.DayKey(Utc(2026, 6, 1, 17, 0, 0)), Is.EqualTo("day:2026-06-01"));
// Later same day → same window.
Assert.That(svc.DayKey(Utc(2026, 6, 1, 23, 59, 59)), Is.EqualTo("day:2026-06-01"));
}
// ------------ WeekKey / MonthKey ------------
[Test]
public void WeekKey_is_iso_week_of_window_start()
{
var svc = NewService(0);
// 2026-05-25 is Monday (ISO week 22 of 2026).
Assert.That(svc.WeekKey(Utc(2026, 5, 25, 12, 0, 0)), Is.EqualTo("week:2026-W22"));
// Sunday of that week.
Assert.That(svc.WeekKey(Utc(2026, 5, 24, 23, 59, 59)), Is.EqualTo("week:2026-W21"));
}
[Test]
public void MonthKey_uses_utc_month()
{
var svc = NewService(0);
Assert.That(svc.MonthKey(Utc(2026, 6, 30, 23, 59, 59)), Is.EqualTo("month:2026-06"));
Assert.That(svc.MonthKey(Utc(2026, 7, 1, 0, 0, 0)), Is.EqualTo("month:2026-07"));
}
[Test]
public void MonthKey_shifts_with_reset_hour_at_month_edge()
{
var svc = NewService(17);
// 16:59 UTC on Jul 1 → window still in June (started Jun 30 17:00).
Assert.That(svc.MonthKey(Utc(2026, 7, 1, 16, 59, 0)), Is.EqualTo("month:2026-06"));
Assert.That(svc.MonthKey(Utc(2026, 7, 1, 17, 0, 0)), Is.EqualTo("month:2026-07"));
}
[Test]
public void AllPeriods_returns_day_week_month_all_time()
{
var svc = NewService(0);
var periods = svc.AllPeriods(Utc(2026, 5, 27, 12, 0, 0));
Assert.That(periods, Is.EquivalentTo(new[] {
"day:2026-05-27", "week:2026-W22", "month:2026-05", GameCalendarPeriods.AllTime,
}));
}
}

View File

@@ -1,52 +0,0 @@
namespace SVSim.UnitTests.Services;
public class JstPeriodTests
{
private static DateTimeOffset Jst(int y, int m, int d, int h, int min, int s) =>
new DateTimeOffset(y, m, d, h, min, s, TimeSpan.FromHours(9));
[Test]
public void DayKey_uses_jst_with_02_00_anchor()
{
// 01:59:59 JST on May 27 belongs to the May 26 "day".
Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.DayKey(Jst(2026, 5, 27, 1, 59, 59)),
Is.EqualTo("day:2026-05-26"));
// 02:00:00 JST on May 27 belongs to the May 27 "day".
Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.DayKey(Jst(2026, 5, 27, 2, 0, 0)),
Is.EqualTo("day:2026-05-27"));
}
[Test]
public void WeekKey_is_iso8601_monday_anchored()
{
// 2026-05-25 (Monday) 02:00 JST → ISO week 2026-W22.
Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.WeekKey(Jst(2026, 5, 25, 2, 0, 0)),
Is.EqualTo("week:2026-W22"));
// The previous Sunday's late evening still belongs to W21 (week reset is Monday 02:00 JST).
Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.WeekKey(Jst(2026, 5, 25, 1, 59, 59)),
Is.EqualTo("week:2026-W21"));
}
[Test]
public void MonthKey_uses_jst_day_anchor_for_month_rollover()
{
// 01:59 JST on June 1 still belongs to May (day-boundary is 02:00 JST).
Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.MonthKey(Jst(2026, 6, 1, 1, 59, 0)),
Is.EqualTo("month:2026-05"));
Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.MonthKey(Jst(2026, 6, 1, 2, 0, 0)),
Is.EqualTo("month:2026-06"));
}
[Test]
public void AllPeriods_returns_day_week_month_all_time()
{
var t = Jst(2026, 5, 27, 12, 0, 0);
var periods = SVSim.EmulatedEntrypoint.Services.JstPeriod.AllPeriods(t);
Assert.That(periods, Is.EquivalentTo(new[] {
"day:2026-05-27", "week:2026-W22", "month:2026-05", "all-time",
}));
}
}

View File

@@ -0,0 +1,266 @@
using SVSim.Database.Enums;
using SVSim.Database.Services;
namespace SVSim.UnitTests.Services;
public class MissionEventKeysTests
{
// ---- Practice.WinAll ----
[Test]
public void Practice_WinAll_emits_all_three_levels_for_known_tier_and_leader()
{
// Wire difficulty 4 = "elite", enemy_class_id 1 = "arisa" (Forestcraft).
var keys = MissionEventKeys.Practice.WinAll(wireDifficulty: 4, enemyClassId: 1);
Assert.That(keys, Is.EqualTo(new[]
{
"practice_win",
"practice_win:elite",
"practice_win:elite:arisa",
}));
}
[Test]
public void Practice_WinAll_maps_all_three_elite_tiers()
{
Assert.That(MissionEventKeys.Practice.WinAll(4, 2), Contains.Item("practice_win:elite:erika"));
Assert.That(MissionEventKeys.Practice.WinAll(6, 2), Contains.Item("practice_win:elite2:erika"));
Assert.That(MissionEventKeys.Practice.WinAll(7, 2), Contains.Item("practice_win:elite3:erika"));
}
[Test]
public void Practice_WinAll_covers_all_eight_leaders()
{
Assert.Multiple(() =>
{
Assert.That(MissionEventKeys.Practice.WinAll(4, 1), Contains.Item("practice_win:elite:arisa"));
Assert.That(MissionEventKeys.Practice.WinAll(4, 2), Contains.Item("practice_win:elite:erika"));
Assert.That(MissionEventKeys.Practice.WinAll(4, 3), Contains.Item("practice_win:elite:isabelle"));
Assert.That(MissionEventKeys.Practice.WinAll(4, 4), Contains.Item("practice_win:elite:rowen"));
Assert.That(MissionEventKeys.Practice.WinAll(4, 5), Contains.Item("practice_win:elite:luna"));
Assert.That(MissionEventKeys.Practice.WinAll(4, 6), Contains.Item("practice_win:elite:urias"));
Assert.That(MissionEventKeys.Practice.WinAll(4, 7), Contains.Item("practice_win:elite:eris"));
Assert.That(MissionEventKeys.Practice.WinAll(4, 8), Contains.Item("practice_win:elite:yuwan"));
});
}
[Test]
public void Practice_WinAll_falls_back_to_top_level_for_non_elite_difficulty()
{
// Wire difficulty 2 = "Advanced" (or similar) — not in the elite tier registry.
// Emit only the top-level counter; hierarchical levels drop off.
var keys = MissionEventKeys.Practice.WinAll(wireDifficulty: 2, enemyClassId: 1);
Assert.That(keys, Is.EqualTo(new[] { "practice_win" }));
}
[Test]
public void Practice_WinAll_drops_leader_level_for_unknown_class()
{
var keys = MissionEventKeys.Practice.WinAll(wireDifficulty: 4, enemyClassId: 99);
Assert.That(keys, Is.EqualTo(new[] { "practice_win", "practice_win:elite" }));
}
// ---- Story.ChapterFinishAll ----
[Test]
public void Story_ChapterFinishAll_emits_all_three_levels()
{
var keys = MissionEventKeys.Story.ChapterFinishAll("main", 42);
Assert.That(keys, Is.EqualTo(new[]
{
"story_chapter_finish",
"story_chapter_finish:main",
"story_chapter_finish:main:42",
}));
}
// ---- ItemPurchase ----
[Test]
public void ItemPurchase_returns_prefixed_id_string()
{
Assert.That(MissionEventKeys.ItemPurchase(501), Is.EqualTo("item_purchase:501"));
}
// ---- IsRegistered ----
[Test]
public void IsRegistered_accepts_bare_top_level_prefix()
{
Assert.That(MissionEventKeys.IsRegistered("practice_win"), Is.True);
Assert.That(MissionEventKeys.IsRegistered("ranked_or_arena_win"), Is.True);
Assert.That(MissionEventKeys.IsRegistered("challenge_full_clear"), Is.True);
}
[Test]
public void IsRegistered_accepts_hierarchical_extensions()
{
Assert.That(MissionEventKeys.IsRegistered("practice_win:elite:arisa"), Is.True);
Assert.That(MissionEventKeys.IsRegistered("class_level_up:forestcraft"), Is.True);
Assert.That(MissionEventKeys.IsRegistered("item_purchase:501"), Is.True);
Assert.That(MissionEventKeys.IsRegistered("rank_achieved:master"), Is.True);
}
[Test]
public void IsRegistered_rejects_typos_and_unknown_prefixes()
{
Assert.That(MissionEventKeys.IsRegistered("practice_wln"), Is.False); // typo
Assert.That(MissionEventKeys.IsRegistered("practice_win_extra"), Is.False); // suffix without colon
Assert.That(MissionEventKeys.IsRegistered("battle_win_total"), Is.False); // test-only fixture
Assert.That(MissionEventKeys.IsRegistered("orphan_event"), Is.False);
Assert.That(MissionEventKeys.IsRegistered(""), Is.False);
}
// ---- Seed drift sanity check ----
// ---- Class name mapping ----
[Test]
public void Class_Name_covers_all_eight_classes()
{
Assert.Multiple(() =>
{
Assert.That(MissionEventKeys.Class.Name(1), Is.EqualTo("forestcraft"));
Assert.That(MissionEventKeys.Class.Name(2), Is.EqualTo("swordcraft"));
Assert.That(MissionEventKeys.Class.Name(3), Is.EqualTo("runecraft"));
Assert.That(MissionEventKeys.Class.Name(4), Is.EqualTo("dragoncraft"));
Assert.That(MissionEventKeys.Class.Name(5), Is.EqualTo("shadowcraft"));
Assert.That(MissionEventKeys.Class.Name(6), Is.EqualTo("bloodcraft"));
Assert.That(MissionEventKeys.Class.Name(7), Is.EqualTo("havencraft"));
Assert.That(MissionEventKeys.Class.Name(8), Is.EqualTo("portalcraft"));
Assert.That(MissionEventKeys.Class.Name(0), Is.Null);
Assert.That(MissionEventKeys.Class.Name(9), Is.Null);
});
}
// ---- Ranked / Free / Challenge ----
[Test]
public void Ranked_WinAll_emits_family_class_and_aggregates()
{
var keys = MissionEventKeys.Ranked.WinAll(classId: 1);
Assert.That(keys, Is.EquivalentTo(new[]
{
"ranked_win",
"ranked_win:forestcraft",
"ranked_or_arena_win",
"daily_match_win",
}));
}
[Test]
public void Ranked_WinAll_drops_class_variant_for_unknown_class()
{
var keys = MissionEventKeys.Ranked.WinAll(classId: 99);
Assert.That(keys, Is.EquivalentTo(new[]
{
"ranked_win", "ranked_or_arena_win", "daily_match_win",
}));
}
[Test]
public void Free_WinAll_emits_only_aggregates()
{
Assert.That(MissionEventKeys.Free.WinAll(),
Is.EquivalentTo(new[] { "ranked_or_arena_win", "daily_match_win" }));
}
[Test]
public void Challenge_MatchPlayAll_top_level_only()
{
Assert.That(MissionEventKeys.Challenge.MatchPlayAll(),
Is.EquivalentTo(new[] { "challenge_play" }));
}
[Test]
public void Challenge_MatchWinAll_includes_play_win_and_aggregates()
{
Assert.That(MissionEventKeys.Challenge.MatchWinAll(), Is.EquivalentTo(new[]
{
"challenge_win", "challenge_play", "ranked_or_arena_win", "daily_match_win",
}));
}
[Test]
public void Challenge_FullClearAll_top_level_only()
{
Assert.That(MissionEventKeys.Challenge.FullClearAll(),
Is.EquivalentTo(new[] { "challenge_full_clear" }));
}
// ---- ClassLevel ----
[Test]
public void ClassLevel_UpAll_emits_family_and_class_variant()
{
var keys = MissionEventKeys.ClassLevel.UpAll(classId: 3);
Assert.That(keys, Is.EquivalentTo(new[] { "class_level_up", "class_level_up:runecraft" }));
}
[Test]
public void ClassLevel_UpAll_drops_class_variant_for_unknown_class()
{
Assert.That(MissionEventKeys.ClassLevel.UpAll(classId: 0),
Is.EquivalentTo(new[] { "class_level_up" }));
}
// ---- Rank ----
[Test]
public void Rank_AchievedAll_maps_all_seven_catalog_tiers()
{
Assert.Multiple(() =>
{
Assert.That(MissionEventKeys.Rank.AchievedAll(1), Contains.Item("rank_achieved:beginner"));
Assert.That(MissionEventKeys.Rank.AchievedAll(5), Contains.Item("rank_achieved:d"));
Assert.That(MissionEventKeys.Rank.AchievedAll(9), Contains.Item("rank_achieved:c"));
Assert.That(MissionEventKeys.Rank.AchievedAll(13), Contains.Item("rank_achieved:b"));
Assert.That(MissionEventKeys.Rank.AchievedAll(17), Contains.Item("rank_achieved:a"));
Assert.That(MissionEventKeys.Rank.AchievedAll(21), Contains.Item("rank_achieved:aa"));
Assert.That(MissionEventKeys.Rank.AchievedAll(25), Contains.Item("rank_achieved:master"));
Assert.That(MissionEventKeys.Rank.AchievedAll(26), Contains.Item("rank_achieved:grand_master"));
});
}
[Test]
public void Rank_AchievedAll_boundary_edges()
{
// Rank 4 = last Beginner slot; rank 5 = first D slot.
Assert.That(RankTier.Name(4), Is.EqualTo("beginner"));
Assert.That(RankTier.Name(5), Is.EqualTo("d"));
// Rank 24 = last AA; rank 25 = Master; rank 26 = first Grand Master.
Assert.That(RankTier.Name(24), Is.EqualTo("aa"));
Assert.That(RankTier.Name(25), Is.EqualTo("master"));
Assert.That(RankTier.Name(26), Is.EqualTo("grand_master"));
// Out of range
Assert.That(RankTier.Name(0), Is.Null);
Assert.That(RankTier.Name(30), Is.Null);
}
[Test]
public void Rank_AchievedAll_drops_tier_variant_for_out_of_range_rank()
{
Assert.That(MissionEventKeys.Rank.AchievedAll(0),
Is.EquivalentTo(new[] { "rank_achieved" }));
Assert.That(MissionEventKeys.Rank.AchievedAll(30),
Is.EquivalentTo(new[] { "rank_achieved" }));
}
[Test]
public void All_seed_prefixes_are_registered()
{
// Every prefix that could appear in a seed row must be in the registry. Enumerating
// explicitly rather than reading the seed JSON — the point is to catch someone removing
// a prefix from the code without noticing the seed still references it.
string[] knownSeedPrefixes = {
"practice_win", "ranked_win", "ranked_or_arena_win", "daily_match_win",
"story_chapter_finish", "class_level_up", "rank_achieved",
"challenge_play", "challenge_win", "challenge_full_clear",
"play_followers", "private_match_distinct_opponent",
};
foreach (var prefix in knownSeedPrefixes)
{
Assert.That(MissionEventKeys.IsRegistered(prefix), Is.True, $"prefix '{prefix}' not registered");
}
}
}

View File

@@ -0,0 +1,286 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Services.RankProgress;
namespace SVSim.UnitTests.Services;
public class RankProgressServiceTests
{
// Values mirror SVSim.Bootstrap/Data/ranks.csv verbatim: RankId, NecessaryPoint,
// AccumulatePoint, LowerLimitPoint, AccumulateMasterPoint.
private static readonly (int Id, int Necessary, int Accumulate, int Lower, int AccMp)[] Ranks =
{
(1, 100, 100, 0, 0), (2, 100, 200, 0, 0),
(3, 500, 700, 0, 0), (4, 500, 1200, 0, 0),
(5, 750, 1950, 1200, 0), (6, 750, 2700, 1200, 0),
(7, 800, 3500, 1200, 0), (8, 1000, 4500, 1200, 0),
(9, 1500, 6000, 4500, 0), (10,1500, 7500, 4500, 0),
(11,1750, 9250, 4500, 0), (12,1750, 11000, 4500, 0),
(13,2000, 13000, 11000, 0), (14,2000, 15000, 11000, 0),
(15,2000, 17000, 11000, 0), (16,2000, 19000, 11000, 0),
(17,3000, 23000, 20000, 0), (18,3000, 26000, 20000, 0),
(19,3000, 29000, 20000, 0), (20,3000, 32000, 20000, 0),
(21,4000, 37000, 33000, 0), (22,4000, 42000, 33000, 0),
(23,4000, 46000, 33000, 0), (24,4000, 50000, 33000, 0),
(25, 0, 0, 50000, 5000),
(26, 0, 0, 50000, 15000),
(27, 0, 0, 50000, 25000),
(28, 0, 0, 50000, 35000),
(29, 0, 0, 50000, 0),
};
private static IGlobalsRepository RankRepo()
{
var mock = new Mock<IGlobalsRepository>(MockBehavior.Loose);
mock.Setup(x => x.GetRankInfo()).ReturnsAsync(
Ranks.Select(r => new RankInfoEntry
{
Id = r.Id,
NecessaryPoint = r.Necessary,
AccumulatePoint = r.Accumulate,
LowerLimitPoint = r.Lower,
AccumulateMasterPoint = r.AccMp,
}).ToList());
return mock.Object;
}
private static Viewer NewViewer(Format format, int point, int masterPoint)
{
var v = new Viewer { Id = 1, DisplayName = "v" };
v.RankProgress.Add(new ViewerRankProgress
{
Format = format, Point = point, MasterPoint = masterPoint,
});
return v;
}
private static RankProgressService NewService() =>
new(RankRepo(), NullLogger<RankProgressService>.Instance);
[Test]
public async Task First_win_from_scratch_awards_100_and_promotes_to_Beginner1()
{
var svc = NewService();
var v = new Viewer { Id = 1, DisplayName = "v" };
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
Assert.That(r.Rank, Is.EqualTo(2));
Assert.That(r.AfterBattlePoint, Is.EqualTo(100));
Assert.That(r.BattlePoint, Is.EqualTo(100));
Assert.That(r.AfterMasterPoint, Is.EqualTo(0));
Assert.That(r.IsMasterRank, Is.False);
Assert.That(r.IsGrandMasterRank, Is.False);
var row = v.RankProgress.Single();
Assert.That(row.Format, Is.EqualTo(Format.Rotation));
Assert.That(row.Point, Is.EqualTo(100));
}
[Test]
public async Task Loss_at_0_points_floors_at_0()
{
var svc = NewService();
var v = NewViewer(Format.Rotation, point: 0, masterPoint: 0);
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
Assert.That(r.Rank, Is.EqualTo(1));
Assert.That(r.AfterBattlePoint, Is.EqualTo(0));
Assert.That(r.BattlePoint, Is.EqualTo(0));
}
[Test]
public async Task Loss_within_beginner_can_drop_between_subranks()
{
var svc = NewService();
var v = NewViewer(Format.Rotation, point: 100, masterPoint: 0);
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
Assert.That(r.Rank, Is.EqualTo(1));
Assert.That(r.AfterBattlePoint, Is.EqualTo(50));
Assert.That(r.BattlePoint, Is.EqualTo(-50));
}
[Test]
public async Task Loss_at_D_tier_floor_stays_at_D()
{
var svc = NewService();
var v = NewViewer(Format.Rotation, point: 1200, masterPoint: 0);
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
Assert.That(r.Rank, Is.EqualTo(5));
Assert.That(r.AfterBattlePoint, Is.EqualTo(1200));
Assert.That(r.BattlePoint, Is.EqualTo(0));
}
[Test]
public async Task Loss_within_D_drops_from_D1_toward_D0_and_stops_at_floor()
{
var svc = NewService();
var v = NewViewer(Format.Rotation, point: 1230, masterPoint: 0);
var r1 = await svc.GrantAsync(v, Format.Rotation, isWin: false);
Assert.That(r1.AfterBattlePoint, Is.EqualTo(1200));
Assert.That(r1.BattlePoint, Is.EqualTo(-30));
Assert.That(r1.Rank, Is.EqualTo(5));
var r2 = await svc.GrantAsync(v, Format.Rotation, isWin: false);
Assert.That(r2.AfterBattlePoint, Is.EqualTo(1200));
Assert.That(r2.BattlePoint, Is.EqualTo(0));
}
[Test]
public async Task Win_crossing_50000_lands_at_Master_with_no_MP_change()
{
var svc = NewService();
var v = NewViewer(Format.Rotation, point: 49950, masterPoint: 0);
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
Assert.That(r.Rank, Is.EqualTo(25));
Assert.That(r.AfterBattlePoint, Is.EqualTo(50050));
Assert.That(r.BattlePoint, Is.EqualTo(100));
Assert.That(r.AfterMasterPoint, Is.EqualTo(0));
Assert.That(r.MasterPoint, Is.EqualTo(0));
Assert.That(r.IsMasterRank, Is.True);
}
[Test]
public async Task Win_at_Master_awards_MasterPoint_not_Point()
{
var svc = NewService();
var v = NewViewer(Format.Rotation, point: 50000, masterPoint: 0);
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
Assert.That(r.BattlePoint, Is.EqualTo(0));
Assert.That(r.MasterPoint, Is.EqualTo(100));
Assert.That(r.AfterMasterPoint, Is.EqualTo(100));
Assert.That(r.Rank, Is.EqualTo(25));
}
[Test]
public async Task MP_crossing_5000_promotes_to_GrandMaster_0()
{
var svc = NewService();
var v = NewViewer(Format.Rotation, point: 50000, masterPoint: 4900);
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
Assert.That(r.AfterMasterPoint, Is.EqualTo(5000));
Assert.That(r.Rank, Is.EqualTo(26));
Assert.That(r.IsGrandMasterRank, Is.True);
Assert.That(r.IsMasterRank, Is.False);
}
[Test]
public async Task Loss_at_GM0_floor_stays_at_5000_and_does_not_demote_to_Master()
{
var svc = NewService();
var v = NewViewer(Format.Rotation, point: 50000, masterPoint: 5000);
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
Assert.That(r.AfterMasterPoint, Is.EqualTo(5000));
Assert.That(r.MasterPoint, Is.EqualTo(0));
Assert.That(r.Rank, Is.EqualTo(26));
}
[Test]
public async Task Loss_at_Master_with_MP_zero_leaves_Point_at_50000()
{
var svc = NewService();
var v = NewViewer(Format.Rotation, point: 50000, masterPoint: 0);
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
Assert.That(r.AfterBattlePoint, Is.EqualTo(50000));
Assert.That(r.BattlePoint, Is.EqualTo(0));
Assert.That(r.Rank, Is.EqualTo(25));
}
[Test]
public async Task Rotation_and_Unlimited_progressions_are_separate()
{
var svc = NewService();
var v = new Viewer { Id = 1, DisplayName = "v" };
v.RankProgress.Add(new ViewerRankProgress { Format = Format.Rotation, Point = 500 });
var r = await svc.GrantAsync(v, Format.Unlimited, isWin: true);
Assert.That(r.AfterBattlePoint, Is.EqualTo(100));
Assert.That(v.RankProgress.Count, Is.EqualTo(2));
Assert.That(v.RankProgress.Single(x => x.Format == Format.Rotation).Point, Is.EqualTo(500));
Assert.That(v.RankProgress.Single(x => x.Format == Format.Unlimited).Point, Is.EqualTo(100));
}
[Test]
public void Crossover_format_throws()
{
var svc = NewService();
var v = new Viewer { Id = 1, DisplayName = "v" };
Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
await svc.GrantAsync(v, Format.Crossover, isWin: true));
}
[Test]
public async Task GetAsync_returns_zero_when_no_row_exists()
{
var svc = NewService();
var v = new Viewer { Id = 1, DisplayName = "v" };
var r = await svc.GetAsync(v, Format.Rotation);
Assert.That(r.Rank, Is.EqualTo(1));
Assert.That(r.AfterBattlePoint, Is.EqualTo(0));
Assert.That(r.AfterMasterPoint, Is.EqualTo(0));
Assert.That(v.RankProgress, Is.Empty);
}
// ---- TierAdvanced ----
[Test]
public async Task TierAdvanced_is_false_when_win_stays_within_same_tier()
{
// Point 100 (Beginner 1, rank 2) + 100 win = 200 (Beginner 3, rank 4). Still beginner.
var svc = NewService();
var v = NewViewer(Format.Rotation, point: 100, masterPoint: 0);
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
Assert.That(RankTier.Name(r.Rank), Is.EqualTo("beginner"));
Assert.That(r.TierAdvanced, Is.False);
}
[Test]
public async Task TierAdvanced_is_true_when_win_crosses_beginner_to_d()
{
// Point 1150 (Beginner 3, rank 4 — needs 1200 to leave beginner) + 100 = 1250 = D0 (rank 5).
var svc = NewService();
var v = NewViewer(Format.Rotation, point: 1150, masterPoint: 0);
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
Assert.That(r.Rank, Is.EqualTo(5), "Expected promotion to rank 5 (D0)");
Assert.That(r.TierAdvanced, Is.True);
}
[Test]
public async Task TierAdvanced_is_false_on_loss_even_if_tier_string_changes()
{
// Loss can't advance a tier — even if it demoted, the achievement doesn't fire backward.
// Point 1200 (D0, rank 5) - 50 loss = 1200 (floored). Still D0. Prove flag stays false.
var svc = NewService();
var v = NewViewer(Format.Rotation, point: 1200, masterPoint: 0);
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
Assert.That(r.TierAdvanced, Is.False);
}
}

View File

@@ -43,6 +43,8 @@ public class StoryServiceTests
configService: StoryServiceTestHelpers.NewConfigService(), configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object, deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object, buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
viewers: new Mock<SVSim.Database.Repositories.Viewer.IViewerRepository>().Object,
xp: new Mock<SVSim.Database.Services.BattleXp.IBattleXpService>().Object,
logger: NullLogger<StoryService>.Instance); logger: NullLogger<StoryService>.Instance);
} }
@@ -81,6 +83,8 @@ public class StoryServiceTests
configService: StoryServiceTestHelpers.NewConfigService(), configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object, deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object, buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
viewers: scope.ServiceProvider.GetRequiredService<SVSim.Database.Repositories.Viewer.IViewerRepository>(),
xp: scope.ServiceProvider.GetRequiredService<SVSim.Database.Services.BattleXp.IBattleXpService>(),
logger: NullLogger<StoryService>.Instance); logger: NullLogger<StoryService>.Instance);
} }
@@ -414,6 +418,8 @@ public class StoryServiceTests
configService: StoryServiceTestHelpers.NewConfigService(), configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object, deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object, buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
viewers: new Mock<SVSim.Database.Repositories.Viewer.IViewerRepository>().Object,
xp: new Mock<SVSim.Database.Services.BattleXp.IBattleXpService>().Object,
logger: NullLogger<StoryService>.Instance); logger: NullLogger<StoryService>.Instance);
} }
@@ -571,9 +577,9 @@ public class StoryServiceTests
var resp = await _service.FinishAsync(StoryApiType.Main, req, viewerId: 7L); var resp = await _service.FinishAsync(StoryApiType.Main, req, viewerId: 7L);
Assert.That(resp.RewardList, Is.Empty); Assert.That(resp.Response.RewardList, Is.Empty);
Assert.That(resp.StoryRewardList, Is.Empty); Assert.That(resp.Response.StoryRewardList, Is.Empty);
Assert.That(resp.GetClassExperience, Is.EqualTo("0")); Assert.That(resp.Response.GetClassExperience, Is.EqualTo("0"));
_viewer.Verify(v => v.UpsertProgressAsync(7L, 100, null, true), Times.Once); _viewer.Verify(v => v.UpsertProgressAsync(7L, 100, null, true), Times.Once);
} }
@@ -622,16 +628,28 @@ public class StoryServiceTests
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
// Viewer started at RedEther=0; grant of 100 → post-state total = 100. // Viewer started at RedEther=0; grant of 100 → post-state total = 100.
Assert.That(resp.RewardList, Has.Count.EqualTo(1)); Assert.That(resp.Response.RewardList, Has.Count.EqualTo(1));
Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100")); Assert.That(resp.Response.RewardList[0].RewardNum, Is.EqualTo("100"));
Assert.That(resp.GetClassExperience, Is.EqualTo("200")); // Story XP resolves via DI-registered IBattleXpService → real IGameConfigService
// (the local NewConfigService mock is passed to StoryService but the XP service
// pulls its own config from DI). BattleXpConfig.ShippedDefaults(): XpPerWin=200,
// StoryXpPerClear=null → falls back to XpPerWin=200. Curve L1=50, L2=150 → 200 XP
// crosses both thresholds: L3 with 0.
Assert.That(resp.Response.GetClassExperience, Is.EqualTo("200"));
Assert.That(resp.Response.ClassExperience, Is.EqualTo(0));
Assert.That(resp.Response.ClassLevel, Is.EqualTo("3"));
_viewer.Verify(v => v.UpsertProgressAsync(viewerId, 100, true, null), Times.Once); _viewer.Verify(v => v.UpsertProgressAsync(viewerId, 100, true, null), Times.Once);
// Confirm currency persisted: fetch fresh viewer from a new scope. // Confirm currency + class XP persisted: fetch fresh viewer from a new scope.
using var verifyScope = factory.Services.CreateScope(); using var verifyScope = factory.Services.CreateScope();
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>(); var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var freshViewer = await db2.Viewers.FirstAsync(v => v.Id == viewerId); var freshViewer = await db2.Viewers
.Include(v => v.Classes).ThenInclude(c => c.Class)
.FirstAsync(v => v.Id == viewerId);
Assert.That(freshViewer.Currency.RedEther, Is.EqualTo(100UL)); Assert.That(freshViewer.Currency.RedEther, Is.EqualTo(100UL));
var cls2 = freshViewer.Classes.Single(c => c.Class.Id == 2);
Assert.That(cls2.Level, Is.EqualTo(3));
Assert.That(cls2.Exp, Is.EqualTo(0));
} }
} }
@@ -652,7 +670,7 @@ public class StoryServiceTests
var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 }; var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 };
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
Assert.That(resp.RewardList, Is.Empty); Assert.That(resp.Response.RewardList, Is.Empty);
// Currency must not have changed from its seed value of 0. // Currency must not have changed from its seed value of 0.
using var verifyScope = factory.Services.CreateScope(); using var verifyScope = factory.Services.CreateScope();
@@ -680,8 +698,8 @@ public class StoryServiceTests
var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 }; var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 };
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
Assert.That(resp.RewardList, Has.Count.EqualTo(1)); Assert.That(resp.Response.RewardList, Has.Count.EqualTo(1));
Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100")); Assert.That(resp.Response.RewardList[0].RewardNum, Is.EqualTo("100"));
using var verifyScope = factory.Services.CreateScope(); using var verifyScope = factory.Services.CreateScope();
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>(); var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
@@ -732,14 +750,14 @@ public class StoryServiceTests
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
// reward_list (post-state) gets BOTH the Card entry AND the cascaded Skin entry. // reward_list (post-state) gets BOTH the Card entry AND the cascaded Skin entry.
Assert.That(resp.RewardList.Any(r => r.RewardType == "5" && r.RewardId == testCardId.ToString()), Is.True, Assert.That(resp.Response.RewardList.Any(r => r.RewardType == "5" && r.RewardId == testCardId.ToString()), Is.True,
"card reward should appear in reward_list"); "card reward should appear in reward_list");
Assert.That(resp.RewardList.Any(r => r.RewardType == "10" && r.RewardId == testSkinId.ToString()), Is.True, Assert.That(resp.Response.RewardList.Any(r => r.RewardType == "10" && r.RewardId == testSkinId.ToString()), Is.True,
"cascade skin should appear in reward_list"); "cascade skin should appear in reward_list");
// story_reward_list (deltas) only carries the top-level chapter reward. // story_reward_list (deltas) only carries the top-level chapter reward.
Assert.That(resp.StoryRewardList.Count(r => r.RewardType == "5"), Is.EqualTo(1)); Assert.That(resp.Response.StoryRewardList.Count(r => r.RewardType == "5"), Is.EqualTo(1));
Assert.That(resp.StoryRewardList.Any(r => r.RewardType == "10"), Is.False, Assert.That(resp.Response.StoryRewardList.Any(r => r.RewardType == "10"), Is.False,
"cascade cosmetics should not appear in story_reward_list deltas"); "cascade cosmetics should not appear in story_reward_list deltas");
} }
@@ -804,7 +822,7 @@ public class StoryServiceTests
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
// Post-state count on the wire should be 3 (2 owned + 1 granted). // Post-state count on the wire should be 3 (2 owned + 1 granted).
var cardEntry = resp.RewardList.SingleOrDefault(r => r.RewardType == "5" && r.RewardId == testCardId.ToString()); var cardEntry = resp.Response.RewardList.SingleOrDefault(r => r.RewardType == "5" && r.RewardId == testCardId.ToString());
Assert.That(cardEntry, Is.Not.Null, "card reward should appear in reward_list"); Assert.That(cardEntry, Is.Not.Null, "card reward should appear in reward_list");
Assert.That(cardEntry!.RewardNum, Is.EqualTo("3"), "post-state count should be incremented, not reset to 1"); Assert.That(cardEntry!.RewardNum, Is.EqualTo("3"), "post-state count should be incremented, not reset to 1");
} }
@@ -828,7 +846,7 @@ internal static class StoryServiceTestHelpers
{ {
var mock = new Mock<SVSim.Database.Services.IGameConfigService>(); var mock = new Mock<SVSim.Database.Services.IGameConfigService>();
mock.Setup(s => s.Get<SVSim.Database.Models.Config.StoryConfig>()) mock.Setup(s => s.Get<SVSim.Database.Models.Config.StoryConfig>())
.Returns(new SVSim.Database.Models.Config.StoryConfig { ClassXpPerClear = 200 }); .Returns(new SVSim.Database.Models.Config.StoryConfig());
return mock.Object; return mock.Object;
} }