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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user