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>
315 lines
15 KiB
C#
315 lines
15 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using SVSim.Database.Models;
|
|
using SVSim.Database.Models.Config;
|
|
using SVSim.Database.Repositories.Globals;
|
|
using SVSim.Database.Repositories.Viewer;
|
|
using SVSim.Database.Services;
|
|
using SVSim.EmulatedEntrypoint.Constants;
|
|
using SVSim.EmulatedEntrypoint.Infrastructure;
|
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
|
|
|
namespace SVSim.EmulatedEntrypoint.Controllers;
|
|
|
|
public class MyPageController : SVSimController
|
|
{
|
|
/// <summary>"yyyy-MM-dd HH:mm:ss" — prod's PHP convention. Used for wire-formatting DateTime
|
|
/// columns that the client parses via DateTime.Parse on its side.</summary>
|
|
private const string WireDateFormat = "yyyy-MM-dd HH:mm:ss";
|
|
|
|
private readonly IViewerRepository _viewerRepository;
|
|
private readonly IGlobalsRepository _globalsRepository;
|
|
private readonly IGameConfigService _config;
|
|
private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns;
|
|
|
|
public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
|
|
IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns)
|
|
{
|
|
_viewerRepository = viewerRepository;
|
|
_globalsRepository = globalsRepository;
|
|
_config = config;
|
|
_arenaTwoPickRuns = arenaTwoPickRuns;
|
|
}
|
|
|
|
[HttpPost("index")]
|
|
public async Task<ActionResult<MyPageIndexResponse>> Index(MyPageIndexRequest request)
|
|
{
|
|
var shortUdidClaim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ShortUdidClaim)?.Value;
|
|
if (shortUdidClaim is null || !long.TryParse(shortUdidClaim, out long shortUdid))
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
Viewer? viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
|
|
if (viewer is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var deviceHeader = Request.Headers["DEVICE"].FirstOrDefault();
|
|
int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0;
|
|
|
|
// Hydrate all the globals slices in parallel-ish — they're independent reads.
|
|
var rotation = _config.Get<RotationConfig>();
|
|
var colosseum = await _globalsRepository.GetCurrentColosseum();
|
|
var sealedSeason = await _globalsRepository.GetCurrentSealedSeason();
|
|
var masterPointPeriod = await _globalsRepository.GetCurrentMasterPointPeriod();
|
|
var bannerEntries = await _globalsRepository.GetBanners();
|
|
var specialDeckFormats = await _globalsRepository.GetActiveSpecialDeckFormats();
|
|
|
|
// Remaining stubs are tagged TODO(mypage-stub) — see docs/api-spec/endpoints/post-login/mypage-index.md.
|
|
return new MyPageIndexResponse
|
|
{
|
|
UserInfo = new UserInfo(deviceType, viewer),
|
|
UnreceivedMissionRewardCount = 0, // TODO(mypage-stub): viewer mission progress
|
|
ReceiveFriendApplyCount = 0, // TODO(mypage-stub): viewer friend-request inbox
|
|
UnreadPresentCount = 0, // TODO(mypage-stub): viewer presents/mail
|
|
FriendBattleInviteCount = 0, // TODO(mypage-stub): viewer room-invite count
|
|
GuildNotification = new GuildNotification(), // TODO(mypage-stub): viewer guild state
|
|
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(viewer.Id),
|
|
IsArenaChallengePeriod = false, // TODO(mypage-stub): globals/ArenaSeason flag
|
|
IsAvailableColosseumFreeEntry = false, // TODO(mypage-stub): viewer + globals free-entry quota
|
|
ColosseumInfo = BuildColosseumInfo(colosseum),
|
|
SealedInfo = BuildSealedInfo(sealedSeason),
|
|
Banner = bannerEntries.Select(BuildBannerInfo).ToList(),
|
|
RoomTypeInSession = new RoomTypeInSession
|
|
{
|
|
SpecialDeckFormatList = specialDeckFormats
|
|
.Select(e => new SpecialDeckFormat
|
|
{
|
|
DeckFormat = e.DeckFormat,
|
|
EndTime = e.EndTime.ToString(WireDateFormat, CultureInfo.InvariantCulture)
|
|
})
|
|
.ToList()
|
|
},
|
|
Convention = new Convention // TODO(mypage-stub): viewer offline-event participation
|
|
{
|
|
IsJoinTournament = false,
|
|
IsAdminWatchUser = false,
|
|
},
|
|
UserConfig = new UserConfig(), // TODO(mypage-stub): persist viewer UserConfig
|
|
Quest = new Quest(), // TODO(mypage-stub): active Quest event + viewer flags
|
|
MasterPointRankingPeriod = BuildMasterPointRankingPeriod(masterPointPeriod),
|
|
PreReleaseStatus = 0, // TODO(mypage-stub): derive from PreReleaseInfo
|
|
UserMyPageInfo = new UserMyPageInfo // TODO(mypage-stub): viewer mypage BG selection
|
|
{
|
|
UserMyPageSetting = new MyPageBgSetting(),
|
|
},
|
|
BasicPuzzle = new Models.Dtos.Common.BadgeFlag { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
|
|
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
|
|
// The client's MyPageTask.Parse (line 155-163) does `_userItemDict.Clear();` whenever
|
|
// user_item_list is present in the response — not when it's non-empty — and then
|
|
// repopulates from the wire. Emitting [] here wipes the inventory the client populated
|
|
// from /load/index, which makes PackChildGachaInfo.CostGoodsCount return 0 and filters
|
|
// out is_hide=1 tutorial packs (the legendary starter 99047) via PackConfig.EnableBuyPack.
|
|
// Populate from viewer.Items so the client's dict stays in sync with the DB.
|
|
UserItemList = viewer.Items.Select(i => new UserItem(i)).ToList(),
|
|
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
|
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
|
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,
|
|
// and the three explicit-null fields (TreasureInfo, LotteryPeriodInfo, AllCardEnabledPeriod)
|
|
// all rely on MyPageIndexResponse field initializers.
|
|
// TODO(mypage-stub): wire competition_info from active tournament row (default false fine until tournaments exist).
|
|
// TODO(mypage-stub): wire shop_notification from per-product shop-appeal state.
|
|
// TODO(mypage-stub): wire story_notification from viewer story progress.
|
|
// TODO(mypage-stub): wire is_hidden_boss_appeared from globals event flag.
|
|
// TODO(mypage-stub): per-viewer state for user_item_list, gathering_info.is_entry, guild_notification, user_offline_event, home_dialog_list.
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Slim notification-delta endpoint — see MyPageRefreshResponse for the 3-field contract.
|
|
/// Client fires this once after main-menu UI settles (and a second time shortly after; both
|
|
/// calls get the same response). No state changes happen here; everything is read-only.
|
|
/// </summary>
|
|
[HttpPost("refresh")]
|
|
public async Task<ActionResult<MyPageRefreshResponse>> Refresh(MyPageRefreshRequest request)
|
|
{
|
|
var shortUdidClaim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ShortUdidClaim)?.Value;
|
|
if (shortUdidClaim is null || !long.TryParse(shortUdidClaim, out long shortUdid))
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
Viewer? viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
|
|
if (viewer is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return new MyPageRefreshResponse
|
|
{
|
|
FriendBattleInviteCount = 0, // TODO(mypage-stub): viewer room-invite count
|
|
ShopNotification = new ShopNotification(), // TODO(mypage-stub): per-product shop-appeal state
|
|
GatheringNotification = new GatheringNotification(), // empty matching message — correct for fresh viewers
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mirrors LoadController.BuildArenaInfosAsync. /mypage/index has no Keys.Contains("arena_info")
|
|
/// guard (ArenaData(jsonData["arena_info"]) at MyPageTask.cs:55 indexes [0] unconditionally), and
|
|
/// the post-parse UI consumer (ChallengeEntry.SetChallengeInfo at ChallengeEntry.cs:35) reads
|
|
/// _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.
|
|
/// </summary>
|
|
private async Task<List<ArenaInfo>> 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<ArenaInfo>
|
|
{
|
|
new ArenaInfo
|
|
{
|
|
Mode = 0,
|
|
Enable = 0,
|
|
Cost = 0,
|
|
RupeeCost = 0,
|
|
TicketCost = 0,
|
|
IsJoin = hasActiveRun,
|
|
},
|
|
};
|
|
}
|
|
|
|
ArenaFormatInfo? format = null;
|
|
if (!string.IsNullOrEmpty(season.FormatInfo) && season.FormatInfo != "{}")
|
|
{
|
|
format = JsonSerializer.Deserialize<ArenaFormatInfo>(season.FormatInfo, JsonbReadOptions.Instance);
|
|
}
|
|
|
|
return new List<ArenaInfo>
|
|
{
|
|
new ArenaInfo
|
|
{
|
|
Mode = season.Mode,
|
|
Enable = season.Enable,
|
|
Cost = season.Cost,
|
|
RupeeCost = season.RupyCost,
|
|
TicketCost = season.TicketCost,
|
|
IsJoin = hasActiveRun,
|
|
FormatInfo = format,
|
|
}
|
|
};
|
|
}
|
|
|
|
private ColosseumInfo BuildColosseumInfo(ColosseumConfig? row)
|
|
{
|
|
if (row is null) return new ColosseumInfo();
|
|
|
|
ColosseumSalesPeriodInfo sales = new();
|
|
if (!string.IsNullOrEmpty(row.SalesPeriodInfo) && row.SalesPeriodInfo != "{}")
|
|
{
|
|
sales = JsonSerializer.Deserialize<ColosseumSalesPeriodInfo>(row.SalesPeriodInfo, JsonbReadOptions.Instance)
|
|
?? new ColosseumSalesPeriodInfo();
|
|
}
|
|
|
|
return new ColosseumInfo
|
|
{
|
|
ColosseumId = row.ColosseumId,
|
|
IsDisplayTips = row.IsDisplayTips,
|
|
TipsId = row.TipsId,
|
|
CardPoolName = row.CardPoolName,
|
|
IsColosseumPeriod = row.IsColosseumPeriod,
|
|
IsRoundPeriod = row.IsRoundPeriod,
|
|
DeckFormat = row.DeckFormat,
|
|
IsNormalTwoPick = row.IsNormalTwoPick,
|
|
IsSpecialMode = row.IsSpecialMode,
|
|
IsAllCardEnabled = row.IsAllCardEnabled,
|
|
StartTime = row.StartTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
|
|
ColosseumName = row.ColosseumName,
|
|
NowRound = row.NowRound,
|
|
EndTime = row.EndTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
|
|
SalesPeriodInfo = sales,
|
|
};
|
|
}
|
|
|
|
private SealedInfo BuildSealedInfo(SealedConfig? row)
|
|
{
|
|
if (row is null) return new SealedInfo();
|
|
|
|
List<int> packInfo = new();
|
|
if (!string.IsNullOrEmpty(row.PackInfo) && row.PackInfo != "[]")
|
|
{
|
|
packInfo = JsonSerializer.Deserialize<List<int>>(row.PackInfo, JsonbReadOptions.Instance) ?? new();
|
|
}
|
|
|
|
SealedSalesPeriodInfo sales = new();
|
|
if (!string.IsNullOrEmpty(row.SalesPeriodInfo) && row.SalesPeriodInfo != "{}")
|
|
{
|
|
sales = JsonSerializer.Deserialize<SealedSalesPeriodInfo>(row.SalesPeriodInfo, JsonbReadOptions.Instance)
|
|
?? new SealedSalesPeriodInfo();
|
|
}
|
|
|
|
return new SealedInfo
|
|
{
|
|
Enable = row.Enable,
|
|
CrystalCost = row.CrystalCost,
|
|
RupyCost = row.RupyCost,
|
|
TicketCost = row.TicketCost,
|
|
IsJoin = row.IsJoin,
|
|
PackInfo = packInfo,
|
|
DeckUsingNumMin = row.DeckUsingNumMin,
|
|
ScheduleId = row.ScheduleId,
|
|
IsDeckCodeMaintenance = row.IsDeckCodeMaintenance,
|
|
SalesPeriodInfo = sales,
|
|
};
|
|
}
|
|
|
|
private static BannerInfo BuildBannerInfo(BannerEntry row)
|
|
{
|
|
List<string> imagePaths = new();
|
|
if (!string.IsNullOrEmpty(row.ImagePaths) && row.ImagePaths != "[]")
|
|
{
|
|
imagePaths = JsonSerializer.Deserialize<List<string>>(row.ImagePaths, JsonbReadOptions.Instance) ?? new();
|
|
}
|
|
|
|
return new BannerInfo
|
|
{
|
|
ImageName = row.ImageName,
|
|
Click = row.Click,
|
|
Status = row.Status,
|
|
// DB stores numeric, wire is string. PHP convention.
|
|
ChangeTime = row.ChangeTime.ToString(CultureInfo.InvariantCulture),
|
|
RemainingTime = row.RemainingTime.ToString(CultureInfo.InvariantCulture),
|
|
ImagePaths = imagePaths,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Far-future fallback EndTime so the client's DateTime.Parse(end_time) succeeds and
|
|
/// Data.Load.data._masterResetNextTime gets seeded even when no globals row is present.
|
|
/// </summary>
|
|
private static MasterPointRankingPeriod BuildMasterPointRankingPeriod(MasterPointRankingPeriodEntry? row)
|
|
{
|
|
if (row is null)
|
|
{
|
|
return new MasterPointRankingPeriod
|
|
{
|
|
EndTime = "2030-01-01 00:00:00",
|
|
};
|
|
}
|
|
|
|
return new MasterPointRankingPeriod
|
|
{
|
|
Id = row.Id,
|
|
PeriodNum = row.PeriodNum,
|
|
NecessaryScore = row.NecessaryScore,
|
|
BeginTime = row.BeginTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
|
|
EndTime = row.EndTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
|
|
};
|
|
}
|
|
}
|