diff --git a/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs b/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs index 603ddd4..86f4619 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs @@ -4,7 +4,9 @@ using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Deck; +using SVSim.Database.Repositories.Globals; using SVSim.EmulatedEntrypoint.Constants; +using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common; @@ -16,27 +18,64 @@ namespace SVSim.EmulatedEntrypoint.Controllers; public class DeckController : SVSimController { private readonly IDeckRepository _deckRepository; + private readonly IGlobalsRepository _globalsRepository; private readonly SVSimDbContext _dbContext; - public DeckController(IDeckRepository deckRepository, SVSimDbContext dbContext) + private static readonly System.Text.Json.JsonSerializerOptions JsonbReadOptions = new() + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower, + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, + }; + + public DeckController(IDeckRepository deckRepository, IGlobalsRepository globalsRepository, SVSimDbContext dbContext) { _deckRepository = deckRepository; + _globalsRepository = globalsRepository; _dbContext = dbContext; } - // TODO: API-side deck_format values may not match the Format enum (see audit on /load/index - // open question — `Data.FormatConvertApi` reverse-mapping not yet captured). For now we cast - // directly; verify against live traffic and add a conversion table if mismatched. - private static Format AsFormat(int apiValue) => (Format)apiValue; + // Request deck_format fields arrive as wire ints (MessagePack-CSharp doesn't honor STJ + // converters on request DTOs, so request DTO properties stay typed as int). Route through + // FromApi here so controllers always work in internal Format space when comparing / + // persisting. + private static Format AsFormat(int apiValue) => FormatExtensions.FromApi(apiValue); [HttpPost("info")] public async Task> Info(DeckInfoRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var decks = await _deckRepository.GetDecks(viewerId, AsFormat(request.DeckFormat)); + + // Globals — same shape every call; could be cached if it becomes a hotspot. + var defaultDecks = await _globalsRepository.GetDefaultDecks(); + var leaderSkinSettings = await _globalsRepository.GetDefaultLeaderSkinSettings(); + return new DeckListResponse { - UserDeckList = decks.Select(d => new UserDeck(d)).ToList() + UserDeckList = decks.Select(d => new UserDeck(d)).ToList(), + DefaultDeckList = defaultDecks.ToDictionary( + d => d.Id.ToString(), + d => new DefaultDeck + { + DeckNo = d.DeckNo, + ClassId = d.ClassId, + SleeveId = d.SleeveId, + LeaderSkinId = d.LeaderSkinId, + DeckName = d.DeckName, + CardIdArray = System.Text.Json.JsonSerializer.Deserialize>(d.CardIdArray, JsonbReadOptions) ?? new(), + }), + UserLeaderSkinSettingList = leaderSkinSettings.ToDictionary( + s => s.Id.ToString(), + s => new DefaultLeaderSkinSetting + { + ClassId = s.ClassId, + IsRandomLeaderSkin = s.IsRandomLeaderSkin, + LeaderSkinId = s.LeaderSkinId, + }), + // trial_deck_list: empty in 2026-05-23 prod; populated during tutorial campaigns — + // entry shape TBD until a capture lands with active content. + TrialDeckList = new(), + MaintenanceCardList = new(), // sourced from same place as /load/index when wired }; } diff --git a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs index 3779b93..ded24af 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs @@ -1,6 +1,10 @@ +using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc; using SVSim.Database.Enums; using SVSim.Database.Models; +using PreReleaseInfoEntity = SVSim.Database.Models.PreReleaseInfo; +using PreReleaseInfoDto = SVSim.EmulatedEntrypoint.Models.Dtos.PreReleaseInfo; using SVSim.Database.Repositories.Card; using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Repositories.Globals; @@ -21,8 +25,11 @@ public class LoadController : SVSimController Format.Rotation, Format.Unlimited, Format.MyRotation, Format.Avatar, Format.Crossover }; - // Until ShadowverseCardSetEntry is seeded by CardImport, hard-code a stub so the client - // doesn't crash on RotationCardSetList[1] / [Count-1] (LoadDetail.cs:184). + // Defense-in-depth: client unconditionally accesses RotationCardSetList[1] and [Count-1] + // (LoadDetail.cs:184), so a list with < 2 entries crashes /load/index parsing. With both + // CardImport and GlobalsImporter run, the real list has ~6 entries. If something goes wrong + // upstream (empty DB, bootstrap not yet run, etc.), fall back to this stub so the client at + // least loads. Removing this is only safe once viewer-side bootstrap is unconditional. private static readonly List StubRotationSets = new() { new CardSetIdentifier { SetId = 10000 }, @@ -30,6 +37,19 @@ public class LoadController : SVSimController new CardSetIdentifier { SetId = 10010 } }; + // The prod-captured globals JSON was seeded with snake_case_lower keys (see SVSim.Bootstrap + // GlobalsImporter — jsonb columns store the original capture verbatim). Deserialize-back must + // use the same naming policy so e.g. `card_pool_name` maps onto `CardPoolName`. + // + // AllowReadingFromString handles prod's PHP-backend convention of emitting numeric values + // as JSON strings (e.g. `"ability_id": "1"`). Numeric-typed DTO properties accept those. + private static readonly JsonSerializerOptions JsonbReadOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = false, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + private readonly IViewerRepository _viewerRepository; private readonly ICardRepository _cardRepository; private readonly ICollectionRepository _collectionRepository; @@ -59,25 +79,45 @@ public class LoadController : SVSimController return NotFound(); } - // Cards. Empty until CardImport lands (audit §3 — user_card_list is blocked). - List allCollectibleCards = await _cardRepository.GetAll(true); - List allBasicCards = await _cardRepository.GetAllBasic(); - List ownedCards = viewer.Cards; - List allCardsAsOwned = allCollectibleCards.GroupJoin(ownedCards, - card => card.Id, - ownedCard => ownedCard.Card.Id, - (card, foundOwnedCards) => foundOwnedCards.DefaultIfEmpty().FirstOrDefault() ?? new OwnedCardEntry + // user_card_list policy (see docs/api-spec/endpoints/post-login/load-index.md + // §user_card_list for the full discussion): + // + // We emit ONLY cards the viewer actually owns (Count > 0), plus basics — which + // the client treats as always-3-of, protected (un-disenchantable). + // + // Prod returns a larger, curated set (~1k entries) that includes some 0-count + // "ever-touched" rows from the viewer's collection history (cards they've owned + // and since disenchanted, or cards in card-sets they've engaged with). We don't + // model "cards ever owned" today, so we can't reproduce that exactly. The client + // tolerates the divergence: GetUserOwnCardData() builds a dict keyed by card_id + // and falls back to 0 for any absent id (DataMgr.cs:1182), so "absent" and + // "Count=0" are semantically interchangeable for lookups, deck construction, and + // craft-cost queries. + // + // The UI difference would show up only in views that iterate UserCardList + // *directly* to enumerate "cards I've held" (e.g. some collection-screen filters). + // To close that gap later, see the "user_card_list — closer-to-prod options" + // section of the spec doc: Option B (union with active-rotation card-set + // members at Count=0) is the cheapest upgrade; Option C requires a new + // ever-touched flag on OwnedCardEntry. + // + // Filters always applied — these are noise in prod too: + // * IsResurgentCard rows: prod returns zero of these + // * card_set_id=90000 (engine tokens, char_type=4): never collectible + // Both naturally fall out of "ownership-only" since the viewer can't own them; + // re-confirm the filter if we later move to Option B and start iterating card-sets. + var basicCards = await _cardRepository.GetAllBasic(); + var basicIds = basicCards.Select(c => c.Id).ToHashSet(); + var ownedCollectibles = viewer.Cards + .Where(c => c.Count > 0 && !basicIds.Contains(c.Card.Id)); + var allCardsAsOwned = ownedCollectibles + .Concat(basicCards.Select(bc => new OwnedCardEntry { - Card = card, - Count = 0, - IsProtected = false - }).ToList(); - allCardsAsOwned = allCardsAsOwned.Union(allBasicCards.Select(bc => new OwnedCardEntry - { - Card = bc, - Count = 3, - IsProtected = true - })).ToList(); + Card = bc, + Count = 3, + IsProtected = true + })) + .ToList(); List allLeaderSkins = await _collectionRepository.GetLeaderSkins(); var classExpCurve = await _globalsRepository.GetClassExpCurve(); @@ -98,23 +138,23 @@ public class LoadController : SVSimController prevNecessaryExp = entry.NecessaryExp; } - List rotationSets = (await _cardRepository.GetCardSets(true)) + // Globals — one cached fetch per slice. GameConfiguration carries six time-varying scalars + // (ts_rotation_id, is_battle_pass_period, etc.) added in the prod-capture migration; the + // other repo methods come from SVSim.Bootstrap.GlobalsImporter seeding. + GameConfiguration cfg = await _globalsRepository.GetGameConfiguration("default"); + + List rotationSets = (await _globalsRepository.GetRotationCardSets()) + .OrderBy(s => s.Id) .Select(set => new CardSetIdentifier { SetId = set.Id }) .ToList(); - if (rotationSets.Count < 2) - { - rotationSets = StubRotationSets; - } + if (rotationSets.Count < 2) rotationSets = StubRotationSets; var deviceHeader = Request.Headers["DEVICE"].FirstOrDefault(); int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0; return new IndexResponse { - UserTutorial = new UserTutorial - { - TutorialStep = viewer.MissionData.TutorialState - }, + UserTutorial = new UserTutorial { TutorialStep = viewer.MissionData.TutorialState }, UserInfo = new UserInfo(deviceType, viewer), UserCurrency = new UserCurrency(viewer), UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(), @@ -144,24 +184,39 @@ public class LoadController : SVSimController MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id.ToString()).ToList(), LootBoxRegulations = new LootBoxRegulations(), GatheringInfo = new GatheringInfo(), - IsBattlePassPeriod = 0, + IsBattlePassPeriod = cfg.IsBattlePassPeriod, + // Optional per spec (load-index.md:228). We have BattlePassLevelEntry rows seeded, but + // no per-viewer Battle Pass progression yet — emit null until that subsystem lands. BattlePassLevelInfo = null, SpecialCrystalInfos = new List(), - AvatarRotationInfo = null, - MyRotationInfo = null, + AvatarRotationInfo = await BuildAvatarInfoAsync(), + MyRotationInfo = await BuildMyRotationInfoAsync(), + // Prod 2026-05-23 emits `[]`; FeatureMaintenanceEntry table is skeleton-seeded for the + // same reason. When a real maintenance window is captured we'll learn the wire shape of + // each entry (the existing FeatureMaintenance enum maps to feature_id but the wrapping + // object's other fields are TBD — see audit Open Questions). FeatureMaintenances = new List(), - PreReleaseInfo = null, - SpotCards = new Dictionary(), - ReprintedCards = new List(), - UnlimitedBanList = new Dictionary(), - LoadingTipCardExclusions = new List(), - MaintenanceCards = new List(), + PreReleaseInfo = await BuildPreReleaseInfoAsync(), + SpotCards = (await _globalsRepository.GetSpotCards()) + .ToDictionary(e => e.Id.ToString(), e => e.Cost), + ReprintedCards = (await _globalsRepository.GetReprintedCards()) + .Select(e => e.Id).ToList(), + UnlimitedBanList = (await _globalsRepository.GetUnlimitedRestrictions()) + .ToDictionary(e => e.Id.ToString(), e => e.RestrictionValue), + LoadingTipCardExclusions = (await _globalsRepository.GetLoadingExclusionCards()) + .Select(e => e.Id).ToList(), + MaintenanceCards = (await _globalsRepository.GetMaintenanceCards()) + .Select(e => e.Id).ToList(), RedEtherOverrides = new List(), - DailyLoginBonus = new DailyLoginBonus(), + // Optional per spec (load-index.md:247). Skeleton-seeded rows in DailyLoginBonuses table + // capture prod's empty-period shape ({"1":[], "3":[], "4":[]}); the spec-shaped DTO + // ({normal?, total?, campaign?[]}) carries nothing meaningful until an active campaign + // is captured. + DailyLoginBonus = null, UserRankedMatches = new List(), UserRankInfo = RankFormats.Select(f => new UserRankInfo { - DeckFormat = (int)f, + DeckFormat = f, Rank = 1, BattlePoints = 0, WinStreak = 0, @@ -170,16 +225,154 @@ public class LoadController : SVSimController IsGrandMasterRank = 0, MasterPoints = 0 }).ToList(), - ArenaConfig = new ArenaConfig(), + ArenaConfig = new ArenaConfig + { + UseChallengePickTwoPremiumCard = cfg.ChallengeUseTwoPickPremiumCard ? 1 : 0, + ChallengePickTwoCardSleeve = (int)cfg.ChallengeTwoPickSleeveId, + }, + ArenaInfos = await BuildArenaInfosAsync(), RotationSets = rotationSets, UserConfig = new UserConfig(), OpenBattlefieldIds = (await _globalsRepository.GetBattlefields(true)) .Select(bf => bf.Id.ToString()).ToList(), - DefaultSettings = new DefaultSettings(await _globalsRepository.GetGameConfiguration("default")), + DefaultSettings = new DefaultSettings(cfg), ClassExp = classExps, RankInfo = (await _globalsRepository.GetRankInfo()).Select(ri => new RankInfo(ri)).ToList(), - DeckFormat = 1, - CardSetIdForResourceDlView = rotationSets.Last().SetId + DeckFormat = Format.Rotation, + CardSetIdForResourceDlView = cfg.CardSetIdForResourceDlView, + }; + } + + /// + /// Builds arena_info as the single-element array the client's ArenaData(JsonData[0]) ctor + /// expects (audit §1). Returns null when no current Take Two season is seeded — the IndexResponse + /// field is omitted on the wire, which the client's Keys.Contains("arena_info") guard + /// (LoadDetail.cs:261) handles cleanly. + /// + private async Task?> BuildArenaInfosAsync() + { + var season = await _globalsRepository.GetCurrentArenaSeason(); + if (season is null) return null; + + ArenaFormatInfo? format = null; + if (!string.IsNullOrEmpty(season.FormatInfo) && season.FormatInfo != "{}") + { + format = JsonSerializer.Deserialize(season.FormatInfo, JsonbReadOptions); + } + + 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, + } + }; + } + + /// + /// Builds my_rotation_info from the joined MyRotationSettingEntry + MyRotationAbilityEntry + /// tables. Each setting row's ReprintedCardIds / RestrictedCardIds jsonb is parsed back to the + /// dict shape the client expects. + /// + private async Task BuildMyRotationInfoAsync() + { + var settings = await _globalsRepository.GetMyRotationSettings(); + var abilities = await _globalsRepository.GetMyRotationAbilities(); + if (settings.Count == 0 && abilities.Count == 0) return null; + + return new MyRotationInfo + { + Settings = settings.ToDictionary( + s => s.Id.ToString(), + s => new SpecialRotationSetting + { + RotationId = s.Id, + CardSetIds = s.CardSetIdsCsv, + Abilities = s.AbilitiesCsv, + }), + Abilities = abilities.ToDictionary( + a => a.Id.ToString(), + a => JsonSerializer.Deserialize(a.Data, JsonbReadOptions) ?? new MyRotationAbility()), + ReprintedCards = settings.ToDictionary( + s => s.Id.ToString(), + s => JsonSerializer.Deserialize>(s.ReprintedCardIds, JsonbReadOptions) ?? new()), + Banlist = settings.ToDictionary( + s => s.Id.ToString(), + s => JsonSerializer.Deserialize>(s.RestrictedCardIds, JsonbReadOptions) ?? new()), + DisabledCardSets = new List(), // prod 2026-05-23 emits empty list; refine if/when populated + Schedules = BuildMyRotationSchedules(), + }; + } + + /// + /// Schedules are not yet stored per-rotation in the DB; prod's shape (free_battle / gathering + /// DateRange) is global. Hardcoded to a default-initialized SpecialRotationSchedule until we + /// capture meaningful values — the client's MyRotationAllInfo.Parse audit will tell us where + /// these come from. + /// + private static SpecialRotationSchedule BuildMyRotationSchedules() => new(); + + /// + /// Builds avatar_info from AvatarAbilityEntry rows. Schedules is an empty list per the + /// 2026-05-23 prod capture (active Avatar windows would populate it; entry shape TBD). + /// + private async Task BuildAvatarInfoAsync() + { + var abilities = await _globalsRepository.GetAvatarAbilities(); + if (abilities.Count == 0) return null; + + return new AvatarInfo + { + Abilities = abilities.ToDictionary( + a => a.Id.ToString(), + a => new AvatarAbility + { + LeaderSkinId = a.LeaderSkinId, + BattleStartFirstPlayerBp = a.BattleStartFirstPlayerTurnBp, + BattleStartSecondPlayerBp = a.BattleStartSecondPlayerTurnBp, + BattleStartMaxLife = a.BattleStartMaxLife, + AbilityCost = a.AbilityCost, + Ability = a.Ability, + PassiveAbility = a.PassiveAbility, + AbilityDesc = a.AbilityDesc, + PassiveAbilityDesc = a.PassiveAbilityDesc, + }), + Schedules = new List(), + }; + } + + /// + /// Builds pre_release_info from the singleton PreReleaseInfo entity. Returns null when + /// the entity is absent. NB: the 2026-05-23 prod capture had stale 1900/2019 dates which the + /// audit flagged as the "no active pre-release" sentinel — we emit them as-is rather than + /// hiding the field, because that's what prod itself does. + /// + private async Task BuildPreReleaseInfoAsync() + { + var pri = await _globalsRepository.GetPreReleaseInfo(); + if (pri is null) return null; + + return new PreReleaseInfoDto + { + Id = pri.PreReleaseId, + StartTime = pri.StartTime, + EndTime = pri.EndTime, + DisplayEndTime = pri.DisplayEndTime, + NextCardSetId = pri.NextCardSetId, + DefaultCardMasterId = pri.DefaultCardMasterId, + PreReleaseCardMasterId = pri.PreReleaseCardMasterId, + FreeMatchStartTime = pri.FreeMatchStartTime, + CardMasterId = pri.CardMasterId, + RotationCardSets = JsonSerializer.Deserialize>(pri.RotationCardSetIdList, JsonbReadOptions) ?? new(), + ReprintedCardIds = JsonSerializer.Deserialize>(pri.ReprintedBaseCardIds, JsonbReadOptions) ?? new(), + LatestReprintedCardIds = JsonSerializer.Deserialize>(pri.LatestReprintedBaseCardIds, JsonbReadOptions) ?? new(), + IsPreRotationFreeMatchTerm = pri.IsPreRotationFreeMatchTerm ? 1 : 0, }; } } diff --git a/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs b/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs new file mode 100644 index 0000000..146e5bd --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs @@ -0,0 +1,131 @@ +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.Models.Dtos; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +public class MyPageController : SVSimController +{ + private readonly IViewerRepository _viewerRepository; + private readonly IGlobalsRepository _globalsRepository; + + public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository) + { + _viewerRepository = viewerRepository; + _globalsRepository = globalsRepository; + } + + [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; + + // 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. + 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 + 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 = 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", + }, + 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, + 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. + // 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. + }; + } + + /// + /// 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. + /// + 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, + }, + }; + } + + return new List + { + new ArenaInfo + { + Mode = season.Mode, + Enable = season.Enable, + Cost = season.Cost, + 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. + } + }; + } +} diff --git a/SVSim.EmulatedEntrypoint/Extensions/FormatExtensions.cs b/SVSim.EmulatedEntrypoint/Extensions/FormatExtensions.cs new file mode 100644 index 0000000..d673b92 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Extensions/FormatExtensions.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SVSim.Database.Enums; + +namespace SVSim.EmulatedEntrypoint.Extensions; + +/// +/// Bridges the server's internal enum (a verbatim copy of the client's +/// Wizard.Format) and the wire deck_format integer the client speaks. The two are not +/// interchangeable: the wire value is what flows over the network and what the client's +/// LoadDetail._userRank dictionary is keyed by AFTER routing through Data.ParseApiFormat; +/// the internal value is what the server uses for switches, dictionary keys, and database +/// columns. +/// +/// Mapping mirrors the client's Wizard.Data.FormatConvertApi +/// (Shadowverse_Code/Assembly-CSharp/Wizard/Data.cs:580); the inverse mirrors +/// Data.ParseApiFormat (Data.cs:635). See +/// docs/api-spec/common/types.ts.md for the table and rationale. +/// +public static class FormatExtensions +{ + /// Internal → wire deck_format integer. + public static int ToApi(this Format format) => format switch + { + Format.Rotation => 1, + Format.Unlimited => 2, + Format.Max => 1, // client sentinel; aliases onto Rotation, same as FormatConvertApi. + Format.PreRotation => 3, + Format.Sealed => 20, + Format.MyRotation => 5, + Format.TwoPick => 10, + Format.Hof => 31, + Format.Windfall => 33, + Format.Avatar => 39, + Format.All => 0, + Format.Crossover => 4, + _ => throw new ArgumentOutOfRangeException(nameof(format), format, + $"No wire deck_format mapping for {format} ({(int)format}). " + + "Update FormatExtensions.ToApi and Data.cs:580 if a new format was added.") + }; + + /// Wire deck_format integer → internal . + public static Format FromApi(int apiValue) => apiValue switch + { + 0 => Format.All, // Client emits 0 only for "all formats" meta-queries. + 1 => Format.Rotation, + 2 => Format.Unlimited, + 3 => Format.PreRotation, + 4 => Format.Crossover, + 5 => Format.MyRotation, + 10 => Format.TwoPick, + 20 => Format.Sealed, + 31 => Format.Hof, + 33 => Format.Windfall, + 39 => Format.Avatar, + _ => throw new ArgumentOutOfRangeException(nameof(apiValue), apiValue, + $"Unknown wire deck_format {apiValue}. The client's ParseApiFormat would warn and " + + "fall back to Format.Max; we throw so the calling controller surfaces the bad input.") + }; +} + +/// +/// System.Text.Json converter that emits / accepts as the wire +/// deck_format integer rather than the underlying enum value. Wired up in Program.cs +/// via AddJsonOptions; applies to every response DTO property typed . +/// +/// IMPORTANT: this only runs on the System.Text.Json serialization path (response writer + +/// model binder). MessagePack-CSharp deserialization of request DTOs does NOT honor STJ +/// converters — keep request DTO format fields typed as int and call +/// in the controller. +/// +public sealed class FormatJsonConverter : JsonConverter +{ + public override Format Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.Number || !reader.TryGetInt32(out int wire)) + { + throw new JsonException( + $"Expected deck_format as a JSON number, got {reader.TokenType}."); + } + return FormatExtensions.FromApi(wire); + } + + public override void Write(Utf8JsonWriter writer, Format value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value.ToApi()); + } +} diff --git a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs index 5d4269e..86ef441 100644 --- a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs +++ b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs @@ -104,7 +104,7 @@ public class ShadowverseTranslationMiddleware : IMiddleware Data = responseData, DataHeaders = new DataHeaders { - Servertime = DateTime.UtcNow.Ticks, + Servertime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), // SID intentionally empty. See docs/api-spec/common/envelope.md §"SID // rotation" — the client's SessionId is a hash-on-read property, so echoing // the request's SID poisons its backing field and the next request hashes @@ -115,8 +115,13 @@ public class ShadowverseTranslationMiddleware : IMiddleware Sid = "", // TODO error handling ResultCode = 1, - ShortUdid = viewer.ShortUdid, - ViewerId = viewer.Id + // Anonymous endpoints (e.g. /check/special_title with [AllowAnonymous]) reach this + // middleware without an authenticated viewer — the auth handler either declined or + // failed to find a Steam-linked viewer. The wire still needs short_udid / viewer_id + // populated (prod sends real numbers for the title check too, but 0 / 0 satisfies + // the client's BaseTask.Parse which only reads result_code + servertime here). + ShortUdid = viewer?.ShortUdid ?? 0, + ViewerId = viewer?.Id ?? 0 } }; diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaFormatInfo.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaFormatInfo.cs index dd38ce8..2231b1a 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaFormatInfo.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/ArenaFormatInfo.cs @@ -1,28 +1,49 @@ using MessagePack; -using SVSim.Database.Enums; using System.Text.Json.Serialization; namespace SVSim.EmulatedEntrypoint.Models.Dtos; +/// +/// Current arena season config. Shape derived from 2026-05-23 prod capture +/// (arena_info[0].format_info). +/// +/// Wire mixes types: two_pick_type and last_card_pack_set_id are strings +/// (PHP-backend stringification), announce_id is an int, and the times use +/// space-separated "yyyy-MM-dd HH:mm:ss" rather than ISO. Numeric-typed properties use +/// AllowReadingFromString on the controller's JsonSerializerOptions so string-quoted +/// ints deserialize cleanly out of the seeded jsonb. +/// [MessagePackObject] public class ArenaFormatInfo { + /// PickTwoFormat as int (0=None,1=Normal,2=Backdraft,3=Cube,4=Chaos,...). [JsonPropertyName("two_pick_type")] [Key("two_pick_type")] - public PickTwoFormat PickTwoFormat { get; set; } + public int TwoPickType { get; set; } + [JsonPropertyName("card_pool_name")] [Key("card_pool_name")] public string CardPoolName { get; set; } = string.Empty; + [JsonPropertyName("announce_id")] [Key("announce_id")] - public string AnnounceId { get; set; } = "0"; - [JsonPropertyName("card_pool_url")] - [Key("card_pool_url")] - public string CardPoolUrl { get; set; } = string.Empty; + public int AnnounceId { get; set; } + + /// The current card pack set id, e.g. "10029". String on the wire. + [JsonPropertyName("last_card_pack_set_id")] + [Key("last_card_pack_set_id")] + public string LastCardPackSetId { get; set; } = string.Empty; + + /// + /// Wire format is "yyyy-MM-dd HH:mm:ss" (space-separated, prod's PHP convention) — NOT ISO. + /// Stored as string here so the jsonb passthrough survives byte-for-byte; the client's + /// DateTime.Parse accepts either format on the receiving side. + /// [JsonPropertyName("start_time")] [Key("start_time")] - public DateTime StartTime { get; set; } + public string StartTime { get; set; } = string.Empty; + [JsonPropertyName("end_time")] [Key("end_time")] - public DateTime EndTime { get; set; } -} \ No newline at end of file + public string EndTime { get; set; } = string.Empty; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/AvatarInfo.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/AvatarInfo.cs index 64ee6cc..7746ba4 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/AvatarInfo.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/AvatarInfo.cs @@ -9,7 +9,12 @@ public class AvatarInfo [JsonPropertyName("abilities")] [Key("abilities")] public Dictionary Abilities { get; set; } = new Dictionary(); + /// + /// Prod (2026-05-23) sends an empty array here. Distinct shape from MyRotationInfo.Schedules, + /// which is a dict {free_battle, gathering}. Entry shape TBD when an active Avatar season is + /// captured — see . + /// [JsonPropertyName("schedules")] [Key("schedules")] - public SpecialRotationSchedule Schedules { get; set; } = new SpecialRotationSchedule(); + public List Schedules { get; set; } = new(); } \ No newline at end of file diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/AvatarSchedule.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/AvatarSchedule.cs new file mode 100644 index 0000000..0fc2659 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/AvatarSchedule.cs @@ -0,0 +1,13 @@ +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos; + +/// +/// Placeholder for an Avatar/Hero mode schedule entry. The 2026-05-23 prod capture had an empty +/// schedules list, so the entry shape is TBD — fill in fields when an active Avatar window is +/// captured. AvatarBattleAllInfo.Parse on the client side is the parser to read for shape. +/// +[MessagePackObject(keyAsPropertyName: true)] +public class AvatarSchedule +{ +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/BasicPuzzle.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/BasicPuzzle.cs new file mode 100644 index 0000000..e469b89 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/BasicPuzzle.cs @@ -0,0 +1,16 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos; + +/// +/// basic_puzzle.is_display_badge — drives the "practice puzzle" badge on the +/// footer. Read by MyPageTask.cs:177. +/// +[MessagePackObject] +public class BasicPuzzle +{ + [JsonPropertyName("is_display_badge")] + [Key("is_display_badge")] + public bool IsDisplayBadge { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/ColosseumInfo.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/ColosseumInfo.cs new file mode 100644 index 0000000..2fb0432 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/ColosseumInfo.cs @@ -0,0 +1,43 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos; + +/// +/// colosseum_info on /mypage/index, consumed by +/// ColosseumEntryInfoTask.SetColosseumInfo (Wizard/ColosseumEntryInfoTask.cs:99). +/// +/// The block is indexed unconditionally — it MUST be present, and +/// `is_colosseum_period` MUST be set. All other fields are only read inside the +/// `if (IsColosseumPeriod)` branch, so when no Take Two cup is active we emit +/// the minimum payload (is_colosseum_period=false) and leave the rest defaulted. +/// +[MessagePackObject] +public class ColosseumInfo +{ + [JsonPropertyName("is_colosseum_period")] + [Key("is_colosseum_period")] + public bool IsColosseumPeriod { get; set; } + + [JsonPropertyName("is_round_period")] + [Key("is_round_period")] + public bool IsRoundPeriod { get; set; } + + [JsonPropertyName("deck_format")] + [Key("deck_format")] + public int DeckFormat { get; set; } + + /// Wire is "1"/"0" string in prod. Client compares with == "1". + [JsonPropertyName("is_normal_two_pick")] + [Key("is_normal_two_pick")] + public string IsNormalTwoPick { get; set; } = "0"; + + /// Used as ColorCodeId (stringified int). + [JsonPropertyName("is_special_mode")] + [Key("is_special_mode")] + public string IsSpecialMode { get; set; } = "0"; + + [JsonPropertyName("colosseum_name")] + [Key("colosseum_name")] + public string ColosseumName { get; set; } = string.Empty; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Convention.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Convention.cs new file mode 100644 index 0000000..26a49f4 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Convention.cs @@ -0,0 +1,29 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos; + +/// +/// Convention/offline-event participation block returned by /mypage/index. +/// Client reads is_join_tournament, recent_start_date (null-checked, optional), +/// and is_admin_watch_user. See MyPageTask.cs:58-63. +/// +[MessagePackObject] +public class Convention +{ + [JsonPropertyName("is_join_tournament")] + [Key("is_join_tournament")] + public bool IsJoinTournament { get; set; } + + /// + /// ISO datetime. Optional — omitted via WhenWritingNull when not set. + /// Client null-checks before parsing (MyPageTask.cs:59). + /// + [JsonPropertyName("recent_start_date")] + [Key("recent_start_date")] + public string? RecentStartDate { get; set; } + + [JsonPropertyName("is_admin_watch_user")] + [Key("is_admin_watch_user")] + public bool IsAdminWatchUser { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/DefaultDeck.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/DefaultDeck.cs new file mode 100644 index 0000000..a076546 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/DefaultDeck.cs @@ -0,0 +1,38 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos; + +/// +/// One of the eight "starter" decks (one per class), as surfaced under +/// /deck/info data.default_deck_list. Wire shape derived from 2026-05-23 prod capture. +/// Used by the client both as new-account defaults and as the source for "use default deck". +/// +[MessagePackObject] +public class DefaultDeck +{ + [JsonPropertyName("deck_no")] + [Key("deck_no")] + public int DeckNo { get; set; } + + [JsonPropertyName("class_id")] + [Key("class_id")] + public int ClassId { get; set; } + + [JsonPropertyName("sleeve_id")] + [Key("sleeve_id")] + public long SleeveId { get; set; } + + [JsonPropertyName("leader_skin_id")] + [Key("leader_skin_id")] + public int LeaderSkinId { get; set; } + + [JsonPropertyName("deck_name")] + [Key("deck_name")] + public string DeckName { get; set; } = string.Empty; + + /// 40 card_id values — same card may repeat (max 3 per card per Shadowverse rules). + [JsonPropertyName("card_id_array")] + [Key("card_id_array")] + public List CardIdArray { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/DefaultLeaderSkinSetting.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/DefaultLeaderSkinSetting.cs new file mode 100644 index 0000000..98f15e9 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/DefaultLeaderSkinSetting.cs @@ -0,0 +1,25 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos; + +/// +/// Default leader skin per class (8 entries — one per class). Surfaced under +/// /deck/info data.user_leader_skin_setting_list. Despite the user_ prefix on the +/// wire, this is GLOBAL data (same for every viewer) — naming is the client's, not ours. +/// +[MessagePackObject] +public class DefaultLeaderSkinSetting +{ + [JsonPropertyName("class_id")] + [Key("class_id")] + public int ClassId { get; set; } + + [JsonPropertyName("is_random_leader_skin")] + [Key("is_random_leader_skin")] + public int IsRandomLeaderSkin { get; set; } + + [JsonPropertyName("leader_skin_id")] + [Key("leader_skin_id")] + public int LeaderSkinId { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/GuildNotification.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/GuildNotification.cs new file mode 100644 index 0000000..0ecab31 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/GuildNotification.cs @@ -0,0 +1,31 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos; + +/// +/// guild_notification on /mypage/index. Consumed by +/// MyPageNotifications.GuildNotification.SetGuildNotification. Prod sends nulls +/// for guild_id / guild_room_message_id when the viewer isn't in a guild; with +/// WhenWritingNull those keys are omitted on our wire, which is equivalent +/// since the parser is null-tolerant. +/// +[MessagePackObject] +public class GuildNotification +{ + [JsonPropertyName("guild_id")] + [Key("guild_id")] + public long? GuildId { get; set; } + + [JsonPropertyName("guild_room_message_id")] + [Key("guild_room_message_id")] + public long? GuildRoomMessageId { get; set; } + + [JsonPropertyName("is_join_request")] + [Key("is_join_request")] + public bool IsJoinRequest { get; set; } + + [JsonPropertyName("is_invited")] + [Key("is_invited")] + public bool IsInvited { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/MasterPointRankingPeriod.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/MasterPointRankingPeriod.cs new file mode 100644 index 0000000..871a60b --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/MasterPointRankingPeriod.cs @@ -0,0 +1,36 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos; + +/// +/// Master Points season window. Client only reads end_time at /mypage/index +/// (MyPageTask.cs:113-114) when _masterResetNextTime hasn't been set yet, but +/// prod also emits id / period_num / necessary_score / begin_time — we mirror +/// them so the wire shape matches. +/// +[MessagePackObject] +public class MasterPointRankingPeriod +{ + [JsonPropertyName("id")] + [Key("id")] + public int Id { get; set; } + + [JsonPropertyName("period_num")] + [Key("period_num")] + public int PeriodNum { get; set; } + + [JsonPropertyName("necessary_score")] + [Key("necessary_score")] + public int NecessaryScore { get; set; } + + /// ISO datetime. + [JsonPropertyName("begin_time")] + [Key("begin_time")] + public string BeginTime { get; set; } = string.Empty; + + /// ISO datetime. Required — client calls DateTime.Parse on it. + [JsonPropertyName("end_time")] + [Key("end_time")] + public string EndTime { get; set; } = string.Empty; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/MyRotationInfo.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/MyRotationInfo.cs index a8e1d31..79d9716 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/MyRotationInfo.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/MyRotationInfo.cs @@ -15,8 +15,9 @@ public class MyRotationInfo [JsonPropertyName("setting")] [Key("setting")] public Dictionary? Settings { get; set; } - [JsonPropertyName("disabled_card_set_ids")] - [Key("disabled_card_set_ids")] + /// Prod wire key is disable_card_set_ids (no trailing 'd' on "disable"). + [JsonPropertyName("disable_card_set_ids")] + [Key("disable_card_set_ids")] public List? DisabledCardSets { get; set; } /// diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/PreReleaseInfo.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/PreReleaseInfo.cs index 324121e..0baa7b0 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/PreReleaseInfo.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/PreReleaseInfo.cs @@ -3,49 +3,74 @@ using System.Text.Json.Serialization; namespace SVSim.EmulatedEntrypoint.Models.Dtos; +/// +/// Pre-release window for an upcoming card set. Wire shape derived from 2026-05-23 prod capture: +/// most numeric fields arrive as quoted strings (prod's PHP backend convention) — only the truly +/// integer fields (card_master_id, statuses) are JSON numbers. Client parses with .ToInt()/.ToString() +/// so either works on read, but matching prod is the right baseline. +/// +/// Optional in /load/index — see Prerelease.Create for parser-side handling. +/// [MessagePackObject] public class PreReleaseInfo { [JsonPropertyName("id")] [Key("id")] - public int Id { get; set; } + public string Id { get; set; } = string.Empty; + [JsonPropertyName("start_time")] [Key("start_time")] public DateTime StartTime { get; set; } + [JsonPropertyName("end_time")] [Key("end_time")] public DateTime EndTime { get; set; } + [JsonPropertyName("display_end_time")] [Key("display_end_time")] public DateTime DisplayEndTime { get; set; } + [JsonPropertyName("next_card_set_id")] [Key("next_card_set_id")] - public int NextCardSetId { get; set; } + public string NextCardSetId { get; set; } = string.Empty; + [JsonPropertyName("default_card_master_id")] [Key("default_card_master_id")] - public int DefaultCardMasterId { get; set; } + public string DefaultCardMasterId { get; set; } = string.Empty; + [JsonPropertyName("pre_release_card_master_id")] [Key("pre_release_card_master_id")] - public int PreReleaseCardMasterId { get; set; } + public string PreReleaseCardMasterId { get; set; } = string.Empty; + [JsonPropertyName("free_match_start_time")] [Key("free_match_start_time")] public DateTime FreeMatchStartTime { get; set; } + [JsonPropertyName("card_master_id")] [Key("card_master_id")] public int CardMasterId { get; set; } + [JsonPropertyName("rotation_card_set_id_list")] [Key("rotation_card_set_id_list")] - public List RotationCardSets { get; set; } = new List(); + public List RotationCardSets { get; set; } = new(); + + /// + /// Prod sends a dict of card_id (string) → card_id (string) — values mirror keys. The + /// purpose is just to enumerate which base card ids count as reprinted in this window. + /// [JsonPropertyName("reprinted_base_card_ids")] [Key("reprinted_base_card_ids")] - public Dictionary ReprintedCardIds { get; set; } = new Dictionary(); + public Dictionary ReprintedCardIds { get; set; } = new(); + [JsonPropertyName("latest_reprinted_base_card_ids")] [Key("latest_reprinted_base_card_ids")] - public List LatestReprintedCardIds { get; set; } = new List(); + public List LatestReprintedCardIds { get; set; } = new(); + [JsonPropertyName("pre_release_status")] [Key("pre_release_status")] public int PreReleaseStatus { get; set; } + [JsonPropertyName("is_pre_rotation_free_match_term")] [Key("is_pre_rotation_free_match_term")] public int IsPreRotationFreeMatchTerm { get; set; } -} \ No newline at end of file +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Quest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Quest.cs new file mode 100644 index 0000000..0693be1 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Quest.cs @@ -0,0 +1,32 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos; + +/// +/// "quest" block on /mypage/index. Consumed by QuestOpenInfo.SetOpenInfo. +/// Empty/closed-quest shape captured from prod 2026-05-23. +/// +[MessagePackObject] +public class Quest +{ + [JsonPropertyName("is_open")] + [Key("is_open")] + public bool IsOpen { get; set; } + + [JsonPropertyName("is_display_badge")] + [Key("is_display_badge")] + public bool IsDisplayBadge { get; set; } + + [JsonPropertyName("is_daily_first_access")] + [Key("is_daily_first_access")] + public bool IsDailyFirstAccess { get; set; } + + [JsonPropertyName("end_time")] + [Key("end_time")] + public string EndTime { get; set; } = string.Empty; + + [JsonPropertyName("name")] + [Key("name")] + public string Name { get; set; } = string.Empty; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/MyPageIndexRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/MyPageIndexRequest.cs new file mode 100644 index 0000000..bd6c7ad --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/MyPageIndexRequest.cs @@ -0,0 +1,12 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests; + +[MessagePackObject] +public class MyPageIndexRequest : BaseRequest +{ + [JsonPropertyName("carrier")] + [Key("carrier")] + public string Carrier { get; set; } = string.Empty; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Deck/DeckListResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Deck/DeckListResponse.cs index ab3ac23..a1b0d0f 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Deck/DeckListResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Deck/DeckListResponse.cs @@ -4,14 +4,36 @@ using System.Text.Json.Serialization; namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck; /// -/// Shape consumed by `DeckGroupListData(jsonData, format)` for a single-format call 窶・/// the format-scoped decks land under `user_deck_list` (vs. the per-format keys used -/// by /practice/deck_list with Format.All). +/// Shape consumed by DeckGroupListData(jsonData, format). Spec at +/// docs/api-spec/endpoints/post-login/deck-info.md only enumerates maintenance_card_list +/// and user_deck_list explicitly (with [k: string]: unknown for the rest); the 2026-05-23 +/// prod capture filled in the gap — default_deck_list, user_leader_skin_setting_list, +/// and trial_deck_list are all present and sourced from globals. /// [MessagePackObject] public class DeckListResponse { [JsonPropertyName("maintenance_card_list")] [Key("maintenance_card_list")] public List MaintenanceCardList { get; set; } = new(); + [JsonPropertyName("user_deck_list")] [Key("user_deck_list")] public List? UserDeckList { get; set; } + + /// + /// Global starter decks, keyed by deck_no as string (prod ids 91-98 — one per class). + /// + [JsonPropertyName("default_deck_list")] + [Key("default_deck_list")] public Dictionary DefaultDeckList { get; set; } = new(); + + /// + /// Default leader skin per class, keyed by class_id as string. + /// + [JsonPropertyName("user_leader_skin_setting_list")] + [Key("user_leader_skin_setting_list")] public Dictionary UserLeaderSkinSettingList { get; set; } = new(); + + /// + /// Trial / tutorial-specific decks. Empty in the 2026-05-23 prod capture; entry shape TBD. + /// + [JsonPropertyName("trial_deck_list")] + [Key("trial_deck_list")] public List TrialDeckList { get; set; } = new(); } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/IndexResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/IndexResponse.cs index 96a597a..f251b59 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/IndexResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/IndexResponse.cs @@ -1,5 +1,6 @@ using MessagePack; using SVSim.Database.Enums; +using System.Text.Json; using System.Text.Json.Serialization; namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses; @@ -24,15 +25,22 @@ public class IndexResponse [JsonPropertyName("room_recovery_status")] [Key("room_recovery_status")] public int RoomRecoveryStatus { get; set; } + /// + /// Prod emits this as bool (per the 2026-05-23 capture); the spec leaves it as a TODO + /// (load-index.md line 296-297). We send bool to match prod; client's `.ToBoolean()` + /// path handles either shape, but matching prod avoids the int-vs-bool drift noted in + /// the seed-data-strategy crash audit. + /// [JsonPropertyName("is_battle_pass_period")] [Key("is_battle_pass_period")] - public int IsBattlePassPeriod { get; set; } + public bool IsBattlePassPeriod { get; set; } [JsonPropertyName("card_set_id_for_resource_dl_view")] [Key("card_set_id_for_resource_dl_view")] public int CardSetIdForResourceDlView { get; set; } + // Serialized as wire deck_format via FormatJsonConverter (registered in Program.cs). [JsonPropertyName("deck_format")] [Key("deck_format")] - public int DeckFormat { get; set; } = 1; + public Format DeckFormat { get; set; } = Format.Rotation; #endregion @@ -123,9 +131,15 @@ public class IndexResponse [Key("user_rank_match_list")] public List UserRankedMatches { get; set; } = new(); + /// + /// Spec: optional. Shape is {normal?, total?, campaign?[]} per common/types.ts.md DailyLoginBonus. + /// Until we have an active login-bonus campaign to surface in spec shape, omit. The skeleton + /// rows in DailyLoginBonuses table (prod sent {"1":[], "3":[], "4":[]}) preserve the capture + /// for archive but don't make it into the wire response. + /// [JsonPropertyName("daily_login_bonus")] [Key("daily_login_bonus")] - public DailyLoginBonus DailyLoginBonus { get; set; } = new(); + public DailyLoginBonus? DailyLoginBonus { get; set; } [JsonPropertyName("challenge_config")] [Key("challenge_config")] @@ -213,7 +227,7 @@ public class IndexResponse [JsonPropertyName("avatar_info")] [Key("avatar_info")] - public MyRotationInfo? AvatarRotationInfo { get; set; } + public AvatarInfo? AvatarRotationInfo { get; set; } [JsonPropertyName("feature_maintenance_list")] [Key("feature_maintenance_list")] @@ -223,6 +237,10 @@ public class IndexResponse [Key("special_crystal_info")] public List SpecialCrystalInfos { get; set; } = new(); + /// + /// Spec: optional, Record<string, BattlePassLevelInfo> keyed by level-as-string + /// (load-index.md:228). Omit (null) when no Battle Pass is active. + /// [JsonPropertyName("battle_pass_level_info")] [Key("battle_pass_level_info")] public Dictionary? BattlePassLevelInfo { get; set; } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/MyPageIndexResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/MyPageIndexResponse.cs new file mode 100644 index 0000000..a7be7dd --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/MyPageIndexResponse.cs @@ -0,0 +1,223 @@ +using MessagePack; +using SVSim.Database.Enums; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses; + +/// +/// /mypage/index ("home screen refresh") response payload. +/// +/// Required fields per the minimum-viable section of +/// docs/api-spec/endpoints/post-login/mypage-index.md and corroborated by +/// MyPageTask.cs direct-index accesses (jsonData["…"] without TryGetValue). +/// Optional fields are nullable and omitted by the global WhenWritingNull +/// policy — the client uses TryGetValue / GetValueOrDefault for those. +/// +[MessagePackObject] +public class MyPageIndexResponse +{ + // ── User identity / counts ───────────────────────────────────────────── + + /// + /// Full UserInfo block. Client only reads .name here (MyPageTask.cs:39) but + /// prod emits the full structure, so we do too. + /// + [JsonPropertyName("user_info")] + [Key("user_info")] + public UserInfo UserInfo { get; set; } = new(); + + [JsonPropertyName("unreceived_mission_reward_count")] + [Key("unreceived_mission_reward_count")] + public int UnreceivedMissionRewardCount { get; set; } + + [JsonPropertyName("receive_friend_apply_count")] + [Key("receive_friend_apply_count")] + public int ReceiveFriendApplyCount { get; set; } + + [JsonPropertyName("unread_present_count")] + [Key("unread_present_count")] + public int UnreadPresentCount { get; set; } + + [JsonPropertyName("friend_battle_invite_count")] + [Key("friend_battle_invite_count")] + public int FriendBattleInviteCount { get; set; } + + // ── Guild ────────────────────────────────────────────────────────────── + + [JsonPropertyName("guild_notification")] + [Key("guild_notification")] + public GuildNotification GuildNotification { get; set; } = new(); + + // ── Announcements ────────────────────────────────────────────────────── + + [JsonPropertyName("last_announce_id")] + [Key("last_announce_id")] + public int LastAnnounceId { get; set; } + + /// ISO datetime. Parse is wrapped in try/catch on the client. + [JsonPropertyName("last_announce_update_time")] + [Key("last_announce_update_time")] + public string LastAnnounceUpdateTime { get; set; } = string.Empty; + + // ── Maintenance ──────────────────────────────────────────────────────── + + /// Same shape as /load/index. Empty list in the 2026-05-23 capture. + [JsonPropertyName("feature_maintenance_list")] + [Key("feature_maintenance_list")] + public List FeatureMaintenanceList { get; set; } = new(); + + // ── Arena / Colosseum ────────────────────────────────────────────────── + + /// + /// Client unconditionally constructs ArenaData(arena_info) which reads [0], + /// so this MUST be a non-empty list. See LoadController BuildArenaInfosAsync + /// — we mirror that, returning null (omitted on wire) when no Take Two + /// season is seeded, in which case the client's Keys.Contains guard at + /// LoadDetail.cs:261 handles it. For mypage there is no equivalent guard; + /// the client always reads it. Until that's reconciled we send a minimal + /// stub on the controller side. + /// + [JsonPropertyName("arena_info")] + [Key("arena_info")] + public List ArenaInfo { get; set; } = new(); + + [JsonPropertyName("is_arena_challenge_period")] + [Key("is_arena_challenge_period")] + public bool IsArenaChallengePeriod { get; set; } + + [JsonPropertyName("is_available_colosseum_free_entry")] + [Key("is_available_colosseum_free_entry")] + public bool IsAvailableColosseumFreeEntry { get; set; } + + /// + /// Required — ColosseumEntryInfoTask.SetColosseumInfo indexes this key + /// directly (Wizard/ColosseumEntryInfoTask.cs:102) and reads + /// is_colosseum_period without a guard. + /// + [JsonPropertyName("colosseum_info")] + [Key("colosseum_info")] + public ColosseumInfo ColosseumInfo { get; set; } = new(); + + // ── Convention / offline event ───────────────────────────────────────── + + [JsonPropertyName("convention")] + [Key("convention")] + public Convention Convention { get; set; } = new(); + + // ── Battle / room recovery (optional) ───────────────────────────────── + + [JsonPropertyName("unfinished_battle_exists")] + [Key("unfinished_battle_exists")] + public bool? UnfinishedBattleExists { get; set; } + + [JsonPropertyName("battle_finish_wait_time")] + [Key("battle_finish_wait_time")] + public int? BattleFinishWaitTime { get; set; } + + [JsonPropertyName("is_joined_room")] + [Key("is_joined_room")] + public bool? IsJoinedRoom { get; set; } + + // ── Login bonus (optional) ───────────────────────────────────────────── + + [JsonPropertyName("can_give_daily_login_bonus")] + [Key("can_give_daily_login_bonus")] + public bool? CanGiveDailyLoginBonus { get; set; } + + // ── User config (settings echo) ──────────────────────────────────────── + + [JsonPropertyName("user_config")] + [Key("user_config")] + public UserConfig UserConfig { get; set; } = new(); + + // ── Quest progress ───────────────────────────────────────────────────── + + [JsonPropertyName("quest")] + [Key("quest")] + public Quest Quest { get; set; } = new(); + + /// + /// Required — QuestOpenInfo.SetOpenInfo unconditionally calls .ToBoolean() + /// on this root-level field (Wizard/QuestOpenInfo.cs:32). Omitting it would + /// surface as a parse crash, not a defaulted value. + /// + [JsonPropertyName("is_hidden_boss_appeared")] + [Key("is_hidden_boss_appeared")] + public bool IsHiddenBossAppeared { get; set; } + + // ── Master Points season window ──────────────────────────────────────── + + [JsonPropertyName("master_point_ranking_period")] + [Key("master_point_ranking_period")] + public MasterPointRankingPeriod MasterPointRankingPeriod { get; set; } = new(); + + // ── Pre-release card preview ─────────────────────────────────────────── + + /// Number cast to Prerelease.eStatus on the client. + [JsonPropertyName("pre_release_status")] + [Key("pre_release_status")] + public int PreReleaseStatus { get; set; } + + // ── MyPage background ────────────────────────────────────────────────── + + [JsonPropertyName("user_mypage_info")] + [Key("user_mypage_info")] + public UserMyPageInfo UserMyPageInfo { get; set; } = new(); + + // ── Basic puzzle badge ───────────────────────────────────────────────── + + [JsonPropertyName("basic_puzzle")] + [Key("basic_puzzle")] + public BasicPuzzle BasicPuzzle { get; set; } = new(); + + // ── Battle Pass period flag ──────────────────────────────────────────── + + /// + /// Parsed by Data.ParseIsBattlePassPeriod. Same field as on /load/index + /// (prod emits bool there too). + /// + [JsonPropertyName("is_battle_pass_period")] + [Key("is_battle_pass_period")] + public bool IsBattlePassPeriod { get; set; } + + // ── Special crystal info ─────────────────────────────────────────────── + + /// + /// Sibling under data, same shape as /load/index. Empty in the prod capture. + /// + [JsonPropertyName("special_crystal_info")] + [Key("special_crystal_info")] + public List SpecialCrystalInfo { get; set; } = new(); + + // ── Notification setters that index root-of-data directly ────────────── + + /// + /// Required — ShopNotification.SetShopNotification indexes the four nested + /// keys (card_pack, build_deck, sleeve, leader_skin) without TryGetValue + /// (Wizard/ShopNotification.cs:33-37). The inner ShopAppealInfo ctor early- + /// returns on empty, so default-constructed values are safe. + /// + [JsonPropertyName("shop_notification")] + [Key("shop_notification")] + public ShopNotification ShopNotification { get; set; } = new(); + + /// + /// Required — StoryNotification.SetStoryNotification indexes this key + /// directly (Wizard/StoryNotification.cs:22) before applying GetValueOrDefault + /// to its sub-fields. + /// + [JsonPropertyName("story_notification")] + [Key("story_notification")] + public StoryNotification StoryNotification { get; set; } = new(); + + // ── Optional UI surface area ─────────────────────────────────────────── + + /// Updated item counts. Refreshes Data.Load.data._userItemDict when present. + [JsonPropertyName("user_item_list")] + [Key("user_item_list")] + public List? UserItemList { get; set; } + + [JsonPropertyName("gathering_info")] + [Key("gathering_info")] + public GatheringInfo? GatheringInfo { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/ShopNotification.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/ShopNotification.cs new file mode 100644 index 0000000..ab14bb2 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/ShopNotification.cs @@ -0,0 +1,51 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos; + +/// +/// shop_notification on /mypage/index, consumed by +/// ShopNotification.SetShopNotification (Wizard/ShopNotification.cs:30). All four +/// sub-keys are directly indexed; each is passed to ShopAppealInfo, which early- +/// returns when `data.Count == 0`. We mirror prod's heterogeneous shape — three +/// empty arrays and one object — so that the wire matches and the client's +/// length-check fires on the empty cases. +/// +[MessagePackObject] +public class ShopNotification +{ + [JsonPropertyName("card_pack")] + [Key("card_pack")] + public ShopCardPackAppeal CardPack { get; set; } = new(); + + /// Prod 2026-05-23: []. Client treats Count==0 as "no notification". + [JsonPropertyName("build_deck")] + [Key("build_deck")] + public List BuildDeck { get; set; } = new(); + + /// Prod 2026-05-23: []. + [JsonPropertyName("sleeve")] + [Key("sleeve")] + public List Sleeve { get; set; } = new(); + + /// Prod 2026-05-23: []. + [JsonPropertyName("leader_skin")] + [Key("leader_skin")] + public List LeaderSkin { get; set; } = new(); +} + +/// +/// card_pack sub-object — drives the free-gacha campaign badge. Both fields are +/// TryGetValue on the client; emitting both as false matches prod's idle shape. +/// +[MessagePackObject] +public class ShopCardPackAppeal +{ + [JsonPropertyName("is_open_free_gacha_campaign")] + [Key("is_open_free_gacha_campaign")] + public bool IsOpenFreeGachaCampaign { get; set; } + + [JsonPropertyName("can_free_gacha")] + [Key("can_free_gacha")] + public bool CanFreeGacha { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/StoryNotification.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/StoryNotification.cs new file mode 100644 index 0000000..851e9a9 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/StoryNotification.cs @@ -0,0 +1,21 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos; + +/// +/// story_notification on /mypage/index, consumed by +/// StoryNotification.SetStoryNotification (Wizard/StoryNotification.cs:20). The +/// outer key is directly indexed; the inner fields are TryGetValue-defaulted. +/// +[MessagePackObject] +public class StoryNotification +{ + [JsonPropertyName("is_display_ribbon")] + [Key("is_display_ribbon")] + public bool IsDisplayRibbon { get; set; } + + [JsonPropertyName("is_display_badge")] + [Key("is_display_badge")] + public bool IsDisplayBadge { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPageInfo.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPageInfo.cs new file mode 100644 index 0000000..1656925 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPageInfo.cs @@ -0,0 +1,37 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos; + +/// +/// user_mypage_info — wrapper around the active home-screen background +/// configuration. Client constructs MyPageBGInfo(user_mypage_setting) at +/// MyPageTask.cs:176. +/// +[MessagePackObject] +public class UserMyPageInfo +{ + [JsonPropertyName("user_mypage_setting")] + [Key("user_mypage_setting")] + public MyPageBgSetting UserMyPageSetting { get; set; } = new(); +} + +/// +/// Active mypage background selection. Shape from prod 2026-05-23. +/// +[MessagePackObject] +public class MyPageBgSetting +{ + [JsonPropertyName("mypage_id")] + [Key("mypage_id")] + public int MyPageId { get; set; } + + /// 0 = single selection (mypage_id), 1+ = random rotation across mypage_id_list. + [JsonPropertyName("select_type")] + [Key("select_type")] + public int SelectType { get; set; } + + [JsonPropertyName("mypage_id_list")] + [Key("mypage_id_list")] + public List MyPageIdList { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/UserRankInfo.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/UserRankInfo.cs index 3e0bcb3..f5981fe 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/UserRankInfo.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/UserRankInfo.cs @@ -1,14 +1,18 @@ using MessagePack; using System.Text.Json.Serialization; +using SVSim.Database.Enums; namespace SVSim.EmulatedEntrypoint.Models.Dtos; [MessagePackObject] public class UserRankInfo { + // Serialized as wire deck_format via FormatJsonConverter (registered globally in + // Program.cs). Storing as Format makes wrong-int-scope bugs (sending internal enum + // ints instead of wire codes) a compile error. [JsonPropertyName("deck_format")] [Key("deck_format")] - public int DeckFormat { get; set; } + public Format DeckFormat { get; set; } [JsonPropertyName("rank")] [Key("rank")] public int Rank { get; set; } diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 4f2a365..388bd89 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -7,6 +7,7 @@ using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Viewer; +using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Middlewares; using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication; using SVSim.EmulatedEntrypoint.Services; @@ -34,6 +35,9 @@ public class Program // value without a null guard. Emitting `"key":null` makes Contains return true and // crashes the client. Drop nulls during serialization so missing == absent. opt.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + // Format-typed properties serialize to/from the wire deck_format int via the + // client's FormatConvertApi mapping. See FormatExtensions.cs. + opt.JsonSerializerOptions.Converters.Add(new FormatJsonConverter()); }); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); diff --git a/SVSim.UnitTests/Controllers/DeckControllerTests.cs b/SVSim.UnitTests/Controllers/DeckControllerTests.cs index ac24f6c..3cab971 100644 --- a/SVSim.UnitTests/Controllers/DeckControllerTests.cs +++ b/SVSim.UnitTests/Controllers/DeckControllerTests.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SVSim.Database; using SVSim.Database.Enums; +using SVSim.EmulatedEntrypoint.Extensions; using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.Controllers; @@ -16,8 +17,12 @@ namespace SVSim.UnitTests.Controllers; /// public class DeckControllerTests { + // ToApi() converts internal Format -> wire deck_format int (e.g. Format.Rotation -> 1). + // Tests MUST send wire values; the controller routes them back via FormatExtensions.FromApi. + // Inline `"deck_format":1` literals below correspond to Format.Rotation (the format the + // SeedDeckAsync fixtures use). private static string DeckFormatRequestJson(Format f) => - $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_format":{{(int)f}}}"""; + $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_format":{{f.ToApi()}}}"""; private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); @@ -145,7 +150,7 @@ public class DeckControllerTests {"viewer_id":"0","steam_id":0,"steam_session_ticket":"", "deck_no":1,"class_id":{{classId}},"leader_skin_id":{{leaderSkinId}}, "is_random_leader_skin":false,"sleeve_id":{{sleeveId}},"deck_name":"Fresh Deck", - "is_delete":0,"deck_format":0} + "is_delete":0,"deck_format":1} """; var response = await client.PostAsync("/deck/update", JsonBody(updateJson)); @@ -173,7 +178,7 @@ public class DeckControllerTests {"viewer_id":"0","steam_id":0,"steam_session_ticket":"", "deck_no":1,"class_id":{{classId}},"leader_skin_id":{{leaderSkinId}}, "is_random_leader_skin":false,"sleeve_id":{{sleeveId}},"deck_name":"Renamed", - "is_delete":0,"deck_format":0} + "is_delete":0,"deck_format":1} """; await client.PostAsync("/deck/update", JsonBody(updateJson)); @@ -197,7 +202,7 @@ public class DeckControllerTests {"viewer_id":"0","steam_id":0,"steam_session_ticket":"", "deck_no":1,"class_id":{{classId}},"leader_skin_id":{{leaderSkinId}}, "is_random_leader_skin":false,"sleeve_id":{{sleeveId}},"deck_name":null, - "is_delete":1,"deck_format":0} + "is_delete":1,"deck_format":1} """; var response = await client.PostAsync("/deck/update", JsonBody(deleteJson)); @@ -222,7 +227,7 @@ public class DeckControllerTests {"viewer_id":"0","steam_id":0,"steam_session_ticket":"", "deck_no":2,"class_id":{{classId}},"leader_skin_id":{{leaderSkinId}}, "is_random_leader_skin":false,"sleeve_id":{{sleeveId}},"deck_name":"Second", - "is_delete":0,"deck_format":0} + "is_delete":0,"deck_format":1} """; var response = await client.PostAsync("/deck/update", JsonBody(updateJson)); @@ -247,7 +252,7 @@ public class DeckControllerTests await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, name: "Old Name"); using var client = factory.CreateAuthenticatedClient(viewerId); - var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_name":"New Name","deck_format":0}"""; + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_name":"New Name","deck_format":1}"""; var response = await client.PostAsync("/deck/update_name", JsonBody(json)); var body = await response.Content.ReadAsStringAsync(); @@ -299,7 +304,7 @@ public class DeckControllerTests } using var client = factory.CreateAuthenticatedClient(viewerId); - var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"leader_skin_id":{{skinId}},"deck_format":0}"""; + var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"leader_skin_id":{{skinId}},"deck_format":1}"""; var response = await client.PostAsync("/deck/update_leader_skin", JsonBody(json)); var body = await response.Content.ReadAsStringAsync(); @@ -325,7 +330,7 @@ public class DeckControllerTests using var client = factory.CreateAuthenticatedClient(viewerId); var json = - $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_format":0,"leader_skin_id_list":[{{string.Join(',', pool)}}]}"""; + $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_format":1,"leader_skin_id_list":[{{string.Join(',', pool)}}]}"""; var response = await client.PostAsync("/deck/update_random_leader_skin", JsonBody(json)); var body = await response.Content.ReadAsStringAsync(); @@ -347,7 +352,7 @@ public class DeckControllerTests using var client = factory.CreateAuthenticatedClient(viewerId); var json = - """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_format":0,"leader_skin_id_list":[]}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_format":1,"leader_skin_id_list":[]}"""; var response = await client.PostAsync("/deck/update_random_leader_skin", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); @@ -365,7 +370,7 @@ public class DeckControllerTests using var client = factory.CreateAuthenticatedClient(viewerId); var json = - """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_order":[2,1],"deck_format":0}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_order":[2,1],"deck_format":1}"""; var response = await client.PostAsync("/deck/update_order", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); @@ -382,7 +387,7 @@ public class DeckControllerTests using var client = factory.CreateAuthenticatedClient(viewerId); var json = - """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no_list":[1,3],"deck_format":0}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no_list":[1,3],"deck_format":1}"""; var response = await client.PostAsync("/deck/delete_deck_list", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); diff --git a/SVSim.UnitTests/Controllers/LoadControllerTests.cs b/SVSim.UnitTests/Controllers/LoadControllerTests.cs index ba1c008..013bf8d 100644 --- a/SVSim.UnitTests/Controllers/LoadControllerTests.cs +++ b/SVSim.UnitTests/Controllers/LoadControllerTests.cs @@ -18,9 +18,14 @@ public class LoadControllerTests /// /// Wire keys (from [Key("...")] / mirrored [JsonPropertyName]) for fields the - /// client reads unconditionally in LoadDetail.ConvertJsonData. These are the names - /// the decompiled client actually looks up — NOT SnakeCaseLower(C# property name). - /// Missing any of these crashes the client with KeyNotFoundException on /load/index. + /// client reads UNCONDITIONALLY in LoadDetail.ConvertJsonData (no Keys.Contains + /// or TryGetValue guard). Missing any of these crashes the client. + /// + /// Fields that ARE guarded by the client get a separate, dedicated assertion (or no assertion) + /// — they're allowed to be omitted. Examples: arena_info, daily_login_bonus, + /// battle_pass_level_info, pre_release_info, my_rotation_info, + /// avatar_info, item_expire_date are all optional per + /// docs/api-spec/endpoints/post-login/load-index.md. /// private static readonly string[] RequiredIndexKeys = { @@ -28,7 +33,7 @@ public class LoadControllerTests "user_deck_rotation", "user_deck_unlimited", "user_deck_my_rotation", "user_card_list", "user_class_list", "user_sleeve_list", "user_emblem_list", "user_degree_list", "user_leader_skin_list", "user_mypage_list", - "user_rank", "user_rank_match_list", "daily_login_bonus", "challenge_config", + "user_rank", "user_rank_match_list", "challenge_config", "red_ether_overwrite_list", "maintenance_card_list", "rank_info", "class_exp", "loading_exclusion_card_list", "default_setting", "unlimited_restricted_base_card_id_list", "rotation_card_set_id_list", @@ -116,6 +121,29 @@ public class LoadControllerTests Assert.That(root.GetProperty("user_rank").GetArrayLength(), Is.EqualTo(5)); } + [Test] + public async Task Index_user_rank_deck_formats_are_wire_codes_not_internal_enum() + { + // Regression for the /load/index KeyNotFoundException crash (2026-05-23): + // server was emitting (int)Format directly, so deck_format 0 (Format.Rotation + // internal) reached the client, ParseApiFormat mapped wire-0 to Format.Max, and + // LoadDetail._userRank[2] threw. Wire codes per Data.FormatConvertApi: + // Rotation→1, Unlimited→2, Crossover→4, MyRotation→5, Avatar→39. + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + + var root = await PostIndexAndReadBody(factory, viewerId); + + var deckFormats = root.GetProperty("user_rank").EnumerateArray() + .Select(e => e.GetProperty("deck_format").GetInt32()) + .ToList(); + Assert.That(deckFormats, Is.EquivalentTo(new[] { 1, 2, 5, 39, 4 }), + "user_rank entries must carry wire deck_format codes, not internal Format ints."); + + // The top-level deck_format default is also a wire code (Rotation = wire 1). + Assert.That(root.GetProperty("deck_format").GetInt32(), Is.EqualTo(1)); + } + [Test] public async Task Index_rotation_card_set_id_list_has_at_least_two_entries() { @@ -146,6 +174,31 @@ public class LoadControllerTests "If you re-add it, populate at least one entry with a valid format_info."); } + [Test] + public async Task Index_user_card_list_excludes_zero_count_entries() + { + // Documents the divergence from prod (see load-index.md §user_card_list policy). + // Our server emits only owned cards (Count > 0) plus basics; prod returns a + // larger curated set that includes some 0-count "ever-touched" rows we don't + // model. The client falls back to 0 for absent ids (DataMgr.cs:1182), so this + // is semantically safe — but if anything ever starts emitting Count=0 rows again + // (e.g. someone re-introduces a left-join against the full card catalog), this + // test pins the policy. + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + + var root = await PostIndexAndReadBody(factory, viewerId); + + var userCards = root.GetProperty("user_card_list"); + Assert.That(userCards.ValueKind, Is.EqualTo(JsonValueKind.Array)); + var zeroCount = userCards.EnumerateArray() + .Where(c => c.GetProperty("number").GetInt32() == 0) + .ToList(); + Assert.That(zeroCount, Is.Empty, + "user_card_list must not contain Count=0 entries; we ship only the owned-only " + + "subset (plus basics with count=3). See load-index.md §user_card_list policy."); + } + [Test] public async Task Index_when_viewer_has_no_decks_returns_empty_format_lists() { @@ -167,4 +220,79 @@ public class LoadControllerTests $"{key}.user_deck_list must be an empty array for a deckless viewer, not null."); } } + + [Test] + public async Task Index_surfaces_seeded_globals_after_bootstrap() + { + // Verifies the end-to-end seed → repo → controller wiring for the prod-captured globals. + // Counts and spot-checked values come from the 2026-05-23 capture; if a recapture lands + // with different cardinalities, update the assertions alongside. + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(); + + var root = await PostIndexAndReadBody(factory, viewerId); + + // SpotCards: dict[card_id_str] → cost, 239 entries + var spotCards = root.GetProperty("spot_cards"); + Assert.That(spotCards.ValueKind, Is.EqualTo(JsonValueKind.Object)); + Assert.That(spotCards.EnumerateObject().Count(), Is.EqualTo(239), "spot_cards entry count"); + + // ReprintedCards: flat number[], 54 entries + var reprinted = root.GetProperty("reprinted_base_card_ids"); + Assert.That(reprinted.GetArrayLength(), Is.EqualTo(54), "reprinted_base_card_ids length"); + + // UnlimitedBanList: dict[card_id_str] → restriction value, 3 entries; 107813030 = hard ban + var bans = root.GetProperty("unlimited_restricted_base_card_id_list"); + Assert.That(bans.EnumerateObject().Count(), Is.EqualTo(3)); + Assert.That(bans.GetProperty("107813030").GetInt32(), Is.EqualTo(1)); + + // LoadingExclusion: 176 ids + Assert.That(root.GetProperty("loading_exclusion_card_list").GetArrayLength(), Is.EqualTo(176)); + + // GameConfiguration-sourced scalars + Assert.That(root.GetProperty("is_battle_pass_period").GetBoolean(), Is.True, + "is_battle_pass_period is bool on the wire (matches prod 2026-05-23)"); + Assert.That(root.GetProperty("card_set_id_for_resource_dl_view").GetInt32(), Is.EqualTo(1)); + + // challenge_config sourced from GameConfiguration cols + var challenge = root.GetProperty("challenge_config"); + Assert.That(challenge.GetProperty("use_challenge_two_pick_premium_card").GetInt32(), Is.EqualTo(0)); + Assert.That(challenge.GetProperty("challenge_two_pick_sleeve_id").GetInt32(), Is.EqualTo(3000011)); + + // arena_info: single element with format_info populated + Assert.That(root.TryGetProperty("arena_info", out var arenaInfo), Is.True, + "arena_info present once an ArenaSeasonConfig row is seeded"); + Assert.That(arenaInfo.GetArrayLength(), Is.EqualTo(1)); + var fi = arenaInfo[0].GetProperty("format_info"); + Assert.That(fi.GetProperty("card_pool_name").GetString(), Does.Contain("Take Two")); + + // my_rotation_info: setting dict has 27 entries + var mri = root.GetProperty("my_rotation_info"); + Assert.That(mri.GetProperty("setting").EnumerateObject().Count(), Is.EqualTo(27)); + Assert.That(mri.GetProperty("abilities").EnumerateObject().Count(), Is.EqualTo(6)); + + // avatar_info: abilities dict has 24 entries; schedules is empty list + var ai = root.GetProperty("avatar_info"); + Assert.That(ai.GetProperty("abilities").EnumerateObject().Count(), Is.EqualTo(24)); + Assert.That(ai.GetProperty("schedules").ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(ai.GetProperty("schedules").GetArrayLength(), Is.EqualTo(0)); + + // pre_release_info: present (singleton seeded, even with stale dates per audit) + Assert.That(root.TryGetProperty("pre_release_info", out var pri), Is.True); + Assert.That(pri.GetProperty("id").GetString(), Is.EqualTo("1")); + + // rotation_card_set_id_list: now comes from the real CardSets table — six entries after + // GlobalsImporter flags IsInRotation on the rotation_card_set_id_list seeded ids. But + // CardImport isn't run in tests, so the table is empty and we fall back to StubRotationSets + // (3 entries). That's still ≥ 2 so the client won't crash. + Assert.That(root.GetProperty("rotation_card_set_id_list").GetArrayLength(), + Is.GreaterThanOrEqualTo(2)); + + // Optional/absent fields stay absent when nothing meaningful to surface + Assert.That(root.TryGetProperty("daily_login_bonus", out _), Is.False, + "daily_login_bonus optional per spec; emit null when no active campaign"); + Assert.That(root.TryGetProperty("battle_pass_level_info", out _), Is.False, + "battle_pass_level_info optional per spec; emit null until viewer pass state is wired"); + } } diff --git a/SVSim.UnitTests/Controllers/PracticeControllerTests.cs b/SVSim.UnitTests/Controllers/PracticeControllerTests.cs index b026e89..a01f89b 100644 --- a/SVSim.UnitTests/Controllers/PracticeControllerTests.cs +++ b/SVSim.UnitTests/Controllers/PracticeControllerTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text; using System.Text.Json; using SVSim.Database.Enums; +using SVSim.EmulatedEntrypoint.Extensions; using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.Controllers; @@ -16,8 +17,9 @@ public class PracticeControllerTests private const string BaseRequestJson = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + // ToApi() converts internal Format -> wire deck_format int (Format.All -> 0, etc.). private static string DeckFormatRequestJson(Format f) => - $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_format":{{(int)f}}}"""; + $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_format":{{f.ToApi()}}}"""; [Test] public async Task Info_returns_non_empty_opponent_array() @@ -103,8 +105,11 @@ public class PracticeControllerTests // recoveryData is an opaque JSON blob serialized to string by the client; the server // is supposed to accept it without validation. Anything goes. + // deck_format:1 = Format.Rotation on the wire. The controller ignores the field today + // (practice is per-format upstream), but sending a coherent wire code keeps the test + // intent clean if Finish ever starts validating it. var finishJson = - """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"is_win":1,"evolve_count":2,"total_turn":5,"enemy_class_id":3,"difficulty":1,"deck_format":0,"class_id":1,"recovery_data":"{\"opaque\":\"blob\"}"}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"is_win":1,"evolve_count":2,"total_turn":5,"enemy_class_id":3,"difficulty":1,"deck_format":1,"class_id":1,"recovery_data":"{\"opaque\":\"blob\"}"}"""; var response = await client.PostAsync("/practice/finish", new StringContent(finishJson, Encoding.UTF8, "application/json")); diff --git a/SVSim.UnitTests/Extensions/FormatExtensionsTests.cs b/SVSim.UnitTests/Extensions/FormatExtensionsTests.cs new file mode 100644 index 0000000..4e0c01f --- /dev/null +++ b/SVSim.UnitTests/Extensions/FormatExtensionsTests.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using SVSim.Database.Enums; +using SVSim.EmulatedEntrypoint.Extensions; + +namespace SVSim.UnitTests.Extensions; + +/// +/// Pins the wire deck_format mapping against the client's Data.FormatConvertApi +/// (Shadowverse_Code/Assembly-CSharp/Wizard/Data.cs:580). Each row in +/// is "internal Format ↔ wire int" and must match the client decompile exactly — see +/// docs/api-spec/common/types.ts.md for the table. +/// +public class FormatExtensionsTests +{ + /// (internal Format, wire deck_format int) + public static IEnumerable<(Format Format, int Wire)> WireMapping => new (Format, int)[] + { + (Format.Rotation, 1), + (Format.Unlimited, 2), + (Format.PreRotation, 3), + (Format.Crossover, 4), + (Format.MyRotation, 5), + (Format.TwoPick, 10), + (Format.Sealed, 20), + (Format.Hof, 31), + (Format.Windfall, 33), + (Format.Avatar, 39), + (Format.All, 0), + }; + + [TestCaseSource(nameof(WireMapping))] + public void ToApi_matches_client_FormatConvertApi((Format Format, int Wire) row) + { + Assert.That(row.Format.ToApi(), Is.EqualTo(row.Wire)); + } + + [TestCaseSource(nameof(WireMapping))] + public void FromApi_inverts_ToApi((Format Format, int Wire) row) + { + Assert.That(FormatExtensions.FromApi(row.Wire), Is.EqualTo(row.Format)); + } + + [Test] + public void ToApi_Max_aliases_onto_Rotation_per_client_FormatConvertApi() + { + // Format.Max is a client-side sentinel ("invalid/unknown"); the client's + // FormatConvertApi sends it as wire 1 (same slot as Rotation). Pinned so we can spot + // the day the client decompile changes this. + Assert.That(Format.Max.ToApi(), Is.EqualTo(1)); + } + + [Test] + public void ToApi_throws_on_unmapped_format() + { + Assert.Throws(() => ((Format)9999).ToApi()); + } + + [TestCase(7)] + [TestCase(99)] + [TestCase(-1)] + public void FromApi_throws_on_unknown_wire_code(int wire) + { + Assert.Throws(() => FormatExtensions.FromApi(wire)); + } + + // ---- JSON converter ---- + + private static JsonSerializerOptions ConverterOptions() + { + var opts = new JsonSerializerOptions(); + opts.Converters.Add(new FormatJsonConverter()); + return opts; + } + + [TestCaseSource(nameof(WireMapping))] + public void JsonConverter_writes_wire_code((Format Format, int Wire) row) + { + string json = JsonSerializer.Serialize(row.Format, ConverterOptions()); + Assert.That(json, Is.EqualTo(row.Wire.ToString())); + } + + [TestCaseSource(nameof(WireMapping))] + public void JsonConverter_reads_wire_code((Format Format, int Wire) row) + { + Format result = JsonSerializer.Deserialize(row.Wire.ToString(), ConverterOptions()); + Assert.That(result, Is.EqualTo(row.Format)); + } + + [Test] + public void JsonConverter_rejects_non_numeric_input() + { + Assert.Throws(() => + JsonSerializer.Deserialize("\"rotation\"", ConverterOptions())); + } +}