using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Viewer; using SVSim.EmulatedEntrypoint.Infrastructure; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin; namespace SVSim.EmulatedEntrypoint.Controllers; /// /// Util endpoints for bootstrapping the dev environment. Anonymous-allowed today — security /// audit pending (don't expose these to the public internet). /// public class AdminController : SVSimController { private readonly IViewerRepository _viewerRepository; private readonly SVSimDbContext _dbContext; public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext) { _viewerRepository = viewerRepository; _dbContext = dbContext; } /// /// Upsert a viewer from external data (typically captured from the live game via the /// SVSimLoader dump). Matches existing viewers by SteamId; creates a new one if missing. /// Only essential fields are imported today — extend as needed. /// [AllowAnonymous] [HttpPost("import_viewer")] public async Task> ImportViewer(ImportViewerRequest request) { if (request.SteamId == 0) { return BadRequest("steam_id is required"); } // SocialAccountConnection is [Owned]-by-Viewer — can't query the owned table directly; // look up the Viewer with a matching owned connection instead. var existing = await _dbContext.Viewers .AsNoTracking() .FirstOrDefaultAsync(v => v.SocialAccountConnections.Any(sac => sac.AccountType == SocialAccountType.Steam && sac.AccountId == request.SteamId)); long viewerId; bool wasCreated; if (existing is null) { var created = await _viewerRepository.RegisterViewer( request.DisplayName ?? "Imported Viewer", SocialAccountType.Steam, request.SteamId); viewerId = created.Id; wasCreated = true; } else { viewerId = existing.Id; wasCreated = false; } // Reload with all the nav properties we need to mutate. RegisterViewer SaveChanges'd // already, so we re-fetch with full graph and apply the updates. AsSplitQuery to avoid // the cartesian-explosion across all the many-to-many cosmetic collections. var viewer = await _dbContext.Viewers .AsSplitQuery() .Include(v => v.Info).ThenInclude(i => i.SelectedEmblem) .Include(v => v.Info).ThenInclude(i => i.SelectedDegree) .Include(v => v.Currency) .Include(v => v.MissionData) .Include(v => v.Classes).ThenInclude(c => c.Class) .Include(v => v.Sleeves) .Include(v => v.Emblems) .Include(v => v.Degrees) .Include(v => v.LeaderSkins) .Include(v => v.MyPageBackgrounds) .FirstAsync(v => v.Id == viewerId); if (request.DisplayName is not null) viewer.DisplayName = request.DisplayName; if (request.CountryCode is not null) viewer.Info.CountryCode = request.CountryCode; if (request.TutorialState.HasValue) viewer.MissionData.TutorialState = request.TutorialState.Value; if (request.Currency is not null) { if (request.Currency.Crystals.HasValue) viewer.Currency.Crystals = request.Currency.Crystals.Value; if (request.Currency.Rupees.HasValue) viewer.Currency.Rupees = request.Currency.Rupees.Value; if (request.Currency.RedEther.HasValue) viewer.Currency.RedEther = request.Currency.RedEther.Value; } if (request.SelectedEmblemId.HasValue) { var emblem = await _dbContext.Emblems.FindAsync(request.SelectedEmblemId.Value); if (emblem is not null) viewer.Info.SelectedEmblem = emblem; } if (request.SelectedDegreeId.HasValue) { var degree = await _dbContext.Degrees.FindAsync(request.SelectedDegreeId.Value); if (degree is not null) viewer.Info.SelectedDegree = degree; } await ReplaceOwned(viewer.Sleeves, request.OwnedSleeveIds, _dbContext.Sleeves); await ReplaceOwned(viewer.Emblems, request.OwnedEmblemIds, _dbContext.Emblems); await ReplaceOwned(viewer.Degrees, request.OwnedDegreeIds, _dbContext.Degrees); await ReplaceOwned(viewer.LeaderSkins, request.OwnedLeaderSkinIds, _dbContext.LeaderSkins); await ReplaceOwned(viewer.MyPageBackgrounds, request.OwnedMyPageBackgroundIds, _dbContext.MyPageBackgrounds); if (request.Classes is not null) { foreach (var importClass in request.Classes) { var existingClass = viewer.Classes.FirstOrDefault(c => c.Class.Id == importClass.ClassId); if (existingClass is not null) { existingClass.Level = importClass.Level; existingClass.Exp = importClass.Exp; } } } // Clone the 8 starter decks into the viewer when freshly created — workaround for a // client-side NRE in the deck-edit menu (DeckListUI.IsVisibleCreateNewButton at // decompile Wizard/DeckListUI.cs:316 unconditionally reads `_deckGroup.DeckFormat`, but // _deckGroup is null when GetCustomDeckGroup() finds no matching CustomDeck group in // DeckGroupDataBase — which is exactly what happens for a fresh viewer). Prod players // acquire decks via tutorial; we shortcut by seeding the 8 defaults at import time. // See docs/audits/deck-edit-empty-decklist-nre-2026-05-23.md for the full background. if (wasCreated) { await CloneDefaultDecksToViewerAsync(viewer); } await _dbContext.SaveChangesAsync(); return new ImportViewerResponse { ViewerId = viewer.Id, ShortUdid = viewer.ShortUdid, WasCreated = wasCreated }; } /// /// Replaces the owned-collection with the master rows matching the supplied ids. /// Null `ids` is a no-op (preserve existing). Empty list clears the collection. /// private async Task ReplaceOwned(List owned, List? ids, DbSet table) where TEntity : class { if (ids is null) return; owned.Clear(); if (ids.Count == 0) return; var rows = await table.Where(e => ids.Contains(EF.Property(e, "Id"))).ToListAsync(); owned.AddRange(rows); } /// /// Default sleeve id used for cloned starter decks. Matches prod's wire shape — every /// default_deck_list entry on /deck/info has sleeve_id: 3000011. /// private const long DefaultSleeveId = 3000011L; /// /// Formats we clone the starter decks into. Each format the player can open the deck-edit /// menu for needs at least one CustomDeck group in Data.DeckGroupDataBase, otherwise /// the client NREs on _deckGroup.DeckFormat in DeckListUI.IsVisibleCreateNewButton. /// Rotation / Unlimited / MyRotation are the always-active base formats; PreRotation / /// Crossover / Avatar are seasonal and gated by UI state — leave them empty for now (see /// docs/audits/deck-edit-empty-decklist-nre-2026-05-23.md follow-ups). /// private static readonly Format[] SeededDeckFormats = { Format.Rotation, Format.Unlimited, Format.MyRotation }; /// /// Materialize the 8 default decks into the viewer's deck collection, once per seeded format. /// The tracked instance gets new ShadowverseDeckEntry rows added to /// its Decks navigation; EF picks them up on the caller's SaveChangesAsync. /// private async Task CloneDefaultDecksToViewerAsync(Viewer viewer) { var defaultDecks = await _dbContext.DefaultDecks.AsNoTracking().OrderBy(d => d.Id).ToListAsync(); if (defaultDecks.Count == 0) return; // Resolve nav-property entities once. Classes need LeaderSkins included for the // DefaultLeaderSkin nav lookup. Cards are fetched in one bulk query keyed by id. var classes = await _dbContext.Classes.Include(c => c.LeaderSkins).ToDictionaryAsync(c => c.Id); var defaultSleeve = await _dbContext.Sleeves.FindAsync((int)DefaultSleeveId); var allCardIds = defaultDecks .SelectMany(d => JsonSerializer.Deserialize>(d.CardIdArray, JsonbReadOptions.Instance) ?? new List()) .Distinct() .ToList(); var cards = await _dbContext.Cards.Where(c => allCardIds.Contains(c.Id)).ToDictionaryAsync(c => c.Id); // Seeded MyRotation placeholder decks need a real rotation_id, otherwise the client's // DeckData.GetMyRotationClassName NREs on `info.LastPackText` when the user clicks one // (info is null because Data.MyRotationAllInfo.Get(null) returns null). Pick the highest // rotation id available — it includes the most recent pack and therefore covers every // class (including class_id=8 Nemesis, which requires last_pack >= 10007). var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking() .Select(s => (int?)s.Id) .OrderByDescending(id => id) .FirstOrDefaultAsync())?.ToString(); foreach (var format in SeededDeckFormats) { int slot = 1; foreach (var d in defaultDecks) { if (!classes.TryGetValue(d.ClassId, out var classEntry)) continue; var leaderSkin = classEntry.DefaultLeaderSkin ?? classEntry.LeaderSkins.FirstOrDefault(); if (leaderSkin is null || defaultSleeve is null) continue; var cardIdArray = JsonSerializer.Deserialize>(d.CardIdArray, JsonbReadOptions.Instance) ?? new List(); var deckCards = cardIdArray .GroupBy(id => id) .Where(g => cards.ContainsKey(g.Key)) .Select(g => new DeckCard { Card = cards[g.Key], Count = g.Count() }) .ToList(); viewer.Decks.Add(new ShadowverseDeckEntry { Name = d.DeckName, Number = slot++, Format = format, Class = classEntry, Sleeve = defaultSleeve, LeaderSkin = leaderSkin, RandomLeaderSkin = false, Cards = deckCards, MyRotationId = format == Format.MyRotation ? latestMyRotationId : null, }); } } } }