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 { /// "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. private const string WireDateFormat = "yyyy-MM-dd HH:mm:ss"; private readonly IViewerRepository _viewerRepository; private readonly IGlobalsRepository _globalsRepository; private readonly IGameConfigService _config; public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, IGameConfigService config) { _viewerRepository = viewerRepository; _globalsRepository = globalsRepository; _config = config; } [HttpPost("index")] public async Task> 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(); 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(), 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, 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. }; } /// /// 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. /// [HttpPost("refresh")] public async Task> 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 }; } /// /// 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. /// private async Task> BuildArenaInfosAsync() { var season = await _globalsRepository.GetCurrentArenaSeason(); if (season is null) { return new List { new ArenaInfo { Mode = 0, Enable = 0, Cost = 0, RupeeCost = 0, TicketCost = 0, IsJoin = false, }, }; } ArenaFormatInfo? format = null; if (!string.IsNullOrEmpty(season.FormatInfo) && season.FormatInfo != "{}") { format = JsonSerializer.Deserialize(season.FormatInfo, JsonbReadOptions.Instance); } return new List { new ArenaInfo { Mode = season.Mode, Enable = season.Enable, Cost = season.Cost, RupeeCost = season.RupyCost, TicketCost = season.TicketCost, IsJoin = season.IsJoin, 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(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 packInfo = new(); if (!string.IsNullOrEmpty(row.PackInfo) && row.PackInfo != "[]") { packInfo = JsonSerializer.Deserialize>(row.PackInfo, JsonbReadOptions.Instance) ?? new(); } SealedSalesPeriodInfo sales = new(); if (!string.IsNullOrEmpty(row.SalesPeriodInfo) && row.SalesPeriodInfo != "{}") { sales = JsonSerializer.Deserialize(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 imagePaths = new(); if (!string.IsNullOrEmpty(row.ImagePaths) && row.ImagePaths != "[]") { imagePaths = JsonSerializer.Deserialize>(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, }; } /// /// 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. /// 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), }; } }