Commit Graph

539 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
2d9a6eea4b engine cleanup passes 4-7 + multi-instancing ambient rip
Squashes 146 commits from battle-engine-extraction. Net: 2,045 files changed,
+11,896 / -158,687 lines. Ships engine passes 4-7 (dead-code cull, view-layer
stub, receive-path shrink) plus the Phase-5 AsyncLocal ambient deletion that
turns concurrent battles into a type-system property rather than a scope
contract.

## What landed

**Passes 4-7 (chunks 1-34):** Extended the Phase-4 const-false collapse into a
cascading cull across the skill graph, view layer, and receive-path periphery.
Six mode flags (IsWatchBattle/IsReplayBattle/IsAdmin/IsAdminWatch/IsPuzzleQuest/
IsAINetwork) became `const false`, every guarded block deleted. Field*.cs
subclass ctors + BackGroundBase + ObjectChecker culled to no-ops. Mulligan
family reworked to take a mgr param through IMulliganMgr.InitMulligan.
Emotion/Recovery/Resource clusters null-stubbed. Prediction/OperationSimulator/
skill filters converted from static ambient reads to per-mgr reads via
SkillPrm.ownerCard.SelfBattlePlayer.BattleMgr / ins.BattleMgr / this.BattleMgr.

**Phase-5 ambient rip (chunks 35-47):** Deleted BattleAmbient / BattleAmbient-
Context / TestBattleScope in full. Every per-battle mutable slot now lives on
the mgr instance itself:
  mgr.InstanceIsForecast / InstanceIsRandomDraw / InstanceRecoveryInfo /
  InstanceViewerId / InstanceNetworkAgent / GameMgr
BattleManagerBase.GetIns() returns null unconditionally; the residual static
flags + 3 façades (Certification.ViewerId, Data.BattleRecoveryInfo,
ToolboxGame.RealTimeNetworkAgent) are null-tolerant defaults kept for the
handful of engine-internal readers that still reference their types. Zero
BattleAmbient references anywhere in engine + node + tests.

Added pre-seeded GameMgr ctor overload threaded through the mgr chain
(BattleManagerBase → SingleBattleMgr / NetworkBattleManagerBase → NetworkStandard-
BattleMgr → HeadlessBattleMgr / HeadlessNetworkBattleMgr). Fixtures build a
GameMgr, seed it via HeadlessEngineEnv.SeedCharaIds/SeedNetUser, and pass it
to the mgr's ctor — no ambient reach.

Node side (SVSim.BattleNode/SessionBattleEngine): _ctx replaced with a plain
GameMgr field; 34 `using var _ambient = BattleAmbient.Enter(_ctx)` scope wraps
ripped from every accessor and mutator; EngineGlobalInit.WirePerSessionGameMgr
takes GameMgr as a param and runs from SessionBattleEngine.SetupInternal
BEFORE mgr construction.

Test side: TestBattleScope deleted; 18 fixture [SetUp]s migrated to
`HeadlessEngineEnv.EnsureProcessGlobals()`; MultiInstanceEngineTests rewritten
around per-mgr construction (GetIns() → null is the pinned invariant).

## Regression fixes

- **chunk-48** (MulliganCtrl): chunk-35's `= null` stubs on card lookups broke
  the live receive-driven Deal path (BattlePlayerBase.DrawCard NRE'd downstream
  of NetworkPlayerMulliganCtrl.StartMulliganVfx). Restored the three lookups
  via `_battlePlayer.BattleMgr.GetBattleCardIdx`. Engine tests were satisfied
  by the WireMulliganPhase seam; unit tests exposed the live-path gap.

## Ship state

- SVSim.BattleEngine.Tests: 56/56 pass, 2 skip
- SVSim.UnitTests: 1554/1554 pass (was 1523/31-fail before chunk 48)
- Solution build: 0 source warnings (40 pre-existing NU1902 MessagePack CVEs
  in SVSim.EmulatedEntrypoint, unrelated)
- Sequential PVP smoke: verified live (two back-to-back battles, no regression
  on cleanup/spinup)
- Concurrent PVP smoke: verified live

Adds tools/engine-port/ClosureAnalyzer/ — the Roslyn transitive-type-closure
analyzer needed to make future cascade cleanup safe (per feedback memory
"Engine cleanup needs closure tool" from the 2026-06-28 pass-3 failure).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 19:18:54 -04:00
gamer147
5c1db83967 feat(pack): /pack/set_rotation_starter_class + selected_class_id read-back
Implements Task 9 from docs/superpowers/plans/2026-06-13-decomp-only-followups.md
— the pre-purchase commit endpoint the client fires from StarterClassSelectDialog
before opening a RotationStarterCardPack. Without this the per-class draw path
from yesterday's work is unreachable: the dialog blocks on the AJAX call and
never advances to /pack/open.

