Deck list work

This commit is contained in:
gamer147
2026-05-23 19:57:34 -04:00
parent 66184b3685
commit d3b2970e11
41 changed files with 70683 additions and 81 deletions

View File

@@ -1,8 +1,11 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
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;
@@ -11,6 +14,10 @@ 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;
@@ -38,9 +45,15 @@ public class MyPageController : SVSimController
var deviceHeader = Request.Headers["DEVICE"].FirstOrDefault();
int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0;
// Stubs below are tagged TODO(mypage-stub). See the "Current server implementation"
// section of docs/api-spec/endpoints/post-login/mypage-index.md for the table of what
// each one would source from. Grep for "mypage-stub" to enumerate them.
// Hydrate all the globals slices in parallel-ish — they're independent reads.
var cfg = await _globalsRepository.GetGameConfiguration("default");
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),
@@ -55,6 +68,19 @@ public class MyPageController : SVSimController
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,
@@ -62,36 +88,61 @@ public class MyPageController : SVSimController
},
UserConfig = new UserConfig(), // TODO(mypage-stub): persist viewer UserConfig
Quest = new Quest(), // TODO(mypage-stub): active Quest event + viewer flags
MasterPointRankingPeriod = new MasterPointRankingPeriod
{
// TODO(mypage-stub): source begin_time/end_time/period_num/necessary_score from the
// current Master Points season row in globals. Far-future fallback so the client's
// DateTime.Parse(end_time) succeeds and _masterResetNextTime gets seeded.
EndTime = "2030-01-01 00:00:00",
},
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 BasicPuzzle { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
IsBattlePassPeriod = (await _globalsRepository.GetGameConfiguration("default")).IsBattlePassPeriod,
IsBattlePassPeriod = cfg.IsBattlePassPeriod,
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
// ColosseumInfo, ShopNotification, StoryNotification, IsHiddenBossAppeared all
// default-constructed by MyPageIndexResponse's field initializers.
// TODO(mypage-stub): wire colosseum_info from current Colosseum cup row.
// 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>
/// Same shape as LoadController.BuildArenaInfosAsync, but /mypage/index has no
/// Keys.Contains("arena_info") guard on the client (ArenaData(jsonData["arena_info"])
/// at MyPageTask.cs:55 indexes [0] unconditionally). When no current Take Two season is
/// seeded we fall back to a minimal one-entry list so the client's ArenaData ctor doesn't
/// crash with IndexOutOfRange.
/// 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()
{
@@ -112,6 +163,12 @@ public class MyPageController : SVSimController
};
}
ArenaFormatInfo? format = null;
if (!string.IsNullOrEmpty(season.FormatInfo) && season.FormatInfo != "{}")
{
format = JsonSerializer.Deserialize<ArenaFormatInfo>(season.FormatInfo, JsonbReadOptions.Instance);
}
return new List<ArenaInfo>
{
new ArenaInfo
@@ -122,10 +179,115 @@ public class MyPageController : SVSimController
RupeeCost = season.RupyCost,
TicketCost = season.TicketCost,
IsJoin = season.IsJoin,
// format_info is intentionally omitted here — /mypage/index's ArenaData
// ctor only needs the top-level fields. /load/index round-trips it via
// JsonbReadOptions; pull it in if a downstream check ever needs it.
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),
};
}
}