diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index f812ba3..2ec341a 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -241,8 +241,88 @@ internal sealed class InventoryTransaction : IInventoryTransaction public bool OwnsCard(long cardId) => throw new NotImplementedException(); public bool OwnsCosmetic(CosmeticType type, int id) => throw new NotImplementedException(); - public Task CommitAsync(CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task CommitAsync(CancellationToken ct = default) + { + ThrowIfCommitted(); + + await _db.SaveChangesAsync(ct); + await _dbTx.CommitAsync(ct); + _committed = true; + + var rewardList = BuildRewardList(); + var deltas = BuildDeltas(); + return new InventoryCommitResult(rewardList, deltas); + } + + private IReadOnlyList BuildRewardList() + { + // Pass 1 — for each currency type, find the last op (spend OR grant) that touched it + // and emit a single entry with its post-state. Skip the sentinel item-debit currency. + var lastCurrencyPost = new Dictionary(); + var orderedTouches = new List(); // preserve first-touch order for stable output + + foreach (var op in _ops) + { + switch (op) + { + case SpendOp s when (int)s.Currency >= 0: + var goodsForSpend = SpendCurrencyToGoodsType(s.Currency); + if (!lastCurrencyPost.ContainsKey(goodsForSpend)) orderedTouches.Add(goodsForSpend); + lastCurrencyPost[goodsForSpend] = checked((int)s.PostState); + break; + + case GrantOp g when IsCurrency(g.Type): + if (!lastCurrencyPost.ContainsKey(g.Type)) orderedTouches.Add(g.Type); + lastCurrencyPost[g.Type] = g.PostStateOrCount; + break; + } + } + + var output = new List(); + foreach (var type in orderedTouches) + { + output.Add(new GrantedReward((int)type, 0, lastCurrencyPost[type])); + } + + // Pass 2 — non-currency grants: one entry per (type, id) using LAST post-state for items + // and cards (collapses multi-add to final count) and 1 for cosmetics. + var nonCurrencyKey = new Dictionary<(UserGoodsType, long), int>(); + var nonCurrencyOrder = new List<(UserGoodsType, long)>(); + + foreach (var op in _ops.OfType()) + { + if (IsCurrency(op.Type)) continue; + var key = (op.Type, op.DetailId); + if (!nonCurrencyKey.ContainsKey(key)) nonCurrencyOrder.Add(key); + nonCurrencyKey[key] = op.PostStateOrCount; + } + foreach (var (type, id) in nonCurrencyOrder) + { + output.Add(new GrantedReward((int)type, id, nonCurrencyKey[(type, id)])); + } + return output; + } + + private IReadOnlyList BuildDeltas() + => _ops.OfType() + .Where(o => !o.IsCascade) + .Select(o => new GrantedReward((int)o.Type, o.DetailId, o.Num)) + .ToList(); + + private static bool IsCurrency(UserGoodsType t) => + t is UserGoodsType.Crystal + or UserGoodsType.Rupy + or UserGoodsType.RedEther + or UserGoodsType.SpotCardPoint; + + private static UserGoodsType SpendCurrencyToGoodsType(SpendCurrency c) => c switch + { + SpendCurrency.Crystal => UserGoodsType.Crystal, + SpendCurrency.Rupee => UserGoodsType.Rupy, + SpendCurrency.RedEther => UserGoodsType.RedEther, + SpendCurrency.SpotPoint => UserGoodsType.SpotCardPoint, + _ => throw new ArgumentOutOfRangeException(nameof(c)), + }; private static IReadOnlyList Single(UserGoodsType type, long id, int num) => new[] { new GrantedReward((int)type, id, num) }; diff --git a/SVSim.UnitTests/Services/Inventory/InventoryCommitTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryCommitTests.cs new file mode 100644 index 0000000..45e11dc --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventoryCommitTests.cs @@ -0,0 +1,104 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Inventory; + +public class InventoryCommitTests +{ + [Test] + public async Task Commit_emits_one_currency_entry_with_grant_post_state_when_spend_then_grant() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 1000; + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + await tx.TrySpendAsync(SpendCurrency.Crystal, 500); + await tx.GrantAsync(UserGoodsType.Crystal, 0, 200); + var result = await tx.CommitAsync(); + + var crystals = result.RewardList.Where(r => r.RewardType == (int)UserGoodsType.Crystal).ToList(); + Assert.That(crystals, Has.Count.EqualTo(1)); + Assert.That(crystals[0].RewardNum, Is.EqualTo(700), "spend 500 then grant 200 → 1000-500+200=700, grant's post-state wins"); + } + + [Test] + public async Task Commit_emits_one_currency_entry_with_spend_post_state_when_grant_then_spend() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 1000; + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + await tx.GrantAsync(UserGoodsType.Crystal, 0, 200); + await tx.TrySpendAsync(SpendCurrency.Crystal, 500); + var result = await tx.CommitAsync(); + + var crystals = result.RewardList.Where(r => r.RewardType == (int)UserGoodsType.Crystal).ToList(); + Assert.That(crystals, Has.Count.EqualTo(1)); + Assert.That(crystals[0].RewardNum, Is.EqualTo(700)); + } + + [Test] + public async Task Commit_persists_mutations() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Rupees = 100; + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using (var tx = await inv.BeginAsync(viewerId)) + { + await tx.GrantAsync(UserGoodsType.Rupy, 0, 50); + await tx.CommitAsync(); + } + + using var verifyScope = factory.Services.CreateScope(); + var ctx2 = verifyScope.ServiceProvider.GetRequiredService(); + var v2 = await ctx2.Viewers.AsNoTracking().FirstAsync(x => x.Id == viewerId); + Assert.That(v2.Currency.Rupees, Is.EqualTo(150UL)); + } + + [Test] + public async Task Deltas_are_verbatim_queued_no_cascade() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + long cardId = await factory.SeedCardAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + const int sleeveId = 2_000_030_000; + ctx.Sleeves.Add(new SVSim.Database.Models.SleeveEntry { Id = sleeveId }); + ctx.CardCosmeticRewards.Add(new SVSim.Database.Models.CardCosmeticReward { CardId = cardId, CosmeticId = sleeveId, Type = CosmeticType.Sleeve }); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + await tx.GrantAsync(UserGoodsType.Card, cardId, 1); + var result = await tx.CommitAsync(); + + Assert.That(result.Deltas, Has.Count.EqualTo(1), "verbatim — card only, no cascade"); + Assert.That(result.Deltas[0].RewardType, Is.EqualTo((int)UserGoodsType.Card)); + Assert.That(result.RewardList.Any(e => e.RewardType == (int)UserGoodsType.Sleeve), Is.True, + "cascade appears in RewardList but not Deltas"); + } +}