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();