Things were working, suddenly regressed
This commit is contained in:
@@ -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<ActionResult<DeckListResponse>> 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<List<long>>(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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CardSetIdentifier> 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<ShadowverseCardEntry> allCollectibleCards = await _cardRepository.GetAll(true);
|
||||
List<ShadowverseCardEntry> allBasicCards = await _cardRepository.GetAllBasic();
|
||||
List<OwnedCardEntry> ownedCards = viewer.Cards;
|
||||
List<OwnedCardEntry> 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<LeaderSkinEntry> allLeaderSkins = await _collectionRepository.GetLeaderSkins();
|
||||
var classExpCurve = await _globalsRepository.GetClassExpCurve();
|
||||
@@ -98,23 +138,23 @@ public class LoadController : SVSimController
|
||||
prevNecessaryExp = entry.NecessaryExp;
|
||||
}
|
||||
|
||||
List<CardSetIdentifier> 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<CardSetIdentifier> 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<SpecialCrystalInfo>(),
|
||||
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<FeatureMaintenance>(),
|
||||
PreReleaseInfo = null,
|
||||
SpotCards = new Dictionary<string, int>(),
|
||||
ReprintedCards = new List<long>(),
|
||||
UnlimitedBanList = new Dictionary<string, int>(),
|
||||
LoadingTipCardExclusions = new List<long>(),
|
||||
MaintenanceCards = new List<long>(),
|
||||
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<RedEtherOverride>(),
|
||||
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<UserRankedMatches>(),
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <c>arena_info</c> 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 <c>Keys.Contains("arena_info")</c> guard
|
||||
/// (LoadDetail.cs:261) handles cleanly.
|
||||
/// </summary>
|
||||
private async Task<List<ArenaInfo>?> 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<ArenaFormatInfo>(season.FormatInfo, JsonbReadOptions);
|
||||
}
|
||||
|
||||
return new List<ArenaInfo>
|
||||
{
|
||||
new ArenaInfo
|
||||
{
|
||||
Mode = season.Mode,
|
||||
Enable = season.Enable,
|
||||
Cost = season.Cost,
|
||||
RupeeCost = season.RupyCost,
|
||||
TicketCost = season.TicketCost,
|
||||
IsJoin = season.IsJoin,
|
||||
FormatInfo = format,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <c>my_rotation_info</c> from the joined MyRotationSettingEntry + MyRotationAbilityEntry
|
||||
/// tables. Each setting row's ReprintedCardIds / RestrictedCardIds jsonb is parsed back to the
|
||||
/// dict shape the client expects.
|
||||
/// </summary>
|
||||
private async Task<MyRotationInfo?> 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<MyRotationAbility>(a.Data, JsonbReadOptions) ?? new MyRotationAbility()),
|
||||
ReprintedCards = settings.ToDictionary(
|
||||
s => s.Id.ToString(),
|
||||
s => JsonSerializer.Deserialize<Dictionary<string, int>>(s.ReprintedCardIds, JsonbReadOptions) ?? new()),
|
||||
Banlist = settings.ToDictionary(
|
||||
s => s.Id.ToString(),
|
||||
s => JsonSerializer.Deserialize<Dictionary<string, int>>(s.RestrictedCardIds, JsonbReadOptions) ?? new()),
|
||||
DisabledCardSets = new List<int>(), // prod 2026-05-23 emits empty list; refine if/when populated
|
||||
Schedules = BuildMyRotationSchedules(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static SpecialRotationSchedule BuildMyRotationSchedules() => new();
|
||||
|
||||
/// <summary>
|
||||
/// Builds <c>avatar_info</c> from AvatarAbilityEntry rows. Schedules is an empty list per the
|
||||
/// 2026-05-23 prod capture (active Avatar windows would populate it; entry shape TBD).
|
||||
/// </summary>
|
||||
private async Task<AvatarInfo?> 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<AvatarSchedule>(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <c>pre_release_info</c> 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.
|
||||
/// </summary>
|
||||
private async Task<PreReleaseInfoDto?> 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<List<int>>(pri.RotationCardSetIdList, JsonbReadOptions) ?? new(),
|
||||
ReprintedCardIds = JsonSerializer.Deserialize<Dictionary<string, string>>(pri.ReprintedBaseCardIds, JsonbReadOptions) ?? new(),
|
||||
LatestReprintedCardIds = JsonSerializer.Deserialize<List<int>>(pri.LatestReprintedBaseCardIds, JsonbReadOptions) ?? new(),
|
||||
IsPreRotationFreeMatchTerm = pri.IsPreRotationFreeMatchTerm ? 1 : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
131
SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs
Normal file
131
SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs
Normal file
@@ -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<ActionResult<MyPageIndexResponse>> Index(MyPageIndexRequest request)
|
||||
{
|
||||
var shortUdidClaim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ShortUdidClaim)?.Value;
|
||||
if (shortUdidClaim is null || !long.TryParse(shortUdidClaim, out long shortUdid))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
Viewer? viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
|
||||
if (viewer is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var deviceHeader = Request.Headers["DEVICE"].FirstOrDefault();
|
||||
int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0;
|
||||
|
||||
// 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.
|
||||
};
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private async Task<List<ArenaInfo>> BuildArenaInfosAsync()
|
||||
{
|
||||
var season = await _globalsRepository.GetCurrentArenaSeason();
|
||||
if (season is null)
|
||||
{
|
||||
return new List<ArenaInfo>
|
||||
{
|
||||
new ArenaInfo
|
||||
{
|
||||
Mode = 0,
|
||||
Enable = 0,
|
||||
Cost = 0,
|
||||
RupeeCost = 0,
|
||||
TicketCost = 0,
|
||||
IsJoin = false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return new List<ArenaInfo>
|
||||
{
|
||||
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.
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
88
SVSim.EmulatedEntrypoint/Extensions/FormatExtensions.cs
Normal file
88
SVSim.EmulatedEntrypoint/Extensions/FormatExtensions.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges the server's internal <see cref="Format"/> enum (a verbatim copy of the client's
|
||||
/// Wizard.Format) and the wire <c>deck_format</c> 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 <c>Wizard.Data.FormatConvertApi</c>
|
||||
/// (Shadowverse_Code/Assembly-CSharp/Wizard/Data.cs:580); the inverse mirrors
|
||||
/// <c>Data.ParseApiFormat</c> (Data.cs:635). See
|
||||
/// docs/api-spec/common/types.ts.md for the table and rationale.
|
||||
/// </summary>
|
||||
public static class FormatExtensions
|
||||
{
|
||||
/// <summary>Internal <see cref="Format"/> → wire <c>deck_format</c> integer.</summary>
|
||||
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.")
|
||||
};
|
||||
|
||||
/// <summary>Wire <c>deck_format</c> integer → internal <see cref="Format"/>.</summary>
|
||||
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.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// System.Text.Json converter that emits / accepts <see cref="Format"/> as the wire
|
||||
/// <c>deck_format</c> integer rather than the underlying enum value. Wired up in Program.cs
|
||||
/// via AddJsonOptions; applies to every response DTO property typed <see cref="Format"/>.
|
||||
///
|
||||
/// 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 <c>int</c> and call
|
||||
/// <see cref="FormatExtensions.FromApi"/> in the controller.
|
||||
/// </summary>
|
||||
public sealed class FormatJsonConverter : JsonConverter<Format>
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,28 +1,49 @@
|
||||
using MessagePack;
|
||||
using SVSim.Database.Enums;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Current arena season config. Shape derived from 2026-05-23 prod capture
|
||||
/// (<c>arena_info[0].format_info</c>).
|
||||
///
|
||||
/// Wire mixes types: <c>two_pick_type</c> and <c>last_card_pack_set_id</c> are strings
|
||||
/// (PHP-backend stringification), <c>announce_id</c> is an int, and the times use
|
||||
/// space-separated "yyyy-MM-dd HH:mm:ss" rather than ISO. Numeric-typed properties use
|
||||
/// <c>AllowReadingFromString</c> on the controller's JsonSerializerOptions so string-quoted
|
||||
/// ints deserialize cleanly out of the seeded jsonb.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class ArenaFormatInfo
|
||||
{
|
||||
/// <summary>PickTwoFormat as int (0=None,1=Normal,2=Backdraft,3=Cube,4=Chaos,...).</summary>
|
||||
[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; }
|
||||
|
||||
/// <summary>The current card pack set id, e.g. "10029". String on the wire.</summary>
|
||||
[JsonPropertyName("last_card_pack_set_id")]
|
||||
[Key("last_card_pack_set_id")]
|
||||
public string LastCardPackSetId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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; }
|
||||
}
|
||||
public string EndTime { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ public class AvatarInfo
|
||||
[JsonPropertyName("abilities")]
|
||||
[Key("abilities")]
|
||||
public Dictionary<string, AvatarAbility> Abilities { get; set; } = new Dictionary<string, AvatarAbility>();
|
||||
/// <summary>
|
||||
/// 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 <see cref="AvatarSchedule"/>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schedules")]
|
||||
[Key("schedules")]
|
||||
public SpecialRotationSchedule Schedules { get; set; } = new SpecialRotationSchedule();
|
||||
public List<AvatarSchedule> Schedules { get; set; } = new();
|
||||
}
|
||||
13
SVSim.EmulatedEntrypoint/Models/Dtos/AvatarSchedule.cs
Normal file
13
SVSim.EmulatedEntrypoint/Models/Dtos/AvatarSchedule.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[MessagePackObject(keyAsPropertyName: true)]
|
||||
public class AvatarSchedule
|
||||
{
|
||||
}
|
||||
16
SVSim.EmulatedEntrypoint/Models/Dtos/BasicPuzzle.cs
Normal file
16
SVSim.EmulatedEntrypoint/Models/Dtos/BasicPuzzle.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// basic_puzzle.is_display_badge — drives the "practice puzzle" badge on the
|
||||
/// footer. Read by MyPageTask.cs:177.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BasicPuzzle
|
||||
{
|
||||
[JsonPropertyName("is_display_badge")]
|
||||
[Key("is_display_badge")]
|
||||
public bool IsDisplayBadge { get; set; }
|
||||
}
|
||||
43
SVSim.EmulatedEntrypoint/Models/Dtos/ColosseumInfo.cs
Normal file
43
SVSim.EmulatedEntrypoint/Models/Dtos/ColosseumInfo.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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; }
|
||||
|
||||
/// <summary>Wire is "1"/"0" string in prod. Client compares with == "1".</summary>
|
||||
[JsonPropertyName("is_normal_two_pick")]
|
||||
[Key("is_normal_two_pick")]
|
||||
public string IsNormalTwoPick { get; set; } = "0";
|
||||
|
||||
/// <summary>Used as ColorCodeId (stringified int).</summary>
|
||||
[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;
|
||||
}
|
||||
29
SVSim.EmulatedEntrypoint/Models/Dtos/Convention.cs
Normal file
29
SVSim.EmulatedEntrypoint/Models/Dtos/Convention.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class Convention
|
||||
{
|
||||
[JsonPropertyName("is_join_tournament")]
|
||||
[Key("is_join_tournament")]
|
||||
public bool IsJoinTournament { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ISO datetime. Optional — omitted via WhenWritingNull when not set.
|
||||
/// Client null-checks before parsing (MyPageTask.cs:59).
|
||||
/// </summary>
|
||||
[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; }
|
||||
}
|
||||
38
SVSim.EmulatedEntrypoint/Models/Dtos/DefaultDeck.cs
Normal file
38
SVSim.EmulatedEntrypoint/Models/Dtos/DefaultDeck.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// One of the eight "starter" decks (one per class), as surfaced under
|
||||
/// <c>/deck/info data.default_deck_list</c>. 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".
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>40 card_id values — same card may repeat (max 3 per card per Shadowverse rules).</summary>
|
||||
[JsonPropertyName("card_id_array")]
|
||||
[Key("card_id_array")]
|
||||
public List<long> CardIdArray { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Default leader skin per class (8 entries — one per class). Surfaced under
|
||||
/// <c>/deck/info data.user_leader_skin_setting_list</c>. Despite the <c>user_</c> prefix on the
|
||||
/// wire, this is GLOBAL data (same for every viewer) — naming is the client's, not ours.
|
||||
/// </summary>
|
||||
[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; }
|
||||
}
|
||||
31
SVSim.EmulatedEntrypoint/Models/Dtos/GuildNotification.cs
Normal file
31
SVSim.EmulatedEntrypoint/Models/Dtos/GuildNotification.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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; }
|
||||
|
||||
/// <summary>ISO datetime.</summary>
|
||||
[JsonPropertyName("begin_time")]
|
||||
[Key("begin_time")]
|
||||
public string BeginTime { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>ISO datetime. Required — client calls DateTime.Parse on it.</summary>
|
||||
[JsonPropertyName("end_time")]
|
||||
[Key("end_time")]
|
||||
public string EndTime { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -15,8 +15,9 @@ public class MyRotationInfo
|
||||
[JsonPropertyName("setting")]
|
||||
[Key("setting")]
|
||||
public Dictionary<string, SpecialRotationSetting>? Settings { get; set; }
|
||||
[JsonPropertyName("disabled_card_set_ids")]
|
||||
[Key("disabled_card_set_ids")]
|
||||
/// <summary>Prod wire key is <c>disable_card_set_ids</c> (no trailing 'd' on "disable").</summary>
|
||||
[JsonPropertyName("disable_card_set_ids")]
|
||||
[Key("disable_card_set_ids")]
|
||||
public List<int>? DisabledCardSets { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,49 +3,74 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<int> RotationCardSets { get; set; } = new List<int>();
|
||||
public List<int> RotationCardSets { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reprinted_base_card_ids")]
|
||||
[Key("reprinted_base_card_ids")]
|
||||
public Dictionary<string, long> ReprintedCardIds { get; set; } = new Dictionary<string, long>();
|
||||
public Dictionary<string, string> ReprintedCardIds { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("latest_reprinted_base_card_ids")]
|
||||
[Key("latest_reprinted_base_card_ids")]
|
||||
public List<int> LatestReprintedCardIds { get; set; } = new List<int>();
|
||||
public List<int> 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; }
|
||||
}
|
||||
}
|
||||
|
||||
32
SVSim.EmulatedEntrypoint/Models/Dtos/Quest.cs
Normal file
32
SVSim.EmulatedEntrypoint/Models/Dtos/Quest.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// "quest" block on /mypage/index. Consumed by QuestOpenInfo.SetOpenInfo.
|
||||
/// Empty/closed-quest shape captured from prod 2026-05-23.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,14 +4,36 @@ using System.Text.Json.Serialization;
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>DeckGroupListData(jsonData, format)</c>. Spec at
|
||||
/// <c>docs/api-spec/endpoints/post-login/deck-info.md</c> only enumerates <c>maintenance_card_list</c>
|
||||
/// and <c>user_deck_list</c> explicitly (with <c>[k: string]: unknown</c> for the rest); the 2026-05-23
|
||||
/// prod capture filled in the gap — <c>default_deck_list</c>, <c>user_leader_skin_setting_list</c>,
|
||||
/// and <c>trial_deck_list</c> are all present and sourced from globals.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class DeckListResponse
|
||||
{
|
||||
[JsonPropertyName("maintenance_card_list")]
|
||||
[Key("maintenance_card_list")] public List<long> MaintenanceCardList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("user_deck_list")]
|
||||
[Key("user_deck_list")] public List<UserDeck>? UserDeckList { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Global starter decks, keyed by deck_no as string (prod ids 91-98 — one per class).
|
||||
/// </summary>
|
||||
[JsonPropertyName("default_deck_list")]
|
||||
[Key("default_deck_list")] public Dictionary<string, DefaultDeck> DefaultDeckList { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Default leader skin per class, keyed by class_id as string.
|
||||
/// </summary>
|
||||
[JsonPropertyName("user_leader_skin_setting_list")]
|
||||
[Key("user_leader_skin_setting_list")] public Dictionary<string, DefaultLeaderSkinSetting> UserLeaderSkinSettingList { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Trial / tutorial-specific decks. Empty in the 2026-05-23 prod capture; entry shape TBD.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trial_deck_list")]
|
||||
[Key("trial_deck_list")] public List<UserDeck> TrialDeckList { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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> UserRankedMatches { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<SpecialCrystalInfo> SpecialCrystalInfos { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Spec: optional, Record<string, BattlePassLevelInfo> keyed by level-as-string
|
||||
/// (load-index.md:228). Omit (null) when no Battle Pass is active.
|
||||
/// </summary>
|
||||
[JsonPropertyName("battle_pass_level_info")]
|
||||
[Key("battle_pass_level_info")]
|
||||
public Dictionary<string, BattlePassLevel>? BattlePassLevelInfo { get; set; }
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
using MessagePack;
|
||||
using SVSim.Database.Enums;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// /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.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class MyPageIndexResponse
|
||||
{
|
||||
// ── User identity / counts ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Full UserInfo block. Client only reads .name here (MyPageTask.cs:39) but
|
||||
/// prod emits the full structure, so we do too.
|
||||
/// </summary>
|
||||
[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; }
|
||||
|
||||
/// <summary>ISO datetime. Parse is wrapped in try/catch on the client.</summary>
|
||||
[JsonPropertyName("last_announce_update_time")]
|
||||
[Key("last_announce_update_time")]
|
||||
public string LastAnnounceUpdateTime { get; set; } = string.Empty;
|
||||
|
||||
// ── Maintenance ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Same shape as /load/index. Empty list in the 2026-05-23 capture.</summary>
|
||||
[JsonPropertyName("feature_maintenance_list")]
|
||||
[Key("feature_maintenance_list")]
|
||||
public List<FeatureMaintenance> FeatureMaintenanceList { get; set; } = new();
|
||||
|
||||
// ── Arena / Colosseum ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[JsonPropertyName("arena_info")]
|
||||
[Key("arena_info")]
|
||||
public List<ArenaInfo> 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; }
|
||||
|
||||
/// <summary>
|
||||
/// Required — ColosseumEntryInfoTask.SetColosseumInfo indexes this key
|
||||
/// directly (Wizard/ColosseumEntryInfoTask.cs:102) and reads
|
||||
/// is_colosseum_period without a guard.
|
||||
/// </summary>
|
||||
[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();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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 ───────────────────────────────────────────
|
||||
|
||||
/// <summary>Number cast to Prerelease.eStatus on the client.</summary>
|
||||
[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 ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parsed by Data.ParseIsBattlePassPeriod. Same field as on /load/index
|
||||
/// (prod emits bool there too).
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_battle_pass_period")]
|
||||
[Key("is_battle_pass_period")]
|
||||
public bool IsBattlePassPeriod { get; set; }
|
||||
|
||||
// ── Special crystal info ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sibling under data, same shape as /load/index. Empty in the prod capture.
|
||||
/// </summary>
|
||||
[JsonPropertyName("special_crystal_info")]
|
||||
[Key("special_crystal_info")]
|
||||
public List<SpecialCrystalInfo> SpecialCrystalInfo { get; set; } = new();
|
||||
|
||||
// ── Notification setters that index root-of-data directly ──────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[JsonPropertyName("shop_notification")]
|
||||
[Key("shop_notification")]
|
||||
public ShopNotification ShopNotification { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Required — StoryNotification.SetStoryNotification indexes this key
|
||||
/// directly (Wizard/StoryNotification.cs:22) before applying GetValueOrDefault
|
||||
/// to its sub-fields.
|
||||
/// </summary>
|
||||
[JsonPropertyName("story_notification")]
|
||||
[Key("story_notification")]
|
||||
public StoryNotification StoryNotification { get; set; } = new();
|
||||
|
||||
// ── Optional UI surface area ───────────────────────────────────────────
|
||||
|
||||
/// <summary>Updated item counts. Refreshes Data.Load.data._userItemDict when present.</summary>
|
||||
[JsonPropertyName("user_item_list")]
|
||||
[Key("user_item_list")]
|
||||
public List<UserItem>? UserItemList { get; set; }
|
||||
|
||||
[JsonPropertyName("gathering_info")]
|
||||
[Key("gathering_info")]
|
||||
public GatheringInfo? GatheringInfo { get; set; }
|
||||
}
|
||||
51
SVSim.EmulatedEntrypoint/Models/Dtos/ShopNotification.cs
Normal file
51
SVSim.EmulatedEntrypoint/Models/Dtos/ShopNotification.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class ShopNotification
|
||||
{
|
||||
[JsonPropertyName("card_pack")]
|
||||
[Key("card_pack")]
|
||||
public ShopCardPackAppeal CardPack { get; set; } = new();
|
||||
|
||||
/// <summary>Prod 2026-05-23: <c>[]</c>. Client treats Count==0 as "no notification".</summary>
|
||||
[JsonPropertyName("build_deck")]
|
||||
[Key("build_deck")]
|
||||
public List<object> BuildDeck { get; set; } = new();
|
||||
|
||||
/// <summary>Prod 2026-05-23: <c>[]</c>.</summary>
|
||||
[JsonPropertyName("sleeve")]
|
||||
[Key("sleeve")]
|
||||
public List<object> Sleeve { get; set; } = new();
|
||||
|
||||
/// <summary>Prod 2026-05-23: <c>[]</c>.</summary>
|
||||
[JsonPropertyName("leader_skin")]
|
||||
[Key("leader_skin")]
|
||||
public List<object> LeaderSkin { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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; }
|
||||
}
|
||||
21
SVSim.EmulatedEntrypoint/Models/Dtos/StoryNotification.cs
Normal file
21
SVSim.EmulatedEntrypoint/Models/Dtos/StoryNotification.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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; }
|
||||
}
|
||||
37
SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPageInfo.cs
Normal file
37
SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPageInfo.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// user_mypage_info — wrapper around the active home-screen background
|
||||
/// configuration. Client constructs MyPageBGInfo(user_mypage_setting) at
|
||||
/// MyPageTask.cs:176.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class UserMyPageInfo
|
||||
{
|
||||
[JsonPropertyName("user_mypage_setting")]
|
||||
[Key("user_mypage_setting")]
|
||||
public MyPageBgSetting UserMyPageSetting { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Active mypage background selection. Shape from prod 2026-05-23.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class MyPageBgSetting
|
||||
{
|
||||
[JsonPropertyName("mypage_id")]
|
||||
[Key("mypage_id")]
|
||||
public int MyPageId { get; set; }
|
||||
|
||||
/// <summary>0 = single selection (mypage_id), 1+ = random rotation across mypage_id_list.</summary>
|
||||
[JsonPropertyName("select_type")]
|
||||
[Key("select_type")]
|
||||
public int SelectType { get; set; }
|
||||
|
||||
[JsonPropertyName("mypage_id_list")]
|
||||
[Key("mypage_id_list")]
|
||||
public List<int> MyPageIdList { get; set; } = new();
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
/// </summary>
|
||||
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));
|
||||
|
||||
@@ -18,9 +18,14 @@ public class LoadControllerTests
|
||||
|
||||
/// <summary>
|
||||
/// Wire keys (from <c>[Key("...")]</c> / mirrored <c>[JsonPropertyName]</c>) for fields the
|
||||
/// client reads unconditionally in <c>LoadDetail.ConvertJsonData</c>. These are the names
|
||||
/// the decompiled client actually looks up — NOT <c>SnakeCaseLower(C# property name)</c>.
|
||||
/// Missing any of these crashes the client with <c>KeyNotFoundException</c> on /load/index.
|
||||
/// client reads UNCONDITIONALLY in <c>LoadDetail.ConvertJsonData</c> (no <c>Keys.Contains</c>
|
||||
/// or <c>TryGetValue</c> 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: <c>arena_info</c>, <c>daily_login_bonus</c>,
|
||||
/// <c>battle_pass_level_info</c>, <c>pre_release_info</c>, <c>my_rotation_info</c>,
|
||||
/// <c>avatar_info</c>, <c>item_expire_date</c> are all optional per
|
||||
/// <c>docs/api-spec/endpoints/post-login/load-index.md</c>.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
95
SVSim.UnitTests/Extensions/FormatExtensionsTests.cs
Normal file
95
SVSim.UnitTests/Extensions/FormatExtensionsTests.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System.Text.Json;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
|
||||
namespace SVSim.UnitTests.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the wire deck_format mapping against the client's <c>Data.FormatConvertApi</c>
|
||||
/// (Shadowverse_Code/Assembly-CSharp/Wizard/Data.cs:580). Each row in <see cref="WireMapping"/>
|
||||
/// is "internal Format ↔ wire int" and must match the client decompile exactly — see
|
||||
/// docs/api-spec/common/types.ts.md for the table.
|
||||
/// </summary>
|
||||
public class FormatExtensionsTests
|
||||
{
|
||||
/// <summary>(internal Format, wire deck_format int)</summary>
|
||||
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<ArgumentOutOfRangeException>(() => ((Format)9999).ToApi());
|
||||
}
|
||||
|
||||
[TestCase(7)]
|
||||
[TestCase(99)]
|
||||
[TestCase(-1)]
|
||||
public void FromApi_throws_on_unknown_wire_code(int wire)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => 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<Format>(row.Wire.ToString(), ConverterOptions());
|
||||
Assert.That(result, Is.EqualTo(row.Format));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void JsonConverter_rejects_non_numeric_input()
|
||||
{
|
||||
Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<Format>("\"rotation\"", ConverterOptions()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user