Wire surface:
- POST /pack/set_rotation_starter_class { pack_id, class_id } → EmptyData.
  Validates 1..8, rejects unknown packs (404), non-RS packs (400), and second
  commits (400 — one-shot per pack per spec).
- PackConfigDto carries selected_class_id (nullable, omitted via global
  WhenWritingNull policy when unset). Placement verified against decomp at
  PackInfoTask.cs:86 — it's on the parent PackConfig, NOT the child gacha as
  the original plan text had it.
- /pack/open cross-checks request.class_id against the persisted choice for
  RS packs; rejects 400 on missing-commit or class-mismatch so a tampered
  request can't swap pools after the choice is locked.

Storage:
- ViewerPackStarterClass owned entity; (ViewerId, PackId) unique index via the
  pattern from project_owned_collection_unique_index memory.
- Migration PackStarterClass — composite PK + FK cascade verified against
  fresh Postgres bootstrap.

Tests (9 new in PackControllerSetRotationStarterClassTests):
- Round-trip set → /pack/info shows selected_class_id.
- Field absent before any commit.
- Invalid class (0/9/-1/100) → 400.
- Already-chosen rejection.
- Non-RS pack / unknown pack rejection.
- /pack/open rejects no-prior-commit AND class-mismatch; succeeds when class
  matches the persisted choice.

Full suite: 1552 passed (1543 + 9).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 20:06:46 -04:00
gamer147
869727cc06 feat(pack-draw): per-class card pools for rotation-starter packs
Add a nullable ClassId axis to PackDrawCardWeightEntry. Null means the
row applies to any draw (the existing behavior for non-RS packs); 1..8
restricts the row to a specific class draw on a RotationStarterCardPack.
Slot rates are class-invariant (verified against per-klan captures), so
PackDrawSlotRateEntry is unchanged.

PackOpenService.Draw now takes an optional classId parameter. With a
class supplied, the card pool is filtered to (ClassId == classId ||
ClassId == null); without one, class-tagged rows are excluded so a
mistaken non-RS call against a mixed table can't leak class-specific
cards.

PackController.Open lifts the 501 guard on RotationStarterCardPack and
plumbs class_id through. Class_id is now required (and validated 1..8)
for RS packs and rejected as 400 BadRequest on non-RS packs. The skin
overload (target_card_id) remains 501 — out of scope for this work.

Seed JSON regenerated end-to-end: 32 RS packs now carry 30,160 class-
tagged weight rows; 247 non-RS packs keep their existing ~87k null-class
rows. Slot rate fix from the upstream extractor change replaces the
~99.99% conditional-rate sums on 97xxx/93xxx packs with the page's
authoritative 1.5/6/25/67.5 distribution.

Migration AddPackDrawCardWeightClassId. End-to-end bootstrap verified
against fresh Postgres; pack 93025 reads back exactly 122/119/120/120/
120/121/118/119 weights for classes 1-8.

Tests: 3 new PackOpenServiceTests (class filter happy path, disjoint
pools, defensive null-class isolation); existing class_id-on-non-RS
controller test updated 501 → 400 BadRequest; one stub-importer test
corrected to match extractor-authored gacha_detail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 19:35:08 -04:00
gamer147
ff77d5e5b6 fix(guild): final-review remediation — replay_detail flat, FK on leader, transaction wraps, _db extraction
C1: replay_detail — flatten stored payload to data level. ChatReplayDetailTask.Parse() calls
new ReplayDetailInfo(data) which unguardedly accesses data[battleId], data[seed], data[vid1],
etc. Wrapping under replay_info key crashes the client. Controller now returns Ok(JsonElement)
directly so stored battle fields are at data root. Wire-shape test added.

C2: Guild.LeaderViewerId long to long?; add HasOne<Viewer> FK with OnDelete=SetNull; migration
AddGuildLeaderViewerIdFk; all consumers null-guarded with ?? 0L.

C3: BreakupAsync — wrap 6 destructive ops in IDbContextTransaction with InMemory fallback.

C4: CommitJoinAsync — wrap member-add + viewer-guildId-set + invite/request cleanup in
IDbContextTransaction with InMemory fallback. Chat event emitted after commit.

I1: GuildController — remove SVSimDbContext field; inject IViewerRepository + IGuildMemberRepository.
Add IGuildMemberRepository.GetViewerIdsInAGuildAsync (batch guild-membership check).
All _db.Viewers / _db.GuildMembers queries replaced with repo calls.

