Files
SVSimServer/SVSim.Database/Repositories/Card/CardInventoryRepository.cs
2026-05-25 16:34:24 -04:00

92 lines
4.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<DestructOutcome> DestructCards(long viewerId, IReadOnlyDictionary<long, int> 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<long, int>(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));
}
}