From 71b3c3e19f076bf6570f1634fc7291e999909300 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 18:22:44 -0400 Subject: [PATCH] feat(import): import owned card collection with unknown-card skip Extends POST /admin/import_viewer to accept owned_cards (snapshot full-replace). Unknown card_ids are skipped, counted in skipped_card_count on the response, and logged server-side. Count is clamped to OwnedCardEntry.MaxCopies (3). Also injects ILogger into AdminController and adds Cards/Items/Decks to the viewer reload graph. Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/AdminController.cs | 53 +++++++++++- .../Requests/Admin/ImportViewerRequest.cs | 9 +++ .../Responses/Admin/ImportViewerResponse.cs | 1 + .../Controllers/AdminControllerTests.cs | 80 +++++++++++++++++++ 4 files changed, 141 insertions(+), 2 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs index 5cc6fb2..ae17d63 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; @@ -20,11 +21,14 @@ public class AdminController : SVSimController { private readonly IViewerRepository _viewerRepository; private readonly SVSimDbContext _dbContext; + private readonly ILogger _logger; - public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext) + public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext, + ILogger logger) { _viewerRepository = viewerRepository; _dbContext = dbContext; + _logger = logger; } /// @@ -81,6 +85,9 @@ public class AdminController : SVSimController .Include(v => v.Degrees) .Include(v => v.LeaderSkins) .Include(v => v.MyPageBackgrounds) + .Include(v => v.Cards).ThenInclude(c => c.Card) + .Include(v => v.Items).ThenInclude(i => i.Item) + .Include(v => v.Decks).ThenInclude(d => d.Cards) .FirstAsync(v => v.Id == viewerId); if (request.DisplayName is not null) viewer.DisplayName = request.DisplayName; @@ -124,6 +131,38 @@ public class AdminController : SVSimController } } + // Accumulates distinct card_ids referenced by the import (owned list + deck lists) + // that aren't in our card master. Surfaced in the response and logged after save. + var skippedCardIds = new HashSet(); + + if (request.OwnedCards is not null) + { + var wanted = request.OwnedCards + .GroupBy(c => c.CardId) + .Select(g => g.First()) + .ToList(); + var ids = wanted.Select(c => c.CardId).ToList(); + var cardMaster = await _dbContext.Cards + .Where(c => ids.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id); + + viewer.Cards.Clear(); + foreach (var c in wanted) + { + if (!cardMaster.TryGetValue(c.CardId, out var card)) + { + skippedCardIds.Add(c.CardId); + continue; + } + viewer.Cards.Add(new OwnedCardEntry + { + Card = card, + Count = Math.Clamp(c.Count, 1, OwnedCardEntry.MaxCopies), + IsProtected = c.IsProtected, + }); + } + } + // 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 @@ -138,11 +177,21 @@ public class AdminController : SVSimController await _dbContext.SaveChangesAsync(); + if (skippedCardIds.Count > 0) + { + _logger.LogWarning( + "ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown " + + "card_id(s) not present in the card master. Sample: [{Sample}]", + request.SteamId, viewer.Id, skippedCardIds.Count, + string.Join(", ", skippedCardIds.Take(20))); + } + return new ImportViewerResponse { ViewerId = viewer.Id, ShortUdid = viewer.ShortUdid, - WasCreated = wasCreated + WasCreated = wasCreated, + SkippedCardCount = skippedCardIds.Count, }; } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs index 8dabf19..e909a42 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs @@ -26,6 +26,8 @@ public class ImportViewerRequest [JsonPropertyName("owned_mypage_background_ids")] public List? OwnedMyPageBackgroundIds { get; set; } [JsonPropertyName("classes")] public List? Classes { get; set; } + + [JsonPropertyName("owned_cards")] public List? OwnedCards { get; set; } } public class ImportCurrency @@ -41,3 +43,10 @@ public class ImportClassData [JsonPropertyName("level")] public int Level { get; set; } [JsonPropertyName("exp")] public int Exp { get; set; } } + +public class ImportCard +{ + [JsonPropertyName("card_id")] public long CardId { get; set; } + [JsonPropertyName("count")] public int Count { get; set; } + [JsonPropertyName("is_protected")] public bool IsProtected { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Admin/ImportViewerResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Admin/ImportViewerResponse.cs index 9210932..c3f754e 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Admin/ImportViewerResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Admin/ImportViewerResponse.cs @@ -7,4 +7,5 @@ public class ImportViewerResponse [JsonPropertyName("viewer_id")] public long ViewerId { get; set; } [JsonPropertyName("short_udid")] public long ShortUdid { get; set; } [JsonPropertyName("was_created")] public bool WasCreated { get; set; } + [JsonPropertyName("skipped_card_count")] public int SkippedCardCount { get; set; } } diff --git a/SVSim.UnitTests/Controllers/AdminControllerTests.cs b/SVSim.UnitTests/Controllers/AdminControllerTests.cs index 0bf0942..93bd57e 100644 --- a/SVSim.UnitTests/Controllers/AdminControllerTests.cs +++ b/SVSim.UnitTests/Controllers/AdminControllerTests.cs @@ -115,4 +115,84 @@ public class AdminControllerTests Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); } + + [Test] + public async Task ImportViewer_imports_owned_cards_and_skips_unknown() + { + using var factory = new SVSimTestFactory(); + using var client = factory.CreateClient(); + + // 10001001 is in the minimal test card set; 99999999 is not. + var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = 76_561_198_111_222_333UL, + OwnedCards = new List + { + new() { CardId = 10001001L, Count = 2, IsProtected = true }, + new() { CardId = 99999999L, Count = 1, IsProtected = false }, + } + }); + + 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 99999999 must be skipped and counted."); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card) + .FirstAsync(v => v.Id == body.ViewerId); + + Assert.That(stored.Cards.Count, Is.EqualTo(1), "Only the known card should be stored."); + var owned = stored.Cards.Single(); + Assert.That(owned.Card.Id, Is.EqualTo(10001001L)); + Assert.That(owned.Count, Is.EqualTo(2)); + Assert.That(owned.IsProtected, Is.True); + } + + [Test] + public async Task ImportViewer_clamps_card_count_to_max_copies() + { + 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_334UL, + OwnedCards = new List { new() { CardId = 10001002L, Count = 5 } } + }); + 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.Cards).ThenInclude(c => c.Card) + .FirstAsync(v => v.Id == body!.ViewerId); + Assert.That(stored.Cards.Single().Count, Is.EqualTo(3), + "Count must clamp to OwnedCardEntry.MaxCopies (3)."); + } + + [Test] + public async Task ImportViewer_replaces_existing_card_collection() + { + using var factory = new SVSimTestFactory(); + const ulong steamId = 76_561_198_111_222_335UL; + long viewerId = await factory.SeedViewerAsync(steamId: steamId); + await factory.SeedOwnedCardAsync(viewerId, 10001001L, count: 3); + + using var client = factory.CreateClient(); + var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = steamId, + OwnedCards = new List { new() { CardId = 10001002L, Count = 1 } } + }); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), + await response.Content.ReadAsStringAsync()); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card) + .FirstAsync(v => v.Id == viewerId); + Assert.That(stored.Cards.Select(c => c.Card.Id), Is.EquivalentTo(new[] { 10001002L }), + "Full replace: the pre-seeded 10001001 must be gone, only 10001002 present."); + } }