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) { Models.Viewer viewer = new Models.Viewer { DisplayName = displayName }; var player = _config.Get(); var grants = _config.Get(); var loadout = _config.Get(); viewer.SocialAccountConnections.Add(new SocialAccountConnection { AccountId = socialAccountIdentifier, AccountType = socialType }); 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); _dbContext.Set().Add(viewer); await _dbContext.SaveChangesAsync(); return viewer; } }