diff --git a/SVSim.Database/Repositories/Deck/DeckRepository.cs b/SVSim.Database/Repositories/Deck/DeckRepository.cs index 7f76863..d47eb7c 100644 --- a/SVSim.Database/Repositories/Deck/DeckRepository.cs +++ b/SVSim.Database/Repositories/Deck/DeckRepository.cs @@ -19,6 +19,7 @@ public class DeckRepository : IDeckRepository { var viewer = await _dbContext.Viewers .AsNoTracking() + .AsSplitQuery() .Include(v => v.Decks).ThenInclude(d => d.Class) .Include(v => v.Decks).ThenInclude(d => d.Sleeve) .Include(v => v.Decks).ThenInclude(d => d.LeaderSkin) @@ -33,6 +34,7 @@ public class DeckRepository : IDeckRepository var requested = formats.ToHashSet(); var viewer = await _dbContext.Viewers .AsNoTracking() + .AsSplitQuery() .Include(v => v.Decks).ThenInclude(d => d.Class) .Include(v => v.Decks).ThenInclude(d => d.Sleeve) .Include(v => v.Decks).ThenInclude(d => d.LeaderSkin) @@ -53,6 +55,7 @@ public class DeckRepository : IDeckRepository { var viewer = await _dbContext.Viewers .AsNoTracking() + .AsSplitQuery() .Include(v => v.Decks).ThenInclude(d => d.Class) .Include(v => v.Decks).ThenInclude(d => d.Sleeve) .Include(v => v.Decks).ThenInclude(d => d.LeaderSkin) @@ -75,6 +78,7 @@ public class DeckRepository : IDeckRepository Action mutate) { var viewer = await _dbContext.Viewers + .AsSplitQuery() .Include(v => v.Decks).ThenInclude(d => d.Class) .Include(v => v.Decks).ThenInclude(d => d.Sleeve) .Include(v => v.Decks).ThenInclude(d => d.LeaderSkin) diff --git a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs index 5c5f669..b2d18e3 100644 --- a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs +++ b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs @@ -39,8 +39,14 @@ public class ViewerRepository : IViewerRepository /// 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) diff --git a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs index 03449d5..653fed0 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -5,6 +6,7 @@ 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; @@ -65,8 +67,10 @@ public class AdminController : SVSimController } // 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. + // 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) @@ -120,6 +124,18 @@ public class AdminController : SVSimController } } + // 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 @@ -144,4 +160,72 @@ public class AdminController : SVSimController 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); + + 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, + }); + } + } + } } diff --git a/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs b/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs index d39f36c..5f2691f 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs @@ -96,9 +96,6 @@ public class DeckController : SVSimController IsRandomLeaderSkin = s.IsRandomLeaderSkin, LeaderSkinId = s.LeaderSkinId, }), - // trial_deck_list: empty in 2026-05-23 prod; populated during tutorial campaigns — - // entry shape TBD until a capture lands with active content. - TrialDeckList = new(), MaintenanceCardList = new(), // sourced from same place as /load/index when wired }; @@ -112,6 +109,9 @@ public class DeckController : SVSimController response.UserDeckRotation = byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList(); response.UserDeckUnlimited = byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList(); response.UserDeckMyRotation = byFormat[Format.MyRotation].Select(d => new UserDeck(d)).ToList(); + // trial_deck_list is prod-emitted on /deck/info (All format) but omitted on /deck/my_list + // (specific format). Empty array in the 2026-05-23 prod capture. + response.TrialDeckList = new(); } else { diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Deck/DeckListResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Deck/DeckListResponse.cs index 5b8f163..d1c92e4 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Deck/DeckListResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Deck/DeckListResponse.cs @@ -52,8 +52,11 @@ public class DeckListResponse [Key("user_leader_skin_setting_list")] public Dictionary UserLeaderSkinSettingList { get; set; } = new(); /// - /// Trial / tutorial-specific decks. Empty in the 2026-05-23 prod capture; entry shape TBD. + /// Trial / tutorial-specific decks. Prod emits this on /deck/info (All format) but + /// OMITS the key entirely on /deck/my_list (specific-format) — controller mirrors that + /// asymmetry by leaving this null on specific-format responses. Empty array in the + /// 2026-05-23 prod capture; entry shape TBD. /// [JsonPropertyName("trial_deck_list")] - [Key("trial_deck_list")] public List TrialDeckList { get; set; } = new(); + [Key("trial_deck_list")] public List? TrialDeckList { get; set; } }