Commit Graph

121 Commits

Author SHA1 Message Date
gamer147
43a3665775 refactor(arena-colosseum): dedup colosseum_info source-of-truth
/mypage/index data.colosseum_info and /arena_colosseum/{top,get_fee_info}
now both project through ColosseumLobbyInfoBuilder.Build(ColosseumSeasonConfig).
One source of truth for the home-tab gating and the lobby reads — admins
flip the season on by writing to GameConfigs (per the runbook), no second
table to keep in sync.

* New ColosseumLobbyInfoBuilder — static projection from ColosseumSeasonConfig
  to the wire ColosseumLobbyInfo. Replaces ArenaColosseumController's private
  BuildColosseumInfo and MyPageController's BuildColosseumInfo(ColosseumConfig).
* MyPageController reads ColosseumSeasonConfig via IGameConfigService; the
  IGlobalsRepository.GetCurrentColosseum() call goes away.
* MyPageIndexResponse.ColosseumInfo retyped to the new ColosseumLobbyInfo.
* ColosseumSalesPeriodInfo relocated into the ArenaColosseum namespace
  alongside the rest of the family DTOs.
* Drops: Colosseums DB table (migration DropColosseumsTable), ColosseumConfig
  entity, the captured-shape Models.Dtos.ColosseumInfo DTO, ColosseumSeed,
  MyPageGlobalsImporter.ImportColosseumAsync + seed file colosseum.json,
  IGlobalsRepository.GetCurrentColosseum + impl, the two related tests.

Net: home-screen Grand Prix tab now hidden by default because the season
ships with IsColosseumPeriod=false. Full suite: 1345/1345 (was 1347 — the
2 dropped tests covered the now-dead surface).
2026-06-13 13:31:03 -04:00
gamer147
d126649ad4 feat(arena-colosseum): 2-pick + curated deck sources (phase 3)
Closes the family. arena-colosseum 10/16 → 15/16 zero stubs (the 16th —
finish_load — is dead per project_dead_battle_endpoints).

* 2-Pick draft lift onto ArenaColosseumController:
  - get_candidate_classes samples from ArenaTwoPickConfig.AllowedClassIds
    and persists the slate onto the run.
  - class_choose accepts class_id XOR chaos_id; both populate run.ClassId,
    chaos branch stores ChaosId for replay.
  - get_candidate_cards is the idempotent draft-resume snapshot.
  - card_choose appends both cards from the picked pair, advances turn 1..15.
  - Pool override: ArenaTwoPickCardPoolService gets a non-breaking
    GeneratePickSetsForTurn(..., poolCardSetIds) overload; Colosseum routes
    pass ColosseumSeasonConfig.PoolCardSetIds (falls back to challenge →
    rotation when empty).
* Curated-deck schema: ColosseumHofDeck / ColosseumWindFallDeck /
  ColosseumAvatarDeck — three identical tables (separate per per-pool
  operational lifecycle), unique on DeckNo. Migration AddColosseumCuratedDecks.
* IColosseumCuratedDeck interface + a generic ColosseumCuratedDeckImporterBase<T>
  with three concrete subclasses (HOF / WindFall / Avatar), registered in
  Bootstrap. Seed files ship empty.
* 6 curated endpoints on ArenaColosseumController: get_{hof|windfall|avatar}_deck_list
  return BARE-ARRAY data per spec; register_{hof|windfall|avatar}_deck share
  one generic RegisterCuratedAsync<T> dispatcher. Cross-pool register
  rejected via per-pool lookup. Curated register has no is_published flag
  (constructed-only) — clears the run's flag for state consistency.
* Tests: 5 draft HTTP tests + 11 curated-deck HTTP tests (4 parameterized
  3 ways across HOF/WindFall/Avatar + a cross-pool isolation test + an
  is_published clear test). Existing TwoPick service tests updated for the
  new pool overload. Full suite: 1347/1347.

