Commit Graph

154 Commits

Author SHA1 Message Date
gamer147
c6259e5a14 feat(replay): add ReplayController for /replay/{info,detail}
/replay/info reads from ReplayHistoryReader, newest-first, capped at 50.
/replay/detail returns 400 with result_code=99 - local cache is the
canonical playback source so this endpoint is cache-miss fallback only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:59:51 -04:00
gamer147
2d13f0b72d feat(friend): FriendController with 12 endpoints + integration tests 2026-06-09 22:12:00 -04:00
gamer147
742058403c refactor(campaign): delegate gift-reward-type check to GiftRewardTypes
Delete local IsSupportedGiftRewardType and replace its single call site with
GiftRewardTypes.IsSupported — Card (5) and Sleeve (6) are now accepted.
Update unsupported-type test sentinel from 5 (Card) to 11 (SpotCard).
Add Card and Sleeve success-path tests; full suite 1152/1152.
2026-06-09 20:52:47 -04:00
gamer147
b2a5b69423 fix(gift): wire reward_type is UserGoodsType integer, not legacy 1/4/9 encoding
Replace WireRewardTypeToUserGoodsType switch with a validating identity cast backed
by GiftRewardTypes.IsSupported. Wire type 1 is RedEther (UserGoodsType.RedEther),
not Crystal (UserGoodsType.Crystal=2); the old switch silently granted the wrong
wallet for every tutorial-completion claim. Update all 5 GiftControllerTests assertions
and 1 TutorialFlowEndToEndTests assertion to expect RedEther instead of Crystals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 20:45:49 -04:00
gamer147
0d32d2167b feat(campaign): CampaignController.RegisterSerialCode + DTOs + tests
Adds POST /campaign/regist_serial_code with 9 integration tests covering
success path, all disqualifier conditions (unknown/disabled/expired/future/
already-redeemed/unsupported-reward-type), 401 on missing auth, and case-
sensitivity. IsSupportedGiftRewardType uses gift-wire literals (1/4/9) not
UserGoodsType enum values, matching GiftController.WireRewardTypeToUserGoodsType.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 18:51:03 -04:00
gamer147
11215bd69f feat(profile): ProfileController + DTOs + integration tests
Add /profile/index endpoint that returns user_rank_match_total_win (stubbed 0)
and user_class_list built from viewer Classes + owned LeaderSkins. Six NUnit
integration tests cover zero wins, all classes present, level/exp/default skin,
leader_skin_id_list population, is_random_leader_skin round-trip, and 401 on
unauthenticated access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:37:35 -04:00
gamer147
f204656f4d refactor(user-class): require owned skin list, read IsRandomLeaderSkin from data 2026-06-09 17:31:06 -04:00
gamer147
483cc1c1e0 feat(mypage): /mypage/index reflects persisted bg selection
Wires MyPageController.Index to read Viewer.MyPageBgId/SelectType/BgRotation
instead of emitting an empty MyPageBgSetting placeholder; adds Include for
MyPageBgRotation in ViewerRepository.GetViewerByShortUdid; adds fresh-viewer
zero-defaults test and write→read round-trip test (9/9 controller tests pass).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:53:34 -04:00
gamer147
6123a64b37 test(user-mypage): explain Clear() + cover unparseable rotation entries
Add a comment above MyPageBgRotation.Clear() explaining the EF OwnsMany
delete-on-clear semantics. Add a 7th test covering garbage/"" entries in
mypage_id_list falling back to BgId=0 while adjacent valid entries are
unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:50:38 -04:00
gamer147
9ff6c70faf feat(user-mypage): UserMyPageController + DTOs + persistence tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:46:49 -04:00
gamer147
c2c3abc6f0 feat(gift): tag receive-gift tx as AdminGrant for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:08:27 -04:00
gamer147
6ec6a9c3fc feat(spot-card): tag exchange tx as GachaPointExchange for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:08:03 -04:00
gamer147
d759465cf2 feat(achievement): tag receive-reward tx as AchievementReward for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:07:42 -04:00
gamer147
349d7f32cd feat(leader-skin): tag buy tx as LeaderSkinBuy for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:07:19 -04:00
gamer147
30cb4727f6 feat(sleeve): tag buy tx as SleeveBuy for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:06:47 -04:00
gamer147
38a21e5e72 feat(build-deck): tag buy tx as BuildDeckBuy for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:06:23 -04:00
gamer147
5cf3cf70e8 feat(item-purchase): tag purchase tx as ItemPurchase for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:05:59 -04:00
gamer147
21ee113d21 feat(puzzle): tag inventory tx as PuzzleReward for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:03:45 -04:00
gamer147
4aa1367b6f feat(pack): tag gacha-point exchange tx for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:03:21 -04:00
gamer147
f1d96ff554 feat(pack): tag inventory tx as PackOpen for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:02:56 -04:00
gamer147
37d89aa602 refactor(inventory): share retention cap + invariant-culture date format
Introduce InventoryHistoryConfig.RetentionRowsPerViewer as the single
source of truth for the 300-row audit-log cap; InventoryTransaction
aliases it and ItemAcquireHistoryController.Take() references it
directly so the two sites cannot drift. Also adds CultureInfo.InvariantCulture
to the AcquireTime.ToString() call, matching every other WireDateFormat
site in the codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:55:43 -04:00
gamer147
f9a971a546 feat(item-acquire-history): controller + DTOs
Add ItemAcquireHistoryController (POST /item_acquire_history/info) with its
three DTOs and two integration tests (ordering + empty-viewer). The endpoint
reads ViewerAcquireHistory rows written by InventoryTransaction.CommitAsync,
ordered newest-first, capped at 300. Tests access doc.RootElement.histories
directly (no envelope wrapper in the test path — middleware skips non-UnityPlayer UA).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:49:43 -04:00
gamer147
7118b92522 refactor(pack): type PackChildGachaEntry.TypeDetail as CardPackType enum 2026-06-09 08:48:16 -04:00
gamer147
57d231cd56 feat(pack): /pack/open supports type_detail=10 FREE_PACKS with per-campaign daily quota 2026-06-08 21:43:04 -04:00
gamer147
6c7e8ae8ad feat(pack): /pack/info filters spent free-pack children and emits campaign metadata 2026-06-08 21:38:26 -04:00
gamer147
f1d881b26a fix(gift): drop RowVersion (SQLite incompatible) + restore wire reward_type map
[Timestamp] byte[] doesn't work under SQLite (the test backend) — EF
expects the DB to populate it on insert, but SQLite has no equivalent
of Postgres's xmin. The WHERE Status = Unclaimed filter plus
IInventoryService's viewer-level concurrency is the practical defense;
RowVersion was only a backstop. Regenerated the migration without the
RowVersion column.

