Implements ICardInventoryRepository.SetProtected — loads only the owned-cards collection (no decks/currency), guards against the EF owned-nav Card.Id==0 default-init quirk, and accepts Count=0 rows (destruct→re-protect round-trip). Covered by 4 new NUnit tests (flip, unset, zero-count-row, unknown-card error). Full suite: 533/533. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
175 lines
7.9 KiB
C#
175 lines
7.9 KiB
C#
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, RewardGrantService grants)
|
||
{
|
||
_db = db;
|
||
_grants = grants;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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)
|
||
{
|
||
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));
|
||
}
|
||
|
||
public async Task<CreateOutcome> CreateCards(long viewerId, IReadOnlyDictionary<long, int> 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<GrantedReward>();
|
||
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));
|
||
}
|
||
|
||
public async Task<ProtectOutcome> SetProtected(long viewerId, long cardId, bool isProtected)
|
||
{
|
||
// Lighter load than create/destruct: only need viewer's owned-cards collection. No decks,
|
||
// no currency, no CollectionInfo.
|
||
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 || owned.Card.Id == 0)
|
||
return ProtectOutcome.Fail(ProtectError.UnknownCard);
|
||
|
||
owned.IsProtected = isProtected;
|
||
await _db.SaveChangesAsync();
|
||
return ProtectOutcome.Ok();
|
||
}
|
||
}
|