Phase 3 ship gate met. Branch ready for merge.
2026-06-13 12:49:57 -04:00
gamer147
cbee0f9a50 feat(arena-colosseum): playable bracket + rank-match promotion (phase 2)
Closes 4 more arena-colosseum spec shapes (6/16 → 10/16): the dual-prefix
battle URLs (colosseum_battle/* and colosseum_rank_battle/*) for both
do_matching and per-match finish, plus the bracket-end /finish + /retire
pair on /arena_colosseum.

* ColosseumProgressionService: pure-logic decisions for win-threshold
  advancement, 3008 promotion trigger, and per-round reward bundles.
  Extends ColosseumRoundsConfig with FinishRewards/RetireRewards per
  round + a top-level ChampionRewards list.
* MatchContextBuilder.BuildForColosseumAsync: lifts the registered deck
  via IDeckRepository (NOT viewer-graph) per project_ef_nav_include_pitfall.
* ArenaColosseumBattleController: dual route prefixes off one controller
  via explicit absolute [HttpPost("/...")] routes — does NOT extend
  SVSimController's [Route("[controller]")]. Same pattern as FreeBattle.
  URL-vs-IsRankMatching mismatch returns 400 with a phase-mismatch error;
  no-deck triggers the standard 3001 matchmaking-illegal state. 3008
  flips run.IsRankMatching exactly once via the progression service.
* /arena_colosseum/finish + /retire share FinishResponse (rewards +
  reward_list + colosseum_status). Champion finish appends ChampionRewards
  and surfaces colosseum_status.is_champion = true. Both delete the run
  row after granting via IInventoryTransaction.GrantAsync.
* DI: ModePolicy entries for "colosseum_battle" and "colosseum_rank_battle".
* Tests: 8 progression-service unit tests (pure logic), 5 battle-controller
  HTTP tests, 5 bracket-terminate HTTP tests, 2 full-bracket E2E walks
  (champion path + retire mid-round). Full suite: 1331/1331.
2026-06-13 12:32:56 -04:00
gamer147
110867358c feat(arena-colosseum): lobby + constructed entry (phase 1)
Closes 5 of arena-colosseum's 16 spec shapes (1/16 → 6/16). Lobby reads
(/top, /get_fee_info, /event_info) render an empty "no event scheduled"
payload by default; /entry + /register_deck activate via admin-flipped
ColosseumSeason + ColosseumRounds config sections.

* Schema: ViewerArenaColosseumRun standalone table (unique on ViewerId)
  with jsonb run-state columns mirroring ViewerArenaTwoPickRun.
* Config sections: ColosseumSeasonConfig (event-level), ColosseumRoundsConfig
  (the 3-round bracket). Empty defaults — IsColosseumPeriod=false.
* Migration AddArenaColosseumRun (DDL only; seed rows come from the section
  ShippedDefaults via EnsureSeedDataAsync).
* DTOs: ColosseumLobbyInfo (round-level, /top + /get_fee_info), ColosseumEventInfo
  (event-level, /event_info), ColosseumOwnStatus (shared status block),
  ColosseumEntryRef, ColosseumFeeList, ColosseumUserDeck, ColosseumBattleResults,
  ColosseumRoundDetail + ColosseumGroupRow. EventInfoResponse uses explicit
  [JsonPropertyName("1"|"2"|"3")] for the string-keyed rounds shape per spec.
* Controller: ArenaColosseumController replaces the 1-action stub with
  the five lifecycle endpoints. /top emits leader_skin_id even when 0
  (project_wire_null_policy override).
* Tests: 11 controller-level tests + 6 config round-trip tests covering
  empty-season payloads, run-seeded /top round-trip, crystal/rupy entry
  debits, now_round_id mismatch rejection, deck_no_list round-trip + reject.
2026-06-13 12:16:22 -04:00
gamer147
a5fe484775 feat(account): /account/update_birth persists viewer birth date
Strict yyyy-MM-dd parse (matches /load/index round-trip format) — rejects
malformed input. data_headers.servertime is emitted by the standard
envelope, which is what the client reads into BirthDayUpdateServerTime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 11:35:41 -04:00
gamer147
715dd4dea8 feat(config): wire /config/update + /config/update_challenge_config (closes family 4/4)
is_skip_gacha_effect, use_challenge_two_pick_premium_card, and
challenge_two_pick_sleeve_id all persist on ViewerInfo and round-trip
through /load/index.

The two challenge fields move from the global ChallengeConfig section to
ViewerInfo — they're viewer preferences, not server-wide policy. Premium
card defaults to 0; sleeve falls back to DefaultLoadoutConfig.SleeveId
(3000011) when unset. MatchContextBuilder's TK2 sleeve read switches to
the same viewer-scoped path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 11:25:40 -04:00
gamer147
43dee9d00b feat(card): /card/create_foil_card Seer's Globe conversion (1 orb + 1 base → 1 foil) 2026-06-13 09:58:55 -04:00
gamer147
911374106f feat(emblem): /emblem/update_emblem sets viewer.SelectedEmblem (ownership-validated) 2026-06-13 08:16:19 -04:00
gamer147
bc110e2274 feat(emblem): /emblem/favorite accept-only (persistence deferred) 2026-06-13 08:15:29 -04:00
gamer147
921297227e feat(emblem): /emblem/emblem_list returns owned ids 2026-06-13 08:14:36 -04:00
gamer147
c5c61b6e9d feat(degree): /degree/update_degree sets viewer.SelectedDegree (ownership-validated) 2026-06-13 08:13:49 -04:00
gamer147
356e58ba25 feat(degree): /degree/favorite accept-only (persistence deferred) 2026-06-13 08:12:02 -04:00
gamer147
68741b2980 feat(degree): /degree/degree_list returns owned ids 2026-06-13 08:11:13 -04:00
gamer147
025c2a8c56 feat(mission): /mission/receive_reward stub (refresh-only, no decomp)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 23:33:50 -04:00
gamer147
30b8af2d16 feat(pack): /pack/get_leader_skin_owned_status (empty per-class buckets) 2026-06-12 23:26:29 -04:00
gamer147
c870b95be9 feat(sleeve): /sleeve/favorite accept-only (persistence deferred) 2026-06-12 23:22:25 -04:00
gamer147
f879c05dca feat(sleeve): /sleeve/sleeve_list returns owned ids 2026-06-12 23:17:10 -04:00
gamer147
43e4c06a2c feat(mypage): /mypage/finish_battle (returns check=0 — no in-flight state yet) 2026-06-12 23:08:42 -04:00
gamer147
b4a87792dc feat(account): /account/update_region_code (accept-only) 2026-06-12 23:01:53 -04:00
gamer147
56ef994275 style(profile): drop file-path header comments from quick-wins task 1 2026-06-12 22:59:35 -04:00
gamer147
398764a4cc feat(profile): /profile/update_official_mark_display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 22:54:52 -04:00
gamer147
1054c86038 feat(free-battle): add FreeBattleFinishResponseDto 2026-06-12 21:17:04 -04:00
gamer147
36eb25da44 feat(free-battle): add FreeBattleFinishRequestDto 2026-06-12 21:16:47 -04:00
gamer147
1a55561c5d feat(free-battle): add DoMatchingResponseDto 2026-06-12 21:16:20 -04:00
gamer147
fe9ec1fc68 feat(free-battle): add DoMatchingRequestDto 2026-06-12 21:15:59 -04:00
gamer147
b4a279ef0c feat(load-index): emit data.card_master_hash on mismatch (tier-1 freshness gate) 2026-06-12 12:27:40 -04:00
gamer147
29f1cdd7d5 fix(card-master): make CardMasterRequest.CardMasterHash non-nullable to match wire spec 2026-06-12 12:09:13 -04:00
gamer147
dc39ed4146 feat(card-master): add request/response DTOs for /immutable_data/card_master 2026-06-12 12:05:14 -04:00
gamer147
1960e28298 refactor(auth): decouple Steam handler from request DTO shape
Translation middleware now extracts viewer_id/steam_id/steam_session_ticket
from the decrypted msgpack dict into HttpContext.Items before the typed
DTO deserialize. The Steam handler reads from there instead of re-parsing
Request.Body — so authed action DTOs no longer need to inherit BaseRequest
to keep the auth fields alive through the msgpack→DTO→JSON pivot.

Retires the recurring footgun documented in
docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md
(2026-05-25 basic-puzzle, 2026-05-28 deck-code, 2026-06-02 Phase 3 Bot,
2026-06-10 profile/index + item_acquire_history/info + user_mypage/update).

Pinned by AuthDecouplingTests — posts an encrypted msgpack body to
/profile/index (DTO does not inherit BaseRequest) through the real
translation middleware + auth handler and asserts 200. Adds an
EncryptedMsgpackHelper + useRealAuthHandler factory flag, reusable for
future wire-shape tests.

ProfileIndexRequest, ItemAcquireHistoryInfoRequest, and
UserMyPageUpdateRequest revert to the naked shape — the per-DTO
workarounds become vestigial under the new architecture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 12:29:10 -04:00
gamer147
f743b27696 feat(ranking): stub /ranking/* (6 endpoints)
Rankings menu opens. Period picker shows deterministic monthly schedule.
Every leaderboard returns { period, ranking: [] }.

Endpoints:
- /ranking/get_viewable_ranking_period_list
- /ranking/master_point_{rotation,unlimited}_info
- /ranking/rank_match_class_win_{rotation,unlimited}_info
- /ranking/two_pick_win_info

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 10:46:00 -04:00
gamer147
2f7a2305da feat(replay): add /replay/info and /replay/detail DTOs
All numeric fields on ReplayInfoItemDto ship as string — matches prod
capture (frame 96 of traffic_prod_misc_clicking.ndjson). api-spec doc
will be updated separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:51:00 -04:00
gamer147
a6e5c9f0bc feat(friend): wire DTOs for /friend/* endpoints
12 files: 3 entry types (FriendEntryDto, FriendApplyEntryDto,
PlayedTogetherEntryDto), 5 response wrappers, 4 request DTOs.
All carry [MessagePackObject] + [Key] + [JsonPropertyName] per convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:06:36 -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
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
b447f5032d fix(mypage): wire mypage_id/select_type/mypage_id_list as strings
Prod capture (traffic_prod_misc_clicking.ndjson) shows all three
MyPageBgSetting fields arrive as decimal strings, not ints.  Update the
DTO from int/int/List<int> to string/string/List<string> with "0"/empty
defaults, and add a literal wire-shape round-trip test pinning the
exact JSON against the capture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:40:38 -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
b9c29b53d9 feat(pack): add free-pack metadata fields to PackChildGachaDto 2026-06-08 21:34:49 -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
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
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
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
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
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
9776873073 fix(arena-tk2): include card_master_id in do_matching success response
The decompiled client's DoMatchingBase.SettingCardMasterId calls
jsonData["card_master_id"].ToInt() with no Keys.Contains guard when
matching_state ∈ {3004, 3007, 3011}. Omitting the field crashes the
client with KeyNotFoundException at Cute.NetworkManager+Connect.

Add CardMasterId to DoMatchingResponseDto with a default value of 1
(matching the /load/index response and prod captures). Extend the
controller test to assert the field is present.

Caught during the v1 smoke walk-through; full client log line:
  [Error: Unity Log] KeyNotFoundException: The given key was not
  present in the dictionary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:52:57 -04:00
gamer147
ff51c33b6c feat(arena-tk2): do_matching mints battle via IMatchingBridge, returns 3004 2026-05-31 22:53:20 -04:00
gamer147
8e017c9d10 feat(check): stub /check/check_time_slip_card_master_hash
Bare BaseTask call fired from DeckDecisionUI.cs:140 (Arena "View Deck"
path) and the TK2 prep screen. Client task has no Parse() override —
just checks result_code, ignores body. Prod (4 captured instances
across traffic_prod_taketwo_selections + traffic_prod_tradeables_capture)
unanimously responds with data: [].

Routing smoke added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:29:27 -04:00
gamer147
ac2f31103d fix(arena): match prod get_challenge_info wire shape; stub ranking_history
Prod /arena/get_challenge_info capture (Season 26):
- reward_step_info.reward_step_list is a Dict<string,string>
  ({"5":"5","10":"10","15":"15"}), not the List<int> I'd assumed
- max_reward_step is stringified

The previous stub would have parsed at the client (LitJson tolerates the
shape via indexed iteration), but cleaning to match prod exactly.

Also stubs /arena/get_challenge_ranking_history (new endpoint observed
in the same capture). Prod ships {two_pick: [], sealed: []} with no
history populated — empty lists match. Routing smoke added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:21:44 -04:00