Things were working, suddenly regressed
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user