From 1af56b4ec40f2363ad4b6556bbcaba4b5d6bde70 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 13:13:11 -0400 Subject: [PATCH] fix(tk2): per-viewer is_join in arena_info + stub /arena/get_challenge_info Bug 1 ("pay to enter again after restart"): arena_info[0].is_join shipped from the static ArenaSeasonConfig seed, so /load/index and /mypage/index always emitted false regardless of viewer state. The client uses is_join to choose between the "Pay to enter" and "Resume run" dialogs (Wizard/ChallengeEntry.cs:165 + the ArenaEntryBase._isJoinFunc pivot). Without a per-viewer override every cold start after a partial run looked like "no run" and the player got charged again. LoadController + MyPageController now compute is_join from ViewerArenaTwoPickRuns presence. MyPageController grew an IArenaTwoPickRunRepository dep (LoadController already had _db). Bug 2: /arena/get_challenge_info 404. Stubbed via a new ArenaController + DTO pair. Returns the season seed's begin/end_time + name where available; placeholder zeros for win history. All 6 keys required by ChallangeHistoryInfoTask.Parse are present (unconditional JsonData lookups). Routing smoke added for /arena/get_challenge_info. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/ArenaController.cs | 59 +++++++++++++++++++ .../Controllers/LoadController.cs | 16 ++++- .../Controllers/MyPageController.cs | 20 +++++-- .../Requests/Arena/GetChallengeInfoRequest.cs | 7 +++ .../Arena/GetChallengeInfoResponseDto.cs | 40 +++++++++++++ SVSim.UnitTests/RoutingSmokeTests.cs | 1 + 6 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 SVSim.EmulatedEntrypoint/Controllers/ArenaController.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Arena/GetChallengeInfoRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Arena/GetChallengeInfoResponseDto.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaController.cs new file mode 100644 index 0000000..0dcd36b --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Mvc; +using SVSim.Database.Repositories.Globals; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Arena; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Arena; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// Generic /arena/* family — primarily challenge-history info read by the TK2 entry screen's +/// detail button. TODO: lifetime TK2 stats tracking; today we emit a stub. +/// +[Route("arena")] +public class ArenaController : SVSimController +{ + private readonly IGlobalsRepository _globalsRepository; + + public ArenaController(IGlobalsRepository globalsRepository) + { + _globalsRepository = globalsRepository; + } + + [HttpPost("get_challenge_info")] + public async Task GetChallengeInfo([FromBody] GetChallengeInfoRequest req) + { + if (!TryGetViewerId(out _)) return Unauthorized(); + + var season = await _globalsRepository.GetCurrentArenaSeason(); + // Best-effort: pull begin/end_time + name from the season seed when present; otherwise + // emit deterministic stub values. All 6 ChallangeHistoryInfoTask.Parse fields must be + // present — the parser accesses them unconditionally. + var beginTime = "2026-05-01 02:00:00"; + var endTime = "2026-06-01 01:59:59"; + var name = "Take Two"; + if (season is not null && !string.IsNullOrEmpty(season.FormatInfo) && season.FormatInfo != "{}") + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(season.FormatInfo); + if (doc.RootElement.TryGetProperty("start_time", out var st)) beginTime = st.GetString() ?? beginTime; + if (doc.RootElement.TryGetProperty("end_time", out var et)) endTime = et.GetString() ?? endTime; + if (doc.RootElement.TryGetProperty("card_pool_name", out var cp)) name = cp.GetString() ?? name; + } + catch { /* fall back to defaults */ } + } + + return Ok(new GetChallengeInfoResponseDto + { + ChallengeName = name, + BeginTime = beginTime, + EndTime = endTime, + TwoPickAllWinCount = 0, + RewardStepInfo = new RewardStepInfoDto + { + MaxRewardStep = 0, + RewardStepList = new List(), + }, + }); + } +} diff --git a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs index d9feae1..d088242 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; @@ -244,7 +245,7 @@ public class LoadController : SVSimController UseChallengePickTwoPremiumCard = challenge.UseTwoPickPremiumCard ? 1 : 0, ChallengePickTwoCardSleeve = (int)challenge.TwoPickSleeveId, }, - ArenaInfos = await BuildArenaInfosAsync(), + ArenaInfos = await BuildArenaInfosAsync(viewer.Id), RotationSets = rotationSets, UserConfig = new UserConfig(), OpenBattlefieldIds = (await _globalsRepository.GetBattlefields(true)) @@ -263,7 +264,7 @@ public class LoadController : SVSimController /// field is omitted on the wire, which the client's Keys.Contains("arena_info") guard /// (LoadDetail.cs:261) handles cleanly. /// - private async Task?> BuildArenaInfosAsync() + private async Task?> BuildArenaInfosAsync(long viewerId) { var season = await _globalsRepository.GetCurrentArenaSeason(); if (season is null) return null; @@ -274,6 +275,15 @@ public class LoadController : SVSimController format = JsonSerializer.Deserialize(season.FormatInfo, JsonbReadOptions.Instance); } + // is_join must reflect the viewer's actual TK2 state — true if they have an + // active ViewerArenaTwoPickRun row. The client uses this to decide between the + // "Pay to enter" and "Resume run" dialogs (Wizard/ChallengeEntry.cs:165 + ArenaEntryBase). + // Without a per-viewer override here, every cold start after a partial run shows + // "Pay to enter" — losing the in-progress draft from the player's perspective. + bool hasActiveRun = await _db.ViewerArenaTwoPickRuns + .AsNoTracking() + .AnyAsync(r => r.ViewerId == viewerId); + return new List { new ArenaInfo @@ -283,7 +293,7 @@ public class LoadController : SVSimController Cost = season.Cost, RupeeCost = season.RupyCost, TicketCost = season.TicketCost, - IsJoin = season.IsJoin, + IsJoin = hasActiveRun, FormatInfo = format, } }; diff --git a/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs b/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs index fe63bbb..2e76b5f 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs @@ -23,12 +23,15 @@ public class MyPageController : SVSimController private readonly IViewerRepository _viewerRepository; private readonly IGlobalsRepository _globalsRepository; private readonly IGameConfigService _config; + private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns; - public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, IGameConfigService config) + public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, + IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns) { _viewerRepository = viewerRepository; _globalsRepository = globalsRepository; _config = config; + _arenaTwoPickRuns = arenaTwoPickRuns; } [HttpPost("index")] @@ -69,7 +72,7 @@ public class MyPageController : SVSimController LastAnnounceId = 0, // TODO(mypage-stub): globals announcement metadata LastAnnounceUpdateTime = string.Empty, // TODO(mypage-stub): globals announcement metadata FeatureMaintenanceList = new(), // TODO(mypage-stub): FeatureMaintenanceEntry rows - ArenaInfo = await BuildArenaInfosAsync(), + ArenaInfo = await BuildArenaInfosAsync(viewer.Id), IsArenaChallengePeriod = false, // TODO(mypage-stub): globals/ArenaSeason flag IsAvailableColosseumFreeEntry = false, // TODO(mypage-stub): viewer + globals free-entry quota ColosseumInfo = BuildColosseumInfo(colosseum), @@ -155,9 +158,16 @@ public class MyPageController : SVSimController /// _twoPickData.ChallengeData which is only built when arena_info[0].format_info is present. /// So we always populate format_info from the same ArenaSeason.FormatInfo jsonb /load/index uses. /// - private async Task> BuildArenaInfosAsync() + private async Task> BuildArenaInfosAsync(long viewerId) { var season = await _globalsRepository.GetCurrentArenaSeason(); + + // is_join MUST reflect the viewer's actual TK2 state — true iff they have an + // active ViewerArenaTwoPickRun row. The client uses this to choose between the + // "Pay to enter" and "Resume run" dialogs (Wizard/ChallengeEntry.cs:165 + ArenaEntryBase). + // See LoadController.BuildArenaInfosAsync for the matching /load/index path. + bool hasActiveRun = (await _arenaTwoPickRuns.GetByViewerIdAsync(viewerId)) is not null; + if (season is null) { return new List @@ -169,7 +179,7 @@ public class MyPageController : SVSimController Cost = 0, RupeeCost = 0, TicketCost = 0, - IsJoin = false, + IsJoin = hasActiveRun, }, }; } @@ -189,7 +199,7 @@ public class MyPageController : SVSimController Cost = season.Cost, RupeeCost = season.RupyCost, TicketCost = season.TicketCost, - IsJoin = season.IsJoin, + IsJoin = hasActiveRun, FormatInfo = format, } }; diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Arena/GetChallengeInfoRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Arena/GetChallengeInfoRequest.cs new file mode 100644 index 0000000..2d4208a --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Arena/GetChallengeInfoRequest.cs @@ -0,0 +1,7 @@ +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Arena; + +[MessagePackObject] +public class GetChallengeInfoRequest : BaseRequest { } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Arena/GetChallengeInfoResponseDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Arena/GetChallengeInfoResponseDto.cs new file mode 100644 index 0000000..3c9f5fd --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Arena/GetChallengeInfoResponseDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Arena; + +/// +/// Wire shape for /arena/get_challenge_info. Parsed by Wizard/ChallangeHistoryInfoTask.cs:25. +/// All 6 fields below are accessed unconditionally — KeyNotFoundException if any is omitted. +/// Stub values for now; TODO: source from a per-season "challenge history" snapshot when we +/// track viewer's lifetime TK2 stats. +/// +[MessagePackObject] +public class GetChallengeInfoResponseDto +{ + [JsonPropertyName("challenge_name")] [Key("challenge_name")] + public string ChallengeName { get; set; } = ""; + + /// Client parses via DateTime.Parse — "yyyy-MM-dd HH:mm:ss" works. + [JsonPropertyName("begin_time")] [Key("begin_time")] + public string BeginTime { get; set; } = ""; + + [JsonPropertyName("end_time")] [Key("end_time")] + public string EndTime { get; set; } = ""; + + [JsonPropertyName("two_pick_all_win_count")] [Key("two_pick_all_win_count")] + public int TwoPickAllWinCount { get; set; } + + [JsonPropertyName("reward_step_info")] [Key("reward_step_info")] + public RewardStepInfoDto RewardStepInfo { get; set; } = new(); +} + +[MessagePackObject] +public class RewardStepInfoDto +{ + [JsonPropertyName("max_reward_step")] [Key("max_reward_step")] + public int MaxRewardStep { get; set; } + + [JsonPropertyName("reward_step_list")] [Key("reward_step_list")] + public List RewardStepList { get; set; } = new(); +} diff --git a/SVSim.UnitTests/RoutingSmokeTests.cs b/SVSim.UnitTests/RoutingSmokeTests.cs index 36792ed..143fad8 100644 --- a/SVSim.UnitTests/RoutingSmokeTests.cs +++ b/SVSim.UnitTests/RoutingSmokeTests.cs @@ -109,6 +109,7 @@ public class RoutingSmokeTests [TestCase("/arena_two_pick_battle/do_matching")] [TestCase("/arena_two_pick_battle/finish")] [TestCase("/arena_colosseum/get_fee_info")] + [TestCase("/arena/get_challenge_info")] public async Task Authenticated_route_resolves(string path) { using var factory = new TestFactory();