feat(import): import decks; remove obsolete default-deck cloning
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -7,6 +6,7 @@ using SVSim.Database;
|
|||||||
using SVSim.Database.Enums;
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
using SVSim.Database.Repositories.Viewer;
|
using SVSim.Database.Repositories.Viewer;
|
||||||
|
using SVSim.EmulatedEntrypoint.Extensions;
|
||||||
using SVSim.EmulatedEntrypoint.Infrastructure;
|
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.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
|
if (request.Decks is not null)
|
||||||
// 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);
|
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();
|
await _dbContext.SaveChangesAsync();
|
||||||
@@ -234,77 +287,4 @@ public class AdminController : SVSimController
|
|||||||
/// default_deck_list entry on /deck/info has <c>sleeve_id: 3000011</c>.
|
/// default_deck_list entry on /deck/info has <c>sleeve_id: 3000011</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const long DefaultSleeveId = 3000011L;
|
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ public class ImportViewerRequest
|
|||||||
[JsonPropertyName("owned_cards")] public List<ImportCard>? OwnedCards { get; set; }
|
[JsonPropertyName("owned_cards")] public List<ImportCard>? OwnedCards { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("items")] public List<ImportItem>? Items { get; set; }
|
[JsonPropertyName("items")] public List<ImportItem>? Items { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("decks")] public List<ImportDeck>? Decks { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ImportDeck
|
||||||
|
{
|
||||||
|
[JsonPropertyName("deck_format")] public int DeckFormat { get; set; } // wire code; map via FormatExtensions.FromApi
|
||||||
|
[JsonPropertyName("deck_no")] public int DeckNo { get; set; }
|
||||||
|
[JsonPropertyName("deck_name")] public string? DeckName { get; set; }
|
||||||
|
[JsonPropertyName("class_id")] public int ClassId { get; set; }
|
||||||
|
[JsonPropertyName("card_id_array")] public List<long>? CardIdArray { get; set; }
|
||||||
|
[JsonPropertyName("sleeve_id")] public long? SleeveId { get; set; }
|
||||||
|
[JsonPropertyName("leader_skin_id")] public int? LeaderSkinId { get; set; }
|
||||||
|
[JsonPropertyName("is_random_leader_skin")] public int? IsRandomLeaderSkin { get; set; }
|
||||||
|
[JsonPropertyName("my_rotation_id")] public string? MyRotationId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ImportCurrency
|
public class ImportCurrency
|
||||||
|
|||||||
@@ -223,4 +223,123 @@ public class AdminControllerTests
|
|||||||
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 88888), Is.EqualTo(0),
|
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 88888), Is.EqualTo(0),
|
||||||
"Unknown item master id must not be inserted.");
|
"Unknown item master id must not be inserted.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImportViewer_imports_deck_with_correct_format_and_skips_unknown_cards()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
const ulong steamId = 76_561_198_111_222_337UL;
|
||||||
|
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
||||||
|
|
||||||
|
int classId, leaderSkinId; long sleeveId;
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
classId = (await db.Classes.FirstAsync()).Id;
|
||||||
|
sleeveId = (await db.Sleeves.FirstAsync()).Id;
|
||||||
|
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||||
|
{
|
||||||
|
SteamId = steamId,
|
||||||
|
Decks = new List<ImportDeck>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
DeckFormat = 1, // wire Rotation
|
||||||
|
DeckNo = 1,
|
||||||
|
DeckName = "Imported Rotation",
|
||||||
|
ClassId = classId,
|
||||||
|
SleeveId = sleeveId,
|
||||||
|
LeaderSkinId = leaderSkinId,
|
||||||
|
CardIdArray = new List<long> { 10001001L, 10001001L, 99999999L }, // last is unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||||
|
await response.Content.ReadAsStringAsync());
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||||
|
Assert.That(body!.SkippedCardCount, Is.EqualTo(1), "Unknown deck card 99999999 counts as skipped.");
|
||||||
|
|
||||||
|
using var scope2 = factory.Services.CreateScope();
|
||||||
|
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var stored = await db2.Viewers
|
||||||
|
.Include(v => v.Decks).ThenInclude(d => d.Cards).ThenInclude(c => c.Card)
|
||||||
|
.FirstAsync(v => v.Id == viewerId);
|
||||||
|
var deck = stored.Decks.Single(d => d.Name == "Imported Rotation");
|
||||||
|
Assert.That(deck.Format, Is.EqualTo(Format.Rotation));
|
||||||
|
Assert.That(deck.Cards.Single().Card.Id, Is.EqualTo(10001001L));
|
||||||
|
Assert.That(deck.Cards.Single().Count, Is.EqualTo(2), "Two copies of 10001001 grouped.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImportViewer_myrotation_deck_gets_rotation_id()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync(); // populates MyRotationSettings
|
||||||
|
const ulong steamId = 76_561_198_111_222_338UL;
|
||||||
|
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
||||||
|
|
||||||
|
int classId, leaderSkinId; long sleeveId;
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
classId = (await db.Classes.FirstAsync()).Id;
|
||||||
|
sleeveId = (await db.Sleeves.FirstAsync()).Id;
|
||||||
|
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||||
|
{
|
||||||
|
SteamId = steamId,
|
||||||
|
Decks = new List<ImportDeck>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
DeckFormat = 5, // wire MyRotation
|
||||||
|
DeckNo = 1,
|
||||||
|
DeckName = "Imported MyRot",
|
||||||
|
ClassId = classId,
|
||||||
|
SleeveId = sleeveId,
|
||||||
|
LeaderSkinId = leaderSkinId,
|
||||||
|
CardIdArray = new List<long> { 10001001L },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||||
|
await response.Content.ReadAsStringAsync());
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||||
|
|
||||||
|
using var scope2 = factory.Services.CreateScope();
|
||||||
|
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var deck = await db2.Set<SVSim.Database.Models.ShadowverseDeckEntry>()
|
||||||
|
.FirstAsync(d => d.Name == "Imported MyRot");
|
||||||
|
Assert.That(deck.Format, Is.EqualTo(Format.MyRotation));
|
||||||
|
Assert.That(deck.MyRotationId, Is.Not.Null.And.Not.Empty,
|
||||||
|
"MyRotation decks need a rotation id or the client NREs on click.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImportViewer_fresh_user_has_no_decks_when_none_imported()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||||
|
{
|
||||||
|
SteamId = 76_561_198_111_222_339UL,
|
||||||
|
DisplayName = "No Decks"
|
||||||
|
});
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||||
|
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var stored = await db.Viewers.Include(v => v.Decks).FirstAsync(v => v.Id == body!.ViewerId);
|
||||||
|
Assert.That(stored.Decks, Is.Empty,
|
||||||
|
"Default-deck cloning was removed; a fresh viewer with no imported decks has none.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user