diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index 53e5c3e..8c2dc04 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -277,6 +277,10 @@ internal sealed class InventoryTransaction : IInventoryTransaction ThrowIfCommitted(); await _db.SaveChangesAsync(ct); + + WriteAcquireHistory(); + await _db.SaveChangesAsync(ct); + await _dbTx.CommitAsync(ct); _committed = true; @@ -285,6 +289,39 @@ internal sealed class InventoryTransaction : IInventoryTransaction return new InventoryCommitResult(rewardList, deltas); } + private void WriteAcquireHistory() + { + var now = DateTime.UtcNow; + var primaryMessage = GrantSourceMessages.For(_source); + var cascadeMessage = GrantSourceMessages.For(GrantSource.CardCosmeticCascade); + + foreach (var op in _ops) + { + if (op is not GrantOp grant) continue; + + var rowSource = grant.IsCascade ? GrantSource.CardCosmeticCascade : _source; + var rowMessage = grant.IsCascade ? cascadeMessage : primaryMessage; + var detailId = IsWalletCurrency(grant.Type) ? 0L : grant.DetailId; + + _db.ViewerAcquireHistory.Add(new ViewerAcquireHistoryEntry + { + ViewerId = Viewer.Id, + RewardType = (int)grant.Type, + RewardDetailId = detailId, + RewardCount = grant.Num, + AcquireType = (int)rowSource, + Message = rowMessage, + AcquireTime = now, + }); + } + } + + private static bool IsWalletCurrency(UserGoodsType type) => type + is UserGoodsType.Crystal + or UserGoodsType.Rupy + or UserGoodsType.RedEther + or UserGoodsType.SpotCardPoint; + private IReadOnlyList BuildRewardList() { // Pass 1 — for each currency type, find the last op (spend OR grant) that touched it diff --git a/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs index 7de50e4..9d0f93f 100644 --- a/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs +++ b/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs @@ -66,4 +66,154 @@ public class InventoryHistoryTests Assert.That(roundtrip[0].RewardCount, Is.EqualTo(50)); Assert.That(roundtrip[0].AcquireType, Is.EqualTo((int)GrantSource.DailyBonus)); } + + [Test] + public async Task Commit_writes_one_history_row_per_grant_tagged_with_source() + { + using var factory = new SVSim.UnitTests.Infrastructure.SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + + DateTime before = DateTime.UtcNow; + await using (var tx = await inv.BeginAsync(viewerId, configure: c => c.Source = GrantSource.DailyBonus)) + { + await tx.GrantAsync(SVSim.Database.Enums.UserGoodsType.Rupy, 0, 20); + await tx.CommitAsync(); + } + DateTime after = DateTime.UtcNow; + + using var verifyScope = factory.Services.CreateScope(); + var ctx = verifyScope.ServiceProvider.GetRequiredService(); + var rows = await ctx.ViewerAcquireHistory.AsNoTracking() + .Where(h => h.ViewerId == viewerId).ToListAsync(); + + Assert.That(rows, Has.Count.EqualTo(1)); + Assert.That(rows[0].RewardType, Is.EqualTo((int)SVSim.Database.Enums.UserGoodsType.Rupy)); + Assert.That(rows[0].RewardDetailId, Is.EqualTo(0)); + Assert.That(rows[0].RewardCount, Is.EqualTo(20)); + Assert.That(rows[0].AcquireType, Is.EqualTo((int)GrantSource.DailyBonus)); + Assert.That(rows[0].Message, Is.EqualTo("Daily Bonus")); + Assert.That(rows[0].AcquireTime, Is.InRange(before, after)); + } + + [Test] + public async Task Commit_writes_multiple_rows_in_order_with_shared_timestamp() + { + using var factory = new SVSim.UnitTests.Infrastructure.SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + + await using (var tx = await inv.BeginAsync(viewerId, configure: c => c.Source = GrantSource.PackOpen)) + { + await tx.GrantAsync(SVSim.Database.Enums.UserGoodsType.Rupy, 0, 1); + await tx.GrantAsync(SVSim.Database.Enums.UserGoodsType.Crystal, 0, 2); + await tx.GrantAsync(SVSim.Database.Enums.UserGoodsType.RedEther, 0, 3); + await tx.CommitAsync(); + } + + using var verifyScope = factory.Services.CreateScope(); + var ctx = verifyScope.ServiceProvider.GetRequiredService(); + var rows = await ctx.ViewerAcquireHistory.AsNoTracking() + .Where(h => h.ViewerId == viewerId) + .OrderBy(h => h.Id) + .ToListAsync(); + + Assert.That(rows, Has.Count.EqualTo(3)); + Assert.That(rows.Select(r => r.RewardCount), Is.EqualTo(new[] { 1, 2, 3 })); + Assert.That(rows.Select(r => r.AcquireTime).Distinct().Count(), Is.EqualTo(1), + "all rows in one commit share AcquireTime"); + Assert.That(rows.All(r => r.AcquireType == (int)GrantSource.PackOpen), Is.True); + Assert.That(rows.All(r => r.Message == "From buying card packs"), Is.True); + } + + [Test] + public async Task Commit_writes_no_history_rows_for_spend_only_transactions() + { + using var factory = new SVSim.UnitTests.Infrastructure.SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var seedScope = factory.Services.CreateScope(); + var seedCtx = seedScope.ServiceProvider.GetRequiredService(); + var v = await seedCtx.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 1000; + await seedCtx.SaveChangesAsync(); + + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + await using (var tx = await inv.BeginAsync(viewerId, configure: c => c.Source = GrantSource.ItemPurchase)) + { + await tx.TrySpendAsync(SVSim.Database.Services.SpendCurrency.Crystal, 500); + await tx.CommitAsync(); + } + + using var verifyScope = factory.Services.CreateScope(); + var ctx = verifyScope.ServiceProvider.GetRequiredService(); + var rows = await ctx.ViewerAcquireHistory.AsNoTracking() + .Where(h => h.ViewerId == viewerId).ToListAsync(); + Assert.That(rows, Is.Empty); + } + + [Test] + public async Task Commit_writes_cascade_cosmetic_with_distinct_source_and_message() + { + using var factory = new SVSim.UnitTests.Infrastructure.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_001; + ctx.Sleeves.Add(new SVSim.Database.Models.SleeveEntry { Id = sleeveId }); + ctx.CardCosmeticRewards.Add(new SVSim.Database.Models.CardCosmeticReward + { + CardId = (int)cardId, + CosmeticId = sleeveId, + Type = SVSim.Database.Enums.CosmeticType.Sleeve, + }); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using (var tx = await inv.BeginAsync(viewerId, configure: c => c.Source = GrantSource.PackOpen)) + { + await tx.GrantAsync(SVSim.Database.Enums.UserGoodsType.Card, cardId, 1); + await tx.CommitAsync(); + } + + using var verifyScope = factory.Services.CreateScope(); + var ctx2 = verifyScope.ServiceProvider.GetRequiredService(); + var rows = await ctx2.ViewerAcquireHistory.AsNoTracking() + .Where(h => h.ViewerId == viewerId) + .OrderBy(h => h.Id) + .ToListAsync(); + + Assert.That(rows, Has.Count.EqualTo(2), "card grant + cascade sleeve grant"); + Assert.That(rows[0].RewardType, Is.EqualTo((int)SVSim.Database.Enums.UserGoodsType.Card)); + Assert.That(rows[0].AcquireType, Is.EqualTo((int)GrantSource.PackOpen)); + Assert.That(rows[0].Message, Is.EqualTo("From buying card packs")); + Assert.That(rows[1].RewardType, Is.EqualTo((int)SVSim.Database.Enums.UserGoodsType.Sleeve)); + Assert.That(rows[1].AcquireType, Is.EqualTo((int)GrantSource.CardCosmeticCascade)); + Assert.That(rows[1].Message, Is.EqualTo("Card cosmetic")); + } + + [Test] + public async Task Commit_zero_pads_detail_id_for_wallet_currencies() + { + using var factory = new SVSim.UnitTests.Infrastructure.SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + + await using (var tx = await inv.BeginAsync(viewerId, configure: c => c.Source = GrantSource.DailyBonus)) + { + // detailId=99 is meaningful for some types but ignored for wallets — should still write 0. + await tx.GrantAsync(SVSim.Database.Enums.UserGoodsType.Crystal, 99, 5); + await tx.CommitAsync(); + } + + using var verifyScope = factory.Services.CreateScope(); + var ctx = verifyScope.ServiceProvider.GetRequiredService(); + var row = await ctx.ViewerAcquireHistory.AsNoTracking() + .Where(h => h.ViewerId == viewerId).FirstAsync(); + Assert.That(row.RewardDetailId, Is.EqualTo(0)); + } }