I2: GuildService — extract 3 _db.Viewers queries: GetEquippedEmblemIdAsync (CreateAsync),
LoadGuildProfileBatchAsync (ListOutgoingInvitesAsync + ListPendingJoinRequestsForMyGuildAsync).
Add GuildMemberProfile record with IsOfficialMarkDisplayed. GetEmblemListAsync for EmblemList.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 16:33:59 -04:00
gamer147
754b2ca466 fix(guild_chat): wait_interval as raw int + capture-replay request field name
Remove StringifiedIntConverter from GuildChatMessagesResponse.WaitInterval so the
wire emits a raw JSON number (3) matching prod, not a quoted string ("3").
Update GuildChatPollTests (2 assertions) and GuildCaptureReplayTests (1 assertion +
comment) to expect JsonValueKind.Number. Fix last_message_id → start_message_id typo
in GuildChatMessages_Member_ReturnsProdShape request body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 15:40:30 -04:00
gamer147
6467223b52 test(guild): capture-replay against traffic_prod_guild_create.ndjson
5 integration tests in GuildCaptureReplayTests replay the prod guild/info (pre-join +
post-create), guild/search_guild, guild/emblem_list, and guild_chat/messages flows
against the in-process WebApplicationFactory server.

Assertions are limited to always-present, session-independent fields:
- guild/info pre-join: guild_status=0, max_member_num=30,
  max_sub_leader_num=2, usable_stamp_list=[100001..100020] (exact match)
- guild/info post-create: guild_status=2, guild object present
- guild/search_guild: list field is a JSON array
- guild/emblem_list: guild_emblem_list field is a JSON array
- guild_chat/messages: chat_message[] + maintenance_card_list[] arrays present,
  wait_interval > 0 (accepts either raw int or stringified form)

Bodies are AES-encrypted in the capture, so requests are re-issued as plain JSON
to the test server (same shape, different auth context). Manual end-to-end (live
Steam client + clean Postgres) deferred to operator — can't automate in this session.

1534 → 1539 tests; 0 failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 15:32:47 -04:00
gamer147
d62f1b7ba7 feat(guild): MyPage guild_notification + load_index audit
- Replace GuildNotification TODO stub in MyPageController.Index with
  BuildGuildNotificationAsync: populates guild_id, guild_room_message_id,
  is_join_request, is_invited from real DB state.
- Inject IGuildService, IGuildInviteRepository, IGuildJoinRequestRepository,
  IGuildChatMessageRepository into MyPageController.
- Add GetMaxMessageIdSafelyAsync to IGuildChatMessageRepository (returns null
  when no messages exist, vs GetMaxMessageIdAsync which returns 0).
- LoadDetail.cs audit: grep returned no guild fields — no LoadController changes.
- Add GuildCrossSurfaceTests (4 tests): guild member, pending invite, pending
  join request, no-guild viewer all verified green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 15:24:46 -04:00
gamer147
eb65c2081c feat(guild_chat): deck + replay attachments
Implement all five guild_chat attachment endpoints:
- add_deck: server looks up deck by (viewerId, format, deckNo) and snapshots
  the current state into a DECK-type chat message DeckPayload column.
- delete_deck: author OR leader/sub-leader may clear; returns refreshed
  deck_log. Extended IGuildChatMessageRepository.ClearDeckAsync with
  leaderOverride flag; added GetDeckMessagesAsync + GetByMessageIdAsync.
- add_replay: stores {"battle_id": N} in ReplayPayload; full shape is
  TODO(post-merge) pending replay-subsystem capture.
- replay_detail: returns the stored ReplayPayload as JsonElement passthrough.
- deck_log: returns all active DECK messages grouped by API-side Format string
  key ("1"=Rotation, "2"=Unlimited, "3"=PreRotation always present;
  "4"=Crossover, "5"=MyRotation only when non-empty).

