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 <noreply@anthropic.com>
This commit is contained in:
59
SVSim.EmulatedEntrypoint/Controllers/ArenaController.cs
Normal file
59
SVSim.EmulatedEntrypoint/Controllers/ArenaController.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[Route("arena")]
|
||||||
|
public class ArenaController : SVSimController
|
||||||
|
{
|
||||||
|
private readonly IGlobalsRepository _globalsRepository;
|
||||||
|
|
||||||
|
public ArenaController(IGlobalsRepository globalsRepository)
|
||||||
|
{
|
||||||
|
_globalsRepository = globalsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("get_challenge_info")]
|
||||||
|
public async Task<IActionResult> 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<int>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
using SVSim.Database.Enums;
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
@@ -244,7 +245,7 @@ public class LoadController : SVSimController
|
|||||||
UseChallengePickTwoPremiumCard = challenge.UseTwoPickPremiumCard ? 1 : 0,
|
UseChallengePickTwoPremiumCard = challenge.UseTwoPickPremiumCard ? 1 : 0,
|
||||||
ChallengePickTwoCardSleeve = (int)challenge.TwoPickSleeveId,
|
ChallengePickTwoCardSleeve = (int)challenge.TwoPickSleeveId,
|
||||||
},
|
},
|
||||||
ArenaInfos = await BuildArenaInfosAsync(),
|
ArenaInfos = await BuildArenaInfosAsync(viewer.Id),
|
||||||
RotationSets = rotationSets,
|
RotationSets = rotationSets,
|
||||||
UserConfig = new UserConfig(),
|
UserConfig = new UserConfig(),
|
||||||
OpenBattlefieldIds = (await _globalsRepository.GetBattlefields(true))
|
OpenBattlefieldIds = (await _globalsRepository.GetBattlefields(true))
|
||||||
@@ -263,7 +264,7 @@ public class LoadController : SVSimController
|
|||||||
/// field is omitted on the wire, which the client's <c>Keys.Contains("arena_info")</c> guard
|
/// field is omitted on the wire, which the client's <c>Keys.Contains("arena_info")</c> guard
|
||||||
/// (LoadDetail.cs:261) handles cleanly.
|
/// (LoadDetail.cs:261) handles cleanly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<List<ArenaInfo>?> BuildArenaInfosAsync()
|
private async Task<List<ArenaInfo>?> BuildArenaInfosAsync(long viewerId)
|
||||||
{
|
{
|
||||||
var season = await _globalsRepository.GetCurrentArenaSeason();
|
var season = await _globalsRepository.GetCurrentArenaSeason();
|
||||||
if (season is null) return null;
|
if (season is null) return null;
|
||||||
@@ -274,6 +275,15 @@ public class LoadController : SVSimController
|
|||||||
format = JsonSerializer.Deserialize<ArenaFormatInfo>(season.FormatInfo, JsonbReadOptions.Instance);
|
format = JsonSerializer.Deserialize<ArenaFormatInfo>(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<ArenaInfo>
|
return new List<ArenaInfo>
|
||||||
{
|
{
|
||||||
new ArenaInfo
|
new ArenaInfo
|
||||||
@@ -283,7 +293,7 @@ public class LoadController : SVSimController
|
|||||||
Cost = season.Cost,
|
Cost = season.Cost,
|
||||||
RupeeCost = season.RupyCost,
|
RupeeCost = season.RupyCost,
|
||||||
TicketCost = season.TicketCost,
|
TicketCost = season.TicketCost,
|
||||||
IsJoin = season.IsJoin,
|
IsJoin = hasActiveRun,
|
||||||
FormatInfo = format,
|
FormatInfo = format,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,12 +23,15 @@ public class MyPageController : SVSimController
|
|||||||
private readonly IViewerRepository _viewerRepository;
|
private readonly IViewerRepository _viewerRepository;
|
||||||
private readonly IGlobalsRepository _globalsRepository;
|
private readonly IGlobalsRepository _globalsRepository;
|
||||||
private readonly IGameConfigService _config;
|
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;
|
_viewerRepository = viewerRepository;
|
||||||
_globalsRepository = globalsRepository;
|
_globalsRepository = globalsRepository;
|
||||||
_config = config;
|
_config = config;
|
||||||
|
_arenaTwoPickRuns = arenaTwoPickRuns;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("index")]
|
[HttpPost("index")]
|
||||||
@@ -69,7 +72,7 @@ public class MyPageController : SVSimController
|
|||||||
LastAnnounceId = 0, // TODO(mypage-stub): globals announcement metadata
|
LastAnnounceId = 0, // TODO(mypage-stub): globals announcement metadata
|
||||||
LastAnnounceUpdateTime = string.Empty, // TODO(mypage-stub): globals announcement metadata
|
LastAnnounceUpdateTime = string.Empty, // TODO(mypage-stub): globals announcement metadata
|
||||||
FeatureMaintenanceList = new(), // TODO(mypage-stub): FeatureMaintenanceEntry rows
|
FeatureMaintenanceList = new(), // TODO(mypage-stub): FeatureMaintenanceEntry rows
|
||||||
ArenaInfo = await BuildArenaInfosAsync(),
|
ArenaInfo = await BuildArenaInfosAsync(viewer.Id),
|
||||||
IsArenaChallengePeriod = false, // TODO(mypage-stub): globals/ArenaSeason flag
|
IsArenaChallengePeriod = false, // TODO(mypage-stub): globals/ArenaSeason flag
|
||||||
IsAvailableColosseumFreeEntry = false, // TODO(mypage-stub): viewer + globals free-entry quota
|
IsAvailableColosseumFreeEntry = false, // TODO(mypage-stub): viewer + globals free-entry quota
|
||||||
ColosseumInfo = BuildColosseumInfo(colosseum),
|
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.
|
/// _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.
|
/// So we always populate format_info from the same ArenaSeason.FormatInfo jsonb /load/index uses.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<List<ArenaInfo>> BuildArenaInfosAsync()
|
private async Task<List<ArenaInfo>> BuildArenaInfosAsync(long viewerId)
|
||||||
{
|
{
|
||||||
var season = await _globalsRepository.GetCurrentArenaSeason();
|
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)
|
if (season is null)
|
||||||
{
|
{
|
||||||
return new List<ArenaInfo>
|
return new List<ArenaInfo>
|
||||||
@@ -169,7 +179,7 @@ public class MyPageController : SVSimController
|
|||||||
Cost = 0,
|
Cost = 0,
|
||||||
RupeeCost = 0,
|
RupeeCost = 0,
|
||||||
TicketCost = 0,
|
TicketCost = 0,
|
||||||
IsJoin = false,
|
IsJoin = hasActiveRun,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -189,7 +199,7 @@ public class MyPageController : SVSimController
|
|||||||
Cost = season.Cost,
|
Cost = season.Cost,
|
||||||
RupeeCost = season.RupyCost,
|
RupeeCost = season.RupyCost,
|
||||||
TicketCost = season.TicketCost,
|
TicketCost = season.TicketCost,
|
||||||
IsJoin = season.IsJoin,
|
IsJoin = hasActiveRun,
|
||||||
FormatInfo = format,
|
FormatInfo = format,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using MessagePack;
|
||||||
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Arena;
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public class GetChallengeInfoRequest : BaseRequest { }
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Arena;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public class GetChallengeInfoResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("challenge_name")] [Key("challenge_name")]
|
||||||
|
public string ChallengeName { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Client parses via DateTime.Parse — "yyyy-MM-dd HH:mm:ss" works.</summary>
|
||||||
|
[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<int> RewardStepList { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -109,6 +109,7 @@ public class RoutingSmokeTests
|
|||||||
[TestCase("/arena_two_pick_battle/do_matching")]
|
[TestCase("/arena_two_pick_battle/do_matching")]
|
||||||
[TestCase("/arena_two_pick_battle/finish")]
|
[TestCase("/arena_two_pick_battle/finish")]
|
||||||
[TestCase("/arena_colosseum/get_fee_info")]
|
[TestCase("/arena_colosseum/get_fee_info")]
|
||||||
|
[TestCase("/arena/get_challenge_info")]
|
||||||
public async Task Authenticated_route_resolves(string path)
|
public async Task Authenticated_route_resolves(string path)
|
||||||
{
|
{
|
||||||
using var factory = new TestFactory();
|
using var factory = new TestFactory();
|
||||||
|
|||||||
Reference in New Issue
Block a user