using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SVSim.Database; using SVSim.Database.Entities.Story; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Viewer; using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Infrastructure; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin; using SVSim.EmulatedEntrypoint.Services; 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; private readonly ILogger _logger; private readonly IGameCalendarService _calendar; public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext, ILogger logger, IGameCalendarService calendar) { _viewerRepository = viewerRepository; _dbContext = dbContext; _logger = logger; _calendar = calendar; } /// /// 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.Missions) .Include(v => v.Achievements) .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) .Include(v => v.Cards).ThenInclude(c => c.Card) .Include(v => v.Items).ThenInclude(i => i.Item) .Include(v => v.Decks).ThenInclude(d => d.Cards) .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; } } } // Accumulates distinct card_ids referenced by the import (owned list + deck lists) // that aren't in our card master. Surfaced in the response and logged after save. var skippedCardIds = new HashSet(); if (request.OwnedCards is not null) { var wanted = request.OwnedCards .GroupBy(c => c.CardId) .Select(g => g.First()) .ToList(); var ids = wanted.Select(c => c.CardId).ToList(); var cardMaster = await _dbContext.Cards .Where(c => ids.Contains(c.Id)) .ToDictionaryAsync(c => c.Id); viewer.Cards.Clear(); foreach (var c in wanted) { if (!cardMaster.TryGetValue(c.CardId, out var card)) { skippedCardIds.Add(c.CardId); continue; } viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = Math.Clamp(c.Count, 1, OwnedCardEntry.MaxCopies), IsProtected = c.IsProtected, }); } } if (request.Items is not null) { var wanted = request.Items .GroupBy(i => i.ItemId) .Select(g => g.First()) .ToList(); var ids = wanted.Select(i => i.ItemId).ToList(); var itemMaster = await _dbContext.Items .Where(i => ids.Contains(i.Id)) .ToDictionaryAsync(i => i.Id); viewer.Items.Clear(); foreach (var i in wanted) { if (!itemMaster.TryGetValue(i.ItemId, out var item)) continue; // unknown master id viewer.Items.Add(new OwnedItemEntry { Item = item, Count = i.Count, Viewer = viewer }); } } if (request.Decks is not null) { var allDeckCardIds = request.Decks .Where(d => d.CardIdArray is not null) .SelectMany(d => d.CardIdArray!) .Distinct() .ToList(); var deckCardMaster = await _dbContext.Cards .Where(c => allDeckCardIds.Contains(c.Id)) .ToDictionaryAsync(c => c.Id); var classes = await _dbContext.Classes.Include(c => c.LeaderSkins).ToDictionaryAsync(c => c.Id); var sleeves = await _dbContext.Sleeves.ToDictionaryAsync(s => (long)s.Id); var leaderSkins = await _dbContext.LeaderSkins.ToDictionaryAsync(s => s.Id); var defaultSleeve = await _dbContext.Sleeves.FindAsync((int)DefaultSleeveId); var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking() .Select(s => (int?)s.Id) .OrderByDescending(id => id) .FirstOrDefaultAsync())?.ToString(); _dbContext.RemoveRange(viewer.Decks); viewer.Decks.Clear(); foreach (var d in request.Decks) { // A /load/index dump carries every deck slot, most of them empty placeholders // (no cards). Skip them: the client manages empty slots itself (it's why the old // default-deck cloning was removed), and importing empty MyRotation slots would // otherwise persist decks with a bogus rotation id. if ((d.CardIdArray?.Count ?? 0) == 0) continue; Format format; try { format = FormatExtensions.FromApi(d.DeckFormat); } catch (ArgumentOutOfRangeException) { continue; } // skip unsupported wire format if (!classes.TryGetValue(d.ClassId, out var classEntry)) continue; SleeveEntry? sleeve = null; if (d.SleeveId.HasValue) sleeves.TryGetValue(d.SleeveId.Value, out sleeve); sleeve ??= defaultSleeve; LeaderSkinEntry? leaderSkin = null; if (d.LeaderSkinId.HasValue) leaderSkins.TryGetValue(d.LeaderSkinId.Value, out leaderSkin); leaderSkin ??= classEntry.DefaultLeaderSkin ?? classEntry.LeaderSkins.FirstOrDefault(); if (sleeve is null || leaderSkin is null) continue; var cards = (d.CardIdArray ?? new List()) .GroupBy(id => id) .Where(g => { if (deckCardMaster.ContainsKey(g.Key)) return true; skippedCardIds.Add(g.Key); return false; }) .Select(g => new DeckCard { Card = deckCardMaster[g.Key], Count = g.Count() }) .ToList(); viewer.Decks.Add(new ShadowverseDeckEntry { Name = d.DeckName ?? $"Deck {d.DeckNo}", Number = d.DeckNo, Format = format, Class = classEntry, Sleeve = sleeve, LeaderSkin = leaderSkin, RandomLeaderSkin = (d.IsRandomLeaderSkin ?? 0) != 0, Cards = cards, MyRotationId = format == Format.MyRotation ? (d.MyRotationId ?? latestMyRotationId) : null, }); } } // Pass A: MissionMeta scalars if (request.MissionMeta is { } meta) { viewer.MissionData ??= new ViewerMissionData(); if (meta.HasReceivedPickTwoMission is { } hrptm) viewer.MissionData.HasReceivedPickTwoMission = hrptm; if (meta.MissionReceiveType is { } mrt) viewer.MissionData.MissionReceiveType = mrt; if (meta.MissionChangeTime is { } mct) viewer.MissionData.MissionChangeTime = DateTimeOffset.FromUnixTimeSeconds(mct).UtcDateTime; } // Pass B: Missions + ViewerEventCounter var skippedMissionIds = new HashSet(); var skippedMissionCounterIds = new HashSet(); if (request.Missions is { } missions) { // Snapshot the OLD mission catalog ids BEFORE clearing, then load their catalog // entries so we can compute which (EventKey, Period) counter keys are going away. var oldMissionCatalogIds = viewer.Missions.Select(m => m.MissionCatalogId).Distinct().ToList(); var nowUtcForOld = DateTimeOffset.UtcNow; var oldCatalogs = await _dbContext.MissionCatalog .Where(c => oldMissionCatalogIds.Contains(c.Id)) .ToListAsync(); var oldMissionCounterKeys = oldCatalogs .Select(c => ResolveMissionCounter(c, nowUtcForOld)) .Where(r => r is not null) .Select(r => r!.Value) .ToHashSet(); viewer.Missions.Clear(); var missionIds = missions.Select(m => m.MissionId).Distinct().ToList(); var catalogs = await _dbContext.MissionCatalog .Where(c => missionIds.Contains(c.Id)) .ToDictionaryAsync(c => c.Id); var nowUtc = DateTimeOffset.UtcNow; // Resolve counter keys for all missions up-front so we can load existing counters // in one query and avoid duplicate-Add races when two missions share the same // (EventKey, Period) within the same import batch. var resolvedCounterKeys = new Dictionary(); foreach (var m in missions) { if (!catalogs.TryGetValue(m.MissionId, out var cat)) continue; var r = ResolveMissionCounter(cat, nowUtc); if (r is not null) resolvedCounterKeys[m.MissionId] = r.Value; } // Delete orphan ViewerEventCounter rows: keys that existed for OLD missions but are // NOT referenced by any mission in the new payload. var newMissionCounterKeys = resolvedCounterKeys.Values.ToHashSet(); var orphanMissionKeys = oldMissionCounterKeys .Where(k => !newMissionCounterKeys.Contains(k)) .ToList(); if (orphanMissionKeys.Count > 0) { var orphanEventKeys = orphanMissionKeys.Select(k => k.EventKey).Distinct().ToList(); var orphanPeriods = orphanMissionKeys.Select(k => k.Period).Distinct().ToList(); var orphanCounters = await _dbContext.ViewerEventCounters .Where(c => c.ViewerId == viewer.Id && orphanEventKeys.Contains(c.EventKey) && orphanPeriods.Contains(c.Period)) .ToListAsync(); // Filter in-memory to the exact (EventKey, Period) pairs (the DB query widens slightly). var toDelete = orphanCounters .Where(c => orphanMissionKeys.Contains((c.EventKey, c.Period))) .ToList(); _dbContext.ViewerEventCounters.RemoveRange(toDelete); } // Collect all distinct (EventKey, Period) pairs we will touch. var allEventKeys = resolvedCounterKeys.Values.Select(v => v.EventKey).Distinct().ToList(); var allPeriods = resolvedCounterKeys.Values.Select(v => v.Period).Distinct().ToList(); // Load existing counters in one query (widens slightly; filter in memory below — fine for small N). var existingCounters = await _dbContext.ViewerEventCounters .Where(c => c.ViewerId == viewer.Id && allEventKeys.Contains(c.EventKey) && allPeriods.Contains(c.Period)) .ToListAsync(); var counterCache = existingCounters .ToDictionary(c => (c.EventKey, c.Period)); foreach (var m in missions) { if (!catalogs.TryGetValue(m.MissionId, out var cat)) { skippedMissionIds.Add(m.MissionId); continue; } viewer.Missions.Add(new ViewerMission { MissionCatalogId = cat.Id, Slot = m.Slot ?? (cat.LotType == 6 ? 0 : 1), MissionStatus = m.MissionStatus, AssignedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), ClaimedAt = null }); if (!resolvedCounterKeys.TryGetValue(m.MissionId, out var resolved)) { skippedMissionCounterIds.Add(m.MissionId); continue; } var (eventKey, period) = resolved; if (!counterCache.TryGetValue((eventKey, period), out var counter)) { counter = new ViewerEventCounter { ViewerId = viewer.Id, EventKey = eventKey, Period = period, }; _dbContext.ViewerEventCounters.Add(counter); counterCache[(eventKey, period)] = counter; } counter.Count = m.TotalCount; } } // Pass C: Achievements + ViewerEventCounter var skippedAchievementTypes = new HashSet(); var skippedAchievementCounterTypes = new HashSet(); if (request.Achievements is { } achievements) { // Snapshot the OLD achievement types BEFORE clearing, then load their catalog entries // so we can compute which (EventKey, Period) counter keys are going away. var oldAchievementTypes = viewer.Achievements.Select(a => a.AchievementType).Distinct().ToList(); var oldAchCatalogs = await _dbContext.AchievementCatalog .Where(c => oldAchievementTypes.Contains(c.AchievementType)) .GroupBy(c => c.AchievementType) .ToDictionaryAsync(g => g.Key, g => g.First()); var oldAchievementCounterKeys = oldAchCatalogs.Values .Select(c => ResolveAchievementCounter(c)) .Where(r => r is not null) .Select(r => r!.Value) .ToHashSet(); viewer.Achievements.Clear(); var types = achievements.Select(a => a.AchievementType).Distinct().ToList(); var catalogs = await _dbContext.AchievementCatalog .Where(c => types.Contains(c.AchievementType)) .GroupBy(c => c.AchievementType) .ToDictionaryAsync(g => g.Key, g => g.First()); // Resolve counter keys for all achievements up-front so we can load existing counters // in one query and avoid duplicate-Add races when two achievements share the same // EventType (same (EventKey, AllTime) cache key) within the same import batch. var resolvedAchievementCounterKeys = new Dictionary(); foreach (var a in achievements) { if (!catalogs.TryGetValue(a.AchievementType, out var cat)) continue; var r = ResolveAchievementCounter(cat); if (r is not null) resolvedAchievementCounterKeys[a.AchievementType] = r.Value; } // Delete orphan ViewerEventCounter rows: keys that existed for OLD achievements but // are NOT referenced by any achievement in the new payload. var newAchievementCounterKeys = resolvedAchievementCounterKeys.Values.ToHashSet(); var orphanAchKeys = oldAchievementCounterKeys .Where(k => !newAchievementCounterKeys.Contains(k)) .ToList(); if (orphanAchKeys.Count > 0) { var orphanAchEventKeys = orphanAchKeys.Select(k => k.EventKey).Distinct().ToList(); var orphanAchPeriods = orphanAchKeys.Select(k => k.Period).Distinct().ToList(); var orphanAchCounters = await _dbContext.ViewerEventCounters .Where(c => c.ViewerId == viewer.Id && orphanAchEventKeys.Contains(c.EventKey) && orphanAchPeriods.Contains(c.Period)) .ToListAsync(); // Filter in-memory to the exact (EventKey, Period) pairs. var toDeleteAch = orphanAchCounters .Where(c => orphanAchKeys.Contains((c.EventKey, c.Period))) .ToList(); _dbContext.ViewerEventCounters.RemoveRange(toDeleteAch); } // Collect all distinct (EventKey, Period) pairs we will touch. var allAchEventKeys = resolvedAchievementCounterKeys.Values.Select(v => v.EventKey).Distinct().ToList(); var allAchPeriods = resolvedAchievementCounterKeys.Values.Select(v => v.Period).Distinct().ToList(); // Load existing counters in one query. var existingAchCounters = await _dbContext.ViewerEventCounters .Where(c => c.ViewerId == viewer.Id && allAchEventKeys.Contains(c.EventKey) && allAchPeriods.Contains(c.Period)) .ToListAsync(); var achievementCounterCache = existingAchCounters .ToDictionary(c => (c.EventKey, c.Period)); foreach (var a in achievements) { if (!catalogs.TryGetValue(a.AchievementType, out var cat)) { skippedAchievementTypes.Add(a.AchievementType); continue; } viewer.Achievements.Add(new ViewerAchievement { ViewerId = viewer.Id, AchievementType = a.AchievementType, Level = a.Level, NowAchievedLevel = a.NowAchievedLevel, ResultAnnounceSawLevel = a.ResultAnnounceSawLevel, AchievementStatus = 0 }); if (!resolvedAchievementCounterKeys.TryGetValue(a.AchievementType, out var resolved)) { skippedAchievementCounterTypes.Add(a.AchievementType); continue; } var (eventKey, period) = resolved; if (!achievementCounterCache.TryGetValue((eventKey, period), out var counter)) { counter = new ViewerEventCounter { ViewerId = viewer.Id, EventKey = eventKey, Period = period, }; _dbContext.ViewerEventCounters.Add(counter); achievementCounterCache[(eventKey, period)] = counter; } counter.Count = a.TotalCount; } } // Pass D: StoryProgress with per-family offsets var skippedStoryIds = new HashSet(); if (request.StoryProgress is { } storyRows) { var existingStory = await _dbContext.ViewerStoryProgress .Where(p => p.ViewerId == viewer.Id).ToListAsync(); _dbContext.ViewerStoryProgress.RemoveRange(existingStory); // Compute effective ids and bulk-load referenced catalog rows. var effectiveIds = new List(storyRows.Count); var rowEffective = new List<(ImportStoryProgress row, int effective)>(storyRows.Count); foreach (var r in storyRows) { long offset = r.StoryApiType switch { 1 => 0L, 2 => 10_000_000L, 3 => 20_000_000L, _ => -1L }; if (offset < 0) { skippedStoryIds.Add(r.StoryId); continue; } int wireId = r.SubChapterId ?? r.StoryId; int effective = checked((int)(wireId + offset)); effectiveIds.Add(effective); rowEffective.Add((r, effective)); } var present = (await _dbContext.StoryWorlds .Where(s => effectiveIds.Contains(s.Id)) .Select(s => s.Id).ToListAsync()).ToHashSet(); var nowUtc = DateTime.UtcNow; foreach (var (r, eff) in rowEffective) { if (!present.Contains(eff)) { skippedStoryIds.Add(eff); continue; } _dbContext.ViewerStoryProgress.Add(new ViewerStoryProgress { ViewerId = viewer.Id, StoryId = eff, IsFinish = r.IsFinish, IsSkipped = r.IsSkipped, FinishedAt = r.IsFinish ? nowUtc : null, SkippedAt = r.IsSkipped ? nowUtc : null }); } } await _dbContext.SaveChangesAsync(); if (skippedCardIds.Count > 0) { _logger.LogWarning( "ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown " + "card_id(s) not present in the card master. Sample: [{Sample}]", request.SteamId, viewer.Id, skippedCardIds.Count, string.Join(", ", skippedCardIds.Take(20))); } if (skippedMissionIds.Count > 0) { _logger.LogWarning( "ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown mission_id(s). Sample: [{Sample}]", request.SteamId, viewer.Id, skippedMissionIds.Count, string.Join(", ", skippedMissionIds.Take(10))); } if (skippedMissionCounterIds.Count > 0) { _logger.LogWarning( "ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): {Count} mission(s) had unresolvable EventKey, counter not written. Sample: [{Sample}]", request.SteamId, viewer.Id, skippedMissionCounterIds.Count, string.Join(", ", skippedMissionCounterIds.Take(10))); } if (skippedAchievementTypes.Count > 0) { _logger.LogWarning( "ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown achievement_type(s). Sample: [{Sample}]", request.SteamId, viewer.Id, skippedAchievementTypes.Count, string.Join(", ", skippedAchievementTypes.Take(10))); } if (skippedAchievementCounterTypes.Count > 0) { _logger.LogWarning( "ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): {Count} achievement(s) had unresolvable EventKey, counter not written. Sample: [{Sample}]", request.SteamId, viewer.Id, skippedAchievementCounterTypes.Count, string.Join(", ", skippedAchievementCounterTypes.Take(10))); } if (skippedStoryIds.Count > 0) { _logger.LogWarning( "ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown story_id(s). Sample: [{Sample}]", request.SteamId, viewer.Id, skippedStoryIds.Count, string.Join(", ", skippedStoryIds.Take(10))); } return new ImportViewerResponse { ViewerId = viewer.Id, ShortUdid = viewer.ShortUdid, WasCreated = wasCreated, SkippedCardCount = skippedCardIds.Count, SkippedMissionCount = skippedMissionIds.Count, SkippedMissionCounterCount = skippedMissionCounterIds.Count, SkippedAchievementCount = skippedAchievementTypes.Count, SkippedAchievementCounterCount = skippedAchievementCounterTypes.Count, SkippedStoryCount = skippedStoryIds.Count, }; } /// /// 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); } // TODO: unify with MissionAssembler.cs — same logic duplicated here. private (string EventKey, string Period)? ResolveMissionCounter(MissionCatalogEntry catalog, DateTimeOffset nowUtc) { if (string.IsNullOrEmpty(catalog.EventType)) return null; var period = catalog.LotType switch { 6 => _calendar.DayKey(nowUtc), 2 => _calendar.WeekKey(nowUtc), _ => null }; if (period is null) return null; return (catalog.EventType!, period); } // TODO: unify with MissionAssembler.cs — same logic duplicated here. private static (string EventKey, string Period)? ResolveAchievementCounter(AchievementCatalogEntry catalog) { if (string.IsNullOrEmpty(catalog.EventType)) return null; return (catalog.EventType!, GameCalendarPeriods.AllTime); } /// /// Fallback sleeve id used when an imported deck has no resolvable sleeve_id. /// 3000011 is prod's default deck sleeve. /// private const long DefaultSleeveId = 3000011L; }