374 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
9a47ac8d90 app: wire Serilog into SVSim.EmulatedEntrypoint
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>
2026-07-04 02:07:41 -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
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
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
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