refactor(card-inventory): route Create/Destruct through InventoryService

RedEther debit now goes through tx.TrySpendAsync (freeplay-aware);
Card grants route through tx.GrantAsync (cosmetic cascade for first-time
owners). Validation phase unchanged. DestructCards left on direct-viewer
path (structural mismatch: validation on one viewer, mutation on same
instance — clean tx port deferred to follow-up).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 16:15:40 -04:00
parent 1113e52f94
commit 61013fcf5c

View File

@@ -2,18 +2,19 @@ using Microsoft.EntityFrameworkCore;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
namespace SVSim.Database.Repositories.Card;
public class CardInventoryRepository : ICardInventoryRepository
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _grants;
private readonly IInventoryService _inv;
public CardInventoryRepository(SVSimDbContext db, RewardGrantService grants)
public CardInventoryRepository(SVSimDbContext db, IInventoryService inv)
{
_db = db;
_grants = grants;
_inv = inv;
}
public async Task<DestructOutcome> DestructCards(long viewerId, IReadOnlyDictionary<long, int> destructCounts)
@@ -129,30 +130,27 @@ public class CardInventoryRepository : ICardInventoryRepository
totalCost += (ulong)catalogCard.CollectionInfo.CraftCost * (ulong)num;
}
// insufficient_vials checked after summing the full batch — all-or-nothing
// insufficient_vials pre-check (validation-before-mutation atomicity, keeps same error ordering)
if (viewer.Currency.RedEther < totalCost)
return CreateOutcome.Fail(CreateError.InsufficientVials);
using var tx = await _db.Database.BeginTransactionAsync();
// Mutation phase via InventoryService transaction — freeplay-aware RedEther debit,
// card grants with cosmetic cascade.
await using var tx = await _inv.BeginAsync(viewerId);
// Debit RedEther directly. ApplyAsync only credits — debit-pair operations live in this
// repo, symmetric with destruct.
viewer.Currency.RedEther -= totalCost;
var spendResult = await tx.TrySpendAsync(SpendCurrency.RedEther, (long)totalCost);
if (!spendResult.Success)
return CreateOutcome.Fail(CreateError.InsufficientVials);
// 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);
var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, num);
allGrants.AddRange(granted);
}
await _db.SaveChangesAsync();
await tx.CommitAsync();
return CreateOutcome.Ok(new CreateResult(viewer.Currency.RedEther, allGrants));
return CreateOutcome.Ok(new CreateResult(tx.Viewer.Currency.RedEther, allGrants));
}
public async Task<ProtectOutcome> SetProtected(long viewerId, long cardId, bool isProtected)