From 12fb2f4801c96d504c2492d508ce2d48c2745816 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 24 May 2026 14:42:44 -0400 Subject: [PATCH] Card liquefication --- .../Card/CardInventoryRepository.cs | 88 ++++++ .../Card/ICardInventoryRepository.cs | 44 +++ .../Controllers/CardController.cs | 130 ++++++++ .../Dtos/Requests/Card/CardDestructRequest.cs | 18 ++ .../Responses/Card/CardDestructResponse.cs | 19 ++ SVSim.EmulatedEntrypoint/Program.cs | 1 + .../Controllers/CardControllerTests.cs | 195 ++++++++++++ .../Infrastructure/SVSimTestFactory.cs | 83 +++++ .../CardInventoryRepositoryTests.cs | 284 ++++++++++++++++++ 9 files changed, 862 insertions(+) create mode 100644 SVSim.Database/Repositories/Card/CardInventoryRepository.cs create mode 100644 SVSim.Database/Repositories/Card/ICardInventoryRepository.cs create mode 100644 SVSim.EmulatedEntrypoint/Controllers/CardController.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Card/CardDestructRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Card/CardDestructResponse.cs create mode 100644 SVSim.UnitTests/Controllers/CardControllerTests.cs create mode 100644 SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs diff --git a/SVSim.Database/Repositories/Card/CardInventoryRepository.cs b/SVSim.Database/Repositories/Card/CardInventoryRepository.cs new file mode 100644 index 0000000..a944472 --- /dev/null +++ b/SVSim.Database/Repositories/Card/CardInventoryRepository.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Database.Models; + +namespace SVSim.Database.Repositories.Card; + +public class CardInventoryRepository : ICardInventoryRepository +{ + private readonly SVSimDbContext _db; + + public CardInventoryRepository(SVSimDbContext db) + { + _db = db; + } + + public async Task DestructCards(long viewerId, IReadOnlyDictionary destructCounts) + { + // Load covers cards + currency + decks. DeckCard.Card and OwnedCardEntry.Card both + // need explicit Includes — owned-collection auto-loading does not cover nested nav refs + // (see project_ef_nav_include_pitfall memory). + // + // AsSplitQuery is essential here. Without it, EF emits one SQL with a cartesian JOIN + // across OwnedCardEntry × DeckCard, materializing ~|owned_cards| × |deck_cards| rows + // for a single destruct. For a real account that's ~1500 × ~1600 = 2.4M rows and ~5s + // round-trip. Split queries issue separate SELECTs per Include chain — total rows + // stay linear in the data instead of multiplicative. + var viewer = await _db.Viewers + .Include(v => v.Cards).ThenInclude(c => c.Card).ThenInclude(c => c.CollectionInfo) + .Include(v => v.Decks).ThenInclude(d => d.Cards).ThenInclude(c => c.Card) + .AsSplitQuery() + .FirstAsync(v => v.Id == viewerId); + + var ownedByCardId = viewer.Cards.ToDictionary(c => c.Card.Id); + + foreach (var (cardId, num) in destructCounts) + { + // TryGetValue can succeed with Card.Id == 0 due to an EF owned-collection nav-ref + // default-init quirk (see project_ef_nav_include_pitfall memory). + if (!ownedByCardId.TryGetValue(cardId, out var owned) || owned.Card.Id == 0) + return DestructOutcome.Fail(DestructError.UnknownCard); + if (owned.IsProtected) + return DestructOutcome.Fail(DestructError.CardProtected); + if (owned.Card.CollectionInfo is null || owned.Card.CollectionInfo.DustReward <= 0) + return DestructOutcome.Fail(DestructError.NotDestructible); + if (owned.Count < num) + return DestructOutcome.Fail(DestructError.InsufficientCards); + } + + // Explicit transaction. The current implementation only issues one SaveChangesAsync, which + // EF would wrap implicitly. Making it explicit pins the boundary as the surface grows + // (e.g., if a future revision splits the save into multiple round-trips). + using var tx = await _db.Database.BeginTransactionAsync(); + + ulong totalVials = 0; + var postCounts = new Dictionary(destructCounts.Count); + foreach (var (cardId, num) in destructCounts) + { + var owned = ownedByCardId[cardId]; + owned.Count -= num; + totalVials += (ulong)owned.Card.CollectionInfo!.DustReward * (ulong)num; + postCounts[cardId] = owned.Count; + } + viewer.Currency.RedEther += totalVials; + + // Deck auto-strip: any deck holding more copies of a destructed card than the viewer now owns + // has the excess removed. DeckCard.Count is the multiplicity; a row that hits 0 is deleted so + // wire serialization (card_id_array expansion) doesn't emit a phantom. + foreach (var deck in viewer.Decks) + { + // Iterate a snapshot so we can remove rows mid-loop. + foreach (var deckCard in deck.Cards.ToList()) + { + if (!postCounts.TryGetValue(deckCard.Card.Id, out int newOwned)) + continue; + int excess = deckCard.Count - newOwned; + if (excess <= 0) + continue; + deckCard.Count -= excess; + if (deckCard.Count == 0) + deck.Cards.Remove(deckCard); + } + } + + await _db.SaveChangesAsync(); + await tx.CommitAsync(); + + return DestructOutcome.Ok(new DestructResult(viewer.Currency.RedEther, postCounts)); + } +} diff --git a/SVSim.Database/Repositories/Card/ICardInventoryRepository.cs b/SVSim.Database/Repositories/Card/ICardInventoryRepository.cs new file mode 100644 index 0000000..4dc66f2 --- /dev/null +++ b/SVSim.Database/Repositories/Card/ICardInventoryRepository.cs @@ -0,0 +1,44 @@ +namespace SVSim.Database.Repositories.Card; + +/// +/// Mutating operations on a viewer's card inventory (destruct, create, protect…). +/// Read-only catalog queries live on . +/// +public interface ICardInventoryRepository +{ + /// + /// Validate-then-mutate destruct of owned cards. Atomic: all validation runs before any + /// mutation, and the mutation phase is wrapped in an explicit DB transaction so a mid-flight + /// EF failure rolls back currency + inventory + deck-strip together. + /// + /// Authenticated viewer. + /// cardId → num_to_destruct. Empty dict is rejected by the caller. + /// + /// with post-state totals on success, or a + /// when validation fails. On error nothing is written. + /// + Task DestructCards(long viewerId, IReadOnlyDictionary destructCounts); +} + +/// +/// Either a success payload or an error code. Discriminated by which field is set. +/// +public sealed record DestructOutcome(DestructResult? Result, DestructError? Error) +{ + public bool IsSuccess => Result is not null; + + public static DestructOutcome Ok(DestructResult r) => new(r, null); + public static DestructOutcome Fail(DestructError e) => new(null, e); +} + +public sealed record DestructResult( + ulong NewRedEtherTotal, + IReadOnlyDictionary NewOwnedCounts); // cardId → post-destruct Count + +public enum DestructError +{ + UnknownCard, + NotDestructible, + CardProtected, + InsufficientCards, +} diff --git a/SVSim.EmulatedEntrypoint/Controllers/CardController.cs b/SVSim.EmulatedEntrypoint/Controllers/CardController.cs new file mode 100644 index 0000000..b825e4a --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/CardController.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Mvc; +using SVSim.Database.Repositories.Card; +using SVSim.EmulatedEntrypoint.Models.Dtos; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Card; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Card; +using System.Text.Json; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// /card/* — viewer card-inventory mutations. v1 implements /card/destruct only; reserved +/// for /card/create, /card/protect, /card/create-foil-card. +/// +[Route("card")] +public class CardController : SVSimController +{ + private readonly ICardInventoryRepository _inventory; + private readonly ILogger _log; + + public CardController(ICardInventoryRepository inventory, ILogger log) + { + _inventory = inventory; + _log = log; + } + + [HttpPost("destruct")] + public async Task> Destruct(CardDestructRequest request) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + + if (!TryParseDestructDict(request.CardIdNumberArray, out var destructCounts, out var snapshots, out var parseError)) + return BadRequest(new { error = parseError }); + + if (destructCounts.Count == 0) + return BadRequest(new { error = "malformed_request" }); + + var outcome = await _inventory.DestructCards(viewerId, destructCounts); + if (!outcome.IsSuccess) + return BadRequest(new { error = ErrorKey(outcome.Error!.Value) }); + + // Client snapshot mismatch is warn-log only; never blocks the request. + foreach (var (cardId, snapshot) in snapshots) + { + // We don't carry pre-state counts back, but post + destructed = pre. + int destructedNum = destructCounts[cardId]; + int reconstructedPre = outcome.Result!.NewOwnedCounts[cardId] + destructedNum; + if (reconstructedPre != snapshot) + { + _log.LogWarning( + "Destruct possession-snapshot mismatch: card={CardId} client_snapshot={Snapshot} server_pre={ServerPre}", + cardId, snapshot, reconstructedPre); + } + } + + // Wire spec is int; clamp the ulong total so a hypothetical 2B+ balance can't underflow + // to a negative wire value. Realistic balances are well under int.MaxValue. + int redEtherWire = outcome.Result!.NewRedEtherTotal > int.MaxValue + ? int.MaxValue + : (int)outcome.Result!.NewRedEtherTotal; + var rewardList = new List + { + new() { RewardType = 1, RewardId = 0, RewardNum = redEtherWire }, + }; + foreach (var (cardId, postCount) in outcome.Result!.NewOwnedCounts) + { + rewardList.Add(new RewardListEntry { RewardType = 5, RewardId = cardId, RewardNum = postCount }); + } + + return new CardDestructResponse { RewardList = rewardList }; + } + + private static string ErrorKey(DestructError error) => error switch + { + DestructError.UnknownCard => "unknown_card", + DestructError.NotDestructible => "not_destructible", + DestructError.CardProtected => "card_protected", + DestructError.InsufficientCards => "insufficient_cards", + _ => "malformed_request", + }; + + /// + /// Decodes the inner JSON of card_id_number_array. Values are + /// "<num_to_destruct>,<client_possession_snapshot>" — both strings. + /// Returns false (and sets ) on any structural problem. + /// + private static bool TryParseDestructDict( + string raw, + out Dictionary destructCounts, + out Dictionary clientSnapshots, + out string errorKey) + { + destructCounts = new(); + clientSnapshots = new(); + errorKey = "malformed_request"; + + if (string.IsNullOrWhiteSpace(raw)) + return false; + + JsonDocument? doc; + try { doc = JsonDocument.Parse(raw); } + catch (JsonException) { return false; } + + using (doc) + { + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return false; + + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (!long.TryParse(prop.Name, out long cardId)) + return false; + if (prop.Value.ValueKind != JsonValueKind.String) + return false; + + var pair = prop.Value.GetString()!.Split(','); + if (pair.Length != 2) + return false; + if (!int.TryParse(pair[0], out int num) || num <= 0) + return false; + if (!int.TryParse(pair[1], out int snapshot) || snapshot < 0) + return false; + + destructCounts[cardId] = num; + clientSnapshots[cardId] = snapshot; + } + } + + return true; + } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Card/CardDestructRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Card/CardDestructRequest.cs new file mode 100644 index 0000000..677b92f --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Card/CardDestructRequest.cs @@ -0,0 +1,18 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Card; + +/// +/// POST /card/destruct. The single payload field is a JSON-encoded STRING (double-encoded +/// — see docs/api-spec/endpoints/post-login/card-destruct.md). The inner object maps +/// cardId → "<num_to_destruct>,<client_possession_snapshot>". Both inner values +/// are strings. This DTO keeps it as a single string; parsing happens in CardController. +/// +[MessagePackObject] +public class CardDestructRequest : BaseRequest +{ + [JsonPropertyName("card_id_number_array")] + [Key("card_id_number_array")] + public string CardIdNumberArray { get; set; } = string.Empty; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Card/CardDestructResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Card/CardDestructResponse.cs new file mode 100644 index 0000000..383a630 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Card/CardDestructResponse.cs @@ -0,0 +1,19 @@ +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Card; + +/// +/// /card/destruct response data. reward_list entries are POST-STATE TOTALS (the client's +/// PlayerStaticData.UpdateHaveUserGoodsNumByJsonData does direct assignment) — one +/// RewardType=1 RedEther entry plus one RewardType=5 Card entry per destructed cardId. +/// +[MessagePackObject] +public class CardDestructResponse +{ + [JsonPropertyName("reward_list")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + [Key("reward_list")] + public List RewardList { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 54394c1..d6c2916 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -59,6 +59,7 @@ public class Program }); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/SVSim.UnitTests/Controllers/CardControllerTests.cs b/SVSim.UnitTests/Controllers/CardControllerTests.cs new file mode 100644 index 0000000..2f60f35 --- /dev/null +++ b/SVSim.UnitTests/Controllers/CardControllerTests.cs @@ -0,0 +1,195 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +/// +/// End-to-end coverage for POST /card/destruct. Exercises the controller's double-decode of +/// the JSON-string-in-JSON payload and the reward_list post-state-totals shape. The +/// validate/mutate logic itself is covered by CardInventoryRepositoryTests; the tests +/// here just confirm the wire contract. +/// +public class CardControllerTests +{ + private static StringContent DestructBody(string innerJson) => + new( + $$"""{"card_id_number_array":{{JsonSerializer.Serialize(innerJson)}},"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""", + Encoding.UTF8, + "application/json"); + + [Test] + public async Task Destruct_happy_path_returns_redether_and_card_post_totals() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50); + + using var client = factory.CreateAuthenticatedClient(viewerId); + + // Inner JSON: cardId -> ",". The snapshot is informational only. + var response = await client.PostAsync("/card/destruct", + DestructBody("{\"10001001\":\"2,5\"}")); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + // The ShadowverseTranslationMiddleware only fires for UnityPlayer UA requests; test + // clients send plain HTTP so the controller's JSON is returned unwrapped. + var rewardList = JsonDocument.Parse(body).RootElement + .GetProperty("reward_list"); + + // Two entries — one RedEther (type 1), one Card (type 5). + var entries = rewardList.EnumerateArray() + .Select(e => (Type: e.GetProperty("reward_type").GetInt32(), + Id: e.GetProperty("reward_id").GetInt64(), + Num: e.GetProperty("reward_num").GetInt32())) + .ToList(); + + Assert.That(entries, Has.Member((Type: 1, Id: 0L, Num: 100)), + "RedEther post-state total = 2 * 50 = 100"); + Assert.That(entries, Has.Member((Type: 5, Id: 10001001L, Num: 3)), + "Card post-state owned count = 5 - 2 = 3"); + } + + [Test] + public async Task Destruct_without_auth_header_returns_401() + { + using var factory = new SVSimTestFactory(); + using var client = factory.CreateClient(); // no auth header + + var response = await client.PostAsync("/card/destruct", + DestructBody("{\"10001001\":\"1,1\"}")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [TestCase("", Description = "empty string")] + [TestCase("not json", Description = "non-JSON garbage")] + [TestCase("{\"10001001\":\"1\"}", Description = "value missing snapshot")] + [TestCase("{\"10001001\":\"0,5\"}", Description = "num=0 not allowed")] + [TestCase("{\"10001001\":\"-1,5\"}", Description = "negative num")] + [TestCase("{\"abc\":\"1,5\"}", Description = "non-numeric cardId")] + [TestCase("{\"10001001\":5}", Description = "value not a string")] + [TestCase("[]", Description = "root must be object, not array")] + public async Task Destruct_with_malformed_inner_json_returns_400(string innerJson) + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/card/destruct", DestructBody(innerJson)); + var body = await response.Content.ReadAsStringAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body); + Assert.That(body, Does.Contain("malformed_request")); + } + + [Test] + public async Task Destruct_with_empty_inner_object_returns_400() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/card/destruct", DestructBody("{}")); + var body = await response.Content.ReadAsStringAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body); + Assert.That(body, Does.Contain("malformed_request")); + } + + [Test] + public async Task Destruct_unknown_card_returns_400_unknown_card() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/card/destruct", + DestructBody("{\"99999999\":\"1,0\"}")); + var body = await response.Content.ReadAsStringAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body); + Assert.That(body, Does.Contain("unknown_card")); + } + + [Test] + public async Task Destruct_not_destructible_returns_400_not_destructible() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 0, craftCost: 0); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/card/destruct", + DestructBody("{\"10001001\":\"1,3\"}")); + var body = await response.Content.ReadAsStringAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body); + Assert.That(body, Does.Contain("not_destructible")); + } + + [Test] + public async Task Destruct_protected_returns_400_card_protected() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50, isProtected: true); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/card/destruct", + DestructBody("{\"10001001\":\"1,3\"}")); + var body = await response.Content.ReadAsStringAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body); + Assert.That(body, Does.Contain("card_protected")); + } + + [Test] + public async Task Destruct_insufficient_cards_returns_400_insufficient_cards() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, dustReward: 50); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/card/destruct", + DestructBody("{\"10001001\":\"3,2\"}")); + var body = await response.Content.ReadAsStringAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body); + Assert.That(body, Does.Contain("insufficient_cards")); + } + + [Test] + public async Task Destruct_proceeds_when_client_possession_snapshot_disagrees_with_server() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // Server has 3 owned; client thinks it has 5 (stale snapshot). + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50); + using var client = factory.CreateAuthenticatedClient(viewerId); + + // Inner JSON: destruct 1, client snapshot=5 (disagrees with server count=3). + // Spec: snapshot mismatch is warn-log only, never blocks the request. + var response = await client.PostAsync("/card/destruct", + DestructBody("{\"10001001\":\"1,5\"}")); + var body = await response.Content.ReadAsStringAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + var entries = JsonDocument.Parse(body).RootElement + .GetProperty("reward_list") + .EnumerateArray() + .Select(e => (Type: e.GetProperty("reward_type").GetInt32(), + Id: e.GetProperty("reward_id").GetInt64(), + Num: e.GetProperty("reward_num").GetInt32())) + .ToList(); + + // Vials awarded based on actual server count, not client snapshot. + Assert.That(entries, Has.Member((Type: 1, Id: 0L, Num: 50))); + Assert.That(entries, Has.Member((Type: 5, Id: 10001001L, Num: 2))); + } +} diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index 3bb1d5f..9103cb4 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -216,6 +216,89 @@ internal sealed class SVSimTestFactory : WebApplicationFactory }); } + /// + /// Seeds an OwnedCardEntry for the viewer. Uses an existing card from the minimal test set + /// when matches one (10001001/10001002/10001003); otherwise the + /// caller must have inserted the card row themselves. is written + /// onto the card's CollectionInfo so destruct tests can compute expected vials. + /// + /// NOTE: This helper ALWAYS resets the viewer's RedEther to 0 (so destruct tests can assert + /// literal post-state totals). Callers that need a non-zero balance should re-assign after seeding. + /// + public async Task SeedOwnedCardAsync( + long viewerId, + long cardId, + int count, + int dustReward = 50, + int craftCost = 200, + bool isProtected = false) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == cardId); + if (card is null) + { + card = new ShadowverseCardEntry + { + Id = cardId, + Name = $"SeededCard{cardId}", + Rarity = Rarity.Bronze, + CollectionInfo = new CardCollectionInfo { CraftCost = craftCost, DustReward = dustReward }, + }; + db.Cards.Add(card); + await db.SaveChangesAsync(); + } + else if (card.CollectionInfo is null || card.CollectionInfo.DustReward != dustReward || card.CollectionInfo.CraftCost != craftCost) + { + card.CollectionInfo = new CardCollectionInfo { CraftCost = craftCost, DustReward = dustReward }; + await db.SaveChangesAsync(); + } + + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + var owned = viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId); + if (owned is null) + { + viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = count, IsProtected = isProtected }); + } + else + { + owned.Count = count; + owned.IsProtected = isProtected; + } + viewer.Currency.RedEther = 0; // Reset RedEther so destruct tests can assert literal post-state totals + await db.SaveChangesAsync(); + } + + /// + /// Puts copies of into the viewer's deck + /// in the given format + slot. Tests use this to set up deck-strip scenarios for /card/destruct. + /// The card must already exist (typically via SeedOwnedCardAsync, which inserts the card row). + /// + public async Task AddCardToDeckAsync(long viewerId, Format format, int deckNumber, long cardId, int count) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var viewer = await db.Viewers + .Include(v => v.Decks).ThenInclude(d => d.Cards).ThenInclude(c => c.Card) + .FirstAsync(v => v.Id == viewerId); + + var deck = viewer.Decks.First(d => d.Format == format && d.Number == deckNumber); + var card = await db.Cards.FirstAsync(c => c.Id == cardId); + + var existing = deck.Cards.FirstOrDefault(c => c.Card.Id == cardId); + if (existing is null) + { + deck.Cards.Add(new DeckCard { Card = card, Count = count }); + } + else + { + existing.Count = count; + } + await db.SaveChangesAsync(); + } + protected override void Dispose(bool disposing) { base.Dispose(disposing); diff --git a/SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs b/SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs new file mode 100644 index 0000000..0eb6be9 --- /dev/null +++ b/SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs @@ -0,0 +1,284 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Repositories.Card; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Repositories; + +/// +/// Coverage for CardInventoryRepository.DestructCards. Exercises the validate→mutate +/// loop directly so tests don't need to round-trip through HTTP; the controller-level wire +/// behavior is covered separately in CardControllerTests. +/// +public class CardInventoryRepositoryTests +{ + [Test] + public async Task Destruct_single_card_decrements_count_and_awards_vials() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 1 } }); + + Assert.That(outcome.IsSuccess, Is.True, outcome.Error?.ToString()); + Assert.That(outcome.Result!.NewRedEtherTotal, Is.EqualTo(50UL)); + Assert.That(outcome.Result!.NewOwnedCounts[10001001L], Is.EqualTo(4)); + + // Verify persisted state matches what was returned + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Currency.RedEther, Is.EqualTo(50UL)); + Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(4)); + } + + [Test] + public async Task Destruct_batch_decrements_each_card_and_sums_vials() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001002L, count: 2, dustReward: 200); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.DestructCards(viewerId, new Dictionary + { + { 10001001L, 2 }, + { 10001002L, 1 }, + }); + + Assert.That(outcome.IsSuccess, Is.True); + // 2 * 50 + 1 * 200 = 300 + Assert.That(outcome.Result!.NewRedEtherTotal, Is.EqualTo(300UL)); + Assert.That(outcome.Result!.NewOwnedCounts[10001001L], Is.EqualTo(1)); + Assert.That(outcome.Result!.NewOwnedCounts[10001002L], Is.EqualTo(1)); + } + + [Test] + public async Task Destruct_leaves_zero_count_row_after_destructing_last_copy() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // We just verify the OwnedCardEntry row survives a destruct-to-zero, so future operations (re-protect, re-craft) can attach to it. + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 1, dustReward: 50); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 1 } }); + Assert.That(outcome.IsSuccess, Is.True); + + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + var owned = viewer.Cards.FirstOrDefault(c => c.Card.Id == 10001001L); + Assert.That(owned, Is.Not.Null, "OwnedCardEntry row should remain after destruct-to-zero"); + Assert.That(owned!.Count, Is.EqualTo(0)); + } + + [Test] + public async Task Destruct_rejects_unknown_card_without_mutation() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.DestructCards(viewerId, new Dictionary { { 99_999_999L, 1 } }); + + Assert.That(outcome.IsSuccess, Is.False); + Assert.That(outcome.Error, Is.EqualTo(DestructError.UnknownCard)); + + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Currency.RedEther, Is.EqualTo(0UL)); + Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(5)); + } + + [Test] + public async Task Destruct_rejects_insufficient_count_without_mutation() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, dustReward: 50); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 3 } }); + + Assert.That(outcome.IsSuccess, Is.False); + Assert.That(outcome.Error, Is.EqualTo(DestructError.InsufficientCards)); + + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Currency.RedEther, Is.EqualTo(0UL)); + Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(2)); + } + + [Test] + public async Task Destruct_rejects_protected_card_without_mutation() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50, isProtected: true); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 1 } }); + + Assert.That(outcome.IsSuccess, Is.False); + Assert.That(outcome.Error, Is.EqualTo(DestructError.CardProtected)); + + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Currency.RedEther, Is.EqualTo(0UL)); + Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(3)); + } + + [Test] + public async Task Destruct_rejects_non_destructible_card_without_mutation() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // dustReward=0 marks a card as IsNotCraftDestruct (e.g. tokens, basics) + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 0, craftCost: 0); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 1 } }); + + Assert.That(outcome.IsSuccess, Is.False); + Assert.That(outcome.Error, Is.EqualTo(DestructError.NotDestructible)); + + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(3)); + } + + [Test] + public async Task Destruct_validates_full_batch_before_mutating() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001002L, count: 3, dustReward: 200, isProtected: true); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.DestructCards(viewerId, new Dictionary + { + { 10001001L, 2 }, // would-be valid + { 10001002L, 1 }, // protected — fails validation + }); + + Assert.That(outcome.IsSuccess, Is.False); + Assert.That(outcome.Error, Is.EqualTo(DestructError.CardProtected)); + + // Critical: the valid card must be untouched. Proves validation runs against the full + // batch before any inventory write. + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Currency.RedEther, Is.EqualTo(0UL), "no vials awarded when batch fails"); + Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(5)); + Assert.That(viewer.Cards.First(c => c.Card.Id == 10001002L).Count, Is.EqualTo(3)); + } + + [Test] + public async Task Destruct_strips_excess_copies_from_a_deck() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50); + await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1); + await factory.AddCardToDeckAsync(viewerId, Format.Rotation, deckNumber: 1, cardId: 10001001L, count: 3); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + // Destruct 2 — owned drops from 3 to 1 — deck must lose 2 of the 3 copies it had. + var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 2 } }); + Assert.That(outcome.IsSuccess, Is.True); + + var db = scope.ServiceProvider.GetRequiredService(); + var deck = await db.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.Decks) + .Include(d => d.Cards).ThenInclude(c => c.Card) + .FirstAsync(d => d.Format == Format.Rotation && d.Number == 1); + + var deckCard = deck.Cards.First(c => c.Card.Id == 10001001L); + Assert.That(deckCard.Count, Is.EqualTo(1), "deck should now hold only 1 copy of the card"); + } + + [Test] + public async Task Destruct_strips_excess_across_multiple_decks() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50); + await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1); + await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 2); + await factory.AddCardToDeckAsync(viewerId, Format.Rotation, 1, cardId: 10001001L, count: 3); + await factory.AddCardToDeckAsync(viewerId, Format.Rotation, 2, cardId: 10001001L, count: 3); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + // Destruct 1 — owned drops from 3 to 2 — each deck must lose 1 copy. + var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 1 } }); + Assert.That(outcome.IsSuccess, Is.True); + + var db = scope.ServiceProvider.GetRequiredService(); + var decks = await db.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.Decks) + .Include(d => d.Cards).ThenInclude(c => c.Card) + .Where(d => d.Format == Format.Rotation) + .ToListAsync(); + + foreach (var deck in decks) + { + Assert.That(deck.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(2), + $"deck {deck.Number} should now hold 2 copies"); + } + } + + [Test] + public async Task Destruct_leaves_deck_untouched_when_owned_still_covers() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50); + await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1); + await factory.AddCardToDeckAsync(viewerId, Format.Rotation, 1, cardId: 10001001L, count: 2); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + // Destruct 2 — owned drops from 5 to 3 — deck still uses only 2, no strip needed. + var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 2 } }); + Assert.That(outcome.IsSuccess, Is.True); + + var db = scope.ServiceProvider.GetRequiredService(); + var deck = await db.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.Decks) + .Include(d => d.Cards).ThenInclude(c => c.Card) + .FirstAsync(d => d.Format == Format.Rotation && d.Number == 1); + + Assert.That(deck.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(2), + "deck untouched because owned (3) still covers usage (2)"); + } +}