Adds DeckLogDataDto (typed) for deck_log / delete_deck responses.
Replay response uses JsonElement passthrough (TODO(post-merge)).
8 new integration tests — all green. Full suite 1530/1530 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 15:14:06 -04:00
gamer147
ab1e23b7cb feat(guild_chat): post text + stamp
Implements PostTextOrStampAsync: validates membership (NotInGuild),
validates stamp id against UsableStampList (PermissionDenied), rejects
type other than 0 or 1, allocates next per-guild message_id, appends row.
Wires /guild_chat/post controller action mapping failures to result_code=2.
Response is GuildChatPostResponse (empty body per spec). 7 integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 14:58:18 -04:00
gamer147
a248c167e0 refactor(guild_chat): extract chat-user lookup to viewer repo; fix NEW-direction exclusivity
Add ChatUserProfile record and IViewerRepository.LoadChatProfilesAsync (AsNoTracking,
projects Name/EmblemId/CountryCode/Rank/DegreeId from Viewer+Info nav refs).
GuildChatController.Messages now injects IViewerRepository instead of SVSimDbContext;
calls LoadChatProfilesAsync to populate users[] (removes direct db access from controller).
GuildChatMessageRepository direction=2 (NEW): change >= start to > start (exclusive).
GuildChatPollTests updated to assert the start message is NOT returned by direction=NEW.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 14:51:44 -04:00
gamer147
358f43aa7a feat(guild_chat): messages window + system events
- GuildChatService.EmitSystemEventAsync: replaces no-op stub with real
  chat-row insertion via _msgs.AppendAsync (per-guild monotonic id).
- GuildChatService.GetWindowAsync: real implementation — verifies caller
  membership, delegates to IGuildChatMessageRepository.GetWindowAsync
  (direction 1=OLD/2=NEW/3=BOTH, limit 50), returns adaptive wait interval
  (ChatPollActiveSeconds when messages returned, ChatPollIdleSeconds otherwise).
- Drop unused repo injections from GuildChatService (guilds, invites, joinRequests).
- GuildChatController.Messages: wires the full response — chat_message[],
  users[] (deduplicated author profiles with Emblem+Degree nav includes),
  maintenance_card_list (empty until maintenance service lands), wait_interval.
- GuildChatPollTests (7 integration tests): fresh-guild CreateGuild event,
  sequenced Create+Join ordering, direction=NEW filter, direction=BOTH window,
  users[] deduplication, empty result for viewer-not-in-guild, active vs idle
  interval comparison. All 7 pass; full suite 1515/1515.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 14:44:09 -04:00
gamer147
484b51086a fix(guild): friend_list returns bare array-at-root (matches GuildFriendListTask.Parse)
GuildFriendListTask.Parse() reads base.ResponseData[data][i] directly —
data must be a bare JSON array, not {friends:[...]}.  Delete the
GuildFriendListResponse wrapper DTO; promote GuildInviteCandidateDto to its
own file.  Controller now returns List<GuildInviteCandidateDto> so the
middleware wraps it as data:[…].  Update wire-shape and integration tests to
assert array-at-root.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 14:35:20 -04:00
gamer147
f2b996a593 feat(guild): change_role atomic transfer + friend_list
- ChangeRoleAsync: Leader-only role management with SubLeader cap check.
  Atomic leader transfer loads both member rows + Guild.LeaderViewerId in
  one _db.SaveChangesAsync call (no split saves). Emits ChangeLeader (6)
  or ChangeSubLeader (7) chat events. Same-role is no-op / returns Ok.
- /guild/change_role controller: calls ChangeRoleAsync, returns full updated
  member list (GuildChangeRoleResponse.Members[]).
- /guild/friend_list controller: calls IFriendService.GetFriendsAsync, then
  annotates each friend with is_join_guild from Viewer.GuildId column.
- IGuildRepository.UpdateLeaderViewerIdAsync: added for completeness even
  though the atomic transfer path bypasses it via direct EF mutation.
- Tests: 8 GuildServiceChangeRoleTests (role changes, cap, atomic transfer,
  no-op, permission); 3 GuildChangeRoleFriendListFlowTests (HTTP roundtrip,
  is_join_guild); 2 GuildWireShape tests (literal JSON shape); 1508 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 14:27:13 -04:00
gamer147
00fb28681b feat(guild): leave + remove with leader-leaves rule
- LeaveAsync: regular/subleader removes self; leader blocked if members remain;
  sole-member leader auto-routes to BreakupAsync.
- RemoveAsync: leader-only kick (SubLeader has no remove authority per
  GuildManager.cs decompile); self-remove and cross-guild target rejected.
- GuildController: wired /guild/leave (BaseRequest) and /guild/remove
  (GuildRemoveRequest.remove_viewer_id field).
- SpyGuildChatService: extended Emissions with EmissionsWithBody tuple to
  allow body-content assertions in leave/remove tests.