Wire reward_type on the gift endpoint uses a gift-specific scheme that
diverges from UserGoodsType for currencies: wire 1 = Crystal (enum=2),
wire 9 = Rupy (enum=9), wire 4 = Item (enum=4). A naked cast resolves
wire 1 to UserGoodsType.RedEther and silently grants the wrong wallet
— restored the explicit WireRewardTypeToUserGoodsType map from the old
tutorial controller.

Retrofits existing GiftControllerTests to call SeedTutorialPresentsAsync
on the new helper (RegisterViewer doesn't auto-seed; only the prod
signup path does). All 7 existing tests pass.
2026-06-08 20:44:52 -04:00
gamer147
2b35ae0890 feat(gift): unified GiftController over ViewerPresent + route aliases 2026-06-08 20:36:44 -04:00
gamer147
bf51dabcff refactor(dtos): promote PresentDto to Common/ 2026-06-08 20:35:42 -04:00
gamer147
9d6a5cc3b9 feat(home-dialog): populate home_dialog_list on /mypage/index
Walk-down behavior: each call emits the highest-priority unfired
active dialog; subsequent calls walk to the next-priority entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:55:48 -04:00
gamer147
1007cf24d2 refactor(battlenode): type MatchContext.ClassId as CardClass enum (§C)
Behavior-preserving; full solution builds, 1013 tests green.

ClassId is the one genuinely-closed set of the three flagged stringly fields, so it
becomes a CardClass enum (1..8). Wire stays "1".."8": producer casts
(CardClass)run.ClassId, ServerBattleFrames renders via CardClassWire.ToWireValue().
RankBattleController's AI-start path drops a fragile int.TryParse(...)?:-1 for (int)cast.

CharaId (free-form leader/skin id, e.g. "5000123") and CountryCode (open-ended account
data) stay string with proper XML docs; CountryCodes.Korea/Japan name the captured values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:04:49 -04:00
gamer147
e9af7af1b8 fix(ranked-ai): randomize bot selection and seed for AI fallback matches
Bot roster pick was hashing (UserName, ClassId) — same player always
faced the same bot class. Now hashes battleId so different matches get
different opponents while retries of the same pending battle stay
consistent.

