diff --git a/SVSim.Database/Repositories/Card/CardInventoryRepository.cs b/SVSim.Database/Repositories/Card/CardInventoryRepository.cs index 6d1efe2..ab1e02f 100644 --- a/SVSim.Database/Repositories/Card/CardInventoryRepository.cs +++ b/SVSim.Database/Repositories/Card/CardInventoryRepository.cs @@ -1,15 +1,19 @@ using Microsoft.EntityFrameworkCore; +using SVSim.Database.Enums; using SVSim.Database.Models; +using SVSim.Database.Services; namespace SVSim.Database.Repositories.Card; public class CardInventoryRepository : ICardInventoryRepository { private readonly SVSimDbContext _db; + private readonly RewardGrantService _grants; - public CardInventoryRepository(SVSimDbContext db) + public CardInventoryRepository(SVSimDbContext db, RewardGrantService grants) { _db = db; + _grants = grants; } public async Task DestructCards(long viewerId, IReadOnlyDictionary destructCounts) @@ -45,9 +49,6 @@ public class CardInventoryRepository : ICardInventoryRepository 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; @@ -69,7 +70,6 @@ public class CardInventoryRepository : ICardInventoryRepository // 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)) @@ -88,4 +88,70 @@ public class CardInventoryRepository : ICardInventoryRepository return DestructOutcome.Ok(new DestructResult(viewer.Currency.RedEther, postCounts)); } + + public async Task CreateCards(long viewerId, IReadOnlyDictionary createCounts) + { + // Load viewer with owned cards + their catalog rows (for CraftCost). Decks aren't needed — + // create never modifies them. AsSplitQuery for symmetry with destruct and to avoid any + // future cartesian explosion if more Includes are added. + var viewer = await _db.Viewers + .Include(v => v.Cards).ThenInclude(c => c.Card).ThenInclude(c => c.CollectionInfo) + .AsSplitQuery() + .FirstAsync(v => v.Id == viewerId); + + var ownedByCardId = viewer.Cards.ToDictionary(c => c.Card.Id); + + // For unknown_card validation we need the catalog rows for ids the viewer DOESN'T own yet. + var requestedIds = createCounts.Keys.ToList(); + var catalogRows = await _db.Cards + .Include(c => c.CollectionInfo) + .Where(c => requestedIds.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id); + + ulong totalCost = 0; + foreach (var (cardId, num) in createCounts) + { + // unknown_card: must be in the global catalog + if (!catalogRows.TryGetValue(cardId, out var catalogCard)) + return CreateOutcome.Fail(CreateError.UnknownCard); + + // not_craftable: client's IsNotCraftDestruct check — CraftCost ≤ 0 means uncraftable + if (catalogCard.CollectionInfo is null || catalogCard.CollectionInfo.CraftCost <= 0) + return CreateOutcome.Fail(CreateError.NotCraftable); + + // would_exceed_max_copies: viewer already owns N → can craft at most MaxCopies - N + int existingCount = ownedByCardId.TryGetValue(cardId, out var owned) && owned.Card.Id != 0 + ? owned.Count + : 0; + if (existingCount + num > OwnedCardEntry.MaxCopies) + return CreateOutcome.Fail(CreateError.WouldExceedMaxCopies); + + totalCost += (ulong)catalogCard.CollectionInfo.CraftCost * (ulong)num; + } + + // insufficient_vials checked after summing the full batch — all-or-nothing + if (viewer.Currency.RedEther < totalCost) + return CreateOutcome.Fail(CreateError.InsufficientVials); + + using var tx = await _db.Database.BeginTransactionAsync(); + + // Debit RedEther directly. ApplyAsync only credits — debit-pair operations live in this + // repo, symmetric with destruct. + viewer.Currency.RedEther -= totalCost; + + // Per-card grant via RewardGrantService — single source of truth for Card-typed grants, + // and fires the CardCosmeticReward cascade for first-time owners. See + // feedback_reward_grant_service memory. + var allGrants = new List(); + foreach (var (cardId, num) in createCounts) + { + var granted = await _grants.ApplyAsync(viewer, UserGoodsType.Card, cardId, num); + allGrants.AddRange(granted); + } + + await _db.SaveChangesAsync(); + await tx.CommitAsync(); + + return CreateOutcome.Ok(new CreateResult(viewer.Currency.RedEther, allGrants)); + } } diff --git a/SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs b/SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs index 0eb6be9..01b2cf8 100644 --- a/SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs +++ b/SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs @@ -281,4 +281,34 @@ public class CardInventoryRepositoryTests Assert.That(deck.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(2), "deck untouched because owned (3) still covers usage (2)"); } + + [Test] + public async Task Create_single_card_debits_vials_and_grants_copy() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 200); + await factory.SetRedEtherAsync(viewerId, 1_000UL); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.CreateCards(viewerId, new Dictionary { { 10001001L, 1 } }); + + Assert.That(outcome.IsSuccess, Is.True, outcome.Error?.ToString()); + Assert.That(outcome.Result!.NewRedEtherTotal, Is.EqualTo(800UL), "1000 - 200 = 800"); + + var grants = outcome.Result!.Grants; + Assert.That(grants.Any(g => g.RewardType == (int)UserGoodsType.Card + && g.RewardId == 10001001L + && g.RewardNum == 1), Is.True); + + // Verify persisted state + 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(800UL)); + Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(1)); + } }