- 8 new integration tests; all 1493 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 14:16:47 -04:00
gamer147
901c3d7568 fix(guild): subleaders can accept/reject join requests (matches GuildManager.HasAuthorityInviteAndApproval)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 14:06:30 -04:00
gamer147
dd955fe4be feat(guild): join_request_accept + reject_join_request 2026-06-27 13:58:54 -04:00
gamer147
771d884314 fix(guild): /guild/join returns guild_status per branch (APPLYING for approval path, JOINING otherwise)
GuildJoinTask.Parse() reads data[guild_status].ToInt() directly; hardcoding 2
for the APPROVAL path was wrong. GuildOpResult gains nullable GuildStatus; JoinAsync
sets 1 (APPLYING) for the pending-request path and 2 (JOINING) for instant joins.
Controller reads r.GuildStatus. Two new tests assert the wire value per path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 13:51:04 -04:00
gamer147
d7f9ba8f2a feat(guild): join / cancel_join_request / join_request_list
Implements three join-path state machine endpoints:
- /guild/join: FREE (instant), APPROVAL (pending row + idempotent re-apply),
  ONLY_INVITE (pending invite required); CommitJoin consumes invites + cancels
  join requests; MaxMemberNum cap enforced.
- /guild/cancel_join_request: cancels all pending requests for caller (no
  request_id on wire per GuildJoinRequestCancelTask decompile, composite PK kept).
- /guild/join_request_list: leader/subleader sees pending applicants with
  viewer profile (name/emblem/degree/request_time unix secs).

IGuildService.CancelJoinRequestAsync signature changed from (viewerId, guildId)
to (viewerId) since the client sends no guildId. GuildJoinRequestEntry enriched
record added to GuildServiceTypes. Re-apply after cancel resets existing row
in-place to avoid composite-PK conflict. 11 integration tests, all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 13:44:04 -04:00
gamer147
11a129ea90 fix(guild): make GuildInvite unique index partial on Status=Pending to allow re-invite after reject/cancel
Partial index (filter: "Status" = 0) replaces the unconditional unique
index on (GuildId, InviteeViewerId). Old Rejected/Canceled rows no
longer conflict when a new Pending invite is inserted for the same pair.

Adds regression tests: Reinvite_after_reject_succeeds and
Reinvite_after_cancel_succeeds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 13:29:06 -04:00
gamer147
1b7fa2d525 feat(guild): invite state machine
Add surrogate PK (IDENTITY bigint) to GuildInvite for wire invite_id.
Implement InviteAsync, CancelInviteAsync, RejectInviteAsync,
ListOutgoingInvitesAsync, ListInvitedGuildsAsync in GuildService.
Wire 5 endpoints: invite_user_list, invited_guild_list, invite,
cancel_invite, reject_invite in GuildController.
Migration: AddGuildInviteSurrogatePk — drops composite PK, adds Id
IDENTITY column, adds unique index on (GuildId, InviteeViewerId).
6 integration tests in GuildInviteFlowTests all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 13:21:57 -04:00
gamer147
c103ab6a87 fix(guild): populate leader_name in search results (matches prod capture)
Add IViewerRepository.LoadDisplayNamesAsync (batch AsNoTracking select),
extend GuildSearchEntry with LeaderName, enrich GuildService.SearchAsync
with a leader-id batch lookup, and thread the name through the controller's
ToDetailDto call. Adds a leader_name assertion to GuildSearchFlowTests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 13:08:11 -04:00
gamer147
3f8f04eb1a feat(guild): /guild/search_guild
Implement SearchAsync in GuildService (wraps IGuildRepository.SearchAsync +
CountBatchByGuildIdsAsync), wire the controller action, add 7 integration
tests (GuildSearchFlowTests) covering no-filter / activity / join_condition /
bucket-1 / bucket-3 / name-prefix / flat-entry shape, and a wire-shape test
in GuildWireShape confirming list entries are flat (no detail wrapper).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 13:01:58 -04:00
gamer147
09104f4e73 fix(guild): /guild/update response — flat detail under guild (matches GuildUpdateTask.Parse)
GuildUpdateTask.Parse() reads data[guild] as GuildDetailInfo directly; the previous
GuildDetailSubTree wrapper (data[guild][detail]) caused json[guild_id].ToInt() to
crash on the client. Split GuildUpdateResponse (flat) from GuildUpdateEmblemResponse
(nested, correct for GuildEmblemUpdateTask.Parse). Also surfaces guild_name from the
request through UpdateGuildRequest.Name -> service -> repo so the client rename is honoured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 12:54:15 -04:00
gamer147
c0e1c10192 feat(guild): update + update_description + update_emblem 2026-06-27 12:47:00 -04:00
gamer147
f9361fa467 fix(guild): emblem_list wire shape — add wire-shape test locking shape (b)
GuildEmblemListTask.Parse reads jsonData[i][emblem_id].ToLong(),
confirming shape (b): array of objects with emblem_id. The existing
List<GuildEmblemEntry> DTO was already correct. Add
EmblemList_serializes_as_array_of_objects_with_emblem_id test to
GuildWireShape to lock the shape in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 12:38:57 -04:00