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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
/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>
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>
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>
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>
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>
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>
- 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>
- 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>
- 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>
- 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>
- 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>
- 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>
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>
Calls UseSvSimSerilog("svsim-content") right after CreateBuilder and
wraps Main in try/catch/finally. No environment gate needed —
ContentServer has no test-suite path. The existing manual request-log
middleware (uses ILogger<Program>) now flows through Serilog
automatically; swapping it for UseSerilogRequestLogging() is a
follow-up per the spec.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Registers Serilog via SVSim.Hosting.UseSvSimSerilog("svsim-api") right
after CreateBuilder and wraps Main in try/catch/finally so a fatal
startup exception logs before CloseAndFlush.
Also updates the SVSim.Hosting extension to gate the file sink on entry
assembly rather than the ASP.NET environment name. WebApplicationFactory
sets the environment via a hook that resolves after both CreateBuilder
and the UseSerilog callback see their environment view, so
IsEnvironment("Testing") is unreliable in tests. The entry assembly
under `dotnet test` is always "testhost", which is a reliable signal.
appsettings.json grows a Serilog section mirroring the existing
Logging:LogLevel overrides; the Logging block itself is left in place
per the spec's follow-up cleanup note.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds new SVSim.Hosting project carrying Serilog + Serilog.AspNetCore +
Serilog.Settings.Configuration + enricher package refs, and a single
IHostBuilder extension method UseSvSimSerilog(appName) that installs a
colored console sink and a plain-text file sink rolling daily with a
100 MB per-file cap and 7-file retention. ReadFrom.Configuration is
called last so appsettings can override minimum levels per category.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>