using Microsoft.EntityFrameworkCore; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Models.Config; using SVSim.Database.Services; namespace SVSim.Database.Repositories.Viewer; public class ViewerRepository : IViewerRepository { protected readonly SVSimDbContext _dbContext; private readonly IGameConfigService _config; private const int MaxFriends = 20; public ViewerRepository(SVSimDbContext dbContext, IGameConfigService config) { _dbContext = dbContext; _config = config; } public async Task GetViewerBySocialConnection(SocialAccountType accountType, ulong socialId) { // SocialAccountConnection is [Owned]-by-Viewer — can't be queried as a top-level Set. // Look up the Viewer that has a matching owned connection instead. return await _dbContext.Set() .AsNoTracking() .FirstOrDefaultAsync(v => v.SocialAccountConnections.Any(sac => sac.AccountType == accountType && sac.AccountId == socialId)); } public async Task GetViewerWithSocials(long id) { return await _dbContext.Set().AsNoTracking().Include(viewer => viewer.SocialAccountConnections) .FirstOrDefaultAsync(viewer => viewer.Id == id); } /// /// Loads a viewer with every navigation property needed to render the home-screen /// (/load/index). Heavy query — only call from LoadController.Index. /// public async Task GetViewerByShortUdid(long shortUdid) { // AsSplitQuery: each Include() collection runs as a separate SELECT instead of one giant // LEFT JOIN with a cartesian product on the result set. The combined Decks+DeckCard+Cards+ // many-to-many-cosmetics shape was producing hundreds of thousands of duplicate rows after // the import-time default-deck clone landed (24 decks × 40 DeckCards × N cosmetics each), // pushing /load/index to ~17 s/request. Split queries take O(rows) total instead. return await _dbContext.Set() .AsNoTracking() .AsSplitQuery() .Include(v => v.MissionData) .Include(v => v.Info).ThenInclude(i => i.SelectedEmblem) .Include(v => v.Info).ThenInclude(i => i.SelectedDegree) .Include(v => v.Currency) .Include(v => v.Classes).ThenInclude(c => c.Class).ThenInclude(c => c.LeaderSkins) .Include(v => v.Classes).ThenInclude(c => c.LeaderSkin) .Include(v => v.Decks).ThenInclude(d => d.Class) .Include(v => v.Decks).ThenInclude(d => d.Sleeve) .Include(v => v.Decks).ThenInclude(d => d.LeaderSkin) .Include(v => v.Cards).ThenInclude(c => c.Card) .Include(v => v.Items).ThenInclude(i => i.Item) .Include(v => v.Sleeves) .Include(v => v.Emblems) .Include(v => v.Degrees) .Include(v => v.LeaderSkins).ThenInclude(ls => ls.Class) .Include(v => v.MyPageBackgrounds) .FirstOrDefaultAsync(viewer => viewer.ShortUdid == shortUdid); } public async Task RegisterViewer(string displayName, SocialAccountType socialType, ulong socialAccountIdentifier, ulong? shortUdid = null) { var viewer = await BuildDefaultViewer(displayName); viewer.SocialAccountConnections.Add(new SocialAccountConnection { AccountId = socialAccountIdentifier, AccountType = socialType }); _dbContext.Set().Add(viewer); await _dbContext.SaveChangesAsync(); return viewer; } public async Task GetViewerByUdid(Guid udid) { if (udid == Guid.Empty) return null; return await _dbContext.Set() .AsNoTracking() .FirstOrDefaultAsync(v => v.Udid == udid); } public async Task RegisterAnonymousViewer(Guid udid) { if (udid == Guid.Empty) throw new InvalidOperationException("Cannot register viewer for empty UDID."); var viewer = await BuildDefaultViewer("Player"); viewer.Udid = udid; _dbContext.Set().Add(viewer); await _dbContext.SaveChangesAsync(); return viewer; } public async Task LinkSteamToViewer(long viewerId, ulong steamId) { var viewer = await _dbContext.Set() .Include(v => v.SocialAccountConnections) .FirstOrDefaultAsync(v => v.Id == viewerId) ?? throw new InvalidOperationException($"Viewer {viewerId} not found for Steam link."); bool alreadyLinked = viewer.SocialAccountConnections.Any(sac => sac.AccountType == SocialAccountType.Steam && sac.AccountId == steamId); if (alreadyLinked) return; viewer.SocialAccountConnections.Add(new SocialAccountConnection { AccountId = steamId, AccountType = SocialAccountType.Steam }); await _dbContext.SaveChangesAsync(); } private async Task BuildDefaultViewer(string displayName) { Models.Viewer viewer = new Models.Viewer { DisplayName = displayName }; var player = _config.Get(); var grants = _config.Get(); var loadout = _config.Get(); viewer.Info.MaxFriends = player.MaxFriends; viewer.Info.CountryCode = "KOR"; viewer.Info.BirthDate = DateTime.UtcNow; viewer.Currency.Crystals = grants.Crystals; viewer.Currency.Rupees = grants.Rupees; viewer.Currency.RedEther = grants.Ether; viewer.MissionData.TutorialState = 100; // finishes tutorial for now // Load classes WITH their LeaderSkins — DefaultLeaderSkin iterates the nav collection // and would otherwise be null (audit §6 #3 latent NRE — this is the one). List classes = await _dbContext.Set() .Include(c => c.LeaderSkins) .ToListAsync(); viewer.Classes = classes.Select(ce => { var skin = ce.DefaultLeaderSkin ?? ce.LeaderSkins.FirstOrDefault(); return new ViewerClassData { Class = ce, Exp = 0, Level = 0, LeaderSkin = skin ?? new LeaderSkinEntry { Id = 0, Name = "", ClassId = ce.Id } }; }).ToList(); var defaultSleeveId = loadout.SleeveId; var defaultDegreeId = loadout.DegreeId; var defaultEmblemId = loadout.EmblemId; var defaultBgId = loadout.MyPageBackgroundId; var defaultSleeve = await _dbContext.Set().FindAsync(defaultSleeveId); var defaultDegree = await _dbContext.Set().FindAsync(defaultDegreeId); var defaultEmblem = await _dbContext.Set().FindAsync(defaultEmblemId); var defaultBg = await _dbContext.Set().FindAsync(defaultBgId); if (defaultSleeve is not null) viewer.Sleeves.Add(defaultSleeve); if (defaultDegree is not null) viewer.Degrees.Add(defaultDegree); if (defaultEmblem is not null) viewer.Emblems.Add(defaultEmblem); if (defaultBg is not null) viewer.MyPageBackgrounds.Add(defaultBg); // Grant one of each class's default leader skin. Filter out the synthetic placeholders // (Id=0) and dedupe — skins are many-to-many via SleeveEntryViewer-style join. var grantedSkins = viewer.Classes .Select(vcd => vcd.LeaderSkin) .Where(s => s.Id != 0) .DistinctBy(s => s.Id) .ToList(); viewer.LeaderSkins.AddRange(grantedSkins); return viewer; } }