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; } // Direct credit (not via RewardGrantService.ApplyAsync) because destruct is a debit-pair // operation (destroy cards + credit vials) handled atomically here. ApplyAsync is the // standard path for one-shot reward grants — see RewardGrantService for that pattern. 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)); } }