feat(import): import decks; remove obsolete default-deck cloning

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-29 18:42:07 -04:00
parent d7e5557d61
commit 4965851238
3 changed files with 197 additions and 83 deletions

View File

@@ -1,4 +1,3 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -7,6 +6,7 @@ using SVSim.Database;
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;
@@ -182,16 +182,69 @@ 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)
if (request.Decks is not null)
{
await CloneDefaultDecksToViewerAsync(viewer);
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)
{
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<long>())
.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,
});
}
}
await _dbContext.SaveChangesAsync();
@@ -234,77 +287,4 @@ public class AdminController : SVSimController
/// 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);
// Seeded MyRotation placeholder decks need a real rotation_id, otherwise the client's
// DeckData.GetMyRotationClassName NREs on `info.LastPackText` when the user clicks one
// (info is null because Data.MyRotationAllInfo.Get(null) returns null). Pick the highest
// rotation id available — it includes the most recent pack and therefore covers every
// class (including class_id=8 Nemesis, which requires last_pack >= 10007).
var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking()
.Select(s => (int?)s.Id)
.OrderByDescending(id => id)
.FirstOrDefaultAsync())?.ToString();
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,
MyRotationId = format == Format.MyRotation ? latestMyRotationId : null,
});
}
}
}
}