repo(card): CreateCards happy path
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
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)
|
||||
public CardInventoryRepository(SVSimDbContext db, RewardGrantService grants)
|
||||
{
|
||||
_db = db;
|
||||
_grants = grants;
|
||||
}
|
||||
|
||||
public async Task<DestructOutcome> DestructCards(long viewerId, IReadOnlyDictionary<long, int> destructCounts)
|
||||
@@ -45,9 +49,6 @@ public class CardInventoryRepository : ICardInventoryRepository
|
||||
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;
|
||||
@@ -69,7 +70,6 @@ public class CardInventoryRepository : ICardInventoryRepository
|
||||
// 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))
|
||||
@@ -88,4 +88,70 @@ public class CardInventoryRepository : ICardInventoryRepository
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,4 +281,34 @@ public class CardInventoryRepositoryTests
|
||||
Assert.That(deck.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(2),
|
||||
"deck untouched because owned (3) still covers usage (2)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_single_card_debits_vials_and_grants_copy()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 200);
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.CreateCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.True, outcome.Error?.ToString());
|
||||
Assert.That(outcome.Result!.NewRedEtherTotal, Is.EqualTo(800UL), "1000 - 200 = 800");
|
||||
|
||||
var grants = outcome.Result!.Grants;
|
||||
Assert.That(grants.Any(g => g.RewardType == (int)UserGoodsType.Card
|
||||
&& g.RewardId == 10001001L
|
||||
&& g.RewardNum == 1), Is.True);
|
||||
|
||||
// Verify persisted state
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.Currency.RedEther, Is.EqualTo(800UL));
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(1));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user