413 lines
20 KiB
C#
413 lines
20 KiB
C#
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using SVSim.Database;
|
|
using SVSim.Database.Enums;
|
|
using SVSim.Database.Models;
|
|
using SVSim.Database.Models.Config;
|
|
using PreReleaseInfoEntity = SVSim.Database.Models.PreReleaseInfo;
|
|
using PreReleaseInfoDto = SVSim.EmulatedEntrypoint.Models.Dtos.PreReleaseInfo;
|
|
using SVSim.Database.Repositories.Globals;
|
|
using SVSim.Database.Repositories.Viewer;
|
|
using SVSim.Database.Services;
|
|
using SVSim.Database.Services.Inventory;
|
|
using SVSim.EmulatedEntrypoint.Constants;
|
|
using SVSim.EmulatedEntrypoint.Infrastructure;
|
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
|
using SVSim.EmulatedEntrypoint.Services;
|
|
|
|
namespace SVSim.EmulatedEntrypoint.Controllers;
|
|
|
|
public class LoadController : SVSimController
|
|
{
|
|
// Per-format rank entries the wire expects (5 entries, in deck_format discriminator order).
|
|
// Hard-coded until viewer rank-state is persisted (see audit §6 #1).
|
|
private static readonly Format[] RankFormats =
|
|
{
|
|
Format.Rotation, Format.Unlimited, Format.MyRotation, Format.Avatar, Format.Crossover
|
|
};
|
|
|
|
// 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
|
|
// CardImporter and per-domain seed importers 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 },
|
|
new CardSetIdentifier { SetId = 10005 },
|
|
new CardSetIdentifier { SetId = 10010 }
|
|
};
|
|
|
|
private readonly IViewerRepository _viewerRepository;
|
|
private readonly IGlobalsRepository _globalsRepository;
|
|
private readonly IGameConfigService _config;
|
|
private readonly IBattlePassService _battlePass;
|
|
private readonly IViewerMissionStateService _missionState;
|
|
private readonly SVSimDbContext _db;
|
|
private readonly IInventoryService _inv;
|
|
|
|
public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
|
|
IGameConfigService config,
|
|
IBattlePassService battlePass, IViewerMissionStateService missionState,
|
|
SVSimDbContext db, IInventoryService inv)
|
|
{
|
|
_viewerRepository = viewerRepository;
|
|
_globalsRepository = globalsRepository;
|
|
_config = config;
|
|
_battlePass = battlePass;
|
|
_missionState = missionState;
|
|
_db = db;
|
|
_inv = inv;
|
|
}
|
|
|
|
[HttpPost("index")]
|
|
public async Task<ActionResult<IndexResponse>> Index(IndexRequest request, CancellationToken ct)
|
|
{
|
|
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();
|
|
}
|
|
|
|
// Backfill any card-associated cosmetics the viewer should already own. Idempotent.
|
|
// We MUST re-fetch the viewer after this call because GetViewerByShortUdid uses
|
|
// .AsNoTracking() — the local `viewer` instance is detached, and the service's writes
|
|
// (on a separate tracked instance) won't appear on this snapshot. Without the re-fetch,
|
|
// the response payload would be one /load/index behind on newly-granted cosmetics.
|
|
await using var tx = await _inv.BeginAsync(viewer.Id, ct);
|
|
await tx.BackfillCardCosmeticsAsync(ct);
|
|
await tx.CommitAsync(ct);
|
|
|
|
// Lazy-materialize mission/achievement state. Idempotent — safe to call every /load/index.
|
|
await _missionState.EnsureCurrentAsync(viewer.Id);
|
|
await _db.SaveChangesAsync();
|
|
|
|
viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
|
|
if (viewer is null)
|
|
{
|
|
return NotFound(); // defensive — should never happen
|
|
}
|
|
|
|
// 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.
|
|
// Owned-card projection (incl. the freeplay "all cards" path) lives in the entitlements
|
|
// service so both modes share one definition.
|
|
var allCardsAsOwned = await _inv.EffectiveOwnedCardsAsync(viewer, ct);
|
|
|
|
var cosmetics = await _inv.EffectiveCosmeticsAsync(viewer, ct);
|
|
var classExpCurve = await _globalsRepository.GetClassExpCurve();
|
|
|
|
List<ClassExp> classExps = new();
|
|
int accumulateExp = 0;
|
|
int? prevNecessaryExp = null;
|
|
foreach (var entry in classExpCurve)
|
|
{
|
|
accumulateExp += entry.NecessaryExp;
|
|
classExps.Add(new ClassExp
|
|
{
|
|
Level = entry.Id,
|
|
NecessaryExp = entry.NecessaryExp,
|
|
DiffExp = prevNecessaryExp.HasValue ? entry.NecessaryExp - prevNecessaryExp.Value : entry.NecessaryExp,
|
|
AccumulateExp = accumulateExp
|
|
});
|
|
prevNecessaryExp = entry.NecessaryExp;
|
|
}
|
|
|
|
// Globals — one cached fetch per slice. The Rotation/Challenge/DefaultLoadout sections
|
|
// come via IGameConfigService (DB → appsettings → ShippedDefaults). Other repo methods
|
|
// come from the per-domain seed importers in SVSim.Bootstrap.
|
|
var rotation = _config.Get<RotationConfig>();
|
|
var challenge = _config.Get<ChallengeConfig>();
|
|
var defaultLoadout = _config.Get<DefaultLoadoutConfig>();
|
|
|
|
List<CardSetIdentifier> rotationSets = (await _globalsRepository.GetRotationCardSets())
|
|
.OrderBy(s => s.Id)
|
|
.Select(set => new CardSetIdentifier { SetId = set.Id })
|
|
.ToList();
|
|
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 },
|
|
UserInfo = new UserInfo(deviceType, viewer),
|
|
UserCurrency = new UserCurrency(viewer)
|
|
{
|
|
Crystals = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Crystal),
|
|
TotalCrystals = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Crystal),
|
|
Rupees = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Rupee),
|
|
RedEther = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.RedEther),
|
|
},
|
|
UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
|
|
SpotPoint = checked((int)viewer.Currency.SpotPoints),
|
|
UserRotationDecks = new UserFormatDeckInfo
|
|
{
|
|
UserDecks = viewer.Decks.Where(d => d.Format == Format.Rotation)
|
|
.Select(d => new UserDeck(d)).ToList()
|
|
},
|
|
UserUnlimitedDecks = new UserFormatDeckInfo
|
|
{
|
|
UserDecks = viewer.Decks.Where(d => d.Format == Format.Unlimited)
|
|
.Select(d => new UserDeck(d)).ToList()
|
|
},
|
|
UserMyRotationDecks = new UserFormatDeckInfo
|
|
{
|
|
UserDecks = viewer.Decks.Where(d => d.Format == Format.MyRotation)
|
|
.Select(d => new UserDeck(d)).ToList()
|
|
},
|
|
UserCards = allCardsAsOwned.Select(card => new UserCard(card)).ToList(),
|
|
UserClasses = viewer.Classes.Select(vc => new UserClass(vc)).ToList(),
|
|
Sleeves = cosmetics.SleeveIds.Select(id => new SleeveIdentifier { SleeveId = id }).ToList(),
|
|
UserEmblems = cosmetics.EmblemIds.Select(id => new EmblemIdentifier { EmblemId = id }).ToList(),
|
|
UserDegrees = cosmetics.DegreeIds.Select(id => new DegreeIdentifier { DegreeId = id }).ToList(),
|
|
LeaderSkins = cosmetics.AllLeaderSkins
|
|
.Select(skin => new UserLeaderSkin(skin, cosmetics.OwnedLeaderSkinIds.Contains(skin.Id)))
|
|
.ToList(),
|
|
MyPageBackgrounds = cosmetics.MyPageBackgroundIds.Select(id => id.ToString()).ToList(),
|
|
LootBoxRegulations = new LootBoxRegulations(),
|
|
GatheringInfo = new GatheringInfo(),
|
|
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
|
|
BattlePassLevelInfo = await _battlePass.GetLevelCurveAsync(ct),
|
|
SpecialCrystalInfos = new List<SpecialCrystalInfo>(),
|
|
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 = 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>(),
|
|
// 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 = f,
|
|
Rank = 1,
|
|
BattlePoints = 0,
|
|
WinStreak = 0,
|
|
IsPromotion = 0,
|
|
IsMasterRank = 0,
|
|
IsGrandMasterRank = 0,
|
|
MasterPoints = 0
|
|
}).ToList(),
|
|
ArenaConfig = new ArenaConfig
|
|
{
|
|
UseChallengePickTwoPremiumCard = challenge.UseTwoPickPremiumCard ? 1 : 0,
|
|
ChallengePickTwoCardSleeve = (int)challenge.TwoPickSleeveId,
|
|
},
|
|
ArenaInfos = await BuildArenaInfosAsync(viewer.Id),
|
|
RotationSets = rotationSets,
|
|
UserConfig = new UserConfig(),
|
|
OpenBattlefieldIds = (await _globalsRepository.GetBattlefields(true))
|
|
.Select(bf => bf.Id.ToString()).ToList(),
|
|
DefaultSettings = new DefaultSettings(defaultLoadout),
|
|
ClassExp = classExps,
|
|
RankInfo = (await _globalsRepository.GetRankInfo()).Select(ri => new RankInfo(ri)).ToList(),
|
|
DeckFormat = Format.Rotation,
|
|
CardSetIdForResourceDlView = rotation.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(long viewerId)
|
|
{
|
|
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.Instance);
|
|
}
|
|
|
|
// is_join must reflect the viewer's actual TK2 state — true if they have an
|
|
// active ViewerArenaTwoPickRun row. The client uses this to decide between the
|
|
// "Pay to enter" and "Resume run" dialogs (Wizard/ChallengeEntry.cs:165 + ArenaEntryBase).
|
|
// Without a per-viewer override here, every cold start after a partial run shows
|
|
// "Pay to enter" — losing the in-progress draft from the player's perspective.
|
|
bool hasActiveRun = await _db.ViewerArenaTwoPickRuns
|
|
.AsNoTracking()
|
|
.AnyAsync(r => r.ViewerId == viewerId);
|
|
|
|
return new List<ArenaInfo>
|
|
{
|
|
new ArenaInfo
|
|
{
|
|
Mode = season.Mode,
|
|
Enable = season.Enable,
|
|
Cost = season.Cost,
|
|
RupeeCost = season.RupyCost,
|
|
TicketCost = season.TicketCost,
|
|
IsJoin = hasActiveRun,
|
|
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.Instance) ?? new MyRotationAbility()),
|
|
ReprintedCards = settings.ToDictionary(
|
|
s => s.Id.ToString(),
|
|
s => JsonSerializer.Deserialize<Dictionary<string, int>>(s.ReprintedCardIds, JsonbReadOptions.Instance) ?? new()),
|
|
Banlist = settings.ToDictionary(
|
|
s => s.Id.ToString(),
|
|
s => JsonSerializer.Deserialize<Dictionary<string, int>>(s.RestrictedCardIds, JsonbReadOptions.Instance) ?? new()),
|
|
DisabledCardSets = new List<int>(), // prod 2026-05-23 emits empty list; refine if/when populated
|
|
Schedules = BuildMyRotationSchedules(),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps the <c>MyRotationSchedule</c> config section to the wire-shape <c>SpecialRotationSchedule</c>.
|
|
/// The client gates the Custom Rotation format-selector button on <c>FreeBattle</c>'s window
|
|
/// being currently open (Wizard/MyRotationAllInfo.cs:45), so a default-initialised
|
|
/// <c>DateTime.MinValue</c> pair here hides the button. Config defaults reproduce the
|
|
/// 2026-05-23 prod capture; the rotation-config seed file overwrites from newer captures.
|
|
/// </summary>
|
|
private SpecialRotationSchedule BuildMyRotationSchedules()
|
|
{
|
|
var cfg = _config.Get<MyRotationScheduleConfig>();
|
|
return new SpecialRotationSchedule
|
|
{
|
|
Gathering = new DateRange { BeginTime = cfg.Gathering.Begin, EndTime = cfg.Gathering.End },
|
|
FreeBattle = new DateRange { BeginTime = cfg.FreeBattle.Begin, EndTime = cfg.FreeBattle.End },
|
|
};
|
|
}
|
|
|
|
/// <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.Instance) ?? new(),
|
|
ReprintedCardIds = JsonSerializer.Deserialize<Dictionary<string, string>>(pri.ReprintedBaseCardIds, JsonbReadOptions.Instance) ?? new(),
|
|
LatestReprintedCardIds = JsonSerializer.Deserialize<List<int>>(pri.LatestReprintedBaseCardIds, JsonbReadOptions.Instance) ?? new(),
|
|
IsPreRotationFreeMatchTerm = pri.IsPreRotationFreeMatchTerm ? 1 : 0,
|
|
};
|
|
}
|
|
}
|