diff --git a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs index dc0e32a..eb63905 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs @@ -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()) + .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 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); - - // 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>(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, - MyRotationId = format == Format.MyRotation ? latestMyRotationId : null, - }); - } - } - } } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs index f096bdd..0ea16b7 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs @@ -30,6 +30,21 @@ public class ImportViewerRequest [JsonPropertyName("owned_cards")] public List? OwnedCards { get; set; } [JsonPropertyName("items")] public List? Items { get; set; } + + [JsonPropertyName("decks")] public List? 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? 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 diff --git a/SVSim.UnitTests/Controllers/AdminControllerTests.cs b/SVSim.UnitTests/Controllers/AdminControllerTests.cs index 6ed3453..87f6b84 100644 --- a/SVSim.UnitTests/Controllers/AdminControllerTests.cs +++ b/SVSim.UnitTests/Controllers/AdminControllerTests.cs @@ -223,4 +223,123 @@ public class AdminControllerTests Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 88888), Is.EqualTo(0), "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(); + 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 + { + new() + { + DeckFormat = 1, // wire Rotation + DeckNo = 1, + DeckName = "Imported Rotation", + ClassId = classId, + SleeveId = sleeveId, + LeaderSkinId = leaderSkinId, + CardIdArray = new List { 10001001L, 10001001L, 99999999L }, // last is unknown + } + } + }); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), + await response.Content.ReadAsStringAsync()); + var body = await response.Content.ReadFromJsonAsync(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(); + 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(); + 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 + { + new() + { + DeckFormat = 5, // wire MyRotation + DeckNo = 1, + DeckName = "Imported MyRot", + ClassId = classId, + SleeveId = sleeveId, + LeaderSkinId = leaderSkinId, + CardIdArray = new List { 10001001L }, + } + } + }); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), + await response.Content.ReadAsStringAsync()); + var body = await response.Content.ReadFromJsonAsync(JsonOptions); + + using var scope2 = factory.Services.CreateScope(); + var db2 = scope2.ServiceProvider.GetRequiredService(); + var deck = await db2.Set() + .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(JsonOptions); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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."); + } }