AI start response hardcoded Seed=0 for both sides, so the client's
deck shuffle/mulligan/draw RNG was deterministic every match. The
BattleNode's per-battle MasterSeed (Random.Shared) was never sent to
bot-mode clients because InitBattleHandler skips the Matched frame.
Now populates Seed with Random.Shared.Next() on the HTTP response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 21:49:43 -04:00
gamer147
f21ab7a38c refactor(battle-node): remove ScriptedBotParticipant and dev-affordance wiring
Deletes the scripted opponent and every entry point that created a
BattleType.Scripted session (the ?scripted=1 query opt-in, the
SoloDefaultsToScripted toggle, the resolver short-circuit, the WS handler case,
the bridge validation arm). Real two-client PvP and the Bot matchmaking-timeout
fallback are untouched. ResolveAsync drops its scriptedOptIn parameter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:15:48 -04:00
gamer147
05d8169012 refactor: type reward_type columns as UserGoodsType enum
Replace bare `int RewardType` on 12 catalog/reward entities and GrantedReward
with the existing UserGoodsType enum. Verified against the decompiled client:
every wire reward_type decodes through the single Wizard.UserGoods.Type enum, so
one enum is correct across all endpoint families (item_type is a separate
Item.Type axis, left untouched). EF stores the enum as the same int column, so
there is no migration.

- Importers cast seed int -> UserGoodsType at the ingest boundary.
- New GrantedReward.ToRewardList() extension replaces 8 copy-pasted
  GrantedReward -> RewardListEntry projections.
- Fix 3 .ToString() sites that would otherwise emit enum names ("Crystal")
  instead of the int wire value ("2").
- Wire DTOs keep int; the enum is widened to int at the wire boundary only.

Build green; 962/962 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:50:49 -04:00
gamer147
672a89ed46 refactor(matching): IMatchingResolver shared by every do_matching family
SoloDefaultsToScripted was only consulted by ArenaTwoPickBattleController;
RankBattleController did its own inline pair-up + state-code mapping and
ignored the flag entirely. Result: turning on the flag globally only
short-circuited TK2 polls, while rank-battle polls still parked for the
PvpFirstThenAiFallback threshold (15s) before resolving — surfaced today
when the user set the flag and saw rank-battle still queue, then bot-
battle via the client-side AI (not the server-side Scripted lifecycle we
need to test WS traffic against).

New IMatchingResolver owns the cross-cutting decisions:
- honor scriptedOptIn (per-request) OR options.SoloDefaultsToScripted
  (process-wide) — bypass pair-up, register Scripted, return 3004
- otherwise call IMatchingPairUpService.TryPairAsync and translate the
  PairUpResult to the 3002/3004/3007/3011 vocabulary

Family controllers shed the duplicated logic:
- ArenaTwoPickBattleController: ~50 LOC → ~25; preserves ?scripted=1
  query opt-in (parsed permissively for "1"/"true") and the
  ArenaTwoPickException catch
- RankBattleController: ~30 LOC → ~12; preserves the 3001 mapping for
  InvalidOperationException (no deck for format) and card_master_id
  emission

DoMatchingContractTests is the durable enforcement: parametrized over
TK2 + rotation + unlimited rank, asserts SoloDefaultsToScripted=true
makes every family's first poll skip 3002 and return SUCCEEDED with a
battle_id + node_server_url. Adding a fourth family that forgets to
route through IMatchingResolver fails this test — that's the point.

MatchingResolverTests covers the six resolver paths in isolation with
mocks; per-test Harness locals (not fixture-level fields) because the
assembly is [Parallelizable(ParallelScope.All)] and shared mocks race.

