Deck fixes
This commit is contained in:
@@ -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<ShadowverseDeckEntry> 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)
|
||||
|
||||
@@ -39,8 +39,14 @@ public class ViewerRepository : IViewerRepository
|
||||
/// </summary>
|
||||
public async Task<Models.Viewer?> 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<Models.Viewer>()
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(v => v.MissionData)
|
||||
.Include(v => v.Info).ThenInclude(i => i.SelectedEmblem)
|
||||
.Include(v => v.Info).ThenInclude(i => i.SelectedDegree)
|
||||
|
||||
@@ -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<int>(e, "Id"))).ToListAsync();
|
||||
owned.AddRange(rows);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default sleeve id used for cloned starter decks. Matches prod's wire shape — every
|
||||
/// default_deck_list entry on /deck/info has <c>sleeve_id: 3000011</c>.
|
||||
/// </summary>
|
||||
private const long DefaultSleeveId = 3000011L;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Data.DeckGroupDataBase</c>, otherwise
|
||||
/// the client NREs on <c>_deckGroup.DeckFormat</c> 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).
|
||||
/// </summary>
|
||||
private static readonly Format[] SeededDeckFormats = { Format.Rotation, Format.Unlimited, Format.MyRotation };
|
||||
|
||||
/// <summary>
|
||||
/// Materialize the 8 default decks into the viewer's deck collection, once per seeded format.
|
||||
/// The tracked <paramref name="viewer"/> instance gets new ShadowverseDeckEntry rows added to
|
||||
/// its Decks navigation; EF picks them up on the caller's SaveChangesAsync.
|
||||
/// </summary>
|
||||
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<List<long>>(d.CardIdArray, JsonbReadOptions.Instance) ?? new List<long>())
|
||||
.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<List<long>>(d.CardIdArray, JsonbReadOptions.Instance) ?? new List<long>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -52,8 +52,11 @@ public class DeckListResponse
|
||||
[Key("user_leader_skin_setting_list")] public Dictionary<string, DefaultLeaderSkinSetting> UserLeaderSkinSettingList { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Trial / tutorial-specific decks. Empty in the 2026-05-23 prod capture; entry shape TBD.
|
||||
/// Trial / tutorial-specific decks. Prod emits this on <c>/deck/info</c> (All format) but
|
||||
/// OMITS the key entirely on <c>/deck/my_list</c> (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.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trial_deck_list")]
|
||||
[Key("trial_deck_list")] public List<UserDeck> TrialDeckList { get; set; } = new();
|
||||
[Key("trial_deck_list")] public List<UserDeck>? TrialDeckList { get; set; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user