957 tests passing (was 948; +9: 6 resolver + 3 contract parametrizations).
No regressions in the existing TK2 / rank-battle controller suites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:18:48 -04:00
gamer147
898b872edd fix(rank-battle): route ai-start through the queue-time MatchContext
Live-smoke bug 2026-06-02: queued Bloodcraft (deck #5), wire showed
classId=2 (Swordcraft) for self_info on the /ai_unlimited_rank_battle/start
response — client rendered the wrong leader.

Two layers of the same bug:

1. MatchContextBuilder.BuildForRankBattleAsync hardcoded deckNo=1 instead
   of taking it from the do_matching request — verified against
   data_dumps/captures/traffic.ndjson L17 where deck_no=5 was on the wire.
   Signature changes to (viewerId, format, deckNo); DoMatchingInternal
   passes req.DeckNo.

2. AiStartInternal rebuilt MatchContext from scratch — but the /ai_*/start
   request body is BaseRequest only, no deck_no on the wire. The fix uses
   the MatchContext the bridge already stored at do_matching resolution time
   (in the Bot PendingBattle), so deck/cosmetic data is consistent end-to-end.
   New IBattleSessionStore.TryFindPendingForViewer(viewerId) finds the
   viewer's pending battle for lookup. The store entry persists across
   ai_start (idempotent reads are fine — the WS handler removes on connect).
   No-pending sentinel: ai_id=-1 surfaces the "no AI assigned" error in the
   client.

Tests: 936 → 939 passing.
- MatchContextBuilderTests.BuildForRankBattle_uses_the_caller_supplied_deck_number
  seeds deck #1 (class 1) and deck #5 (class 6) and asserts the deckNo
  argument picks the right one.
- RankBattleControllerTests.AiStart_self_info_class_matches_queued_deck_number
  is the end-to-end regression: register Bot battle with deck #5, hit
  /ai_unlimited_rank_battle/start, assert self_info.classId == 6.
- RankBattleControllerTests.AiStart_without_pending_battle_returns_neg1_sentinel
  locks the defensive ai_id=-1 path.
- Existing AiStart_* tests bypass do_matching, so adapted to call a new
  RegisterBotBattleAsync helper that mirrors what InProcessPairUp does on
  AI-fallback resolution.

SeedDeckAsync gains an optional classId so test cases can differentiate
decks by class (was always picking Classes.First()).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 12:28:42 -04:00
gamer147
24f9b2240e feat(matching): move BotRoster from hardcoded fixture to DB-backed seed
Phase 3 shipped the AI rank battle bot pool as a hardcoded 8-entry list
inlined in SVSim.EmulatedEntrypoint/Matching/BotRoster.cs — editing meant
recompiling. Per PLAN.md 2026-06-02 item (d), move it to a Bootstrap
importer so the roster lives in seeds/bot-roster.json and the DB.

Shape mirrors PracticeOpponent end-to-end:
- BotRosterEntry (SVSim.Database/Models) — PK = AiId via the Id passthrough
  pattern. DbSet<BotRosterEntry> BotRoster on SVSimDbContext.
- AddBotRoster migration (DDL only, per migrations-are-DDL-only rule).
- seeds/bot-roster.json — 8 rows preserving the current prod-verified
  cosmetic ids (sleeve 704141010 / emblem 400001100 / degree 120027 /
  field 5) and series-1 ai_ids from rm_ai_setting.csv (1111..1181).
- BotRosterSeed POCO + BotRosterImporter (idempotent upsert keyed by AiId,
  leaves seed-missing rows intact). Wired into SVSim.Bootstrap/Program.cs
  next to PracticeOpponentImporter.
- IGlobalsRepository.GetBotRoster() + impl.

IBotRoster.Pick → PickAsync because BotRoster now depends on the transient
IGlobalsRepository. RankBattleController awaits the new signature. The
deterministic hash-on-ctx invariant (same ctx → same bot, so /ai_<fmt>/start
retries pick the same opponent) is preserved.

DI: AddSingleton<IBotRoster> → AddTransient (matches IGlobalsRepository's
lifetime). Test fixture's SeedGlobalsAsync also runs the importer so
RankBattleControllerTests + the rewritten BotRosterTests both see seeded
rows.

Tests: 931 → 936 passing. Existing 3 BotRosterTests reshaped for the DB
backing + 1 new "throws on empty roster" guard; 4 new
BotRosterImporterTests mirror PracticeOpponentImporterTests
(round-trip / idempotent / seed-missing-row-intact / ai_id=0 skip).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:58:19 -04:00
gamer147
bf783639c1 fix(rank-battle): inherit BaseRequest so auth fields survive translation roundtrip
The translation middleware decrypts + msgpack-decodes the request body
into the action's first-parameter type, then re-serializes that DTO to
JSON for the auth handler to read. Phase 3's DoMatchingRequestDto and
RankBattleFinishRequestDto didn't inherit BaseRequest, so viewer_id /
steam_id / steam_session_ticket were dropped during the msgpack → DTO
→ JSON pivot — the auth handler then saw a body with no auth fields
and 401'd every request.

Fixed by making both DTOs extend BaseRequest, mirroring the Phase 2 TK2
DoMatchingRequest pattern.

Also added [FromBody] BaseRequest parameters to the previously body-less
actions (AiStart × 2, ForceFinish, AddClientLog, GetLatestMasterPoint).
The translation middleware explicitly requires at least one parameter
to bind the decrypted msgpack body (see L130-136 of the middleware);
without it the request would throw InvalidOperationException at runtime.

Tests updated to post viewer_id / steam_id / steam_session_ticket
placeholder values in the request body, matching the existing TK2 test
pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:29:48 -04:00
gamer147
07eb6f1c05 feat(rank-battle): AiStart returns ai_id + camelCase self/oppo_info
AiStartInternal builds the self MatchContext, picks a bot from
IBotRoster, projects to the AiBattleStartResponseDto with camelCase
wire keys (sleeveId, emblemId, ... — see ai-start.md). turnState=0
(player first) is the safe default per the ai-start.md TODO; live
capture would clarify the enum.

No deck → ai_id=-1 fallback (the documented "no AI assigned" sentinel
per AIBattleStartTask.cs:21). 3 new wire-shape tests assert the
camelCase keys land verbatim in the JSON, plus self/oppo info come from
the right sources.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:24:04 -04:00
gamer147
bb63b0df2f feat(rank-battle): real DoMatching with PvP pair + AI fallback mapping
DoMatchingInternal calls IMatchingPairUpService.TryPairAsync, then maps:
- null result → 3002 RETRY (empty node_server_url, no battle_id)
- IsAiFallback → 3011 AI_BATTLE_MATCHING_SUCCEEDED
- IsOwner → 3007 SUCCEEDED_OWNER (cache pickup)
- joiner → 3004 SUCCEEDED

BuildForRankBattleAsync's InvalidOperationException (typically "no deck
for format") surfaces as 3001 ILLEGAL so the client shows the
matchmaking-error dialog rather than retrying.

card_master_id is a placeholder (0) per the per-battle card-master
split deferral. AI-fallback timing is covered by InProcessPairUp unit
tests; controller tests focus on the wire mapping (3002, 3004, 3007).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:21:38 -04:00
gamer147
7c4aa89d45 feat(rank-battle): RankBattleController shell + DTOs + routing smoke tests
Stands up the controller with all 13 rank-battle URL routes wired via
explicit absolute [HttpPost] attributes (multi-prefix family — can't ride
[Route(\"[controller]\")]). Real DoMatching / AiStart logic arrives in
later tasks; finish + telemetry + force-finish are returnable stubs as
of this task.

DTOs cover the request + response shapes per the spec. Note the
camelCase wire keys on AiBattlePlayerInfo (sleeveId, emblemId, ...) —
the AI battle subsystem uses camelCase, not the project-default
snake_case, per AIBattleStartTask.Parse's literal Keys.Contains lookups.

DoMatchingResponseDto.NodeServerUrl is non-nullable + always-emit (with
[JsonIgnore(Never)]) — matches Phase 2's TK2 fix because the client's
DoMatchingBase parser calls .ToString() without a Keys.Contains guard.

13 routing smoke tests confirm each URL resolves to the controller.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:19:02 -04:00
gamer147
0095bdf0cf feat(arena-tk2): SoloDefaultsToScripted config flag for dev convenience
Adds BattleNodeOptions.SoloDefaultsToScripted (default false). When true,
the TK2 do_matching controller treats every solo poll as if ?scripted=1
were passed and returns a Scripted 3004 match immediately — useful for
the live client (which can't append query params) to drive the scripted
bot without needing a second player.

Toggle via "BattleNode:SoloDefaultsToScripted" in appsettings*.json
(Program.cs now binds the BattleNode section over the AddBattleNode
defaults). Turn off to test real PvP with two clients.

Trade-off documented on the option: while on, two simultaneous pollers
each get their own Scripted match instead of pairing, so PvP is
effectively disabled until the flag is flipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:48:14 -04:00
gamer147
8112b3f81f feat(arena-tk2): split do_matching success into 3007 owner / 3004 joiner
Mirrors prod's TK2 wire flow: the first arriver (parked, picks up cached
pair on a later poll) gets matching_state 3007 (SUCCEEDED_OWNER); the
second arriver (whose poll triggered the pair) gets 3004 (SUCCEEDED).

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:24:13 -04:00
gamer147
0ecd565774 fix(arena-tk2): park returns 3002 RETRY + empty node_server_url
Two client-crash bugs in the do_matching response when no partner is
waiting:

1. matching_state was 3001 (RC_BATTLE_MATCHING_ILLEGAL); the client's
   Matching.OnFinishedDoMatching switch maps that to an error dialog,
   not a retry. The retry state is 3002 (RC_BATTLE_MATCHING_RETRY).

2. node_server_url was omitted entirely. The client's
   DoMatchingBase.SettingDoMatchingData reads it via
   data["node_server_url"].ToString() with no Keys.Contains guard, so
   absence throws KeyNotFoundException out of NetworkManager.Connect
   before the matching_state switch is even reached. Prod RETRY
   captures send "" while waiting and the real URL only on SUCCEEDED;
   match that.

battle_id stays absent; its accessor IS guarded.

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

ASP.NET's default bool binder rejects "1" as a value, so the scripted
opt-in is bound as string? and parsed permissively (accepts "1" and
"true"/"True"/etc.) rather than relying on built-in bool binding.
2026-06-01 22:14:04 -04:00
gamer147
d665f88067 refactor(battle-node): unify IMatchingBridge.RegisterBattle signature
Single RegisterBattle(p1, p2?, type) with contract validation throws on
invalid combinations (Pvp requires both; Bot requires p2==null; Scripted
accepts either). PendingBattle carries Type + P1 + nullable P2. Handler
+ controller adapt; v1.2 behaviour preserved because Scripted is the
only type used today (Phase 2 adds Pvp, Phase 3 adds Bot).
2026-06-01 20:00:52 -04:00
gamer147
01f9bb722a feat(battle-node): thread MatchContext through bridge to BattleSession
IMatchingBridge.RegisterPendingBattle now takes a MatchContext; PendingBattle
carries it; BattleSession stores it. ArenaTwoPickBattleController builds ctx
from IMatchContextBuilder. ScriptedLifecycle still uses ScriptedProfiles for
the player half — Tasks 5/6 migrate the lifecycle.

Existing tests updated: MatchingBridgeTests, BattleNodeFlowTests,
InMemoryBattleSessionStoreTests, BattleSessionDispatchTests, BattleSession
PumpTests, ArenaTwoPickBattleControllerTests (which now seeds a TK2 run +
adds a no-active-run 400 case).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:44:42 -04:00
gamer147
0b859f1c8e fix(check): merge anonymous resignup viewer into Steam-linked viewer
GameStart already detects the Steam-vs-UDID mismatch produced by
wipe-and-resignup; it now also reclaims the orphan. New
ViewerRepository.MergeAnonymousViewerInto transfers the fresh UDID
from V_new onto V_old in one save (freeing the unique-index slot),
then deletes V_new in a second save. Partial-failure mode is a
benign null-UDID viewer; two rows never contend for the same UDID.
Side benefit: future GetViewerByUdid lookups now short-circuit to
V_old without going through the Steam handler.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:59:47 -04:00
gamer147
e7dac31d52 fix(check): emit rewrite_viewer_id when UDID and Steam viewers disagree
Wipe-and-resignup left the client stuck with the blank V_new's id in
Certification.ViewerId. /tool/signup is anonymous, so it can't see the
Steam ticket and creates a fresh anonymous viewer keyed on the new UDID;
the Steam handler on the next request resolves to V_old and serves its
data, but no normal-response hook overwrites Certification.ViewerId.
GameStart now compares the UDID-keyed viewer to the auth-resolved one
and emits rewrite_viewer_id when they differ, which Cute/GameStartCheckTask
writes back into Certification.ViewerId.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:49:56 -04:00
gamer147
ff51c33b6c feat(arena-tk2): do_matching mints battle via IMatchingBridge, returns 3004 2026-05-31 22:53:20 -04:00
gamer147
c37c04c1b7 refactor(gacha-point): route TryExchangeAsync through IInventoryTransaction
Change signature from (Viewer, packId, cardId) to (IInventoryTransaction, packId, cardId).
Drop RewardGrantService from GachaPointService ctor. PackController.ExchangeGachaPoint opens
tx with GachaPointBalances/Received extra includes, passes tx, commits on success.
Update GachaPointServiceTests to use inv.BeginAsync + tx pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:55:08 -04:00