From 220e5699cd1fd9bbbb23c66cdbec4ce5252fedcc Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 15:38:51 -0400 Subject: [PATCH 01/32] feat(inventory): scaffold InventoryService namespace types Empty interfaces + records for IInventoryService, IInventoryTransaction, InventoryCommitResult, InventoryLoadConfig, InventoryCatalogException. Implementation lands in subsequent commits. Co-Authored-By: Claude Opus 4.7 --- .../Services/Inventory/IInventoryService.cs | 28 +++++++++++++++++ .../Inventory/IInventoryTransaction.cs | 30 ++++++++++++++++++ .../Inventory/InventoryCatalogException.cs | 10 ++++++ .../Inventory/InventoryCommitResult.cs | 20 ++++++++++++ .../Services/Inventory/InventoryLoadConfig.cs | 31 +++++++++++++++++++ 5 files changed, 119 insertions(+) create mode 100644 SVSim.Database/Services/Inventory/IInventoryService.cs create mode 100644 SVSim.Database/Services/Inventory/IInventoryTransaction.cs create mode 100644 SVSim.Database/Services/Inventory/InventoryCatalogException.cs create mode 100644 SVSim.Database/Services/Inventory/InventoryCommitResult.cs create mode 100644 SVSim.Database/Services/Inventory/InventoryLoadConfig.cs diff --git a/SVSim.Database/Services/Inventory/IInventoryService.cs b/SVSim.Database/Services/Inventory/IInventoryService.cs new file mode 100644 index 0000000..e1d6ce3 --- /dev/null +++ b/SVSim.Database/Services/Inventory/IInventoryService.cs @@ -0,0 +1,28 @@ +using SVSim.Database.Models; +using SVSim.Database.Services; + +namespace SVSim.Database.Services.Inventory; + +public interface IInventoryService +{ + /// + /// Loads the viewer with the canonical inventory graph (Cards.Card, Sleeves, Emblems, + /// LeaderSkins, Degrees, MyPageBackgrounds, Items.Item under AsSplitQuery), opens a DB + /// transaction, and returns a builder for queueing operations. Throws + /// if the viewer does not exist. + /// + Task BeginAsync( + long viewerId, + CancellationToken ct = default, + Action? configure = null); + + Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default); + Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default); + long EffectiveBalance(Viewer viewer, SpendCurrency currency); +} + +public sealed class InventoryViewerNotFoundException : Exception +{ + public InventoryViewerNotFoundException(long viewerId) + : base($"Viewer {viewerId} not found") { } +} diff --git a/SVSim.Database/Services/Inventory/IInventoryTransaction.cs b/SVSim.Database/Services/Inventory/IInventoryTransaction.cs new file mode 100644 index 0000000..1ba2059 --- /dev/null +++ b/SVSim.Database/Services/Inventory/IInventoryTransaction.cs @@ -0,0 +1,30 @@ +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services; + +namespace SVSim.Database.Services.Inventory; + +/// +/// Scoped builder returned by . Queue spend + +/// grant operations; commit to save and assemble the . +/// +/// Dispose without committing rolls back the underlying DB transaction and detaches any +/// in-memory mutations. Always wrap in await using. +/// +/// +public interface IInventoryTransaction : IAsyncDisposable +{ + Viewer Viewer { get; } + bool IsFreeplay { get; } + + Task TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default); + Task TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default); + Task> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default); + Task BackfillCardCosmeticsAsync(CancellationToken ct = default); + + long EffectiveBalance(SpendCurrency currency); + bool OwnsCard(long cardId); + bool OwnsCosmetic(CosmeticType type, int id); + + Task CommitAsync(CancellationToken ct = default); +} diff --git a/SVSim.Database/Services/Inventory/InventoryCatalogException.cs b/SVSim.Database/Services/Inventory/InventoryCatalogException.cs new file mode 100644 index 0000000..d01943f --- /dev/null +++ b/SVSim.Database/Services/Inventory/InventoryCatalogException.cs @@ -0,0 +1,10 @@ +namespace SVSim.Database.Services.Inventory; + +/// +/// Thrown when an inventory operation references a catalog id that doesn't exist +/// (unknown card / item / cosmetic). Programmer error — bubbles to the global error handler. +/// +public sealed class InventoryCatalogException : Exception +{ + public InventoryCatalogException(string message) : base(message) { } +} diff --git a/SVSim.Database/Services/Inventory/InventoryCommitResult.cs b/SVSim.Database/Services/Inventory/InventoryCommitResult.cs new file mode 100644 index 0000000..e058901 --- /dev/null +++ b/SVSim.Database/Services/Inventory/InventoryCommitResult.cs @@ -0,0 +1,20 @@ +using SVSim.Database.Services; + +namespace SVSim.Database.Services.Inventory; + +/// +/// Result of . +/// +/// — wire-shape entries with currency-collision resolved (one entry per +/// (type, id); for currencies that were both spent and granted, the last post-state in op order +/// wins). Use this for response reward_list fields. +/// +/// +/// — verbatim ordered (type, id, num) sequence the caller queued. No +/// collapse, no cosmetic-cascade entries. Use this for BP achieved_info and Story +/// story_reward_list popups. +/// +/// +public sealed record InventoryCommitResult( + IReadOnlyList RewardList, + IReadOnlyList Deltas); diff --git a/SVSim.Database/Services/Inventory/InventoryLoadConfig.cs b/SVSim.Database/Services/Inventory/InventoryLoadConfig.cs new file mode 100644 index 0000000..b11997c --- /dev/null +++ b/SVSim.Database/Services/Inventory/InventoryLoadConfig.cs @@ -0,0 +1,31 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; +using SVSim.Database.Models; + +namespace SVSim.Database.Services.Inventory; + +/// +/// Caller-supplied extra .Include chains on top of the canonical viewer-inventory query +/// in . Use to bring in extra collections needed by +/// the calling controller (e.g. MissionData, BuildDeckPurchases). +/// +public sealed class InventoryLoadConfig +{ + internal List, IQueryable>> Includes { get; } = new(); + + public InventoryLoadConfig WithInclude( + Expression> path) + { + Includes.Add(q => q.Include(path)); + return this; + } + + public InventoryLoadConfig WithInclude( + Expression>> collectionPath, + Expression> thenPath) + { + Includes.Add(q => q.Include(collectionPath).ThenInclude(thenPath)); + return this; + } +} From b181257aaafa6940ece34ec90b1778761412bda8 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 15:42:41 -0400 Subject: [PATCH 02/32] docs(inventory): XML docs for TrySpend/TryDebit/EffectiveBalance Co-Authored-By: Claude Opus 4.7 --- .../Inventory/IInventoryTransaction.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/SVSim.Database/Services/Inventory/IInventoryTransaction.cs b/SVSim.Database/Services/Inventory/IInventoryTransaction.cs index 1ba2059..b3b70f7 100644 --- a/SVSim.Database/Services/Inventory/IInventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/IInventoryTransaction.cs @@ -17,11 +17,30 @@ public interface IInventoryTransaction : IAsyncDisposable Viewer Viewer { get; } bool IsFreeplay { get; } + /// + /// Debits one of the four scalar wallets. Freeplay-aware for Crystal/Rupee/RedEther + /// (returns Success with the configured freeplay amount, balance unchanged); SpotPoint + /// always real. Returns with current balance on + /// failure; viewer state is not mutated on failure. + /// Task TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default); + + /// + /// Type-dispatched debit. Currencies (RedEther/Crystal/Rupy/SpotCardPoint) route to + /// ; Item decrements OwnedItemEntry.Count. Returns + /// whose PostStateTotal is the new wallet balance for + /// currencies and the remaining item count for Item. + /// Task TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default); + Task> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default); Task BackfillCardCosmeticsAsync(CancellationToken ct = default); + /// + /// Freeplay-aware balance read against the live viewer; reflects any spends queued in + /// this transaction. Inside a transaction, use this; outside, use + /// . + /// long EffectiveBalance(SpendCurrency currency); bool OwnsCard(long cardId); bool OwnsCosmetic(CosmeticType type, int id); From 02e86cf16c1f601c6d1816566097c8f85dded372 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 15:46:20 -0400 Subject: [PATCH 03/32] feat(inventory): BeginAsync loads viewer with canonical graph Includes Cards/Sleeves/Emblems/LeaderSkins/Degrees/MyPageBackgrounds/Items under AsSplitQuery, plus caller-supplied extras via InventoryLoadConfig. Opens a DB transaction and returns an InventoryTransaction shell. All mutation methods throw NotImplementedException until subsequent tasks land them. Co-Authored-By: Claude Opus 4.7 --- .../Services/Inventory/InventoryService.cs | 72 +++++++++++++++++++ .../Inventory/InventoryTransaction.cs | 63 ++++++++++++++++ SVSim.EmulatedEntrypoint/Program.cs | 2 + .../Inventory/InventoryServiceBeginTests.cs | 50 +++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 SVSim.Database/Services/Inventory/InventoryService.cs create mode 100644 SVSim.Database/Services/Inventory/InventoryTransaction.cs create mode 100644 SVSim.UnitTests/Services/Inventory/InventoryServiceBeginTests.cs diff --git a/SVSim.Database/Services/Inventory/InventoryService.cs b/SVSim.Database/Services/Inventory/InventoryService.cs new file mode 100644 index 0000000..d48998e --- /dev/null +++ b/SVSim.Database/Services/Inventory/InventoryService.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SVSim.Database.Models; +using SVSim.Database.Models.Config; +using SVSim.Database.Repositories.Card; +using SVSim.Database.Repositories.Collectibles; + +namespace SVSim.Database.Services.Inventory; + +public sealed class InventoryService : IInventoryService +{ + private readonly SVSimDbContext _db; + private readonly IGameConfigService _config; + private readonly ICardRepository _cards; + private readonly ICollectionRepository _collection; + private readonly ILogger _log; + + public InventoryService( + SVSimDbContext db, + IGameConfigService config, + ICardRepository cards, + ICollectionRepository collection, + ILogger log) + { + _db = db; + _config = config; + _cards = cards; + _collection = collection; + _log = log; + } + + public async Task BeginAsync( + long viewerId, + CancellationToken ct = default, + Action? configure = null) + { + var loadCfg = new InventoryLoadConfig(); + configure?.Invoke(loadCfg); + + IQueryable query = _db.Viewers + .Include(v => v.Cards).ThenInclude(c => c.Card) + .Include(v => v.Sleeves) + .Include(v => v.Emblems) + .Include(v => v.LeaderSkins) + .Include(v => v.Degrees) + .Include(v => v.MyPageBackgrounds) + .Include(v => v.Items).ThenInclude(i => i.Item); + + foreach (var include in loadCfg.Includes) + query = include(query); + + var viewer = await query + .AsSplitQuery() + .FirstOrDefaultAsync(v => v.Id == viewerId, ct) + ?? throw new InventoryViewerNotFoundException(viewerId); + + var freeplay = _config.Get(); + var dbTx = await _db.Database.BeginTransactionAsync(ct); + + return new InventoryTransaction(_db, dbTx, viewer, freeplay, _log); + } + + // Stubs for later tasks. + public Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) + => throw new NotImplementedException(); + + public long EffectiveBalance(Viewer viewer, SpendCurrency currency) + => throw new NotImplementedException(); +} diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs new file mode 100644 index 0000000..4cd9c79 --- /dev/null +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Models.Config; + +namespace SVSim.Database.Services.Inventory; + +internal sealed class InventoryTransaction : IInventoryTransaction +{ + private readonly SVSimDbContext _db; + private readonly IDbContextTransaction _dbTx; + private readonly ILogger _log; + private readonly FreeplayConfig _freeplay; + private bool _committed; + + public Viewer Viewer { get; } + public bool IsFreeplay => _freeplay.Enabled; + + public InventoryTransaction( + SVSimDbContext db, + IDbContextTransaction dbTx, + Viewer viewer, + FreeplayConfig freeplay, + ILogger log) + { + _db = db; + _dbTx = dbTx; + Viewer = viewer; + _freeplay = freeplay; + _log = log; + } + + // Implementations land in later tasks. Throw NotImplementedException to keep the build green. + public Task TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task BackfillCardCosmeticsAsync(CancellationToken ct = default) + => throw new NotImplementedException(); + + public long EffectiveBalance(SpendCurrency currency) => throw new NotImplementedException(); + 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 ValueTask DisposeAsync() + { + if (!_committed) + { + await _dbTx.RollbackAsync(); + _db.ChangeTracker.Clear(); + } + await _dbTx.DisposeAsync(); + } +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index f93bc55..28d0733 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -86,6 +86,8 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + + await using var tx = await inv.BeginAsync(viewerId); + + Assert.That(tx.Viewer, Is.Not.Null); + Assert.That(tx.Viewer.Id, Is.EqualTo(viewerId)); + Assert.That(tx.Viewer.Cards, Is.Not.Null, "Cards collection must be loaded"); + Assert.That(tx.Viewer.Sleeves, Is.Not.Null, "Sleeves collection must be loaded"); + Assert.That(tx.Viewer.Items, Is.Not.Null, "Items collection must be loaded"); + } + + [Test] + public async Task BeginAsync_throws_when_viewer_missing() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + + Assert.ThrowsAsync( + async () => { await inv.BeginAsync(viewerId: 9999); }); + } + + [Test] + public async Task BeginAsync_applies_extra_includes_via_configure() + { + using var factory = new 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: + cfg => cfg.WithInclude(v => v.MissionData)); + + Assert.That(tx.Viewer.MissionData, Is.Not.Null); + } +} From 3bc38b407ba85afc6063b9a388e9a010b52dac1f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 15:50:27 -0400 Subject: [PATCH 04/32] feat(inventory): GrantAsync handles currency branches Crystal/Rupy/RedEther/SpotCardPoint grants mutate ViewerCurrency in place and emit post-state-total wire entries. Op log records the post-state for later currency-collision resolution in CommitAsync. Co-Authored-By: Claude Opus 4.7 --- .../Inventory/InventoryTransaction.cs | 52 ++++++++++++++++++- .../Inventory/InventoryGrantCurrencyTests.cs | 41 +++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 SVSim.UnitTests/Services/Inventory/InventoryGrantCurrencyTests.cs diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index 4cd9c79..7cc654e 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -17,6 +17,12 @@ internal sealed class InventoryTransaction : IInventoryTransaction public Viewer Viewer { get; } public bool IsFreeplay => _freeplay.Enabled; + private readonly List _ops = new(); + + internal abstract record InventoryOp; + internal sealed record SpendOp(SpendCurrency Currency, long Cost, long PostState) : InventoryOp; + internal sealed record GrantOp(UserGoodsType Type, long DetailId, int Num, int PostStateOrCount, bool IsCascade) : InventoryOp; + public InventoryTransaction( SVSimDbContext db, IDbContextTransaction dbTx, @@ -38,8 +44,41 @@ internal sealed class InventoryTransaction : IInventoryTransaction public Task TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default) => throw new NotImplementedException(); - public Task> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default) + { + ThrowIfCommitted(); + + switch (type) + { + case UserGoodsType.Rupy: + Viewer.Currency.Rupees += (ulong)num; + var rupy = checked((int)Viewer.Currency.Rupees); + _ops.Add(new GrantOp(type, detailId, num, rupy, false)); + return Single(type, detailId, rupy); + + case UserGoodsType.Crystal: + Viewer.Currency.Crystals += (ulong)num; + var crystal = checked((int)Viewer.Currency.Crystals); + _ops.Add(new GrantOp(type, detailId, num, crystal, false)); + return Single(type, detailId, crystal); + + case UserGoodsType.RedEther: + Viewer.Currency.RedEther += (ulong)num; + var red = checked((int)Viewer.Currency.RedEther); + _ops.Add(new GrantOp(type, detailId, num, red, false)); + return Single(type, detailId, red); + + case UserGoodsType.SpotCardPoint: + Viewer.Currency.SpotPoints += (ulong)num; + var spot = checked((int)Viewer.Currency.SpotPoints); + _ops.Add(new GrantOp(type, detailId, num, spot, false)); + return Single(type, detailId, spot); + + default: + throw new NotImplementedException( + $"UserGoodsType {type} grant lands in a subsequent task"); + } + } public Task BackfillCardCosmeticsAsync(CancellationToken ct = default) => throw new NotImplementedException(); @@ -51,6 +90,15 @@ internal sealed class InventoryTransaction : IInventoryTransaction public Task CommitAsync(CancellationToken ct = default) => throw new NotImplementedException(); + private static IReadOnlyList Single(UserGoodsType type, long id, int num) + => new[] { new GrantedReward((int)type, id, num) }; + + private void ThrowIfCommitted() + { + if (_committed) + throw new InvalidOperationException("Inventory transaction already committed"); + } + public async ValueTask DisposeAsync() { if (!_committed) diff --git a/SVSim.UnitTests/Services/Inventory/InventoryGrantCurrencyTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryGrantCurrencyTests.cs new file mode 100644 index 0000000..1c1db7d --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventoryGrantCurrencyTests.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Inventory; + +public class InventoryGrantCurrencyTests +{ + [TestCase(UserGoodsType.Rupy)] + [TestCase(UserGoodsType.Crystal)] + [TestCase(UserGoodsType.RedEther)] + [TestCase(UserGoodsType.SpotCardPoint)] + public async Task Grant_currency_adds_and_emits_post_state(UserGoodsType type) + { + 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); + switch (type) + { + case UserGoodsType.Rupy: v.Currency.Rupees = 100; break; + case UserGoodsType.Crystal: v.Currency.Crystals = 100; break; + case UserGoodsType.RedEther: v.Currency.RedEther = 100; break; + case UserGoodsType.SpotCardPoint: v.Currency.SpotPoints = 100; break; + } + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + + var granted = await tx.GrantAsync(type, detailId: 0, num: 50); + + Assert.That(granted, Has.Count.EqualTo(1)); + Assert.That(granted[0].RewardType, Is.EqualTo((int)type)); + Assert.That(granted[0].RewardNum, Is.EqualTo(150)); + } +} From a1cf1d7519f7d71a59405addfa02f74800c9ffbf Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 15:51:46 -0400 Subject: [PATCH 05/32] feat(inventory): GrantAsync handles cosmetic branches Sleeve/Emblem/Skin/Degree/MyPageBG grants are idempotent on the viewer's owned-collection but always emit a wire entry at the top level (preserves "+1 sleeve" purchase popup). Unknown ids throw InventoryCatalogException. Co-Authored-By: Claude Opus 4.7 --- .../Inventory/InventoryTransaction.cs | 43 ++++++++++++ .../Inventory/InventoryGrantCosmeticTests.cs | 69 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 SVSim.UnitTests/Services/Inventory/InventoryGrantCosmeticTests.cs diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index 7cc654e..79ff9cf 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -74,6 +74,31 @@ internal sealed class InventoryTransaction : IInventoryTransaction _ops.Add(new GrantOp(type, detailId, num, spot, false)); return Single(type, detailId, spot); + case UserGoodsType.Sleeve: + AddCosmeticIfMissing(Viewer.Sleeves, detailId, _db.Sleeves); + _ops.Add(new GrantOp(type, detailId, num, 1, false)); + return Single(type, detailId, 1); + + case UserGoodsType.Emblem: + AddCosmeticIfMissing(Viewer.Emblems, detailId, _db.Emblems); + _ops.Add(new GrantOp(type, detailId, num, 1, false)); + return Single(type, detailId, 1); + + case UserGoodsType.Skin: + AddCosmeticIfMissing(Viewer.LeaderSkins, detailId, _db.LeaderSkins); + _ops.Add(new GrantOp(type, detailId, num, 1, false)); + return Single(type, detailId, 1); + + case UserGoodsType.Degree: + AddCosmeticIfMissing(Viewer.Degrees, detailId, _db.Degrees); + _ops.Add(new GrantOp(type, detailId, num, 1, false)); + return Single(type, detailId, 1); + + case UserGoodsType.MyPageBG: + AddCosmeticIfMissing(Viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds); + _ops.Add(new GrantOp(type, detailId, num, 1, false)); + return Single(type, detailId, 1); + default: throw new NotImplementedException( $"UserGoodsType {type} grant lands in a subsequent task"); @@ -99,6 +124,24 @@ internal sealed class InventoryTransaction : IInventoryTransaction throw new InvalidOperationException("Inventory transaction already committed"); } + private static bool AddCosmeticIfMissing(List collection, long detailId, Microsoft.EntityFrameworkCore.DbSet catalog) where T : class + { + if (collection.Any(e => GetId(e) == detailId)) return false; + var entity = catalog.Find(checked((int)detailId)) + ?? throw new InventoryCatalogException( + $"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}"); + collection.Add(entity); + return true; + } + + private static long GetId(T e) + { + var prop = typeof(T).GetProperty("Id") + ?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property"); + var val = prop.GetValue(e); + return val switch { long l => l, int i => i, _ => 0 }; + } + public async ValueTask DisposeAsync() { if (!_committed) diff --git a/SVSim.UnitTests/Services/Inventory/InventoryGrantCosmeticTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryGrantCosmeticTests.cs new file mode 100644 index 0000000..0f73c1d --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventoryGrantCosmeticTests.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Inventory; + +public class InventoryGrantCosmeticTests +{ + [Test] + public async Task Sleeve_added_when_missing() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + const int sleeveId = 2_000_000_001; + ctx.Sleeves.Add(new SleeveEntry { Id = sleeveId }); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var granted = await tx.GrantAsync(UserGoodsType.Sleeve, sleeveId, 1); + + Assert.That(granted, Has.Count.EqualTo(1)); + Assert.That(granted[0].RewardType, Is.EqualTo((int)UserGoodsType.Sleeve)); + Assert.That(granted[0].RewardId, Is.EqualTo(sleeveId)); + Assert.That(granted[0].RewardNum, Is.EqualTo(1)); + Assert.That(tx.Viewer.Sleeves.Any(s => s.Id == sleeveId), Is.True); + } + + [Test] + public async Task Sleeve_idempotent_when_already_owned_but_still_emits_entry() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + const int sleeveId = 2_000_000_002; + var sleeve = new SleeveEntry { Id = sleeveId }; + ctx.Sleeves.Add(sleeve); + var v = await ctx.Viewers.Include(x => x.Sleeves).FirstAsync(x => x.Id == viewerId); + v.Sleeves.Add(sleeve); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var granted = await tx.GrantAsync(UserGoodsType.Sleeve, sleeveId, 1); + + Assert.That(granted, Has.Count.EqualTo(1), "top-level cosmetic grant emits even if owned"); + Assert.That(tx.Viewer.Sleeves.Count(s => s.Id == sleeveId), Is.EqualTo(1), "no duplicate row"); + } + + [Test] + public async Task Unknown_cosmetic_id_throws_catalog_exception() + { + using var factory = new 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); + + Assert.ThrowsAsync( + async () => { await tx.GrantAsync(UserGoodsType.Sleeve, 999_999, 1); }); + } +} From 1f3f81d8786176726be57d8f2d2aaed496bb3ffe Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 15:52:42 -0400 Subject: [PATCH 06/32] feat(inventory): GrantAsync handles Item branch Co-Authored-By: Claude Opus 4.7 --- .../Inventory/InventoryTransaction.cs | 20 +++++++ .../Inventory/InventoryGrantItemTests.cs | 53 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 SVSim.UnitTests/Services/Inventory/InventoryGrantItemTests.cs diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index 79ff9cf..4f1bbe7 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -99,6 +99,26 @@ internal sealed class InventoryTransaction : IInventoryTransaction _ops.Add(new GrantOp(type, detailId, num, 1, false)); return Single(type, detailId, 1); + case UserGoodsType.Item: + { + var owned = Viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId); + int post; + if (owned is null) + { + var item = _db.Items.Find((int)detailId) + ?? throw new InventoryCatalogException($"Item {detailId} not in catalog"); + Viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = Viewer }); + post = num; + } + else + { + owned.Count += num; + post = owned.Count; + } + _ops.Add(new GrantOp(type, detailId, num, post, false)); + return Single(type, detailId, post); + } + default: throw new NotImplementedException( $"UserGoodsType {type} grant lands in a subsequent task"); diff --git a/SVSim.UnitTests/Services/Inventory/InventoryGrantItemTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryGrantItemTests.cs new file mode 100644 index 0000000..40c3d59 --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventoryGrantItemTests.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Inventory; + +public class InventoryGrantItemTests +{ + [Test] + public async Task Item_first_grant_creates_owned_entry() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + const int itemId = 31000; + ctx.Items.Add(new ItemEntry { Id = itemId }); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var granted = await tx.GrantAsync(UserGoodsType.Item, itemId, 3); + + Assert.That(granted[0].RewardNum, Is.EqualTo(3)); + Assert.That(tx.Viewer.Items.Single(i => i.Item.Id == itemId).Count, Is.EqualTo(3)); + } + + [Test] + public async Task Item_second_grant_accumulates_post_state() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + const int itemId = 31001; + var item = new ItemEntry { Id = itemId }; + ctx.Items.Add(item); + var v = await ctx.Viewers.Include(x => x.Items).ThenInclude(i => i.Item).FirstAsync(x => x.Id == viewerId); + v.Items.Add(new OwnedItemEntry { Item = item, Count = 5, Viewer = v }); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var granted = await tx.GrantAsync(UserGoodsType.Item, itemId, 4); + + Assert.That(granted[0].RewardNum, Is.EqualTo(9)); + Assert.That(tx.Viewer.Items.Single(i => i.Item.Id == itemId).Count, Is.EqualTo(9)); + } +} From a821b7f6b42ec48caecba90f80783aebb5e628c9 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 15:54:36 -0400 Subject: [PATCH 07/32] feat(inventory): GrantAsync handles Card + cosmetic cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card grants produce a post-state-total entry and run the CardCosmeticReward cascade (foil twin → id-1 lookup). Cascade additions are skipped when the viewer already owns the cosmetic; missing-master-row failures are logged and dropped without failing the parent grant. Co-Authored-By: Claude Opus 4.7 --- .../Inventory/InventoryTransaction.cs | 73 ++++++++++++++++++ .../Infrastructure/SVSimTestFactory.cs | 17 +++++ .../Inventory/InventoryGrantCardTests.cs | 75 +++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 SVSim.UnitTests/Services/Inventory/InventoryGrantCardTests.cs diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index 4f1bbe7..b357ea7 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Logging; using SVSim.Database.Enums; @@ -119,6 +120,14 @@ internal sealed class InventoryTransaction : IInventoryTransaction return Single(type, detailId, post); } + case UserGoodsType.Card: + return await ApplyCardAsync(detailId, num, ct); + + case UserGoodsType.SpotCard: + case UserGoodsType.SpotCardOnlyLatestCardPack: + throw new NotSupportedException( + $"{type} rewards are not yet supported — emitters use Card=5 instead."); + default: throw new NotImplementedException( $"UserGoodsType {type} grant lands in a subsequent task"); @@ -144,6 +153,70 @@ internal sealed class InventoryTransaction : IInventoryTransaction throw new InvalidOperationException("Inventory transaction already committed"); } + private async Task> ApplyCardAsync(long cardId, int num, CancellationToken ct) + { + var owned = Viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId); + int postCount; + if (owned is null) + { + var card = await _db.Cards.FirstOrDefaultAsync(c => c.Id == cardId, ct) + ?? throw new InventoryCatalogException($"Card {cardId} not in catalog"); + owned = new OwnedCardEntry { Card = card, Count = num, IsProtected = false }; + Viewer.Cards.Add(owned); + postCount = num; + } + else + { + owned.Count += num; + postCount = owned.Count; + } + + var results = new List + { + new((int)UserGoodsType.Card, cardId, postCount), + }; + _ops.Add(new GrantOp(UserGoodsType.Card, cardId, num, postCount, false)); + + long lookupId = owned.Card.IsFoil ? cardId - 1 : cardId; + var cascade = await _db.CardCosmeticRewards + .Where(r => r.CardId == lookupId) + .ToListAsync(ct); + + foreach (var reward in cascade) + { + if (TryAddCascadeCosmetic(reward, lookupId)) + { + results.Add(new GrantedReward((int)reward.Type, reward.CosmeticId, 1)); + _ops.Add(new GrantOp((UserGoodsType)(int)reward.Type, reward.CosmeticId, 1, 1, true)); + } + } + + return results; + } + + private bool TryAddCascadeCosmetic(CardCosmeticReward reward, long forCardId) + { + try + { + return reward.Type switch + { + CosmeticType.Sleeve => AddCosmeticIfMissing(Viewer.Sleeves, reward.CosmeticId, _db.Sleeves), + CosmeticType.Emblem => AddCosmeticIfMissing(Viewer.Emblems, reward.CosmeticId, _db.Emblems), + CosmeticType.Skin => AddCosmeticIfMissing(Viewer.LeaderSkins, reward.CosmeticId, _db.LeaderSkins), + CosmeticType.Degree => AddCosmeticIfMissing(Viewer.Degrees, reward.CosmeticId, _db.Degrees), + CosmeticType.MyPageBG => AddCosmeticIfMissing(Viewer.MyPageBackgrounds, reward.CosmeticId, _db.MyPageBackgrounds), + _ => false, + }; + } + catch (InventoryCatalogException ex) + { + _log.LogWarning(ex, + "Card cascade: cosmetic {Type} {Id} for card {CardId} skipped (master row missing)", + reward.Type, reward.CosmeticId, forCardId); + return false; + } + } + private static bool AddCosmeticIfMissing(List collection, long detailId, Microsoft.EntityFrameworkCore.DbSet catalog) where T : class { if (collection.Any(e => GetId(e) == detailId)) return false; diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index f22b797..ada7002 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -427,6 +427,23 @@ internal sealed class SVSimTestFactory : WebApplicationFactory await db.SaveChangesAsync(); } + /// + /// Seeds a bare (no viewer ownership) and returns its id. + /// Used by InventoryGrantCardTests to get a valid card id without also seeding owned state. + /// Ids start at 800_000_000 (non-foil) or 800_000_001 (foil) and increment by 2 per call to + /// keep foil twins aligned. + /// + public async Task SeedCardAsync(bool isFoil = false) + { + using var scope = Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + long id = isFoil ? 800_000_001L : 800_000_000L; + while (await ctx.Cards.AnyAsync(c => c.Id == id)) id += 2; + ctx.Cards.Add(new ShadowverseCardEntry { Id = id, IsFoil = isFoil, Name = $"SeedCard{id}" }); + await ctx.SaveChangesAsync(); + return id; + } + /// /// Sets the viewer's RedEther balance to . Call this AFTER /// , which resets RedEther to 0. Create tests use this diff --git a/SVSim.UnitTests/Services/Inventory/InventoryGrantCardTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryGrantCardTests.cs new file mode 100644 index 0000000..4710c30 --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventoryGrantCardTests.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Inventory; + +public class InventoryGrantCardTests +{ + [Test] + public async Task Card_first_grant_creates_owned_with_post_state_count() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + long cardId = await factory.SeedCardAsync(); // helper added below if missing + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + + var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 2); + + Assert.That(granted, Has.Count.EqualTo(1)); + Assert.That(granted[0].RewardType, Is.EqualTo((int)UserGoodsType.Card)); + Assert.That(granted[0].RewardId, Is.EqualTo(cardId)); + Assert.That(granted[0].RewardNum, Is.EqualTo(2)); + } + + [Test] + public async Task Card_cascade_grants_associated_cosmetic_and_appends_entry() + { + 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_010_000; + ctx.Sleeves.Add(new SleeveEntry { Id = sleeveId }); + ctx.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = cardId, CosmeticId = sleeveId, Type = CosmeticType.Sleeve }); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 1); + + Assert.That(granted, Has.Count.EqualTo(2)); + Assert.That(granted[1].RewardType, Is.EqualTo((int)UserGoodsType.Sleeve)); + Assert.That(granted[1].RewardId, Is.EqualTo(sleeveId)); + } + + [Test] + public async Task Card_cascade_skips_already_owned_cosmetic() + { + 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_010_001; + var sleeve = new SleeveEntry { Id = sleeveId }; + ctx.Sleeves.Add(sleeve); + ctx.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = cardId, CosmeticId = sleeveId, Type = CosmeticType.Sleeve }); + var v = await ctx.Viewers.Include(x => x.Sleeves).FirstAsync(x => x.Id == viewerId); + v.Sleeves.Add(sleeve); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 1); + + Assert.That(granted, Has.Count.EqualTo(1), "owned cosmetic skipped from cascade"); + } +} From 301da9eeca98f02eda532e2d7d6f72381aab3bd9 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 15:59:26 -0400 Subject: [PATCH 08/32] feat(inventory): TrySpendAsync covers all 4 wallets + freeplay Crystal/Rupy/RedEther freeplay no-op (returns configured amount, balance unchanged); SpotPoint always real. Insufficient returns current balance; success returns post-deduction balance. SVSimTestFactory gains freeplayEnabled ctor overload that upserts the Freeplay GameConfigSection row after EnsureSeedData. Co-Authored-By: Claude Opus 4.7 --- .../Inventory/InventoryTransaction.cs | 43 +++++++++++- .../Infrastructure/SVSimTestFactory.cs | 17 ++++- .../Services/Inventory/InventorySpendTests.cs | 70 +++++++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 SVSim.UnitTests/Services/Inventory/InventorySpendTests.cs diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index b357ea7..391d8d2 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -38,9 +38,48 @@ internal sealed class InventoryTransaction : IInventoryTransaction _log = log; } - // Implementations land in later tasks. Throw NotImplementedException to keep the build green. public Task TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default) - => throw new NotImplementedException(); + { + ThrowIfCommitted(); + if (cost < 0) cost = 0; + + if (_freeplay.Enabled && currency != SpendCurrency.SpotPoint) + { + long amount = checked((long)_freeplay.CurrencyAmount); + _ops.Add(new SpendOp(currency, cost, amount)); + return Task.FromResult(new SpendResult(SpendOutcome.Success, amount)); + } + + ulong current = ReadBalance(currency); + if (current < (ulong)cost) + return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current)); + + ulong post = current - (ulong)cost; + WriteBalance(currency, post); + _ops.Add(new SpendOp(currency, cost, (long)post)); + return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post)); + } + + private ulong ReadBalance(SpendCurrency c) => c switch + { + SpendCurrency.Crystal => Viewer.Currency.Crystals, + SpendCurrency.Rupee => Viewer.Currency.Rupees, + SpendCurrency.RedEther => Viewer.Currency.RedEther, + SpendCurrency.SpotPoint => Viewer.Currency.SpotPoints, + _ => throw new ArgumentOutOfRangeException(nameof(c)), + }; + + private void WriteBalance(SpendCurrency c, ulong value) + { + switch (c) + { + case SpendCurrency.Crystal: Viewer.Currency.Crystals = value; break; + case SpendCurrency.Rupee: Viewer.Currency.Rupees = value; break; + case SpendCurrency.RedEther: Viewer.Currency.RedEther = value; break; + case SpendCurrency.SpotPoint: Viewer.Currency.SpotPoints = value; break; + default: throw new ArgumentOutOfRangeException(nameof(c)); + } + } public Task TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default) => throw new NotImplementedException(); diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index ada7002..83b2fea 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -28,9 +28,11 @@ internal sealed class SVSimTestFactory : WebApplicationFactory { private readonly SqliteConnection _connection; private long _nextSeededShortUdid = 400_000_001; + private readonly bool _freeplayEnabled; - public SVSimTestFactory() + public SVSimTestFactory(bool freeplayEnabled = false) { + _freeplayEnabled = freeplayEnabled; // SQLite :memory: lives only as long as a connection is open — keep ours open for the // factory's lifetime so the DbContext can reattach to the same DB across scopes. _connection = new SqliteConnection("DataSource=:memory:"); @@ -59,6 +61,19 @@ internal sealed class SVSimTestFactory : WebApplicationFactory db.Database.EnsureCreated(); db.EnsureSeedDataAsync().GetAwaiter().GetResult(); + if (_freeplayEnabled) + { + using var seedScope = host.Services.CreateScope(); + var seedDb = seedScope.ServiceProvider.GetRequiredService(); + const string freeplayJson = "{\"Enabled\":true,\"CurrencyAmount\":99999,\"CardCopies\":3}"; + var existing = seedDb.GameConfigs.FirstOrDefault(s => s.SectionName == "Freeplay"); + if (existing is null) + seedDb.GameConfigs.Add(new SVSim.Database.Models.GameConfigSection { SectionName = "Freeplay", ValueJson = freeplayJson }); + else + existing.ValueJson = freeplayJson; + seedDb.SaveChanges(); + } + // Reference data is no longer HasData-seeded; load the CSVs via the same importer // production uses so tests exercise the same code path. CardCosmeticRewards skipped — // FK to Cards would reject every row against the minimal 3-card test seed below. diff --git a/SVSim.UnitTests/Services/Inventory/InventorySpendTests.cs b/SVSim.UnitTests/Services/Inventory/InventorySpendTests.cs new file mode 100644 index 0000000..dc6c11d --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventorySpendTests.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models.Config; +using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Inventory; + +public class InventorySpendTests +{ + [Test] + public async Task Spend_sufficient_returns_post_deduction_total() + { + 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); + var r = await tx.TrySpendAsync(SpendCurrency.Crystal, 300); + + Assert.That(r.Success, Is.True); + Assert.That(r.PostStateTotal, Is.EqualTo(700)); + Assert.That(tx.Viewer.Currency.Crystals, Is.EqualTo(700UL)); + } + + [Test] + public async Task Spend_insufficient_returns_insufficient_with_current_balance() + { + 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 = 100; + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var r = await tx.TrySpendAsync(SpendCurrency.Crystal, 300); + + Assert.That(r.Outcome, Is.EqualTo(SpendOutcome.Insufficient)); + Assert.That(r.PostStateTotal, Is.EqualTo(100)); + Assert.That(tx.Viewer.Currency.Crystals, Is.EqualTo(100UL), "balance unchanged"); + } + + [Test] + public async Task Freeplay_returns_success_with_configured_amount_for_main_currencies() + { + using var factory = new SVSimTestFactory(freeplayEnabled: true); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + ulong balanceBefore = tx.Viewer.Currency.Crystals; + var r = await tx.TrySpendAsync(SpendCurrency.Crystal, 99999); + + Assert.That(r.Success, Is.True); + var freeCfg = scope.ServiceProvider.GetRequiredService().Get(); + Assert.That(r.PostStateTotal, Is.EqualTo(checked((long)freeCfg.CurrencyAmount))); + Assert.That(tx.Viewer.Currency.Crystals, Is.EqualTo(balanceBefore), "freeplay never deducts"); + } +} From 46d8239d5afd2a1cbcbb5c5be58f5babc7e5926b Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:00:24 -0400 Subject: [PATCH 09/32] feat(inventory): TryDebitAsync dispatches currencies + Item Co-Authored-By: Claude Opus 4.7 --- .../Inventory/InventoryTransaction.cs | 29 ++++++- .../Services/Inventory/InventoryDebitTests.cs | 76 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 SVSim.UnitTests/Services/Inventory/InventoryDebitTests.cs diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index 391d8d2..a6337db 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -82,7 +82,34 @@ internal sealed class InventoryTransaction : IInventoryTransaction } public Task TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default) - => throw new NotImplementedException(); + { + ThrowIfCommitted(); + return type switch + { + UserGoodsType.Crystal => TrySpendAsync(SpendCurrency.Crystal, num, ct), + UserGoodsType.Rupy => TrySpendAsync(SpendCurrency.Rupee, num, ct), + UserGoodsType.RedEther => TrySpendAsync(SpendCurrency.RedEther, num, ct), + UserGoodsType.SpotCardPoint => TrySpendAsync(SpendCurrency.SpotPoint, num, ct), + UserGoodsType.Item => Task.FromResult(DebitItem(detailId, num)), + _ => throw new NotSupportedException($"Debit not supported for {type}"), + }; + } + + private SpendResult DebitItem(long detailId, int num) + { + var owned = Viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId); + if (owned is null) + throw new InventoryCatalogException($"Item {detailId} not owned by viewer"); + if (owned.Count < num) + return new SpendResult(SpendOutcome.Insufficient, owned.Count); + owned.Count -= num; + // Item debit logged as a synthetic SpendOp so CommitAsync can track it. + // Sentinel currency (int)-1 is filtered out by CommitAsync's currency-collision loop. + _ops.Add(new SpendOp((SpendCurrency)(-1) /* sentinel */, num, owned.Count)); + // IsCascade: true so this GrantOp is excluded from BuildDeltas output. + _ops.Add(new GrantOp(UserGoodsType.Item, detailId, 0, owned.Count, IsCascade: true)); + return new SpendResult(SpendOutcome.Success, owned.Count); + } public async Task> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default) { diff --git a/SVSim.UnitTests/Services/Inventory/InventoryDebitTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryDebitTests.cs new file mode 100644 index 0000000..5412075 --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventoryDebitTests.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Inventory; + +public class InventoryDebitTests +{ + [Test] + public async Task Debit_Crystal_delegates_to_TrySpend() + { + 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 = 500; + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var r = await tx.TryDebitAsync(UserGoodsType.Crystal, 0, 200); + + Assert.That(r.Success, Is.True); + Assert.That(r.PostStateTotal, Is.EqualTo(300)); + } + + [Test] + public async Task Debit_Item_decrements_count_and_returns_post_state() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + const int itemId = 32000; + var item = new ItemEntry { Id = itemId }; + ctx.Items.Add(item); + var v = await ctx.Viewers.Include(x => x.Items).ThenInclude(i => i.Item).FirstAsync(x => x.Id == viewerId); + v.Items.Add(new OwnedItemEntry { Item = item, Count = 10, Viewer = v }); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var r = await tx.TryDebitAsync(UserGoodsType.Item, itemId, 3); + + Assert.That(r.Success, Is.True); + Assert.That(r.PostStateTotal, Is.EqualTo(7)); + } + + [Test] + public async Task Debit_Item_insufficient_returns_current_count_and_does_not_decrement() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + const int itemId = 32001; + var item = new ItemEntry { Id = itemId }; + ctx.Items.Add(item); + var v = await ctx.Viewers.Include(x => x.Items).ThenInclude(i => i.Item).FirstAsync(x => x.Id == viewerId); + v.Items.Add(new OwnedItemEntry { Item = item, Count = 2, Viewer = v }); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var r = await tx.TryDebitAsync(UserGoodsType.Item, itemId, 5); + + Assert.That(r.Outcome, Is.EqualTo(SpendOutcome.Insufficient)); + Assert.That(r.PostStateTotal, Is.EqualTo(2)); + } +} From 1ba3f57709924f3ea1491e15c8265c5210378506 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:01:15 -0400 Subject: [PATCH 10/32] feat(inventory): BackfillCardCosmeticsAsync Co-Authored-By: Claude Opus 4.7 --- .../Inventory/InventoryTransaction.cs | 38 ++++++++++- .../Inventory/InventoryBackfillTests.cs | 66 +++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 SVSim.UnitTests/Services/Inventory/InventoryBackfillTests.cs diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index a6337db..f812ba3 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -200,8 +200,42 @@ internal sealed class InventoryTransaction : IInventoryTransaction } } - public Task BackfillCardCosmeticsAsync(CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task BackfillCardCosmeticsAsync(CancellationToken ct = default) + { + ThrowIfCommitted(); + + var lookupIds = Viewer.Cards + .Select(c => c.Card.IsFoil ? c.Card.Id - 1 : c.Card.Id) + .Distinct() + .ToList(); + + var cascade = await _db.CardCosmeticRewards + .Where(r => lookupIds.Contains(r.CardId)) + .ToListAsync(ct); + + int granted = 0; + foreach (var reward in cascade) + { + if (AlreadyOwnsCosmetic(reward.Type, reward.CosmeticId)) continue; + if (TryAddCascadeCosmetic(reward, reward.CardId)) + { + granted++; + _ops.Add(new GrantOp((UserGoodsType)(int)reward.Type, reward.CosmeticId, 1, 1, true)); + } + } + + return granted; + } + + private bool AlreadyOwnsCosmetic(CosmeticType type, long id) => type switch + { + CosmeticType.Sleeve => Viewer.Sleeves.Any(s => s.Id == id), + CosmeticType.Emblem => Viewer.Emblems.Any(e => e.Id == id), + CosmeticType.Skin => Viewer.LeaderSkins.Any(s => s.Id == id), + CosmeticType.Degree => Viewer.Degrees.Any(d => d.Id == id), + CosmeticType.MyPageBG => Viewer.MyPageBackgrounds.Any(b => b.Id == id), + _ => false, + }; public long EffectiveBalance(SpendCurrency currency) => throw new NotImplementedException(); public bool OwnsCard(long cardId) => throw new NotImplementedException(); diff --git a/SVSim.UnitTests/Services/Inventory/InventoryBackfillTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryBackfillTests.cs new file mode 100644 index 0000000..e9edd00 --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventoryBackfillTests.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Inventory; + +public class InventoryBackfillTests +{ + [Test] + public async Task Backfill_grants_missing_cosmetic_for_already_owned_card() + { + 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_020_000; + ctx.Sleeves.Add(new SleeveEntry { Id = sleeveId }); + ctx.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = cardId, CosmeticId = sleeveId, Type = CosmeticType.Sleeve }); + var card = await ctx.Cards.FirstAsync(c => c.Id == cardId); + var v = await ctx.Viewers.Include(x => x.Cards).ThenInclude(c => c.Card).FirstAsync(x => x.Id == viewerId); + v.Cards.Add(new OwnedCardEntry { Card = card, Count = 3, IsProtected = false }); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + int granted = await tx.BackfillCardCosmeticsAsync(); + + Assert.That(granted, Is.EqualTo(1)); + Assert.That(tx.Viewer.Sleeves.Any(s => s.Id == sleeveId), Is.True); + } + + [Test] + public async Task Backfill_idempotent_on_already_owned_cosmetic() + { + 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_020_001; + var sleeve = new SleeveEntry { Id = sleeveId }; + ctx.Sleeves.Add(sleeve); + ctx.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = cardId, CosmeticId = sleeveId, Type = CosmeticType.Sleeve }); + var card = await ctx.Cards.FirstAsync(c => c.Id == cardId); + var v = await ctx.Viewers + .Include(x => x.Cards).ThenInclude(c => c.Card) + .Include(x => x.Sleeves) + .FirstAsync(x => x.Id == viewerId); + v.Cards.Add(new OwnedCardEntry { Card = card, Count = 3, IsProtected = false }); + v.Sleeves.Add(sleeve); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + int granted = await tx.BackfillCardCosmeticsAsync(); + + Assert.That(granted, Is.EqualTo(0)); + } +} From b0b9901c42e4d09fb07a65fa0c8d949e7a40ec00 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:02:39 -0400 Subject: [PATCH 11/32] feat(inventory): CommitAsync + currency-collision rule Last post-state per currency wins; non-currency grants collapse to final count per (type, id). Deltas are verbatim queued, no cascade. SaveChanges + DB tx commit happen atomically inside Commit; failure leaves rollback to DisposeAsync. CS0649 warning on _committed is now resolved. Co-Authored-By: Claude Opus 4.7 --- .../Inventory/InventoryTransaction.cs | 84 +++++++++++++- .../Inventory/InventoryCommitTests.cs | 104 ++++++++++++++++++ 2 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 SVSim.UnitTests/Services/Inventory/InventoryCommitTests.cs 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"); + } +} From ea340cde2172e3876a731e2456984db420e73aac Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:03:06 -0400 Subject: [PATCH 12/32] =?UTF-8?q?test(inventory):=20lifecycle=20=E2=80=94?= =?UTF-8?q?=20dispose=20rollback=20+=20use-after-commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../Inventory/InventoryLifecycleTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 SVSim.UnitTests/Services/Inventory/InventoryLifecycleTests.cs diff --git a/SVSim.UnitTests/Services/Inventory/InventoryLifecycleTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryLifecycleTests.cs new file mode 100644 index 0000000..a9a5188 --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventoryLifecycleTests.cs @@ -0,0 +1,51 @@ +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 InventoryLifecycleTests +{ + [Test] + public async Task Dispose_without_commit_does_not_persist() + { + 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); + // no commit; dispose runs + } + + 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(100UL), "no persistence without commit"); + } + + [Test] + public async Task Use_after_commit_throws() + { + using var factory = new 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); + await tx.CommitAsync(); + + Assert.ThrowsAsync( + async () => { await tx.GrantAsync(UserGoodsType.Rupy, 0, 1); }); + } +} From 91909c57556b2d8f492d27b4461c1b8274bc2e3d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:05:35 -0400 Subject: [PATCH 13/32] feat(inventory): read-side methods on IInventoryService + tx EffectiveBalance/OwnsCard/OwnsCosmetic on the tx are freeplay-aware against the live viewer. EffectiveOwnedCardsAsync/EffectiveCosmeticsAsync on the service mirror today's ViewerEntitlements projections (used by /load/index). Co-Authored-By: Claude Opus 4.7 --- .../Services/Inventory/InventoryService.cs | 74 ++++++++-- .../Inventory/InventoryTransaction.cs | 34 ++++- .../Inventory/InventoryReadSideTests.cs | 128 ++++++++++++++++++ 3 files changed, 225 insertions(+), 11 deletions(-) create mode 100644 SVSim.UnitTests/Services/Inventory/InventoryReadSideTests.cs diff --git a/SVSim.Database/Services/Inventory/InventoryService.cs b/SVSim.Database/Services/Inventory/InventoryService.cs index d48998e..cc5ea67 100644 --- a/SVSim.Database/Services/Inventory/InventoryService.cs +++ b/SVSim.Database/Services/Inventory/InventoryService.cs @@ -60,13 +60,71 @@ public sealed class InventoryService : IInventoryService return new InventoryTransaction(_db, dbTx, viewer, freeplay, _log); } - // Stubs for later tasks. - public Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default) - => throw new NotImplementedException(); - - public Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) - => throw new NotImplementedException(); - public long EffectiveBalance(Viewer viewer, SpendCurrency currency) - => throw new NotImplementedException(); + { + var cfg = _config.Get(); + if (cfg.Enabled && currency != SpendCurrency.SpotPoint) + return checked((long)cfg.CurrencyAmount); + + return currency switch + { + SpendCurrency.Crystal => (long)viewer.Currency.Crystals, + SpendCurrency.Rupee => (long)viewer.Currency.Rupees, + SpendCurrency.RedEther => (long)viewer.Currency.RedEther, + SpendCurrency.SpotPoint => (long)viewer.Currency.SpotPoints, + _ => throw new ArgumentOutOfRangeException(nameof(currency)), + }; + } + + public async Task> EffectiveOwnedCardsAsync( + Viewer viewer, CancellationToken ct = default) + { + var defaults = await _cards.GetDefaultCards(); + var defaultIds = defaults.Select(c => c.Id).ToHashSet(); + var cfg = _config.Get(); + + if (cfg.Enabled) + { + var all = await _cards.GetAll(onlyCollectible: true); + return all + .Select(c => new OwnedCardEntry + { + Card = c, + Count = cfg.CardCopies, + IsProtected = defaultIds.Contains(c.Id), + }) + .ToList(); + } + + var owned = viewer.Cards.Where(c => c.Count > 0 && !defaultIds.Contains(c.Card.Id)); + return owned + .Concat(defaults.Select(bc => new OwnedCardEntry { Card = bc, Count = 3, IsProtected = true })) + .ToList(); + } + + public async Task EffectiveCosmeticsAsync( + Viewer viewer, CancellationToken ct = default) + { + var allSkins = await _collection.GetLeaderSkins(); + var cfg = _config.Get(); + + if (cfg.Enabled) + { + return new EffectiveCosmetics( + await _collection.GetAllSleeveIds(), + await _collection.GetAllEmblemIds(), + await _collection.GetAllDegreeIds(), + await _collection.GetAllMyPageBackgroundIds(), + allSkins, + allSkins.Select(s => s.Id).ToHashSet()); + } + + return new EffectiveCosmetics( + viewer.Sleeves.Select(s => s.Id).ToList(), + viewer.Emblems.Select(e => e.Id).ToList(), + viewer.Degrees.Select(d => d.Id).ToList(), + viewer.MyPageBackgrounds.Select(m => m.Id).ToList(), + allSkins, + viewer.LeaderSkins.Select(s => s.Id).ToHashSet()); + } } diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index 2ec341a..f36c2c0 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -237,9 +237,37 @@ internal sealed class InventoryTransaction : IInventoryTransaction _ => false, }; - public long EffectiveBalance(SpendCurrency currency) => throw new NotImplementedException(); - public bool OwnsCard(long cardId) => throw new NotImplementedException(); - public bool OwnsCosmetic(CosmeticType type, int id) => throw new NotImplementedException(); + public long EffectiveBalance(SpendCurrency currency) + { + if (_freeplay.Enabled && currency != SpendCurrency.SpotPoint) + return checked((long)_freeplay.CurrencyAmount); + + return currency switch + { + SpendCurrency.Crystal => (long)Viewer.Currency.Crystals, + SpendCurrency.Rupee => (long)Viewer.Currency.Rupees, + SpendCurrency.RedEther => (long)Viewer.Currency.RedEther, + SpendCurrency.SpotPoint => (long)Viewer.Currency.SpotPoints, + _ => throw new ArgumentOutOfRangeException(nameof(currency)), + }; + } + + public bool OwnsCard(long cardId) + => _freeplay.Enabled || Viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0); + + public bool OwnsCosmetic(CosmeticType type, int id) + { + if (_freeplay.Enabled) return true; + return type switch + { + CosmeticType.Sleeve => Viewer.Sleeves.Any(s => s.Id == id), + CosmeticType.Emblem => Viewer.Emblems.Any(e => e.Id == id), + CosmeticType.Degree => Viewer.Degrees.Any(d => d.Id == id), + CosmeticType.Skin => Viewer.LeaderSkins.Any(s => s.Id == id), + CosmeticType.MyPageBG => Viewer.MyPageBackgrounds.Any(m => m.Id == id), + _ => false, + }; + } public async Task CommitAsync(CancellationToken ct = default) { diff --git a/SVSim.UnitTests/Services/Inventory/InventoryReadSideTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryReadSideTests.cs new file mode 100644 index 0000000..fa5bc0a --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventoryReadSideTests.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Inventory; + +public class InventoryReadSideTests +{ + [Test] + public async Task EffectiveBalance_returns_viewer_currency_when_not_freeplay() + { + 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 = 1234; + await ctx.SaveChangesAsync(); + + // Re-load viewer with inventory graph for the read-side call + var v2 = await ctx.Viewers.AsNoTracking().FirstAsync(x => x.Id == viewerId); + var inv = scope.ServiceProvider.GetRequiredService(); + Assert.That(inv.EffectiveBalance(v2, SpendCurrency.Crystal), Is.EqualTo(1234)); + } + + [Test] + public async Task EffectiveOwnedCardsAsync_returns_non_null_collection() + { + 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 + .Include(x => x.Cards).ThenInclude(c => c.Card) + .FirstAsync(x => x.Id == viewerId); + + var inv = scope.ServiceProvider.GetRequiredService(); + var owned = await inv.EffectiveOwnedCardsAsync(v); + + Assert.That(owned, Is.Not.Null); + // If there are basic cards seeded (IsBasic=true) they should be protected; + // if none are seeded the collection may be empty — just confirm it doesn't throw. + } + + [Test] + public async Task EffectiveBalance_returns_freeplay_amount_when_freeplay_enabled() + { + using var factory = new SVSimTestFactory(freeplayEnabled: true); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var v = await ctx.Viewers.AsNoTracking().FirstAsync(x => x.Id == viewerId); + + var inv = scope.ServiceProvider.GetRequiredService(); + var freeCfg = scope.ServiceProvider.GetRequiredService().Get(); + Assert.That(inv.EffectiveBalance(v, SpendCurrency.Crystal), Is.EqualTo(checked((long)freeCfg.CurrencyAmount))); + } + + [Test] + public async Task Transaction_EffectiveBalance_matches_viewer_balance() + { + 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 = 5678; + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + + Assert.That(tx.EffectiveBalance(SpendCurrency.Crystal), Is.EqualTo(5678)); + } + + [Test] + public async Task Transaction_OwnsCard_returns_true_when_card_owned() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + long cardId = await factory.SeedCardAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId, 1); + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + + Assert.That(tx.OwnsCard(cardId), Is.True); + } + + [Test] + public async Task Transaction_OwnsCard_returns_false_when_card_not_owned() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + long cardId = await factory.SeedCardAsync(); + // Do NOT seed owned card + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + + Assert.That(tx.OwnsCard(cardId), Is.False); + } + + [Test] + public async Task Transaction_OwnsCosmetic_returns_true_when_owned() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + const int sleeveId = 2_000_040_000; + var sleeve = new SleeveEntry { Id = sleeveId }; + ctx.Sleeves.Add(sleeve); + var v = await ctx.Viewers.Include(x => x.Sleeves).FirstAsync(x => x.Id == viewerId); + v.Sleeves.Add(sleeve); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + + Assert.That(tx.OwnsCosmetic(CosmeticType.Sleeve, sleeveId), Is.True); + } +} From 1113e52f94856bf268ef3241cd3d7020b067e228 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:12:27 -0400 Subject: [PATCH 14/32] refactor(load): switch to InventoryService for entitlements Co-Authored-By: Claude Opus 4.7 --- .../Controllers/LoadController.cs | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs index d088242..83bec93 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs @@ -10,6 +10,7 @@ using PreReleaseInfoDto = SVSim.EmulatedEntrypoint.Models.Dtos.PreReleaseInfo; using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Infrastructure; using SVSim.EmulatedEntrypoint.Models.Dtos; @@ -42,26 +43,24 @@ public class LoadController : SVSimController private readonly IViewerRepository _viewerRepository; private readonly IGlobalsRepository _globalsRepository; - private readonly ICardAcquisitionService _acquisition; private readonly IGameConfigService _config; private readonly IBattlePassService _battlePass; private readonly IViewerMissionStateService _missionState; private readonly SVSimDbContext _db; - private readonly IViewerEntitlements _entitlements; + private readonly IInventoryService _inv; public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, - ICardAcquisitionService acquisition, IGameConfigService config, + IGameConfigService config, IBattlePassService battlePass, IViewerMissionStateService missionState, - SVSimDbContext db, IViewerEntitlements entitlements) + SVSimDbContext db, IInventoryService inv) { _viewerRepository = viewerRepository; _globalsRepository = globalsRepository; - _acquisition = acquisition; _config = config; _battlePass = battlePass; _missionState = missionState; _db = db; - _entitlements = entitlements; + _inv = inv; } [HttpPost("index")] @@ -84,7 +83,9 @@ public class LoadController : SVSimController // .AsNoTracking() — the local `viewer` instance is detached, and the service's writes // (on a separate tracked instance) won't appear on this snapshot. Without the re-fetch, // the response payload would be one /load/index behind on newly-granted cosmetics. - await _acquisition.BackfillCosmeticsAsync(viewer.Id); + await using var tx = await _inv.BeginAsync(viewer.Id, ct); + await tx.BackfillCardCosmeticsAsync(ct); + await tx.CommitAsync(ct); // Lazy-materialize mission/achievement state. Idempotent — safe to call every /load/index. await _missionState.EnsureCurrentAsync(viewer.Id); @@ -125,9 +126,9 @@ public class LoadController : SVSimController // re-confirm the filter if we later move to Option B and start iterating card-sets. // Owned-card projection (incl. the freeplay "all cards" path) lives in the entitlements // service so both modes share one definition. - var allCardsAsOwned = await _entitlements.EffectiveOwnedCardsAsync(viewer, ct); + var allCardsAsOwned = await _inv.EffectiveOwnedCardsAsync(viewer, ct); - var cosmetics = await _entitlements.EffectiveCosmeticsAsync(viewer, ct); + var cosmetics = await _inv.EffectiveCosmeticsAsync(viewer, ct); var classExpCurve = await _globalsRepository.GetClassExpCurve(); List classExps = new(); @@ -168,10 +169,10 @@ public class LoadController : SVSimController UserInfo = new UserInfo(deviceType, viewer), UserCurrency = new UserCurrency(viewer) { - Crystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal), - TotalCrystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal), - Rupees = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee), - RedEther = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.RedEther), + Crystals = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Crystal), + TotalCrystals = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Crystal), + Rupees = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Rupee), + RedEther = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.RedEther), }, UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(), SpotPoint = checked((int)viewer.Currency.SpotPoints), From 61013fcf5cb7eaa0c6c72d7aac39ffdaece2b519 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:15:40 -0400 Subject: [PATCH 15/32] refactor(card-inventory): route Create/Destruct through InventoryService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Card/CardInventoryRepository.cs | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/SVSim.Database/Repositories/Card/CardInventoryRepository.cs b/SVSim.Database/Repositories/Card/CardInventoryRepository.cs index fb7690f..6c1cc53 100644 --- a/SVSim.Database/Repositories/Card/CardInventoryRepository.cs +++ b/SVSim.Database/Repositories/Card/CardInventoryRepository.cs @@ -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 DestructCards(long viewerId, IReadOnlyDictionary 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(); 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 SetProtected(long viewerId, long cardId, bool isProtected) From 57dd524d9fc4a8291c8667952f0b5643441e1211 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:17:31 -0400 Subject: [PATCH 16/32] refactor(build-deck): route Buy through InventoryService Co-Authored-By: Claude Opus 4.7 --- .../Controllers/BuildDeckController.cs | 115 ++++-------------- 1 file changed, 25 insertions(+), 90 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs b/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs index 01e12df..414befd 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs @@ -1,10 +1,9 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.BuildDeck; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck; @@ -20,39 +19,16 @@ namespace SVSim.EmulatedEntrypoint.Controllers; public class BuildDeckController : SVSimController { private readonly IBuildDeckRepository _repo; - private readonly SVSimDbContext _db; - private readonly RewardGrantService _rewards; - private readonly ICurrencySpendService _spend; + private readonly IInventoryService _inv; public BuildDeckController( IBuildDeckRepository repo, - SVSimDbContext db, - RewardGrantService rewards, - ICurrencySpendService spend) + IInventoryService inv) { _repo = repo; - _db = db; - _rewards = rewards; - _spend = spend; + _inv = inv; } - /// - /// Loads the viewer with the full cosmetic / inventory graph + BuildDeckPurchases. This is - /// the single load /build_deck/buy makes; every subsequent mutation operates on the returned - /// instance and the controller saves once at the end. - /// - private Task LoadViewerGraphAsync(long viewerId) => _db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.LeaderSkins) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .Include(v => v.Items).ThenInclude(i => i.Item) - .Include(v => v.BuildDeckPurchases) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); - // The wire shape for /build_deck/info has `data` as a bare collection of series, not a // DTO with a `series_list` field. The client (BuildDeckPurchaseInfoTask.Parse) iterates // `data` directly via numeric indexer: @@ -194,60 +170,45 @@ public class BuildDeckController : SVSimController break; } - // Single viewer load with the full graph — every subsequent mutation (currency debit, - // purchase counter, card grants, cosmetic grants) operates on this one in-memory instance - // so we can save once at the end. - var viewer = await LoadViewerGraphAsync(viewerId); - var rewardList = new List(); + // Open the inventory transaction — loads canonical graph + BuildDeckPurchases. + await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, + cfg => cfg.WithInclude(v => v.BuildDeckPurchases)); + var viewer = tx.Viewer; - // Debit + post-state currency entry + // Debit currency if (request.SalesType == 1) { - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, priceCrystal!.Value); + var r = await tx.TrySpendAsync(SpendCurrency.Crystal, priceCrystal!.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); - rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }); } else if (request.SalesType == 2) { - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, priceRupy!.Value); + var r = await tx.TrySpendAsync(SpendCurrency.Rupee, priceRupy!.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); - rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }); } - // sales_type == 0 (free): no debit, no currency entry + // sales_type == 0 (free): no debit // Compute series purchase total BEFORE this buy int prevSeriesCount = product.Series!.Products .Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0); int newSeriesCount = prevSeriesCount + 1; - // Increment purchase counter directly on the tracked viewer (we already loaded - // BuildDeckPurchases via LoadViewerGraphAsync). The repo's IncrementPurchaseCount would - // re-attach to the same instance and trigger an extra save — inlining keeps the - // controller's single-save model intact. + // Increment purchase counter on tx.Viewer (tx loaded BuildDeckPurchases via WithInclude). var purchaseRow = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == product.Id); if (purchaseRow is null) viewer.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = product.Id, PurchaseCount = 1 }); else purchaseRow.PurchaseCount += 1; - // Grant the 40 deck cards. Bucket by CardId so duplicate (CardId, IsSpot) rows don't - // emit redundant reward_list entries — ApplyAsync(Card, ...) runs the cosmetic cascade - // and returns a post-state-total entry per call. - var deckGrants = product.Cards - .GroupBy(c => c.CardId) - .Select(g => (UserGoodsType.Card, g.Key, g.Sum(c => c.Number))); - await ApplyRewardsAsync(viewer, deckGrants, rewardList); + // Grant deck cards (grouped by CardId) + foreach (var grp in product.Cards.GroupBy(c => c.CardId)) + await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Sum(c => c.Number)); - // Per-buy rewards from the product catalog: sleeve, emblem, skin, sometimes extra cards - // (Set 4 grants 3 copies of the featured card as a type=5 reward). - await ApplyRewardsAsync(viewer, product.Rewards - .OrderBy(r => r.RewardIndex) - .Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)), - rewardList); + // Per-buy rewards + foreach (var r in product.Rewards.OrderBy(r => r.RewardIndex)) + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); - // Series-reward tier crossings: tiers where prevSeriesCount < TierIndex <= newSeriesCount. - // Captured tiers include type 4 (Item), 5 (Card), 6 (Sleeve), 7 (Emblem) — granting them - // all uniformly avoids the earlier card-only path that dropped non-card tier rewards. + // Series-reward tier crossings var crossedTiers = product.Series.SeriesRewards .Where(r => r.TierIndex > prevSeriesCount && r.TierIndex <= newSeriesCount) .GroupBy(r => r.TierIndex) @@ -257,13 +218,9 @@ public class BuildDeckController : SVSimController var seriesRewards = new List(); foreach (var tier in crossedTiers) { - await ApplyRewardsAsync(viewer, tier - .OrderBy(r => r.ItemIndex) - .Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)), - rewardList); - foreach (var item in tier.OrderBy(r => r.ItemIndex)) { + await tx.GrantAsync((UserGoodsType)item.RewardType, item.RewardDetailId, item.RewardNumber); seriesRewards.Add(new BuildDeckProductRewardDto { RewardType = item.RewardType, @@ -274,39 +231,17 @@ public class BuildDeckController : SVSimController } } - await _db.SaveChangesAsync(); + var result = await tx.CommitAsync(HttpContext.RequestAborted); return new BuildDeckBuyResponse { - RewardList = rewardList, + RewardList = result.RewardList + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(), SeriesRewards = seriesRewards, }; } - /// - /// Dispatches each (type, id, num) tuple through - /// and appends the resulting wire entries to . Caller saves. - /// - private async Task ApplyRewardsAsync( - Viewer viewer, - IEnumerable<(UserGoodsType Type, long DetailId, int Number)> rewards, - List rewardList) - { - foreach (var (type, detailId, number) in rewards) - { - var granted = await _rewards.ApplyAsync(viewer, type, detailId, number); - foreach (var g in granted) - { - rewardList.Add(new RewardListEntry - { - RewardType = g.RewardType, - RewardId = g.RewardId, - RewardNum = g.RewardNum, - }); - } - } - } - [HttpPost("get_purchase_count")] public async Task> GetPurchaseCount( BuildDeckGetPurchaseCountRequest request) From 4d6da2344392936ae646bef9885cddccaae4e1a3 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:20:23 -0400 Subject: [PATCH 17/32] refactor(pack): route Open through InventoryService Co-Authored-By: Claude Opus 4.7 --- .../Controllers/PackController.cs | 106 ++++++------------ 1 file changed, 32 insertions(+), 74 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 8a588af..a6a85e5 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -11,6 +11,7 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Controllers; @@ -30,10 +31,8 @@ public class PackController : SVSimController private readonly ICardFoilLookup _foils; private readonly IRandom _rng; private readonly SVSimDbContext _db; - private readonly ICardAcquisitionService _acquisition; + private readonly IInventoryService _inv; private readonly IGachaPointService _gachaPoint; - private readonly ICurrencySpendService _spend; - private readonly IViewerEntitlements _entitlements; public PackController( IPackRepository packs, @@ -42,10 +41,8 @@ public class PackController : SVSimController ICardFoilLookup foils, IRandom rng, SVSimDbContext db, - ICardAcquisitionService acquisition, - IGachaPointService gachaPoint, - ICurrencySpendService spend, - IViewerEntitlements entitlements) + IInventoryService inv, + IGachaPointService gachaPoint) { _packs = packs; _opener = opener; @@ -53,10 +50,8 @@ public class PackController : SVSimController _foils = foils; _rng = rng; _db = db; - _acquisition = acquisition; + _inv = inv; _gachaPoint = gachaPoint; - _spend = spend; - _entitlements = entitlements; } [HttpPost("info")] @@ -287,13 +282,12 @@ public class PackController : SVSimController if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7)) return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" }); - var viewer = await _db.Viewers - .Include(v => v.PackOpenCounts) - .Include(v => v.GachaPointBalances) - .Include(v => v.MissionData) - .Include(v => v.Items).ThenInclude(i => i.Item) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); + // Load viewer via InventoryService transaction with extra includes for pack-open needs. + await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg + .WithInclude(v => v.PackOpenCounts) + .WithInclude(v => v.GachaPointBalances) + .WithInclude(v => v.MissionData)); + var viewer = tx.Viewer; // Tutorial alias is only valid pre-END. After state>=100 the viewer has already // completed the tutorial — re-running the path would re-consume the ticket they @@ -314,7 +308,7 @@ public class PackController : SVSimController case 2: // CRYSTAL_MULTI (10-pack) { long cost = (long)child.Cost * packNumber; - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, cost); + var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); break; } @@ -322,7 +316,7 @@ public class PackController : SVSimController case 7: // RUPY_MULTI (10-pack) { long cost = (long)child.Cost * packNumber; - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost); + var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); break; } @@ -336,7 +330,7 @@ public class PackController : SVSimController return BadRequest(new { error = "daily_free_already_claimed" }); long cost = (long)child.Cost * packNumber; - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost); + var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); break; } @@ -347,15 +341,11 @@ public class PackController : SVSimController return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" }); int ticketsNeeded = child.Cost * packNumber; - var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); - if (owned is null || owned.Count < ticketsNeeded) - return BadRequest(new { error = "insufficient_tickets" }); - - owned.Count -= ticketsNeeded; + var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded); + if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" }); break; } } - await _db.SaveChangesAsync(); } // Increment open count + mark daily-free timestamp where relevant. @@ -394,48 +384,17 @@ public class PackController : SVSimController ownedCardIds, _foils, _rng); - var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId)); + + // Grant drawn cards through the transaction — cosmetic cascade fires on first-time owners. + foreach (var grp in draw.Cards.GroupBy(c => c.CardId)) + await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count()); // Accrue gacha points (skip tutorial path — the starter pack isn't a real open). if (!isTutorialPath) { _gachaPoint.Accrue(viewer, pack, child, drawCount); - await _db.SaveChangesAsync(); } - // Build reward_list. The service produces the type=5 (Card) entries with post-state counts - // plus any cosmetic grants. Currency entry (type=2 Crystals or type=9 Rupy) stays in the - // controller — it's a pack-purchase concern, not a card-grant concern. The client's - // PlayerStaticData.UpdateHaveUserGoodsNum does direct assignment, so currency/card counts - // must be the new TOTAL — emitting deltas would leave the on-screen balances stale. - var rewardList = new List(); - - // Currency reward entries only apply to purchasable packs; tutorial path omits them. - if (!isTutorialPath) - { - if (child.TypeDetail is 1 or 2) - { - rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) }); - } - else if (child.TypeDetail is 3 or 6 or 7) - { - rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) }); - } - else if (child.TypeDetail is 4 or 5 && child.ItemId is long ticketItemId) - { - // Item post-state count for the ticket we just consumed — client direct-assigns - // _userItemDict, so this must be the new total (project_wire_reward_list_post_state). - var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); - rewardList.Add(new RewardListEntry - { - RewardType = 4, // Item - RewardId = ticketItemId, - RewardNum = owned?.Count ?? 0, // post-state total - }); - } - } - rewardList.AddRange(grant.RewardList); - // Tutorial path consumes the granted ticket (same item_id used to gate display) so the // pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still // shows item_number=1 after the tutorial pack-open, the client lets the user re-click @@ -447,19 +406,12 @@ public class PackController : SVSimController int? responseTutorialStep = null; if (isTutorialPath) { - if (child.ItemId is long ticketItemId) + if (child.ItemId is long tutorialTicketItemId) { - var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); - if (owned is not null) - { - owned.Count = Math.Max(0, owned.Count - packNumber); - rewardList.Add(new RewardListEntry - { - RewardType = 4, // Item - RewardId = ticketItemId, - RewardNum = owned.Count, // POST-STATE total - }); - } + int ticketsToConsume = packNumber; + var debit = await tx.TryDebitAsync(UserGoodsType.Item, tutorialTicketItemId, ticketsToConsume); + // Silently accept if the viewer doesn't have the ticket (already consumed or never granted) + _ = debit; } // Max-preserve: never regress the persisted state, even though Gate B already @@ -468,10 +420,16 @@ public class PackController : SVSimController // the tutorial-END signal the client expects. if (viewer.MissionData.TutorialState < TutorialEndStep) viewer.MissionData.TutorialState = TutorialEndStep; - await _db.SaveChangesAsync(); responseTutorialStep = TutorialEndStep; } + // CommitAsync saves all mutations and produces reward_list with currency-collision resolved. + // Tutorial path never calls TrySpendAsync so no currency op is in the log — correct. + var result = await tx.CommitAsync(HttpContext.RequestAborted); + var rewardList = result.RewardList + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(); + return new PackOpenResponse { PackList = draw.Cards.Select(c => new CardPackEntryDto From 45fa3d75bf703c79cdd5ef2462867e9c147533d8 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:23:50 -0400 Subject: [PATCH 18/32] refactor(leader-skin): route shop through InventoryService Co-Authored-By: Claude Opus 4.7 --- .../Controllers/LeaderSkinController.cs | 239 +++++++----------- 1 file changed, 93 insertions(+), 146 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs index 33239d3..0024540 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs @@ -5,6 +5,7 @@ using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin; @@ -29,19 +30,15 @@ namespace SVSim.EmulatedEntrypoint.Controllers; public class LeaderSkinController : SVSimController { private readonly SVSimDbContext _db; - private readonly RewardGrantService _rewards; + private readonly IInventoryService _inv; private readonly TimeProvider _time; - private readonly ICurrencySpendService _spend; - private readonly IViewerEntitlements _entitlements; private readonly ICollectionRepository _collection; - public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection) + public LeaderSkinController(SVSimDbContext db, IInventoryService inv, TimeProvider time, ICollectionRepository collection) { _db = db; - _rewards = rewards; + _inv = inv; _time = time; - _spend = spend; - _entitlements = entitlements; _collection = collection; } @@ -69,7 +66,8 @@ public class LeaderSkinController : SVSimController var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId); if (skin is null) return BadRequest(new { error = "unknown_skin" }); if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" }); - if (!_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, skin.Id)) + var cosmeticsForSet = await _inv.EffectiveCosmeticsAsync(viewer); + if (!cosmeticsForSet.OwnedLeaderSkinIds.Contains(skin.Id)) return BadRequest(new { error = "skin_not_owned" }); classData.LeaderSkin = skin; @@ -88,18 +86,13 @@ public class LeaderSkinController : SVSimController { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - if (_entitlements.IsFreeplay) - { - var all = (await _collection.GetLeaderSkins()).Select(s => s.Id).OrderBy(id => id).ToList(); - return new LeaderSkinIdsResponse { UserLeaderSkinIds = all }; - } - - var ids = await _db.Viewers - .Where(v => v.Id == viewerId) - .SelectMany(v => v.LeaderSkins.Select(s => s.Id)) - .OrderBy(id => id) - .ToListAsync(); + var viewer = await _db.Viewers + .Include(v => v.LeaderSkins) + .FirstOrDefaultAsync(v => v.Id == viewerId); + if (viewer is null) return Unauthorized(); + var cosmetics = await _inv.EffectiveCosmeticsAsync(viewer); + var ids = cosmetics.OwnedLeaderSkinIds.OrderBy(id => id).ToList(); return new LeaderSkinIdsResponse { UserLeaderSkinIds = ids }; } @@ -108,12 +101,13 @@ public class LeaderSkinController : SVSimController { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - var ownedSkinIds = _entitlements.IsFreeplay - ? (await _collection.GetLeaderSkins()).Select(s => s.Id).ToHashSet() - : (await _db.Viewers - .Where(v => v.Id == viewerId) - .SelectMany(v => v.LeaderSkins.Select(s => s.Id)) - .ToListAsync()).ToHashSet(); + var viewerForProducts = await _db.Viewers + .Include(v => v.LeaderSkins) + .FirstOrDefaultAsync(v => v.Id == viewerId); + if (viewerForProducts is null) return Unauthorized(); + + var cosmeticsForProducts = await _inv.EffectiveCosmeticsAsync(viewerForProducts); + var ownedSkinIds = cosmeticsForProducts.OwnedLeaderSkinIds; var claimedSeries = (await _db.ViewerLeaderSkinSetClaims .Where(c => c.ViewerId == viewerId) @@ -183,21 +177,41 @@ public class LeaderSkinController : SVSimController if (!product.IsEnabled || product.Series is not { IsEnabled: true }) return BadRequest(new { error = "product_not_available" }); - var viewer = await LoadViewerGraphAsync(viewerId); + await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted); // Already-purchased = viewer owns the leader_skin this product grants. - if (_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, product.LeaderSkinId)) + if (tx.OwnsCosmetic(CosmeticType.Skin, product.LeaderSkinId)) return BadRequest(new { error = "already_purchased" }); - var rewardList = new List(); - var debit = await DebitProductPrice(viewer, product, request.SalesType); - if (debit.Error is not null) return BadRequest(new { error = debit.Error }); - if (debit.PostState is not null) rewardList.Add(debit.PostState); + // Debit currency + switch (request.SalesType) + { + case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0: + break; // free + case 0: + return BadRequest(new { error = "price_not_available_for_currency" }); + case 1: + if (product.SinglePriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" }); + { var r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.SinglePriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); } + break; + case 2: + if (product.SinglePriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" }); + { var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.SinglePriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); } + break; + default: + return BadRequest(new { error = "invalid_sales_type" }); + } - await ApplyRewardsAsync(viewer, product.Rewards, rewardList); + foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex)) + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); - await _db.SaveChangesAsync(); - return new LeaderSkinBuyResponse { RewardList = rewardList }; + var result = await tx.CommitAsync(HttpContext.RequestAborted); + return new LeaderSkinBuyResponse + { + RewardList = result.RewardList + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(), + }; } [HttpPost("buy_set")] @@ -218,25 +232,44 @@ public class LeaderSkinController : SVSimController if (!series.IsEnabled || series.SetSalesStatus == 0) return BadRequest(new { error = "set_sale_not_active" }); - var viewer = await LoadViewerGraphAsync(viewerId); + await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted); - if (_entitlements.IsFreeplay) + if (tx.IsFreeplay) return BadRequest(new { error = "already_purchased" }); - var rewardList = new List(); - var debit = await DebitSetPrice(viewer, series, request.SalesType); - if (debit.Error is not null) return BadRequest(new { error = debit.Error }); - if (debit.PostState is not null) rewardList.Add(debit.PostState); - - // Grant every product's rewards; RewardGrantService is idempotent on already-owned - // cosmetics, so partial-set buyers don't double-add. - foreach (var p in series.Products.OrderBy(p => p.Id)) + // Debit set price + switch (request.SalesType) { - await ApplyRewardsAsync(viewer, p.Rewards, rewardList); + case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0: + break; // free + case 0: + return BadRequest(new { error = "price_not_available_for_currency" }); + case 1: + if (series.SetPriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" }); + { var r = await tx.TrySpendAsync(SpendCurrency.Crystal, series.SetPriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); } + break; + case 2: + if (series.SetPriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" }); + { var r = await tx.TrySpendAsync(SpendCurrency.Rupee, series.SetPriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); } + break; + default: + return BadRequest(new { error = "invalid_sales_type" }); } - await _db.SaveChangesAsync(); - return new LeaderSkinBuyResponse { RewardList = rewardList }; + // Grant every product's rewards; tx.GrantAsync is idempotent on already-owned cosmetics. + foreach (var p in series.Products.OrderBy(p => p.Id)) + { + foreach (var r in p.Rewards.OrderBy(r => r.OrderIndex)) + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); + } + + var result = await tx.CommitAsync(HttpContext.RequestAborted); + return new LeaderSkinBuyResponse + { + RewardList = result.RewardList + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(), + }; } [HttpPost("buy_set_item")] @@ -257,16 +290,15 @@ public class LeaderSkinController : SVSimController if (existingClaim is not null) return new LeaderSkinBuyResponse { RewardList = new() }; - var viewer = await LoadViewerGraphAsync(viewerId); + await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted); // Must own every skin in the series to claim the bonus. - var ownedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet(); - bool ownsAll = series.Products.Count > 0 && series.Products.All(p => ownedSkinIds.Contains(p.LeaderSkinId)); + bool ownsAll = series.Products.Count > 0 && series.Products.All(p => tx.OwnsCosmetic(CosmeticType.Skin, p.LeaderSkinId)); if (!ownsAll) return BadRequest(new { error = "series_not_completed" }); - var rewardList = new List(); - await ApplyRewardsAsync(viewer, series.SetCompletionRewards, rewardList); + foreach (var r in series.SetCompletionRewards.OrderBy(r => r.OrderIndex)) + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); _db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim { @@ -275,8 +307,13 @@ public class LeaderSkinController : SVSimController ClaimedAt = _time.GetUtcNow().UtcDateTime, }); - await _db.SaveChangesAsync(); - return new LeaderSkinBuyResponse { RewardList = rewardList }; + var result = await tx.CommitAsync(HttpContext.RequestAborted); + return new LeaderSkinBuyResponse + { + RewardList = result.RewardList + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(), + }; } /// @@ -304,7 +341,7 @@ public class LeaderSkinController : SVSimController return 1; } - private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, HashSet ownedSkinIds) + private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, IReadOnlySet ownedSkinIds) { bool isPurchased = ownedSkinIds.Contains(p.LeaderSkinId); return new SkinProductDto @@ -339,7 +376,7 @@ public class LeaderSkinController : SVSimController /// emblem/sleeve typically come with the skin, so the heuristic is "skin owned → all three /// bundle items are de-facto owned." Refine later if a capture shows independent state. /// - private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, HashSet ownedSkinIds) + private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, IReadOnlySet ownedSkinIds) { // Skin reward: direct check. if (r.RewardType == (int)UserGoodsType.Skin) @@ -350,94 +387,4 @@ public class LeaderSkinController : SVSimController return false; } - private async Task<(RewardListEntry? PostState, string? Error)> DebitProductPrice( - Viewer viewer, LeaderSkinShopProductEntry product, int salesType) - { - switch (salesType) - { - case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0: - return (null, null); - case 0: - return (null, "price_not_available_for_currency"); - case 1: - if (product.SinglePriceCrystal is null) return (null, "price_not_available_for_currency"); - return await DebitCrystal(viewer, product.SinglePriceCrystal.Value); - case 2: - if (product.SinglePriceRupy is null) return (null, "price_not_available_for_currency"); - return await DebitRupy(viewer, product.SinglePriceRupy.Value); - default: - return (null, "invalid_sales_type"); - } - } - - private async Task<(RewardListEntry? PostState, string? Error)> DebitSetPrice( - Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType) - { - switch (salesType) - { - case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0: - return (null, null); - case 0: - return (null, "price_not_available_for_currency"); - case 1: - if (series.SetPriceCrystal is null) return (null, "price_not_available_for_currency"); - return await DebitCrystal(viewer, series.SetPriceCrystal.Value); - case 2: - if (series.SetPriceRupy is null) return (null, "price_not_available_for_currency"); - return await DebitRupy(viewer, series.SetPriceRupy.Value); - default: - return (null, "invalid_sales_type"); - } - } - - private async Task<(RewardListEntry?, string?)> DebitCrystal(Viewer viewer, int amount) - { - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, amount); - if (!r.Success) return (null, "insufficient_crystals"); - return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null); - } - - private async Task<(RewardListEntry?, string?)> DebitRupy(Viewer viewer, int amount) - { - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, amount); - if (!r.Success) return (null, "insufficient_rupees"); - return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null); - } - - private async Task ApplyRewardsAsync( - Viewer viewer, IEnumerable rewards, List rewardList) where T : notnull - { - foreach (var r in rewards) - { - var (type, detailId, number) = ExtractTuple(r); - var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)type, detailId, number); - foreach (var g in granted) - { - rewardList.Add(new RewardListEntry - { - RewardType = g.RewardType, - RewardId = g.RewardId, - RewardNum = g.RewardNum, - }); - } - } - } - - private static (int Type, long Id, int Num) ExtractTuple(object reward) => reward switch - { - LeaderSkinShopProductRewardEntry p => (p.RewardType, p.RewardDetailId, p.RewardNumber), - LeaderSkinShopSeriesRewardEntry s => (s.RewardType, s.RewardDetailId, s.RewardNumber), - _ => throw new InvalidOperationException($"unexpected reward type {reward.GetType().Name}"), - }; - - private Task LoadViewerGraphAsync(long viewerId) => _db.Viewers - .Include(v => v.LeaderSkins) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .Include(v => v.Items).ThenInclude(i => i.Item) - .Include(v => v.Cards).ThenInclude(c => c.Card) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); } From 9436a0d21b075f085a910dc2f78a2f94e339904b Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:25:10 -0400 Subject: [PATCH 19/32] refactor(sleeve): route buy through InventoryService Co-Authored-By: Claude Opus 4.7 --- .../Controllers/SleeveController.cs | 77 +++++++------------ 1 file changed, 26 insertions(+), 51 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs b/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs index e852639..4f70bee 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs @@ -5,6 +5,7 @@ using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Sleeve; @@ -20,17 +21,13 @@ namespace SVSim.EmulatedEntrypoint.Controllers; public class SleeveController : SVSimController { private readonly SVSimDbContext _db; - private readonly RewardGrantService _rewards; - private readonly ICurrencySpendService _spend; - private readonly IViewerEntitlements _entitlements; + private readonly IInventoryService _inv; private readonly ICollectionRepository _collection; - public SleeveController(SVSimDbContext db, RewardGrantService rewards, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection) + public SleeveController(SVSimDbContext db, IInventoryService inv, ICollectionRepository collection) { _db = db; - _rewards = rewards; - _spend = spend; - _entitlements = entitlements; + _inv = inv; _collection = collection; } @@ -42,12 +39,13 @@ public class SleeveController : SVSimController // is_purchased_product is "viewer owns at least one sleeve granted by this product". // Loading the viewer's sleeve-id set once and checking each product against it avoids // an N+1 over products. - var ownedSleeveIds = _entitlements.IsFreeplay - ? (await _collection.GetAllSleeveIds()).Select(id => (long)id).ToHashSet() - : (await _db.Viewers - .Where(v => v.Id == viewerId) - .SelectMany(v => v.Sleeves.Select(s => (long)s.Id)) - .ToListAsync()).ToHashSet(); + var viewerForInfo = await _db.Viewers + .Include(v => v.Sleeves) + .FirstOrDefaultAsync(v => v.Id == viewerId); + if (viewerForInfo is null) return Unauthorized(); + + var cosmeticsForInfo = await _inv.EffectiveCosmeticsAsync(viewerForInfo); + var ownedSleeveIds = cosmeticsForInfo.SleeveIds.Select(id => (long)id).ToHashSet(); var series = await _db.SleeveShopSeries .Where(s => s.IsEnabled) @@ -113,18 +111,17 @@ public class SleeveController : SVSimController if (product.SeriesId != request.SeriesId) return BadRequest(new { error = "series_product_mismatch" }); - var viewer = await LoadViewerGraphAsync(viewerId); + await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted); - if (_entitlements.IsFreeplay) + if (tx.IsFreeplay) return BadRequest(new { error = "already_purchased" }); - if (IsProductPurchased(product, viewer.Sleeves.Select(s => (long)s.Id).ToHashSet())) + if (IsProductPurchased(product, tx.Viewer.Sleeves.Select(s => (long)s.Id).ToHashSet())) return BadRequest(new { error = "already_purchased" }); // Pricing: capture-confirmed shape is single-price-per-currency (no intro/regular tiers // like BuildDeck). At least one of crystal/rupy must match the chosen sales_type; // sales_type==0 means "free", which requires both prices == 0. - var rewardList = new List(); switch (request.SalesType) { case 0: // free @@ -134,39 +131,27 @@ public class SleeveController : SVSimController case 1: // crystal if (product.PriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" }); - var crystalRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, product.PriceCrystal.Value); - if (!crystalRes.Success) return BadRequest(new { error = "insufficient_crystals" }); - rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)crystalRes.PostStateTotal }); + { var r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.PriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); } break; case 2: // rupy if (product.PriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" }); - var rupyRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, product.PriceRupy.Value); - if (!rupyRes.Success) return BadRequest(new { error = "insufficient_rupees" }); - rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)rupyRes.PostStateTotal }); + { var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.PriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); } break; } - // Grant each catalog reward through the central dispatcher — covers sleeve (6), emblem - // (7), and any future bundled grants. ApplyAsync returns post-state-aware reward entries - // suitable for emission as-is. + // Grant each catalog reward through the central dispatcher. foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex)) + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); + + var result = await tx.CommitAsync(HttpContext.RequestAborted); + + return new SleeveBuyResponse { - var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); - foreach (var g in granted) - { - rewardList.Add(new RewardListEntry - { - RewardType = g.RewardType, - RewardId = g.RewardId, - RewardNum = g.RewardNum, - }); - } - } - - await _db.SaveChangesAsync(); - - return new SleeveBuyResponse { RewardList = rewardList }; + RewardList = result.RewardList + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(), + }; } /// @@ -185,14 +170,4 @@ public class SleeveController : SVSimController return false; } - private Task LoadViewerGraphAsync(long viewerId) => _db.Viewers - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.LeaderSkins) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .Include(v => v.Items).ThenInclude(i => i.Item) - .Include(v => v.Cards).ThenInclude(c => c.Card) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); } From ad4d4e06463bf4354f33beebe6b03549c7b40acd Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:26:34 -0400 Subject: [PATCH 20/32] refactor(item-purchase): route through InventoryService Co-Authored-By: Claude Opus 4.7 --- .../Controllers/ItemPurchaseController.cs | 102 +++++------------- 1 file changed, 27 insertions(+), 75 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs b/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs index 7b1d3b4..900dcd6 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs @@ -4,6 +4,7 @@ using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase; @@ -21,16 +22,14 @@ namespace SVSim.EmulatedEntrypoint.Controllers; public class ItemPurchaseController : SVSimController { private readonly SVSimDbContext _db; - private readonly RewardGrantService _rewards; + private readonly IInventoryService _inv; private readonly TimeProvider _time; - private readonly ICurrencySpendService _spend; - public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend) + public ItemPurchaseController(SVSimDbContext db, IInventoryService inv, TimeProvider time) { _db = db; - _rewards = rewards; + _inv = inv; _time = time; - _spend = spend; } [HttpPost("info")] @@ -115,28 +114,17 @@ public class ItemPurchaseController : SVSimController if (rest <= 0) return BadRequest(new { error = "sold_out" }); - var viewer = await LoadViewerGraphAsync(viewerId); - var rewardList = new List(); + await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted); - // Debit the require side. RewardGrantService is grant-only, so handle this inline. - var debit = await TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum); - if (debit.Error is not null) return BadRequest(new { error = debit.Error }); - if (debit.PostState is not null) rewardList.Add(debit.PostState); + // Debit the require side via the tx. + var debit = await tx.TryDebitAsync( + (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum); + if (!debit.Success) return BadRequest(new { error = MapDebitError(entry.RequireItemType) }); - // Grant the purchase side through the central dispatcher. - var granted = await _rewards.ApplyAsync(viewer, - (UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum); - foreach (var g in granted) - { - rewardList.Add(new RewardListEntry - { - RewardType = g.RewardType, - RewardId = g.RewardId, - RewardNum = g.RewardNum, - }); - } + // Grant the purchase side. + await tx.GrantAsync((UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum); - // Increment the per-period counter. + // Increment the per-period counter (tracked via _db, outside the inventory tx). if (counter is null) { _db.ViewerEventCounters.Add(new ViewerEventCounter @@ -151,52 +139,27 @@ public class ItemPurchaseController : SVSimController { counter.Count++; } - await _db.SaveChangesAsync(); - return new ItemPurchasePurchaseResponse { RewardList = rewardList }; - } + var result = await tx.CommitAsync(HttpContext.RequestAborted); - /// - /// Debit of (, ) - /// from the viewer, returning a post-state-aware the client - /// uses to refresh its cached count. Returns an error string on insufficient balance. - /// - private async Task<(RewardListEntry? PostState, string? Error)> TryDebit( - Viewer viewer, UserGoodsType type, long detailId, int num) - { - switch (type) + return new ItemPurchasePurchaseResponse { - case UserGoodsType.RedEther: - { - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.RedEther, num); - if (!r.Success) return (null, "insufficient_red_ether"); - return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null); - } - case UserGoodsType.Crystal: - { - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, num); - if (!r.Success) return (null, "insufficient_crystals"); - return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null); - } - case UserGoodsType.Rupy: - { - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, num); - if (!r.Success) return (null, "insufficient_rupees"); - return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null); - } - case UserGoodsType.Item: - var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId); - if (owned is null || owned.Count < num) - return (null, "insufficient_item"); - owned.Count -= num; - return (new RewardListEntry { RewardType = 4, RewardId = detailId, RewardNum = owned.Count }, null); - - default: - return (null, $"debit_type_not_supported:{type}"); - } + RewardList = result.RewardList + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(), + }; } + private static string MapDebitError(int requireType) => requireType switch + { + (int)UserGoodsType.RedEther => "insufficient_red_ether", + (int)UserGoodsType.Crystal => "insufficient_crystals", + (int)UserGoodsType.Rupy => "insufficient_rupees", + (int)UserGoodsType.Item => "insufficient_item", + _ => "debit_type_not_supported", + }; + private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}"; private static int CounterCount(List counters, ItemPurchaseCatalogEntry entry, string monthKey) @@ -204,15 +167,4 @@ public class ItemPurchaseController : SVSimController var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime; return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0; } - - private Task LoadViewerGraphAsync(long viewerId) => _db.Viewers - .Include(v => v.Items).ThenInclude(i => i.Item) - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.LeaderSkins) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); } From a2cec7c99e75762e9cdb1912cabc0805fbc8edf4 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:35:53 -0400 Subject: [PATCH 21/32] refactor(spot-card-exchange): route through InventoryService Replace RewardGrantService + ICurrencySpendService with IInventoryService tx pattern. BeginAsync loads viewer, TrySpendAsync debits SpotPoint, GrantAsync grants card + cascade, CommitAsync saves. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/SpotCardExchangeController.cs | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs b/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs index d5d8ec9..c5be16e 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs @@ -4,6 +4,7 @@ using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.SpotCardExchange; @@ -14,8 +15,7 @@ namespace SVSim.EmulatedEntrypoint.Controllers; /// /// /spot_card_exchange/* — trade spot points for individual cards from the rotating exchange /// pool. Spot points are earned from battles/missions (not implemented here — earners live in -/// battle/mission finish reward emitters via + -/// ). +/// battle/mission finish reward emitters via ). /// [Route("spot_card_exchange")] public class SpotCardExchangeController : SVSimController @@ -28,16 +28,14 @@ public class SpotCardExchangeController : SVSimController private const int PreReleaseLimit = 2; private readonly SVSimDbContext _db; - private readonly RewardGrantService _rewards; + private readonly IInventoryService _inv; private readonly TimeProvider _time; - private readonly ICurrencySpendService _spend; - public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend) + public SpotCardExchangeController(SVSimDbContext db, IInventoryService inv, TimeProvider time) { _db = db; - _rewards = rewards; + _inv = inv; _time = time; - _spend = spend; } [HttpPost("top")] @@ -126,14 +124,14 @@ public class SpotCardExchangeController : SVSimController return BadRequest(new { error = "pre_release_limit_reached" }); } - var viewer = await LoadViewerGraphAsync(viewerId); + await using var tx = await _inv.BeginAsync(viewerId); var rewardList = new List(); // Debit spot points. Client-supplied exchange_point isn't authoritative — server uses // catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry // first, then grants. - var spotRes = await _spend.TrySpendAsync(viewer, SpendCurrency.SpotPoint, entry.ExchangePoint); + var spotRes = await tx.TrySpendAsync(SpendCurrency.SpotPoint, entry.ExchangePoint); if (!spotRes.Success) return BadRequest(new { error = "insufficient_spot_points" }); rewardList.Add(new RewardListEntry @@ -143,8 +141,8 @@ public class SpotCardExchangeController : SVSimController RewardNum = checked((int)spotRes.PostStateTotal), }); - // Grant the card itself via the existing card dispatcher (handles cosmetic cascade). - var granted = await _rewards.ApplyAsync(viewer, UserGoodsType.Card, entry.Id, 1); + // Grant the card itself via the inventory tx (handles cosmetic cascade). + var granted = await tx.GrantAsync(UserGoodsType.Card, entry.Id, 1); foreach (var g in granted) { rewardList.Add(new RewardListEntry @@ -163,7 +161,7 @@ public class SpotCardExchangeController : SVSimController ExchangedAt = _time.GetUtcNow().UtcDateTime, }); - await _db.SaveChangesAsync(); + await tx.CommitAsync(); return new SpotCardExchangeResponse { RewardList = rewardList }; } @@ -182,14 +180,4 @@ public class SpotCardExchangeController : SVSimController return 0; } - private Task LoadViewerGraphAsync(long viewerId) => _db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.LeaderSkins) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .Include(v => v.Items).ThenInclude(i => i.Item) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); } From 369edd4537d747584c729d4065842ac7412cd6bc Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:37:53 -0400 Subject: [PATCH 22/32] refactor(gift): route tutorial gift_receive through InventoryService Replace RewardGrantService with IInventoryService tx. GrantAsync returns post-state totals directly, eliminating the manual ResolvePostStateRewardNum helper. MissionData loaded via extra include on BeginAsync. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/GiftController.cs | 104 ++++++------------ 1 file changed, 35 insertions(+), 69 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs b/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs index 4ed85ae..b92be10 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; -using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift; @@ -27,12 +27,12 @@ public class GiftController : SVSimController }; private readonly SVSimDbContext _db; - private readonly RewardGrantService _rewards; + private readonly IInventoryService _inv; - public GiftController(SVSimDbContext db, RewardGrantService rewards) + public GiftController(SVSimDbContext db, IInventoryService inv) { _db = db; - _rewards = rewards; + _inv = inv; } [HttpPost("/tutorial/gift_top")] @@ -71,25 +71,7 @@ public class GiftController : SVSimController var requestedIds = request.PresentIdArray.ToHashSet(); - // Load viewer with the collections RewardGrantService mutates. Crystals/Rupees live on - // viewer.Currency (owned, auto-loads); Items live on viewer.Items (owned collection). - // MissionData is an owned type and auto-loads, but Include is listed explicitly to match - // the pattern in TutorialController.Update and to make the intent clear. - // AsSplitQuery is the default-safe pattern when including viewer collections - // (project memory: project_ef_split_query). - // - // ThenInclude(i => i.Item) is load-bearing: OwnedItemEntry.Item is a separate non-owned - // entity whose default initialiser is `new ItemEntry()` (Id=0). Without the explicit - // ThenInclude, RewardGrantService.ApplyAsync's `FirstOrDefault(i => i.Item.Id == ...)` - // never matches a pre-existing row → falls through to add a duplicate → (ViewerId, ItemId) - // unique index throws on SaveChanges (project_ef_nav_include_pitfall). - var viewer = await _db.Viewers - .Include(v => v.Items).ThenInclude(i => i.Item) - .Include(v => v.MissionData) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); - - // Resolve which of the requested ids are still claimable for this viewer. + // Resolve which of the requested ids are still claimable for this viewer before opening tx. var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts .Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId)) .Select(g => g.PresentId) @@ -100,23 +82,43 @@ public class GiftController : SVSimController .Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId)) .ToList(); - // Apply grants via the canonical primitive. Pass the loaded viewer, NOT viewerId. + // Open inventory tx with MissionData loaded for tutorial-step advance. + await using var tx = await _inv.BeginAsync(viewerId, configure: + cfg => cfg.WithInclude(v => v.MissionData)); + + // Apply grants via tx. Collect post-state per (type, detailId) for reward_list. + // Each GrantAsync returns a list of GrantedReward with post-state totals; for currencies + // only one entry is returned; for cards the cascade may return more entries (card + cosmetics). + // reward_list must carry post-state totals (client does direct assignment). + var rewardListEntries = new List(); foreach (var p in toClaim) { var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType)); - await _rewards.ApplyAsync(viewer, goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount)); + var granted = await tx.GrantAsync(goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount)); + // Use the first granted entry's post-state for the top-level gift reward_list entry. + // Gift rewards are currencies and items only (no cards in TutorialGifts), so granted + // always has exactly one element. The post-state total is already correct from tx. + if (granted.Count > 0) + { + rewardListEntries.Add(new GiftRewardListEntry + { + RewardType = p.RewardType, + RewardId = p.RewardDetailId, + RewardNum = granted[0].RewardNum.ToString(System.Globalization.CultureInfo.InvariantCulture), + }); + } } // Advance tutorial state from 31 → 41 as a side-effect of this claim (no separate // /tutorial/update → 41 call appears in the prod capture). Preserve max — don't downgrade // viewers who are already past step 41. const int GiftReceiveTutorialStep = 41; - if (viewer.MissionData.TutorialState < GiftReceiveTutorialStep) + if (tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep) { - viewer.MissionData.TutorialState = GiftReceiveTutorialStep; + tx.Viewer.MissionData.TutorialState = GiftReceiveTutorialStep; } - // Persist claim receipts in the same transaction. + // Persist claim receipts inside the same tx. var now = DateTime.UtcNow; foreach (var p in toClaim) { @@ -127,7 +129,7 @@ public class GiftController : SVSimController ClaimedAt = now, }); } - await _db.SaveChangesAsync(); + await tx.CommitAsync(); var nowString = now.ToString("yyyy-MM-dd HH:mm:ss"); var allClaimedList = await _db.ViewerClaimedTutorialGifts @@ -176,54 +178,18 @@ public class GiftController : SVSimController // Hardcoding false hid the badge after partial claims even though present_list still // carried unclaimed entries. IsUnreceivedPresent = unclaimedPresents.Count > 0, - // reward_list entries must carry POST-STATE TOTALS, not gift deltas. - // The client's PlayerStaticData.UpdateHaveUserGoodsNumByJsonData does direct - // assignment on each entry's reward_num — emitting the delta would clobber - // the client-side cached balance down to the gift amount until the next /load/index. + // reward_list entries carry POST-STATE TOTALS (from tx.GrantAsync). // See project memory: project_wire_reward_list_post_state. - // - // Iterate `toClaim` so idempotent re-receive doesn't re-emit post-state entries - // the client would direct-assign again (no-op on currency, but redundant traffic - // and risk of misinterpretation on item counts). - RewardList = toClaim - .Select(p => new GiftRewardListEntry - { - RewardType = p.RewardType, - RewardId = p.RewardDetailId, - RewardNum = ResolvePostStateRewardNum(p, viewer), - }) - .ToList(), + // Iterate toClaim so idempotent re-receive doesn't re-emit post-state entries. + RewardList = rewardListEntries, // Echo the persisted state, not a hardcoded 41. The state may already be past 41 // for replay/edge-case calls (the Math.Max-preserve block above keeps it stable); // emitting 41 anyway would surface a regressed step to the client and desync the // tutorial-state machine. - TutorialStep = viewer.MissionData.TutorialState, + TutorialStep = tx.Viewer.MissionData.TutorialState, }; } - /// - /// Returns the post-grant viewer balance for the given gift entry, not the gift delta. - /// reward_list on wire carries post-state totals (client does direct assignment). - /// - private static string ResolvePostStateRewardNum(PresentDto gift, SVSim.Database.Models.Viewer viewer) - { - switch (gift.RewardType) - { - case "1": // Crystal - return ((long)viewer.Currency.Crystals).ToString(System.Globalization.CultureInfo.InvariantCulture); - case "9": // Rupy - return ((long)viewer.Currency.Rupees).ToString(System.Globalization.CultureInfo.InvariantCulture); - case "4": // Item - { - int itemId = int.Parse(gift.RewardDetailId, System.Globalization.CultureInfo.InvariantCulture); - var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == itemId); - return ((long)(owned?.Count ?? 0)).ToString(System.Globalization.CultureInfo.InvariantCulture); - } - default: - return gift.RewardCount; // unknown type — fall back to gift count (better than 0) - } - } - private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch { 1 => UserGoodsType.Crystal, From 4ba7d8f6d090ae865a0b33d8d73b42448efb69c8 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:39:07 -0400 Subject: [PATCH 23/32] refactor(achievement): route receive_reward through InventoryService Replace RewardGrantService with IInventoryService tx. EnsureCurrentAsync still runs before BeginAsync to avoid EF concurrent-context conflicts; tx.Viewer replaces the manually loaded viewer graph. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/AchievementController.cs | 42 ++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/AchievementController.cs b/SVSim.EmulatedEntrypoint/Controllers/AchievementController.cs index 8c186b7..e345258 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AchievementController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AchievementController.cs @@ -2,9 +2,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; -using SVSim.Database.Models; using SVSim.Database.Repositories.Mission; -using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos.Achievement; using SVSim.EmulatedEntrypoint.Services; @@ -22,20 +21,20 @@ public class AchievementController : SVSimController private readonly IMissionCatalogRepository _catalog; private readonly IViewerMissionStateService _state; private readonly IMissionAssembler _assembler; - private readonly RewardGrantService _grantService; + private readonly IInventoryService _inv; public AchievementController( SVSimDbContext db, IMissionCatalogRepository catalog, IViewerMissionStateService state, IMissionAssembler assembler, - RewardGrantService grantService) + IInventoryService inv) { _db = db; _catalog = catalog; _state = state; _assembler = assembler; - _grantService = grantService; + _inv = inv; } [HttpPost("receive_reward")] @@ -44,21 +43,15 @@ public class AchievementController : SVSimController { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - // Load viewer with all the collections RewardGrantService may need to mutate. - var viewer = await _db.Viewers - .Include(v => v.MissionData) - .Include(v => v.Currency) - .Include(v => v.Cards) - .Include(v => v.Items) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.Degrees) - .Include(v => v.LeaderSkins) - .Include(v => v.MyPageBackgrounds) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId, ct); + // EnsureCurrentAsync needs a viewer id — use a lightweight pre-check load then + // materialize state before opening the inventory tx. + var viewerIdCheck = await _db.Viewers + .Where(v => v.Id == viewerId) + .Select(v => v.Id) + .FirstOrDefaultAsync(ct); + if (viewerIdCheck == 0) return Unauthorized(); - await _state.EnsureCurrentAsync(viewer.Id, ct); + await _state.EnsureCurrentAsync(viewerId, ct); await _db.SaveChangesAsync(ct); // Re-read viewer's achievement for this type after state-service materialization. @@ -75,9 +68,10 @@ public class AchievementController : SVSimController return Ok(new { result_code = FailureResultCode }); } - // Grant via the canonical RewardGrantService primitive. - var granted = await _grantService.ApplyAsync( - viewer, + // Open inventory tx and grant via InventoryService. + await using var tx = await _inv.BeginAsync(viewerId, ct); + + var granted = await tx.GrantAsync( (UserGoodsType)catalogRow.RewardType, catalogRow.RewardDetailId, catalogRow.RewardNumber, @@ -99,9 +93,9 @@ public class AchievementController : SVSimController } ach.NowAchievedLevel = request.Level; - await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); - var dto = await _assembler.BuildAsync(viewer, ct); + var dto = await _assembler.BuildAsync(tx.Viewer, ct); var resp = new AchievementReceiveRewardResponse { UserMissionList = dto.UserMissionList, From a3106978308a3163b0b6697339a681dd8c60a9e3 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:40:16 -0400 Subject: [PATCH 24/32] refactor(puzzle): route finish rewards through InventoryService Replace RewardGrantService + HttpContext.RequestServices viewer load with IInventoryService tx. Single BeginAsync/GrantAsync/CommitAsync wraps all mission rewards on the win path. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/PuzzleController.cs | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs b/SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs index aad054c..bde4c48 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs @@ -1,12 +1,11 @@ using System.Text.Json; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SVSim.Database; +using SVSim.Database.Enums; using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Viewer; -using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle; @@ -26,20 +25,20 @@ public class PuzzleController : SVSimController private readonly IPuzzleCatalogRepository _catalog; private readonly IPuzzleClearRepository _clears; private readonly PuzzleMissionEvaluator _evaluator; - private readonly RewardGrantService _rewards; + private readonly IInventoryService _inv; private readonly ILogger _logger; public PuzzleController( IPuzzleCatalogRepository catalog, IPuzzleClearRepository clears, PuzzleMissionEvaluator evaluator, - RewardGrantService rewards, + IInventoryService inv, ILogger logger) { _catalog = catalog; _clears = clears; _evaluator = evaluator; - _rewards = rewards; + _inv = inv; _logger = logger; } @@ -175,28 +174,15 @@ public class PuzzleController : SVSimController if (fresh.Count > 0) { - // Load viewer with all the collections RewardGrantService might mutate. Split-query - // to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query"). - var ctx = HttpContext.RequestServices.GetRequiredService(); - var viewer = await ctx.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.LeaderSkins) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .Include(v => v.Items).ThenInclude(i => i.Item) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); + await using var tx = await _inv.BeginAsync(viewerId); foreach (var status in fresh) { - IReadOnlyList granted; + IReadOnlyList granted; try { - granted = await _rewards.ApplyAsync( - viewer, - (SVSim.Database.Enums.UserGoodsType)status.Mission.RewardType, + granted = await tx.GrantAsync( + (UserGoodsType)status.Mission.RewardType, status.Mission.RewardDetailId, status.Mission.RewardNumber); } @@ -229,7 +215,7 @@ public class PuzzleController : SVSimController } } - await ctx.SaveChangesAsync(); + await tx.CommitAsync(); } response.WinCount = "1"; From 7c4bc2966f616aa1684d053454af1cb1d8df3890 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:42:38 -0400 Subject: [PATCH 25/32] refactor(story): route FinishAsync rewards through InventoryService Replace RewardGrantService with IInventoryService tx. Per-reward GrantAsync calls inside try/catch preserve the NotSupportedException skip; CommitAsync returns result.RewardList (post-state totals) and accumulated delta list feeds story_reward_list. Update StoryServiceTests to inject IInventoryService. Co-Authored-By: Claude Opus 4.7 --- .../Services/StoryService.cs | 77 +++++++++---------- SVSim.UnitTests/Story/StoryServiceTests.cs | 13 ++-- 2 files changed, 42 insertions(+), 48 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Services/StoryService.cs b/SVSim.EmulatedEntrypoint/Services/StoryService.cs index b6b62f7..d2ff5df 100644 --- a/SVSim.EmulatedEntrypoint/Services/StoryService.cs +++ b/SVSim.EmulatedEntrypoint/Services/StoryService.cs @@ -8,6 +8,7 @@ using SVSim.Database.Models.Config; using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.BuildDeck; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.Database.Repositories.Story; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Common; @@ -19,7 +20,7 @@ public class StoryService : IStoryService { private readonly IStoryMasterRepository _master; private readonly IViewerStoryProgressRepository _viewer; - private readonly RewardGrantService _rewards; + private readonly IInventoryService _inv; private readonly SVSimDbContext _db; private readonly IGameConfigService _configService; private readonly IDeckRepository _deckRepository; @@ -29,7 +30,7 @@ public class StoryService : IStoryService public StoryService( IStoryMasterRepository master, IViewerStoryProgressRepository viewer, - RewardGrantService rewards, + IInventoryService inv, SVSimDbContext db, IGameConfigService configService, IDeckRepository deckRepository, @@ -38,7 +39,7 @@ public class StoryService : IStoryService { _master = master; _viewer = viewer; - _rewards = rewards; + _inv = inv; _db = db; _configService = configService; _deckRepository = deckRepository; @@ -519,28 +520,26 @@ public class StoryService : IStoryService if (firstClear && chapter.Rewards.Count > 0) { - // Load viewer with all collections RewardGrantService might mutate. Split-query - // to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query"). Skip the - // load entirely when the chapter has no rewards — common for narrative-only - // chapters (limited/event story) where the only side effect is the progress upsert. - var viewer = await _db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.LeaderSkins) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .Include(v => v.Items).ThenInclude(i => i.Item) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); + // Open inventory tx — skip the load entirely when no rewards (narrative-only + // chapters where the only side effect is the progress upsert). + await using var tx = await _inv.BeginAsync(viewerId); + // reward_list and story_reward_list have DIFFERENT semantics for reward_num: + // - reward_list: post-state totals. Client (PlayerStaticData + // .UpdateHaveUserGoodsNum) direct-assigns to in-memory + // balances (e.g. UserRupyCount = num). + // - story_reward_list: deltas. Client (ResultAnimationAgent + // .HandleStoryAndMissionRewards) feeds each entry to + // AddReward(item) which draws a "+N received" popup line. + // GrantAsync may return 1+N entries (Card grants cascade into cosmetics). All + // post-state entries go into reward_list via result.RewardList; story_reward_list + // only gets the top-level mission row's delta (cascade cosmetics have no row). + var storyRewardDeltas = new List(); foreach (var r in chapter.Rewards) { - IReadOnlyList granted; try { - granted = await _rewards.ApplyAsync( - viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); } catch (NotSupportedException ex) { @@ -549,27 +548,8 @@ public class StoryService : IStoryService r.RewardType, r.RewardDetailId, r.RewardNumber, req.StoryId); continue; } - - // reward_list and story_reward_list have DIFFERENT semantics for reward_num: - // - reward_list: post-state totals. Client (PlayerStaticData - // .UpdateHaveUserGoodsNum) direct-assigns to in-memory - // balances (e.g. UserRupyCount = num). - // - story_reward_list: deltas. Client (ResultAnimationAgent - // .HandleStoryAndMissionRewards) feeds each entry to - // AddReward(item) which draws a "+N received" popup line. - // ApplyAsync may return 1+N entries (Card grants cascade into cosmetics). All - // post-state entries go into reward_list; story_reward_list only gets the - // top-level mission row's delta (cascade cosmetics have no corresponding row). - foreach (var g in granted) - { - resp.RewardList.Add(new RewardGrant - { - RewardType = g.RewardType.ToString(), - RewardId = g.RewardId.ToString(), - RewardNum = g.RewardNum.ToString(), - }); - } - resp.StoryRewardList.Add(new RewardGrant + // delta for story_reward_list: raw catalog amounts (not post-state) + storyRewardDeltas.Add(new RewardGrant { RewardType = ((int)r.RewardType).ToString(), RewardId = r.RewardDetailId.ToString(), @@ -577,7 +557,20 @@ public class StoryService : IStoryService }); } - await _db.SaveChangesAsync(); + var result = await tx.CommitAsync(); + + // reward_list = post-state totals from tx (includes cosmetic cascade entries) + foreach (var g in result.RewardList) + { + resp.RewardList.Add(new RewardGrant + { + RewardType = g.RewardType.ToString(), + RewardId = g.RewardId.ToString(), + RewardNum = g.RewardNum.ToString(), + }); + } + // story_reward_list = deltas accumulated above + resp.StoryRewardList.AddRange(storyRewardDeltas); } if (firstClear && isPlayShape) diff --git a/SVSim.UnitTests/Story/StoryServiceTests.cs b/SVSim.UnitTests/Story/StoryServiceTests.cs index 6590ae3..7e1e20a 100644 --- a/SVSim.UnitTests/Story/StoryServiceTests.cs +++ b/SVSim.UnitTests/Story/StoryServiceTests.cs @@ -8,6 +8,7 @@ using SVSim.Database.Entities.Story; using SVSim.Database.Models; using SVSim.Database.Repositories.Story; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos.Story; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; @@ -26,12 +27,12 @@ public class StoryServiceTests { _master = new Mock(); _viewer = new Mock(); - // Non-reward tests never exercise the DB/reward path; use a stub InMemory context. + // Non-reward tests never exercise the DB/reward path; use a stub InMemory context + null inv. var db = StoryServiceTestHelpers.NewInMemoryDb(nameof(SetUp)); - var rewards = new RewardGrantService(db, NullLogger.Instance); + var inv = new Mock().Object; _service = new StoryService( _master.Object, _viewer.Object, - rewards: rewards, + inv: inv, db: db, configService: StoryServiceTestHelpers.NewConfigService(), deckRepository: new Mock().Object, @@ -64,12 +65,12 @@ public class StoryServiceTests scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var rewards = scope.ServiceProvider.GetRequiredService(); + var inv = scope.ServiceProvider.GetRequiredService(); return new StoryService( _master.Object, _viewer.Object, - rewards: rewards, + inv: inv, db: db, configService: StoryServiceTestHelpers.NewConfigService(), deckRepository: new Mock().Object, @@ -402,7 +403,7 @@ public class StoryServiceTests db.SaveChanges(); return new StoryService( _master.Object, _viewer.Object, - rewards: new RewardGrantService(db, NullLogger.Instance), + inv: new Mock().Object, db: db, configService: StoryServiceTestHelpers.NewConfigService(), deckRepository: new Mock().Object, From 26bc4fe2abe75dfa10359232f1e201a987a2b345 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:46:13 -0400 Subject: [PATCH 26/32] refactor(battle-pass): route BuyPremiumAsync and AddPointsAsync through InventoryService Replace RewardGrantService + ICurrencySpendService with IInventoryService tx. CommitAsync's currency-collision rule replaces the manual Crystal RemoveAll+re-append scrub in BuyPremiumAsync. AddPointsAsync uses result.Deltas for NewlyClaimed to preserve per-track visibility (two Rupy grants stay two entries). Co-Authored-By: Claude Opus 4.7 --- .../Services/BattlePassService.cs | 74 +++++++------------ 1 file changed, 26 insertions(+), 48 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs index 2a07fcc..d2ad0c3 100644 --- a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs +++ b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs @@ -5,6 +5,7 @@ using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.BattlePass; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; @@ -22,23 +23,20 @@ public sealed class BattlePassService : IBattlePassService private readonly IViewerBattlePassRepository _viewerBp; private readonly TimeProvider _time; private readonly SVSimDbContext _db; - private readonly RewardGrantService _rewards; - private readonly ICurrencySpendService _spend; + private readonly IInventoryService _inv; public BattlePassService( IBattlePassRepository bp, IViewerBattlePassRepository viewerBp, TimeProvider time, SVSimDbContext db, - RewardGrantService rewards, - ICurrencySpendService spend) + IInventoryService inv) { _bp = bp; _viewerBp = viewerBp; _time = time; _db = db; - _rewards = rewards; - _spend = spend; + _inv = inv; } public async Task?> GetLevelCurveAsync(CancellationToken ct) @@ -156,26 +154,22 @@ public sealed class BattlePassService : IBattlePassService if (productId != season.Id * 1000) return new BattlePassBuyOutcome(0, Array.Empty(), Array.Empty()); - var viewer = await _db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.Sleeves).Include(v => v.Emblems).Include(v => v.LeaderSkins) - .Include(v => v.Degrees).Include(v => v.MyPageBackgrounds).Include(v => v.Items).ThenInclude(i => i.Item) - .AsSplitQuery() // per memory project_ef_split_query - .FirstOrDefaultAsync(v => v.Id == viewerId, ct); - if (viewer is null) + // Guard: viewer must exist (BeginAsync throws InventoryViewerNotFoundException otherwise). + var viewerExists = await _db.Viewers.AnyAsync(v => v.Id == viewerId, ct); + if (!viewerExists) return new BattlePassBuyOutcome(0, Array.Empty(), Array.Empty()); var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct); if (progress.IsPremium) return new BattlePassBuyOutcome(23, Array.Empty(), Array.Empty()); - var spendResult = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, season.PriceCrystal, ct); + // Open inventory tx — loads viewer + opens DB tx. + await using var tx = await _inv.BeginAsync(viewerId, ct); + + var spendResult = await tx.TrySpendAsync(SpendCurrency.Crystal, season.PriceCrystal, ct); if (!spendResult.Success) return new BattlePassBuyOutcome(22, Array.Empty(), Array.Empty()); - // BeginTransactionAsync is a no-op on the SQLite in-memory test DB but is safe to call. - await using var tx = await _db.Database.BeginTransactionAsync(ct); - progress.IsPremium = true; // Retroactive grants: every premium reward at level <= current_level not already claimed. @@ -186,32 +180,21 @@ public sealed class BattlePassService : IBattlePassService var curve = await _bp.GetLevelCurveAsync(ct); int currentLevel = ComputeLevel(curve, progress.CurrentPoint); - // achieved = delta list (the original reward spec amounts — what was just granted). - // postState = post-state totals from RewardGrantService (what goes in reward_list). - var achieved = new List(); - var postState = new List(); foreach (var r in rewards.Where(r => r.Track == BattlePassTrack.Premium && r.Level <= currentLevel)) { if (claimSet.Contains((r.Track, r.Level))) continue; _viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now); - var granted = await _rewards.ApplyAsync( - viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct); - // achieved_info uses the original reward spec (delta), not post-state. - achieved.Add(new GrantedReward(r.RewardType, r.RewardDetailId, r.RewardNumber)); - postState.AddRange(granted); + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct); } - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); + // CommitAsync handles DB save + currency-collision rule. Crystal spend is the first + // op, any grants override the post-state. result.RewardList carries the final + // post-state including the deducted crystal balance. result.Deltas carries the raw + // grant amounts for achieved_info (no spend entry in Deltas, only GrantOps). + var result = await tx.CommitAsync(ct); + await _db.SaveChangesAsync(ct); // flush claim rows added via _viewerBp.AddClaim - // Post-state reward_list must always include the crystal balance after the deduction. - // Unconditionally overwrite: remove any crystal entry ApplyAsync may have added, then - // append the post-deduction total so the client gets the correct final balance. - postState.RemoveAll(r => r.RewardType == (int)UserGoodsType.Crystal); - postState.Add(new GrantedReward( - (int)UserGoodsType.Crystal, 0, (int)spendResult.PostStateTotal)); - - return new BattlePassBuyOutcome(1, achieved, postState); + return new BattlePassBuyOutcome(1, result.Deltas, result.RewardList); } public async Task AddPointsAsync( @@ -225,14 +208,6 @@ public sealed class BattlePassService : IBattlePassService Array.Empty()); } - var viewer = await _db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.Sleeves).Include(v => v.Emblems).Include(v => v.LeaderSkins) - .Include(v => v.Degrees).Include(v => v.MyPageBackgrounds).Include(v => v.Items).ThenInclude(i => i.Item) - .AsSplitQuery() - .FirstOrDefaultAsync(v => v.Id == viewerId, ct) - ?? throw new InvalidOperationException($"viewer {viewerId} not found"); - var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct); int beforePoint = progress.CurrentPoint; @@ -248,13 +223,15 @@ public sealed class BattlePassService : IBattlePassService int afterLevel = ComputeLevel(curve, progress.CurrentPoint); - var newlyClaimed = new List(); + IReadOnlyList newlyClaimed = Array.Empty(); if (afterLevel > beforeLevel) { var rewards = await _bp.GetSeasonRewardsAsync(season.Id, ct); var claims = await _viewerBp.GetClaimsAsync(viewerId, season.Id, ct); var claimSet = claims.Select(c => (c.Track, c.Level)).ToHashSet(); + await using var tx = await _inv.BeginAsync(viewerId, ct); + for (int level = beforeLevel + 1; level <= afterLevel; level++) { foreach (var r in rewards.Where(r => r.Level == level)) @@ -262,11 +239,12 @@ public sealed class BattlePassService : IBattlePassService if (r.Track == BattlePassTrack.Premium && !progress.IsPremium) continue; if (claimSet.Contains((r.Track, r.Level))) continue; _viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now); - var granted = await _rewards.ApplyAsync( - viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct); - newlyClaimed.AddRange(granted); + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct); } } + + var result = await tx.CommitAsync(ct); + newlyClaimed = result.Deltas; } await _db.SaveChangesAsync(ct); From b6bf9b749589e463057ec12bd66812a898362a85 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:51:03 -0400 Subject: [PATCH 27/32] refactor(arena-two-pick): route entry/finish through InventoryService Replace RewardGrantService + ICurrencySpendService + IViewerEntitlements with IInventoryService. tx.IsFreeplay replaces FakeEntitlements.IsFreeplay; debit helpers take IInventoryTransaction. ComputePostStateRewardList deleted (replaced by result.RewardList from CommitAsync). Update 5 test files to new 8-arg ctor. Co-Authored-By: Claude Opus 4.7 --- .../Services/ArenaTwoPickService.cs | 109 ++++++++---------- .../Services/ArenaTwoPickServiceDraftTests.cs | 20 +--- .../Services/ArenaTwoPickServiceEntryTests.cs | 25 +--- .../ArenaTwoPickServiceFinishTests.cs | 20 +--- .../Services/ArenaTwoPickServiceTopTests.cs | 4 +- ...ArenaTwoPickServiceWeightedRewardsTests.cs | 20 +--- 6 files changed, 62 insertions(+), 136 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs index 2652db0..d05f568 100644 --- a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs +++ b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs @@ -1,10 +1,12 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using SVSim.Database; +using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; @@ -17,11 +19,9 @@ public class ArenaTwoPickService : IArenaTwoPickService private readonly IArenaTwoPickCardPoolService _pool; private readonly IGameConfigService _config; private readonly IViewerRepository _viewers; - private readonly RewardGrantService _grants; - private readonly IViewerEntitlements _entitlements; + private readonly IInventoryService _inv; private readonly IRandom _rng; private readonly SVSimDbContext _db; - private readonly ICurrencySpendService _spend; public ArenaTwoPickService( IArenaTwoPickRunRepository runs, @@ -29,15 +29,12 @@ public class ArenaTwoPickService : IArenaTwoPickService IArenaTwoPickCardPoolService pool, IGameConfigService config, IViewerRepository viewers, - RewardGrantService grants, - IViewerEntitlements entitlements, + IInventoryService inv, IRandom rng, - SVSimDbContext db, - ICurrencySpendService spend) + SVSimDbContext db) { _runs = runs; _rewards = rewards; _pool = pool; _config = config; - _viewers = viewers; _grants = grants; _entitlements = entitlements; _rng = rng; _db = db; - _spend = spend; + _viewers = viewers; _inv = inv; _rng = rng; _db = db; } public async Task GetTopAsync(long viewerId) @@ -66,14 +63,16 @@ public class ArenaTwoPickService : IArenaTwoPickService throw new ArenaTwoPickException("arena_two_pick_already_in_progress"); var aCfg = _config.Get(); - var viewer = await LoadViewerForGrantsAsync(viewerId); + + // Open inventory tx for currency/item debit. + await using var tx = await _inv.BeginAsync(viewerId); // Dispatch on the client's chosen payment method (ArenaData.eARENA_PAY). RewardEntryDto? feeEntry = consumeItemType switch { - 1 => await DebitCrystalsAsync(viewer, aCfg.CrystalCost), - 3 => DebitTicket(viewer, aCfg.TicketItemId, aCfg.TicketCost), - 4 => await DebitRupiesAsync(viewer, aCfg.RupyCost), + 1 => await DebitCrystalsAsync(tx, aCfg.CrystalCost), + 3 => await DebitTicketAsync(tx, aCfg.TicketItemId, aCfg.TicketCost), + 4 => await DebitRupiesAsync(tx, aCfg.RupyCost), 5 => null, // Free entry — no fee. _ => throw new ArenaTwoPickException("invalid_consume_item_type"), }; @@ -102,9 +101,12 @@ public class ArenaTwoPickService : IArenaTwoPickService IsRetire = false, }; await _runs.UpsertAsync(run); + // Save to get auto-generated Id before CommitAsync. + await _db.SaveChangesAsync(); run.EntryId = run.Id; await _runs.UpsertAsync(run); - await _db.SaveChangesAsync(); + // CommitAsync saves all pending changes (including run update) and commits the db tx. + await tx.CommitAsync(); var rewardList = feeEntry is null ? new List() : new List { feeEntry }; @@ -117,50 +119,50 @@ public class ArenaTwoPickService : IArenaTwoPickService }; } - private RewardEntryDto DebitTicket(SVSim.Database.Models.Viewer viewer, int ticketItemId, int ticketCost) + private async Task DebitTicketAsync(IInventoryTransaction tx, int ticketItemId, int ticketCost) { - var ticket = viewer.Items.FirstOrDefault(i => i.Item.Id == ticketItemId); - int postStateCount; - if (_entitlements.IsFreeplay) + if (tx.IsFreeplay) { - postStateCount = ticket?.Count ?? 0; - } - else - { - if (ticket is null || ticket.Count < ticketCost) - throw new ArenaTwoPickException("insufficient_ticket"); - ticket.Count -= ticketCost; - postStateCount = ticket.Count; + var ticket = tx.Viewer.Items.FirstOrDefault(i => i.Item.Id == ticketItemId); + return new RewardEntryDto + { + RewardType = (int)UserGoodsType.Item, + RewardId = ticketItemId, + RewardNum = ticket?.Count ?? 0, + }; } + var debitResult = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketCost); + if (!debitResult.Success) + throw new ArenaTwoPickException("insufficient_ticket"); return new RewardEntryDto { - RewardType = (int)SVSim.Database.Enums.UserGoodsType.Item, + RewardType = (int)UserGoodsType.Item, RewardId = ticketItemId, - RewardNum = postStateCount, + RewardNum = (int)debitResult.PostStateTotal, }; } - private async Task DebitCrystalsAsync(SVSim.Database.Models.Viewer viewer, int cost) + private async Task DebitCrystalsAsync(IInventoryTransaction tx, int cost) { - var result = await _spend.TrySpendAsync(viewer, SVSim.Database.Services.SpendCurrency.Crystal, cost); + var result = await tx.TrySpendAsync(SpendCurrency.Crystal, cost); if (!result.Success) throw new ArenaTwoPickException("insufficient_crystal"); return new RewardEntryDto { - RewardType = (int)SVSim.Database.Enums.UserGoodsType.Crystal, + RewardType = (int)UserGoodsType.Crystal, RewardId = 0, RewardNum = (int)result.PostStateTotal, }; } - private async Task DebitRupiesAsync(SVSim.Database.Models.Viewer viewer, int cost) + private async Task DebitRupiesAsync(IInventoryTransaction tx, int cost) { - var result = await _spend.TrySpendAsync(viewer, SVSim.Database.Services.SpendCurrency.Rupee, cost); + var result = await tx.TrySpendAsync(SpendCurrency.Rupee, cost); if (!result.Success) throw new ArenaTwoPickException("insufficient_rupy"); return new RewardEntryDto { - RewardType = (int)SVSim.Database.Enums.UserGoodsType.Rupy, + RewardType = (int)UserGoodsType.Rupy, RewardId = 0, RewardNum = (int)result.PostStateTotal, }; @@ -295,12 +297,11 @@ public class ArenaTwoPickService : IArenaTwoPickService throw new ArenaTwoPickException("arena_two_pick_run_not_complete"); var rewardRows = await _rewards.GetRewardsByWinCountAsync(run.WinCount); - var viewer = await LoadViewerForGrantsAsync(viewerId); // Pre-load item_type for any Item-typed reward so we can populate it on the // per-grant delta entries. Currencies don't need a lookup (item_type stays 0). var itemRewardIds = rewardRows - .Where(r => r.RewardType == (int)SVSim.Database.Enums.UserGoodsType.Item) + .Where(r => r.RewardType == (int)UserGoodsType.Item) .Select(r => (int)r.RewardId) .Distinct() .ToList(); @@ -310,7 +311,9 @@ public class ArenaTwoPickService : IArenaTwoPickService .ToDictionaryAsync(i => i.Id, i => i.Type); var deltas = new List(); - var picks = new List(); + + // Open inventory tx for grants. + await using var tx = await _inv.BeginAsync(viewerId); // Group by RewardGroup, weighted-pick one row per group (Weight==0 excluded). foreach (var group in rewardRows.GroupBy(r => r.RewardGroup)) @@ -318,13 +321,11 @@ public class ArenaTwoPickService : IArenaTwoPickService var pickable = group.Where(r => r.Weight > 0).ToList(); if (pickable.Count == 0) continue; var pick = WeightedPick(pickable, _rng); - picks.Add(pick); // Skip when the rolled outcome is "nothing" (RewardNum == 0). if (pick.RewardNum <= 0) continue; - var goodsType = (SVSim.Database.Enums.UserGoodsType)pick.RewardType; - await _grants.ApplyAsync(viewer, goodsType, pick.RewardId, pick.RewardNum); + await tx.GrantAsync((UserGoodsType)pick.RewardType, pick.RewardId, pick.RewardNum); deltas.Add(new TwoPickRewardReceivedDto { RewardType = pick.RewardType, @@ -334,11 +335,12 @@ public class ArenaTwoPickService : IArenaTwoPickService IsUsable = true, }); } - await _db.SaveChangesAsync(); - // ComputePostStateRewardList reads from the picked rows only — same set the - // grants were applied for — so the post-state list mirrors the deltas exactly. - var postStates = ComputePostStateRewardList(picks.Where(p => p.RewardNum > 0).ToList(), viewer); + var result = await tx.CommitAsync(); + + var postStates = result.RewardList + .Select(g => new RewardEntryDto { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(); await _runs.DeleteAsync(viewerId); return new FinishResponseDto { Rewards = deltas, RewardList = postStates }; @@ -358,25 +360,6 @@ public class ArenaTwoPickService : IArenaTwoPickService return rows[^1]; } - private static List ComputePostStateRewardList( - IReadOnlyList rows, SVSim.Database.Models.Viewer viewer) - { - var entries = new List(); - foreach (var r in rows) - { - int postState = r.RewardType switch - { - (int)SVSim.Database.Enums.UserGoodsType.Rupy => (int)viewer.Currency!.Rupees, - (int)SVSim.Database.Enums.UserGoodsType.Crystal => (int)viewer.Currency!.Crystals, - (int)SVSim.Database.Enums.UserGoodsType.RedEther => (int)viewer.Currency!.RedEther, - (int)SVSim.Database.Enums.UserGoodsType.Item => viewer.Items.FirstOrDefault(i => i.Item.Id == (int)r.RewardId)?.Count ?? r.RewardNum, - _ => r.RewardNum, - }; - entries.Add(new RewardEntryDto { RewardType = r.RewardType, RewardId = r.RewardId, RewardNum = postState }); - } - return entries; - } - public async Task RecordBattleResultAsync(long viewerId, bool isWin) { var run = await _runs.GetByViewerIdAsync(viewerId) diff --git a/SVSim.UnitTests/Services/ArenaTwoPickServiceDraftTests.cs b/SVSim.UnitTests/Services/ArenaTwoPickServiceDraftTests.cs index 3cee2a5..e23331c 100644 --- a/SVSim.UnitTests/Services/ArenaTwoPickServiceDraftTests.cs +++ b/SVSim.UnitTests/Services/ArenaTwoPickServiceDraftTests.cs @@ -7,6 +7,7 @@ using SVSim.Database.Models; using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; @@ -25,19 +26,6 @@ public class ArenaTwoPickServiceDraftTests }; } - private sealed class FakeEntitlements : IViewerEntitlements - { - public bool IsFreeplay { get; init; } - - public long EffectiveBalance(SVSim.Database.Models.Viewer viewer, SpendCurrency currency) => 0; - public bool OwnsCard(SVSim.Database.Models.Viewer viewer, long cardId) => IsFreeplay; - public bool OwnsCosmetic(SVSim.Database.Models.Viewer viewer, CosmeticType type, int id) => IsFreeplay; - public Task> EffectiveOwnedCardsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default) - => Task.FromResult>(new List()); - public Task EffectiveCosmeticsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default) - => throw new NotSupportedException(); - } - private static async Task<(IArenaTwoPickService, IArenaTwoPickRunRepository, long viewerId)> SetupWithActiveRunAsync(int classChosen = 0) { var factory = new SVSimTestFactory(); @@ -73,11 +61,9 @@ public class ArenaTwoPickServiceDraftTests new FakePool(), scope.ServiceProvider.GetRequiredService(), scope.ServiceProvider.GetRequiredService(), - scope.ServiceProvider.GetRequiredService(), - new FakeEntitlements(), + scope.ServiceProvider.GetRequiredService(), new SystemRandom(seed: 1), - db, - scope.ServiceProvider.GetRequiredService()); + db); return (svc, runs, 7); } diff --git a/SVSim.UnitTests/Services/ArenaTwoPickServiceEntryTests.cs b/SVSim.UnitTests/Services/ArenaTwoPickServiceEntryTests.cs index dba71fc..faf1c8a 100644 --- a/SVSim.UnitTests/Services/ArenaTwoPickServiceEntryTests.cs +++ b/SVSim.UnitTests/Services/ArenaTwoPickServiceEntryTests.cs @@ -7,6 +7,7 @@ using SVSim.Database.Models; using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; @@ -23,24 +24,10 @@ public class ArenaTwoPickServiceEntryTests => throw new NotSupportedException("pool not used in EntryAsync"); } - /// Minimal fake that exposes only . - private sealed class FakeEntitlements : IViewerEntitlements - { - public bool IsFreeplay { get; init; } - - public long EffectiveBalance(SVSim.Database.Models.Viewer viewer, SpendCurrency currency) => 0; - public bool OwnsCard(SVSim.Database.Models.Viewer viewer, long cardId) => IsFreeplay; - public bool OwnsCosmetic(SVSim.Database.Models.Viewer viewer, CosmeticType type, int id) => IsFreeplay; - public Task> EffectiveOwnedCardsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default) - => Task.FromResult>(new List()); - public Task EffectiveCosmeticsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default) - => throw new NotSupportedException(); - } - private static async Task<(SVSimDbContext db, IArenaTwoPickService svc, long viewerId)> SetupAsync( int ticketCount, bool freeplay = false, ulong crystals = 0, ulong rupees = 0) { - var factory = new SVSimTestFactory(); + var factory = new SVSimTestFactory(freeplayEnabled: freeplay); var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.EnsureCreatedAsync(); @@ -56,8 +43,8 @@ public class ArenaTwoPickServiceEntryTests db.Viewers.Add(viewer); await db.SaveChangesAsync(); - var grants = scope.ServiceProvider.GetRequiredService(); var config = scope.ServiceProvider.GetRequiredService(); + var inv = scope.ServiceProvider.GetRequiredService(); // Seed reward catalog so GetMaxWinCountAsync returns 7. await new ArenaTwoPickRewardImporter() @@ -69,11 +56,9 @@ public class ArenaTwoPickServiceEntryTests new NullCardPoolService(), config, scope.ServiceProvider.GetRequiredService(), - grants, - new FakeEntitlements { IsFreeplay = freeplay }, + inv, new SystemRandom(seed: 1234), - db, - scope.ServiceProvider.GetRequiredService()); + db); return (db, svc, viewer.Id); } diff --git a/SVSim.UnitTests/Services/ArenaTwoPickServiceFinishTests.cs b/SVSim.UnitTests/Services/ArenaTwoPickServiceFinishTests.cs index b50d066..2069664 100644 --- a/SVSim.UnitTests/Services/ArenaTwoPickServiceFinishTests.cs +++ b/SVSim.UnitTests/Services/ArenaTwoPickServiceFinishTests.cs @@ -8,6 +8,7 @@ using SVSim.Database.Models; using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; @@ -17,19 +18,6 @@ public class ArenaTwoPickServiceFinishTests { private const long TicketItemId = 80001; - private sealed class FakeEntitlements : IViewerEntitlements - { - public bool IsFreeplay { get; init; } - - public long EffectiveBalance(SVSim.Database.Models.Viewer viewer, SpendCurrency currency) => 0; - public bool OwnsCard(SVSim.Database.Models.Viewer viewer, long cardId) => IsFreeplay; - public bool OwnsCosmetic(SVSim.Database.Models.Viewer viewer, CosmeticType type, int id) => IsFreeplay; - public Task> EffectiveOwnedCardsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default) - => Task.FromResult>(new List()); - public Task EffectiveCosmeticsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default) - => throw new NotSupportedException(); - } - private sealed class FakePool : IArenaTwoPickCardPoolService { public List GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new(); @@ -90,11 +78,9 @@ public class ArenaTwoPickServiceFinishTests new FakePool(), scope.ServiceProvider.GetRequiredService(), scope.ServiceProvider.GetRequiredService(), - scope.ServiceProvider.GetRequiredService(), - new FakeEntitlements(), + scope.ServiceProvider.GetRequiredService(), new SystemRandom(seed: 1), - db, - scope.ServiceProvider.GetRequiredService()); + db); return (db, svc, 7L); } diff --git a/SVSim.UnitTests/Services/ArenaTwoPickServiceTopTests.cs b/SVSim.UnitTests/Services/ArenaTwoPickServiceTopTests.cs index aa786e3..2bf7121 100644 --- a/SVSim.UnitTests/Services/ArenaTwoPickServiceTopTests.cs +++ b/SVSim.UnitTests/Services/ArenaTwoPickServiceTopTests.cs @@ -81,7 +81,7 @@ public class ArenaTwoPickServiceTopTests private static IArenaTwoPickService BuildService(SVSimDbContext db, IArenaTwoPickRunRepository runRepo) { // GetTopAsync only uses _runs — every other dep can be null! because the test path - // never touches them. The 9th positional arg (db) is required from Task 13 onward. - return new ArenaTwoPickService(runRepo, null!, null!, null!, null!, null!, null!, null!, db, null!); + // never touches them. + return new ArenaTwoPickService(runRepo, null!, null!, null!, null!, null!, null!, db); } } diff --git a/SVSim.UnitTests/Services/ArenaTwoPickServiceWeightedRewardsTests.cs b/SVSim.UnitTests/Services/ArenaTwoPickServiceWeightedRewardsTests.cs index 52c0324..c5a17ad 100644 --- a/SVSim.UnitTests/Services/ArenaTwoPickServiceWeightedRewardsTests.cs +++ b/SVSim.UnitTests/Services/ArenaTwoPickServiceWeightedRewardsTests.cs @@ -8,6 +8,7 @@ using SVSim.Database.Models; using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; @@ -17,19 +18,6 @@ public class ArenaTwoPickServiceWeightedRewardsTests { private const long TicketItemId = 80001; - private sealed class FakeEntitlements : IViewerEntitlements - { - public bool IsFreeplay { get; init; } - - public long EffectiveBalance(SVSim.Database.Models.Viewer viewer, SpendCurrency currency) => 0; - public bool OwnsCard(SVSim.Database.Models.Viewer viewer, long cardId) => IsFreeplay; - public bool OwnsCosmetic(SVSim.Database.Models.Viewer viewer, CosmeticType type, int id) => IsFreeplay; - public Task> EffectiveOwnedCardsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default) - => Task.FromResult>(new List()); - public Task EffectiveCosmeticsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default) - => throw new NotSupportedException(); - } - private sealed class FakePool : IArenaTwoPickCardPoolService { public List GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new(); @@ -100,11 +88,9 @@ public class ArenaTwoPickServiceWeightedRewardsTests new FakePool(), scope.ServiceProvider.GetRequiredService(), scope.ServiceProvider.GetRequiredService(), - scope.ServiceProvider.GetRequiredService(), - new FakeEntitlements(), + scope.ServiceProvider.GetRequiredService(), rng, - db, - scope.ServiceProvider.GetRequiredService()); + db); return (db, svc, 7L); } From c37c04c1b7c388ecbbf265d7c016a28b12b26082 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:55:08 -0400 Subject: [PATCH 28/32] refactor(gacha-point): route TryExchangeAsync through IInventoryTransaction Change signature from (Viewer, packId, cardId) to (IInventoryTransaction, packId, cardId). Drop RewardGrantService from GachaPointService ctor. PackController.ExchangeGachaPoint opens tx with GachaPointBalances/Received extra includes, passes tx, commits on success. Update GachaPointServiceTests to use inv.BeginAsync + tx pattern. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/PackController.cs | 22 +++----- .../Services/GachaPointService.cs | 32 ++++------- .../Services/IGachaPointService.cs | 12 +++-- .../Services/GachaPointServiceTests.cs | 54 ++++++++++++------- 4 files changed, 60 insertions(+), 60 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index a6a85e5..06fbf4c 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -202,26 +202,18 @@ public class PackController : SVSimController { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - // Load the viewer with the collections the service mutates (balances, received marker, - // cards, cosmetics). AsSplitQuery per project_ef_split_query memory. - var viewer = await _db.Viewers - .Include(v => v.GachaPointBalances) - .Include(v => v.GachaPointReceived) - .Include(v => v.Cards) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.Degrees) - .Include(v => v.LeaderSkins) - .Include(v => v.MyPageBackgrounds) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); + // Open inventory tx with extra includes for GachaPointBalances + GachaPointReceived + // (needed by TryExchangeAsync to validate balance and already-received guard). + await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg + .WithInclude(v => v.GachaPointBalances) + .WithInclude(v => v.GachaPointReceived)); // Use odds_gacha_id (the seasonal pack id) — that's where the balance / received marker // live. Mirrors the GetGachaPointRewards fix. - var outcome = await _gachaPoint.TryExchangeAsync(viewer, request.OddsGachaId, request.CardId); + var outcome = await _gachaPoint.TryExchangeAsync(tx, request.OddsGachaId, request.CardId); if (!outcome.Success) return BadRequest(new { error = outcome.Error }); - await _db.SaveChangesAsync(); + await tx.CommitAsync(); return new ExchangeGachaPointResponse { diff --git a/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs b/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs index c565be8..c909991 100644 --- a/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs +++ b/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs @@ -5,6 +5,7 @@ using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.PackDrawTables; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; namespace SVSim.EmulatedEntrypoint.Services; @@ -13,13 +14,11 @@ public sealed class GachaPointService : IGachaPointService { private readonly SVSimDbContext _db; private readonly IPackDrawTableRepository _drawTables; - private readonly RewardGrantService _grants; - public GachaPointService(SVSimDbContext db, IPackDrawTableRepository drawTables, RewardGrantService grants) + public GachaPointService(SVSimDbContext db, IPackDrawTableRepository drawTables) { _db = db; _drawTables = drawTables; - _grants = grants; } public async Task> GetRewardsAsync(int packId, long viewerId) @@ -176,8 +175,9 @@ public sealed class GachaPointService : IGachaPointService } } - public async Task TryExchangeAsync(Viewer viewer, int packId, long cardId) + public async Task TryExchangeAsync(IInventoryTransaction tx, int packId, long cardId) { + var viewer = tx.Viewer; var pack = await _db.Packs.FirstOrDefaultAsync(p => p.Id == packId); if (pack?.GachaPointConfig is null) return ExchangeOutcome.Fail("pack_not_exchangeable"); @@ -206,23 +206,13 @@ public sealed class GachaPointService : IGachaPointService PackId = packId, CardId = cardId, ReceivedAt = DateTime.UtcNow, }); - // Grant the card itself through RewardGrantService — its CardCosmeticReward cascade - // covers the Emblem (standard legendary) or Skin+Emblem (leader) the catalog - // advertised. The catalog's reward_list is a wire-shape *display* (what the player - // sees on /pack/get_gacha_point_rewards) — the actual grant uses the canonical - // primitive per feedback_reward_grant_service. For leader-card exchanges the catalog - // also advertises a synthetic Sleeve(=card_id) entry, but that's not in - // CardCosmeticRewards; if a capture ever shows leader exchanges granting a sleeve - // row, add that here. Today no leader exchange has been captured. - var granted = await _grants.ApplyAsync(viewer, UserGoodsType.Card, cardId, 1); - var rewardList = new List(); - foreach (var g in granted) - { - rewardList.Add(new RewardListEntry - { - RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum, - }); - } + // Grant the card via the inventory tx — its CardCosmeticReward cascade covers the + // Emblem (standard legendary) or Skin+Emblem (leader). Convert at the wire boundary + // so ExchangeOutcome still carries RewardListEntry for the controller response. + var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 1); + var rewardList = granted + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(); return ExchangeOutcome.Ok(rewardList); } diff --git a/SVSim.EmulatedEntrypoint/Services/IGachaPointService.cs b/SVSim.EmulatedEntrypoint/Services/IGachaPointService.cs index 7d89b5a..fea859c 100644 --- a/SVSim.EmulatedEntrypoint/Services/IGachaPointService.cs +++ b/SVSim.EmulatedEntrypoint/Services/IGachaPointService.cs @@ -1,4 +1,5 @@ using SVSim.Database.Models; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; namespace SVSim.EmulatedEntrypoint.Services; @@ -23,11 +24,14 @@ public interface IGachaPointService void Accrue(Viewer viewer, PackConfigEntry pack, PackChildGachaEntry child, int packNumber); /// - /// Validate + execute an exchange. Returns the grant outcome on success (reward_list - /// entries the controller will return in ), - /// or a failure result describing why. Mutates the in-memory graph; caller saves. + /// Validate + execute an exchange using the provided inventory transaction (which must + /// have GachaPointBalances and GachaPointReceived loaded on tx.Viewer + /// via extra includes). Grants the card via + /// the tx. Returns the grant outcome on success (reward_list entries already converted to + /// ), or a failure result describing why. Caller commits + /// the tx on success. /// - Task TryExchangeAsync(Viewer viewer, int packId, long cardId); + Task TryExchangeAsync(IInventoryTransaction tx, int packId, long cardId); } public sealed record ExchangeOutcome(bool Success, string? Error, IReadOnlyList RewardList) diff --git a/SVSim.UnitTests/Services/GachaPointServiceTests.cs b/SVSim.UnitTests/Services/GachaPointServiceTests.cs index 0c153f4..51d81a1 100644 --- a/SVSim.UnitTests/Services/GachaPointServiceTests.cs +++ b/SVSim.UnitTests/Services/GachaPointServiceTests.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; @@ -380,14 +381,17 @@ public class GachaPointServiceTests SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); - var viewer = await db.Viewers - .Include(v => v.GachaPointBalances) - .FirstAsync(v => v.Id == viewerId); + var viewer = await db.Viewers.Include(v => v.GachaPointBalances).FirstAsync(v => v.Id == viewerId); viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 399 }); await db.SaveChangesAsync(); var svc = scope.ServiceProvider.GetRequiredService(); - var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010); + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId, configure: cfg => cfg + .WithInclude(v => v.GachaPointBalances) + .WithInclude(v => v.GachaPointReceived)); + + var outcome = await svc.TryExchangeAsync(tx, 10008, 108041010); Assert.That(outcome.Success, Is.False); Assert.That(outcome.Error, Is.EqualTo("insufficient_gacha_points")); @@ -403,14 +407,17 @@ public class GachaPointServiceTests SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); - var viewer = await db.Viewers - .Include(v => v.GachaPointBalances) - .FirstAsync(v => v.Id == viewerId); + var viewer = await db.Viewers.Include(v => v.GachaPointBalances).FirstAsync(v => v.Id == viewerId); viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 400 }); await db.SaveChangesAsync(); var svc = scope.ServiceProvider.GetRequiredService(); - var outcome = await svc.TryExchangeAsync(viewer, 10008, cardId: 999999999); // not in pool + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId, configure: cfg => cfg + .WithInclude(v => v.GachaPointBalances) + .WithInclude(v => v.GachaPointReceived)); + + var outcome = await svc.TryExchangeAsync(tx, 10008, cardId: 999999999); // not in pool Assert.That(outcome.Success, Is.False); Assert.That(outcome.Error, Is.EqualTo("card_not_exchangeable")); @@ -438,7 +445,12 @@ public class GachaPointServiceTests await db.SaveChangesAsync(); var svc = scope.ServiceProvider.GetRequiredService(); - var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010); + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId, configure: cfg => cfg + .WithInclude(v => v.GachaPointBalances) + .WithInclude(v => v.GachaPointReceived)); + + var outcome = await svc.TryExchangeAsync(tx, 10008, 108041010); Assert.That(outcome.Success, Is.False); Assert.That(outcome.Error, Is.EqualTo("already_received")); @@ -454,29 +466,31 @@ public class GachaPointServiceTests SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); - var viewer = await db.Viewers + var preViewer = await db.Viewers .Include(v => v.GachaPointBalances) - .Include(v => v.GachaPointReceived) - .Include(v => v.Cards) - .Include(v => v.Emblems) .FirstAsync(v => v.Id == viewerId); - viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 500 }); + preViewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 500 }); await db.SaveChangesAsync(); var svc = scope.ServiceProvider.GetRequiredService(); - var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010); - await db.SaveChangesAsync(); + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId, configure: cfg => cfg + .WithInclude(v => v.GachaPointBalances) + .WithInclude(v => v.GachaPointReceived)); + var outcome = await svc.TryExchangeAsync(tx, 10008, 108041010); Assert.That(outcome.Success, Is.True); - // Balance debited. - Assert.That(viewer.GachaPointBalances.Single().Points, Is.EqualTo(100)); + await tx.CommitAsync(); + + // Balance debited (check via tx.Viewer which is tracked). + Assert.That(tx.Viewer.GachaPointBalances.Single().Points, Is.EqualTo(100)); // Marker written. - Assert.That(viewer.GachaPointReceived + Assert.That(tx.Viewer.GachaPointReceived .Any(r => r.PackId == 10008 && r.CardId == 108041010), Is.True); - // Reward list non-empty: at minimum the card grant and the gacha-point post-state entry. + // Reward list non-empty: at minimum the card grant. Assert.That(outcome.RewardList, Is.Not.Empty); Assert.That(outcome.RewardList.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == 108041010), Is.True, "card grant missing"); From df0e1324593a90902a196dfb9e6a4c8b97d0b152 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 17:03:06 -0400 Subject: [PATCH 29/32] refactor(inventory): move GrantedReward + EffectiveCosmetics into Inventory namespace folder Both types stay in namespace SVSim.Database.Services so existing using directives in controllers, services, and tests resolve without change. Their definitions are extracted to SVSim.Database/Services/Inventory/InventoryGrantTypes.cs; the empty husks in RewardGrantService.cs and IViewerEntitlements.cs will be deleted in the next commit. Co-Authored-By: Claude Opus 4.7 --- .../Services/IViewerEntitlements.cs | 12 --------- .../Services/Inventory/InventoryGrantTypes.cs | 27 +++++++++++++++++++ SVSim.Database/Services/RewardGrantService.cs | 8 ------ 3 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 SVSim.Database/Services/Inventory/InventoryGrantTypes.cs diff --git a/SVSim.Database/Services/IViewerEntitlements.cs b/SVSim.Database/Services/IViewerEntitlements.cs index fd5d23d..7b277f9 100644 --- a/SVSim.Database/Services/IViewerEntitlements.cs +++ b/SVSim.Database/Services/IViewerEntitlements.cs @@ -40,15 +40,3 @@ public interface IViewerEntitlements Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default); } -/// -/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns" -/// (all of them in freeplay). Leader skins are always the full catalog with a per-skin owned flag; -/// is every skin id in freeplay. -/// -public sealed record EffectiveCosmetics( - IReadOnlyList SleeveIds, - IReadOnlyList EmblemIds, - IReadOnlyList DegreeIds, - IReadOnlyList MyPageBackgroundIds, - IReadOnlyList AllLeaderSkins, - IReadOnlySet OwnedLeaderSkinIds); diff --git a/SVSim.Database/Services/Inventory/InventoryGrantTypes.cs b/SVSim.Database/Services/Inventory/InventoryGrantTypes.cs new file mode 100644 index 0000000..fd0d3b0 --- /dev/null +++ b/SVSim.Database/Services/Inventory/InventoryGrantTypes.cs @@ -0,0 +1,27 @@ +using SVSim.Database.Enums; +using SVSim.Database.Models; + +namespace SVSim.Database.Services; + +/// +/// Wire-shape entry returned by and +/// collected in / +/// . Field names match the +/// reward_list entries used by /pack/open, /basic_puzzle/finish, and +/// /story/*/finish. reward_num is a POST-STATE TOTAL for currencies and a count for +/// collection grants — see . +/// +public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum); + +/// +/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns" +/// (all of them in freeplay). Leader skins are always the full catalog with a per-skin owned flag; +/// is every skin id in freeplay. +/// +public sealed record EffectiveCosmetics( + IReadOnlyList SleeveIds, + IReadOnlyList EmblemIds, + IReadOnlyList DegreeIds, + IReadOnlyList MyPageBackgroundIds, + IReadOnlyList AllLeaderSkins, + IReadOnlySet OwnedLeaderSkinIds); diff --git a/SVSim.Database/Services/RewardGrantService.cs b/SVSim.Database/Services/RewardGrantService.cs index 4d4c6d5..95da3d2 100644 --- a/SVSim.Database/Services/RewardGrantService.cs +++ b/SVSim.Database/Services/RewardGrantService.cs @@ -5,14 +5,6 @@ using SVSim.Database.Models; namespace SVSim.Database.Services; -/// -/// Wire-shape entry returned by . Field names match -/// the reward_list entries used by /pack/open, /basic_puzzle/finish, and -/// /story/*/finish. reward_num is a POST-STATE TOTAL for currencies and a count for -/// collection grants — see . -/// -public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum); - /// /// Single canonical grant primitive for every the server hands to a /// viewer. Switch on the type, mutate the appropriate viewer collection / From 2c62a7be8026423f6fe2eff48407319abfad60b8 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 17:07:30 -0400 Subject: [PATCH 30/32] refactor(inventory): delete old primitives after InventoryService cutover Removed RewardGrantService, CurrencySpendService, ICurrencySpendService, ViewerEntitlements, IViewerEntitlements, CardAcquisitionService, ICardAcquisitionService, CardGrantResult and their tests (RewardGrantServiceTests, CurrencySpendServiceTests, CardAcquisitionServiceTests, ViewerEntitlementsTests). Removed four DI registrations from Program.cs. No caller references any deleted type; GrantedReward and EffectiveCosmetics were pre-moved to InventoryGrantTypes.cs in the prior commit. Build clean, 712/712 tests pass. Co-Authored-By: Claude Opus 4.7 --- .../Services/CurrencySpendService.cs | 51 --- .../Services/ICurrencySpendService.cs | 14 - .../Services/IViewerEntitlements.cs | 42 -- SVSim.Database/Services/RewardGrantService.cs | 213 ---------- SVSim.Database/Services/ViewerEntitlements.cs | 107 ----- SVSim.EmulatedEntrypoint/Program.cs | 4 - .../Services/CardAcquisitionService.cs | 101 ----- .../Services/CardGrantResult.cs | 13 - .../Services/ICardAcquisitionService.cs | 18 - .../Services/CardAcquisitionServiceTests.cs | 365 ------------------ .../Services/CurrencySpendServiceTests.cs | 91 ----- .../Services/RewardGrantServiceTests.cs | 280 -------------- .../Services/ViewerEntitlementsTests.cs | 268 ------------- 13 files changed, 1567 deletions(-) delete mode 100644 SVSim.Database/Services/CurrencySpendService.cs delete mode 100644 SVSim.Database/Services/ICurrencySpendService.cs delete mode 100644 SVSim.Database/Services/IViewerEntitlements.cs delete mode 100644 SVSim.Database/Services/RewardGrantService.cs delete mode 100644 SVSim.Database/Services/ViewerEntitlements.cs delete mode 100644 SVSim.EmulatedEntrypoint/Services/CardAcquisitionService.cs delete mode 100644 SVSim.EmulatedEntrypoint/Services/CardGrantResult.cs delete mode 100644 SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs delete mode 100644 SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs delete mode 100644 SVSim.UnitTests/Services/CurrencySpendServiceTests.cs delete mode 100644 SVSim.UnitTests/Services/RewardGrantServiceTests.cs delete mode 100644 SVSim.UnitTests/Services/ViewerEntitlementsTests.cs diff --git a/SVSim.Database/Services/CurrencySpendService.cs b/SVSim.Database/Services/CurrencySpendService.cs deleted file mode 100644 index 1d3ef5e..0000000 --- a/SVSim.Database/Services/CurrencySpendService.cs +++ /dev/null @@ -1,51 +0,0 @@ -using SVSim.Database.Models; - -namespace SVSim.Database.Services; - -public class CurrencySpendService : ICurrencySpendService -{ - private readonly IViewerEntitlements _entitlements; - - public CurrencySpendService(IViewerEntitlements entitlements) => _entitlements = entitlements; - - public Task TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default) - { - if (cost < 0) cost = 0; - - // Freeplay bypass applies only to the three main currencies; SpotPoint always real. - if (_entitlements.IsFreeplay && currency != SpendCurrency.SpotPoint) - { - return Task.FromResult(new SpendResult( - SpendOutcome.Success, _entitlements.EffectiveBalance(viewer, currency))); - } - - ulong current = GetBalance(viewer, currency); - if (current < (ulong)cost) - return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current)); - - ulong post = current - (ulong)cost; - SetBalance(viewer, currency, post); - return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post)); - } - - private static ulong GetBalance(Viewer v, SpendCurrency c) => c switch - { - SpendCurrency.Crystal => v.Currency.Crystals, - SpendCurrency.Rupee => v.Currency.Rupees, - SpendCurrency.RedEther => v.Currency.RedEther, - SpendCurrency.SpotPoint => v.Currency.SpotPoints, - _ => throw new ArgumentOutOfRangeException(nameof(c)), - }; - - private static void SetBalance(Viewer v, SpendCurrency c, ulong value) - { - switch (c) - { - case SpendCurrency.Crystal: v.Currency.Crystals = value; break; - case SpendCurrency.Rupee: v.Currency.Rupees = value; break; - case SpendCurrency.RedEther: v.Currency.RedEther = value; break; - case SpendCurrency.SpotPoint: v.Currency.SpotPoints = value; break; - default: throw new ArgumentOutOfRangeException(nameof(c)); - } - } -} diff --git a/SVSim.Database/Services/ICurrencySpendService.cs b/SVSim.Database/Services/ICurrencySpendService.cs deleted file mode 100644 index 6aa27c1..0000000 --- a/SVSim.Database/Services/ICurrencySpendService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SVSim.Database.Models; - -namespace SVSim.Database.Services; - -/// -/// Centralized debit primitive — the symmetric twin of RewardGrantService.ApplyAsync. -/// Encapsulates the affordability-check + deduction + post-state-total pattern that was inlined -/// across the shop/pack controllers. Does NOT call SaveChangesAsync; the caller saves. -/// Freeplay (for Crystal/Rupee/RedEther) makes spends always succeed without deducting. -/// -public interface ICurrencySpendService -{ - Task TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default); -} diff --git a/SVSim.Database/Services/IViewerEntitlements.cs b/SVSim.Database/Services/IViewerEntitlements.cs deleted file mode 100644 index 7b277f9..0000000 --- a/SVSim.Database/Services/IViewerEntitlements.cs +++ /dev/null @@ -1,42 +0,0 @@ -using SVSim.Database.Enums; -using SVSim.Database.Models; - -namespace SVSim.Database.Services; - -/// -/// The single read/ownership authority for what a viewer is *treated as* owning. Knows the -/// Freeplay flag; all freeplay read-side behavior lives here. See -/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md. -/// -/// -/// Include precondition: methods that inspect the viewer's collections require the -/// viewer to have been loaded with .Include(v => v.Cards).ThenInclude(c => c.Card) -/// and the cosmetic collections -/// (Sleeves, Emblems, Degrees, LeaderSkins, MyPageBackgrounds) -/// included. Without those includes the EF owned-collection nav refs are null or zero-filled -/// (see the EF owned-collection nav-include pitfall in MEMORY.md). -/// -public interface IViewerEntitlements -{ - /// True when the global Freeplay config section is enabled. - bool IsFreeplay { get; } - - /// - /// The balance the viewer is treated as having: the configured freeplay amount for - /// Crystal/Rupee/RedEther when freeplay is on, otherwise (and always for SpotPoint) the real - /// viewer.Currency field. - /// - long EffectiveBalance(Viewer viewer, SpendCurrency currency); - - bool OwnsCard(Viewer viewer, long cardId); - - /// uses (Skin == leader skin). - bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id); - - /// The full owned-card projection for /load/index's user_card_list. - Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default); - - /// The cosmetic id-lists + leader-skin catalog/owned-set for /load/index. - Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default); -} - diff --git a/SVSim.Database/Services/RewardGrantService.cs b/SVSim.Database/Services/RewardGrantService.cs deleted file mode 100644 index 95da3d2..0000000 --- a/SVSim.Database/Services/RewardGrantService.cs +++ /dev/null @@ -1,213 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SVSim.Database.Enums; -using SVSim.Database.Models; - -namespace SVSim.Database.Services; - -/// -/// Single canonical grant primitive for every the server hands to a -/// viewer. Switch on the type, mutate the appropriate viewer collection / -/// field, return the wire-shape entries to embed in the response's reward_list. -/// -/// -/// DO NOT reimplement reward dispatch in a controller or new helper. This service handles -/// RedEther, Crystal, SpotCardPoint, Item, Card (with cascade), -/// Sleeve, Emblem, Degree, Rupy, Skin, MyPageBG — everything except the dead-letter SpotCard / -/// SpotCardOnlyLatestCardPack slots (use Card=5 instead). Endpoint code that takes a -/// list of (type, id, num) tuples should iterate and call -/// per tuple — never switch on type yourself, never filter to "only card-typed rewards", never -/// build a second dispatch table. Past duplicate implementations (ICardAcquisitionService in the -/// EmulatedEntrypoint project, the first pass at /build_deck/buy) all silently dropped subsets of -/// types and produced the same bug: wire reward visible but viewer's collection unchanged. When a -/// new reward type comes up, add a case here. See feedback_reward_grant_service memory. -/// -/// -/// Card grants additionally run the cascade: any cosmetic -/// associated with the granted card that the viewer doesn't yet own is granted too, and produces -/// an additional entry in the returned list. That's why the return type is a list: most types -/// produce one entry, Card produces 1 + N. -/// -/// Caller is responsible for — -/// this service only mutates the in-memory graph so a controller can stack several grants in -/// a single transaction. -/// -public sealed class RewardGrantService -{ - private readonly SVSimDbContext _db; - private readonly ILogger _log; - - public RewardGrantService(SVSimDbContext db, ILogger log) - { - _db = db; - _log = log; - } - - public async Task> ApplyAsync( - Viewer viewer, UserGoodsType type, long detailId, int num, CancellationToken ct = default) - { - switch (type) - { - case UserGoodsType.Sleeve: - AddCosmeticIfMissing(viewer.Sleeves, detailId, _db.Sleeves); - return Single(type, detailId, 1); - - case UserGoodsType.Emblem: - AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems); - return Single(type, detailId, 1); - - case UserGoodsType.Skin: // LeaderSkin in our schema - AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins); - return Single(type, detailId, 1); - - case UserGoodsType.Degree: - AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees); - return Single(type, detailId, 1); - - case UserGoodsType.MyPageBG: - AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds); - return Single(type, detailId, 1); - - case UserGoodsType.Rupy: - viewer.Currency.Rupees += (ulong)num; - return Single(type, detailId, checked((int)viewer.Currency.Rupees)); - - case UserGoodsType.Crystal: - viewer.Currency.Crystals += (ulong)num; - return Single(type, detailId, checked((int)viewer.Currency.Crystals)); - - case UserGoodsType.RedEther: - viewer.Currency.RedEther += (ulong)num; - return Single(type, detailId, checked((int)viewer.Currency.RedEther)); - - case UserGoodsType.SpotCardPoint: - viewer.Currency.SpotPoints += (ulong)num; - return Single(type, detailId, checked((int)viewer.Currency.SpotPoints)); - - case UserGoodsType.Item: - { - var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId); - if (owned is null) - { - var item = _db.Items.Find((int)detailId) - ?? throw new InvalidOperationException($"Item {detailId} not in catalog"); - viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = viewer }); - return Single(type, detailId, num); - } - owned.Count += num; - return Single(type, detailId, owned.Count); - } - - case UserGoodsType.Card: - return await ApplyCardAsync(viewer, detailId, num, ct); - - case UserGoodsType.SpotCard: - case UserGoodsType.SpotCardOnlyLatestCardPack: - // Spot-card-typed grants don't appear in captures — emitters always use Card=5 - // with the spot-card-specific id. These two enum slots remain unimplemented; if a - // capture ever shows one in a reward_list we'll know to wire them up here. - throw new NotSupportedException( - $"{type} rewards are not yet supported — emitters use Card=5 instead."); - - default: - throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService"); - } - } - - private async Task> ApplyCardAsync( - Viewer viewer, long cardId, int num, CancellationToken ct) - { - // Find-or-add OwnedCardEntry. Mirrors the primitive that used to live in - // IPackRepository.GrantCardsToViewer — now inline so we own the in-memory-only contract. - var owned = viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId); - int postCount; - if (owned is null) - { - var card = await _db.Cards.FirstOrDefaultAsync(c => c.Id == cardId, ct) - ?? throw new InvalidOperationException($"Card {cardId} not in catalog"); - owned = new OwnedCardEntry { Card = card, Count = num, IsProtected = false }; - viewer.Cards.Add(owned); - postCount = num; - } - else - { - owned.Count += num; - postCount = owned.Count; - } - - var results = new List - { - new((int)UserGoodsType.Card, cardId, postCount), - }; - - // Cascade: cosmetic mappings live on the non-foil row. If the granted card is foil - // (card_id ends in 1, IsFoil=true), look up cascade against cardId - 1. - long lookupId = owned.Card.IsFoil ? cardId - 1 : cardId; - - var cascade = await _db.CardCosmeticRewards - .Where(r => r.CardId == lookupId) - .ToListAsync(ct); - - foreach (var reward in cascade) - { - if (TryAddCascadeCosmetic(viewer, reward, lookupId)) - { - // CosmeticType numeric values are identical to UserGoodsType — direct cast is safe. - results.Add(new GrantedReward((int)reward.Type, reward.CosmeticId, 1)); - } - } - - return results; - } - - private static IReadOnlyList Single(UserGoodsType type, long id, int num) - => new[] { new GrantedReward((int)type, id, num) }; - - private bool TryAddCascadeCosmetic(Viewer viewer, CardCosmeticReward reward, long forCardId) - { - try - { - return reward.Type switch - { - CosmeticType.Sleeve => AddCosmeticIfMissing(viewer.Sleeves, reward.CosmeticId, _db.Sleeves), - CosmeticType.Emblem => AddCosmeticIfMissing(viewer.Emblems, reward.CosmeticId, _db.Emblems), - CosmeticType.Skin => AddCosmeticIfMissing(viewer.LeaderSkins, reward.CosmeticId, _db.LeaderSkins), - CosmeticType.Degree => AddCosmeticIfMissing(viewer.Degrees, reward.CosmeticId, _db.Degrees), - CosmeticType.MyPageBG => AddCosmeticIfMissing(viewer.MyPageBackgrounds, reward.CosmeticId, _db.MyPageBackgrounds), - _ => false, - }; - } - catch (InvalidOperationException ex) - { - _log.LogWarning(ex, - "Card cascade: cosmetic {Type} {Id} for card {CardId} skipped (master row missing)", - reward.Type, reward.CosmeticId, forCardId); - return false; - } - } - - private static bool AddCosmeticIfMissing(List collection, long detailId, DbSet catalog) where T : class - { - bool alreadyOwned = collection.Any(e => GetId(e) == detailId); - if (alreadyOwned) return false; - - var entity = catalog.Find(checked((int)detailId)) - ?? throw new InvalidOperationException( - $"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}"); - collection.Add(entity); - return true; - } - - /// - /// Reflectively reads an entity's Id property — works for both BaseEntity<int> - /// (cosmetics) and BaseEntity<long> (e.g. Viewer/Card) without forcing two - /// non-generic overloads of . - /// - private static long GetId(T e) - { - var prop = typeof(T).GetProperty("Id") - ?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property"); - var val = prop.GetValue(e); - return val switch { long l => l, int i => i, _ => 0 }; - } -} diff --git a/SVSim.Database/Services/ViewerEntitlements.cs b/SVSim.Database/Services/ViewerEntitlements.cs deleted file mode 100644 index 4e0a137..0000000 --- a/SVSim.Database/Services/ViewerEntitlements.cs +++ /dev/null @@ -1,107 +0,0 @@ -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.Database.Models.Config; -using SVSim.Database.Repositories.Card; -using SVSim.Database.Repositories.Collectibles; - -namespace SVSim.Database.Services; - -public class ViewerEntitlements : IViewerEntitlements -{ - private readonly IGameConfigService _config; - private readonly ICardRepository _cards; - private readonly ICollectionRepository _collection; - - public ViewerEntitlements(IGameConfigService config, ICardRepository cards, ICollectionRepository collection) - { - _config = config; - _cards = cards; - _collection = collection; - } - - private FreeplayConfig Cfg => _config.Get(); - - public bool IsFreeplay => Cfg.Enabled; - - public long EffectiveBalance(Viewer viewer, SpendCurrency currency) - { - var cfg = Cfg; - if (cfg.Enabled && currency != SpendCurrency.SpotPoint) - return checked((long)cfg.CurrencyAmount); - - return currency switch - { - SpendCurrency.Crystal => (long)viewer.Currency.Crystals, - SpendCurrency.Rupee => (long)viewer.Currency.Rupees, - SpendCurrency.RedEther => (long)viewer.Currency.RedEther, - SpendCurrency.SpotPoint => (long)viewer.Currency.SpotPoints, - _ => throw new ArgumentOutOfRangeException(nameof(currency)), - }; - } - - public bool OwnsCard(Viewer viewer, long cardId) - => Cfg.Enabled || viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0); - - public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id) - { - if (Cfg.Enabled) return true; - return type switch - { - CosmeticType.Sleeve => viewer.Sleeves.Any(s => s.Id == id), - CosmeticType.Emblem => viewer.Emblems.Any(e => e.Id == id), - CosmeticType.Degree => viewer.Degrees.Any(d => d.Id == id), - CosmeticType.Skin => viewer.LeaderSkins.Any(s => s.Id == id), - CosmeticType.MyPageBG => viewer.MyPageBackgrounds.Any(m => m.Id == id), - _ => false, - }; - } - - public async Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default) - { - var defaults = await _cards.GetDefaultCards(); - var defaultIds = defaults.Select(c => c.Id).ToHashSet(); - var cfg = Cfg; - - if (cfg.Enabled) - { - var all = await _cards.GetAll(onlyCollectible: true); - return all - .Select(c => new OwnedCardEntry - { - Card = c, - Count = cfg.CardCopies, - IsProtected = defaultIds.Contains(c.Id), - }) - .ToList(); - } - - var owned = viewer.Cards.Where(c => c.Count > 0 && !defaultIds.Contains(c.Card.Id)); - return owned - .Concat(defaults.Select(bc => new OwnedCardEntry { Card = bc, Count = 3, IsProtected = true })) - .ToList(); - } - - public async Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) - { - var allSkins = await _collection.GetLeaderSkins(); - - if (Cfg.Enabled) - { - return new EffectiveCosmetics( - await _collection.GetAllSleeveIds(), - await _collection.GetAllEmblemIds(), - await _collection.GetAllDegreeIds(), - await _collection.GetAllMyPageBackgroundIds(), - allSkins, - allSkins.Select(s => s.Id).ToHashSet()); - } - - return new EffectiveCosmetics( - viewer.Sleeves.Select(s => s.Id).ToList(), - viewer.Emblems.Select(e => e.Id).ToList(), - viewer.Degrees.Select(d => d.Id).ToList(), - viewer.MyPageBackgrounds.Select(m => m.Id).ToList(), - allSkins, - viewer.LeaderSkins.Select(s => s.Id).ToHashSet()); - } -} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 28d0733..f890e53 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -84,12 +84,8 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped GrantManyAsync(long viewerId, IEnumerable newCardIds) - { - var viewer = await LoadViewerWithGraph(viewerId); - var rewardList = new List(); - - // Bucket the input by id so multi-copy grants increment count once but cascade fires once. - foreach (var grp in newCardIds.GroupBy(id => id)) - { - int count = grp.Count(); - var granted = await _rewards.ApplyAsync(viewer, UserGoodsType.Card, grp.Key, count); - foreach (var g in granted) - { - rewardList.Add(new RewardListEntry - { - RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum, - }); - } - } - - await _db.SaveChangesAsync(); - return new CardGrantResult(rewardList); - } - - public async Task BackfillCosmeticsAsync(long viewerId) - { - var viewer = await LoadViewerWithGraph(viewerId); - var rewardList = new List(); - - // Foil resolution: cascade rows live on non-foil ids. Apply the +1 convention. - var lookupCardIds = viewer.Cards - .Select(c => c.Card.IsFoil ? c.Card.Id - 1 : c.Card.Id) - .Distinct() - .ToList(); - - var cascade = await _db.CardCosmeticRewards - .Where(r => lookupCardIds.Contains(r.CardId)) - .ToListAsync(); - - foreach (var reward in cascade) - { - // Skip if the viewer already owns this cosmetic. ApplyAsync's cosmetic branches - // unconditionally return a wire entry (top-level grant semantics), so we must - // filter at the caller side to avoid emitting "+0 received" lines for cosmetics - // the viewer has owned for ages. - if (AlreadyOwnsCosmetic(viewer, reward.Type, reward.CosmeticId)) continue; - - var goodsType = (UserGoodsType)(int)reward.Type; - var granted = await _rewards.ApplyAsync(viewer, goodsType, reward.CosmeticId, 1); - foreach (var g in granted) - { - rewardList.Add(new RewardListEntry - { - RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum, - }); - } - } - - await _db.SaveChangesAsync(); - return new CardGrantResult(rewardList); - } - - private static bool AlreadyOwnsCosmetic(Viewer viewer, CosmeticType type, long id) => type switch - { - CosmeticType.Sleeve => viewer.Sleeves.Any(s => s.Id == id), - CosmeticType.Emblem => viewer.Emblems.Any(e => e.Id == id), - CosmeticType.Skin => viewer.LeaderSkins.Any(s => s.Id == id), - CosmeticType.Degree => viewer.Degrees.Any(d => d.Id == id), - CosmeticType.MyPageBG => viewer.MyPageBackgrounds.Any(b => b.Id == id), - _ => false, - }; - - private Task LoadViewerWithGraph(long viewerId) => _db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.LeaderSkins) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); -} diff --git a/SVSim.EmulatedEntrypoint/Services/CardGrantResult.cs b/SVSim.EmulatedEntrypoint/Services/CardGrantResult.cs deleted file mode 100644 index b1564b5..0000000 --- a/SVSim.EmulatedEntrypoint/Services/CardGrantResult.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SVSim.EmulatedEntrypoint.Models.Dtos; - -namespace SVSim.EmulatedEntrypoint.Services; - -/// -/// Output of . The RewardList is wire-shape: -/// pass directly into a /pack/open or similar response's data.reward_list field. -/// -/// In grant mode, contains one type=5 (Card) entry per distinct newCardId with post-state -/// count, plus one entry per newly-granted cosmetic. -/// In backfill mode, contains only cosmetic entries (no card-count entries). -/// -public record CardGrantResult(IReadOnlyList RewardList); diff --git a/SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs b/SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs deleted file mode 100644 index 6d02bc0..0000000 --- a/SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace SVSim.EmulatedEntrypoint.Services; - -public interface ICardAcquisitionService -{ - /// - /// Grant N cards + their CardCosmeticReward cascades in a single transaction. - /// Used by /pack/open and any future endpoint that grants new cards in bulk. - /// Returns wire-shape reward_list entries (post-state counts for cards, single-grant - /// entries for any newly-added cosmetics). - /// - Task GrantManyAsync(long viewerId, IEnumerable newCardIds); - - /// - /// Scan all owned cards for missing CardCosmeticReward cosmetics; grant any not yet owned. - /// Used by /load/index for retroactive cosmetic reconciliation. Card counts are NOT mutated. - /// - Task BackfillCosmeticsAsync(long viewerId); -} diff --git a/SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs b/SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs deleted file mode 100644 index 9d01bb0..0000000 --- a/SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs +++ /dev/null @@ -1,365 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using SVSim.Database; -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.EmulatedEntrypoint.Services; -using SVSim.UnitTests.Infrastructure; - -namespace SVSim.UnitTests.Services; - -public class CardAcquisitionServiceTests -{ - /// - /// Seeds a viewer (via the factory's real RegisterViewer-backed helper) and gives it the - /// given owned cards (key = card_id, value = count). Card rows are created on-demand if - /// the test's card_id isn't already in the minimal seeded card set (matches the pattern - /// used by SVSimTestFactory.SeedOwnedCardAsync, but inlined so multiple cards can be - /// seeded in one viewer in one call). Returns the viewer's Id. - /// - private static async Task SeedViewerWithCards( - SVSimTestFactory factory, - Dictionary ownedCards, - IEnumerable? grantableCardIds = null) - { - long viewerId = await factory.SeedViewerAsync(); - - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card) - .FirstAsync(v => v.Id == viewerId); - - foreach (var (cardId, count) in ownedCards) - { - var card = await EnsureCardAsync(db, cardId); - viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = count, IsProtected = false }); - } - // Pre-seed bare Cards rows (no ownership) for any cardIds the test plans to grant via - // the service. RewardGrantService.ApplyAsync does FirstOrDefaultAsync on _db.Cards; - // without the row the grant throws InvalidOperationException("Card {id} not in catalog"). - if (grantableCardIds is not null) - { - foreach (var cardId in grantableCardIds) - { - await EnsureCardAsync(db, cardId); - } - } - await db.SaveChangesAsync(); - return viewerId; - } - - private static async Task EnsureCardAsync(SVSimDbContext db, long cardId) - { - var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == cardId); - if (card is null) - { - // Foil twins follow the universal +1 convention (card_id ends in 1). Marking - // IsFoil here keeps test setup tidy so foil-resolution tests don't have to - // hand-patch the card row. - var isFoil = cardId % 10 == 1; - card = new ShadowverseCardEntry { Id = cardId, Name = $"SeededCard{cardId}", Rarity = Database.Enums.Rarity.Bronze, IsFoil = isFoil }; - db.Cards.Add(card); - await db.SaveChangesAsync(); - } - return card; - } - - private static ICardAcquisitionService GetService(SVSimTestFactory factory) - { - var scope = factory.Services.CreateScope(); - return scope.ServiceProvider.GetRequiredService(); - } - - [Test] - public async Task GrantManyAsync_NewBronzeCard_GrantsCardOnly() - { - // 101111010 is a synthetic test card (inserted ad-hoc via grantableCardIds) with no - // CardCosmeticReward associations. Expectation: grant returns only the type=5 entry. - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 101111010L }); - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 101111010L }); - - Assert.That(result.RewardList, Has.Count.EqualTo(1)); - Assert.That(result.RewardList[0].RewardType, Is.EqualTo(5)); // Card - Assert.That(result.RewardList[0].RewardId, Is.EqualTo(101111010L)); - Assert.That(result.RewardList[0].RewardNum, Is.EqualTo(1)); // post-state count - } - - [Test] - public async Task GrantManyAsync_LeaderCard_GrantsCardAndSkin() - { - // Card 704741010 (Aria leader-card variant) has 3 cosmetic associations in the seed: - // skin 407, sleeve 704741010, emblem 704741010. - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); - - // Since SqliteFriendlyModelCustomizer strips CardCosmeticReward seed in tests, insert - // the specific mappings we need for this test. - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.CardCosmeticRewards.AddRange( - new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }, - new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Sleeve, CosmeticId = 704741010L, Quantity = 1 }, - new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Emblem, CosmeticId = 704741010L, Quantity = 1 } - ); - // Ensure master rows exist for the cosmetics we'll grant - if (await db.LeaderSkins.FindAsync(407) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); - if (await db.Sleeves.FindAsync(704741010) is null) - db.Sleeves.Add(new SleeveEntry { Id = 704741010 }); - if (await db.Emblems.FindAsync(704741010) is null) - db.Emblems.Add(new EmblemEntry { Id = 704741010 }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 704741010L }); - - var skinEntry = result.RewardList.SingleOrDefault(r => r.RewardType == 10); - Assert.That(skinEntry, Is.Not.Null, "expected a Skin reward entry"); - Assert.That(skinEntry!.RewardId, Is.EqualTo(407L)); - - // Verify viewer ownership was actually written to DB - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - var viewer = await db.Viewers - .Include(v => v.LeaderSkins) - .FirstAsync(v => v.Id == viewerId); - Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True); - } - } - - [Test] - public async Task GrantManyAsync_AlreadyOwnedSkin_OmitsFromRewardList() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - // Pre-grant the skin to this viewer - var viewer = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); - var skin = await db.LeaderSkins.FindAsync(407) ?? db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }).Entity; - if (!viewer.LeaderSkins.Any(s => s.Id == 407)) - viewer.LeaderSkins.Add(skin); - // Seed the card→skin mapping - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 704741010L }); - - Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.False, - "skin entry should be omitted since viewer already owns it"); - Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 704741010L), Is.True, - "card grant entry should still be emitted"); - } - - [Test] - public async Task GrantManyAsync_FoilLeaderCard_ResolvesToNonFoilCosmetics() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741011L }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - // CardCosmeticReward.CardId has a FK→Cards.Id; ensure the non-foil row exists - // even though we never grant it directly (the foil twin is the granted card). - await EnsureCardAsync(db, 704741010L); - // Map cosmetics to the NON-FOIL card_id (704741010), as the seed convention requires - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); - if (await db.LeaderSkins.FindAsync(407) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 704741011L }); - - var skinEntry = result.RewardList.SingleOrDefault(r => r.RewardType == 10); - Assert.That(skinEntry, Is.Not.Null, "expected skin entry via foil resolution"); - Assert.That(skinEntry!.RewardId, Is.EqualTo(407L)); - - using var scope2 = factory.Services.CreateScope(); - var db2 = scope2.ServiceProvider.GetRequiredService(); - var viewer = await db2.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); - Assert.That(viewer.Cards.Any(c => c.Card.Id == 704741011L), Is.True, "card is the foil"); - Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True); - } - - [Test] - public async Task GrantManyAsync_MultipleCopiesOfSameLeader_GrantsCosmeticOnce() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); - if (await db.LeaderSkins.FindAsync(407) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 704741010L, 704741010L, 704741010L }); - - Assert.That(result.RewardList.Count(r => r.RewardType == 10), Is.EqualTo(1), - "skin should appear exactly once in reward_list"); - var cardEntry = result.RewardList.Single(r => r.RewardType == 5 && r.RewardId == 704741010L); - Assert.That(cardEntry.RewardNum, Is.EqualTo(3), "card count should reflect all 3 copies"); - } - - [Test] - public async Task GrantManyAsync_RecentLeaderCard_GrantsAllFiveCosmeticTypes() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 721141010L }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - // All 5 cosmetic types for this card. Exact ids: from data_dumps captures. - db.CardCosmeticRewards.AddRange( - new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Sleeve, CosmeticId = 721141010L, Quantity = 1 }, - new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Emblem, CosmeticId = 721141010L, Quantity = 1 }, - new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Degree, CosmeticId = 120021L, Quantity = 1 }, - new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Skin, CosmeticId = 4601L, Quantity = 1 }, - new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.MyPageBG, CosmeticId = 721141010L, Quantity = 1 } - ); - // Ensure master rows - if (await db.LeaderSkins.FindAsync(4601) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 4601, Name = "TestSkin4601" }); - if (await db.Sleeves.FindAsync(721141010) is null) - db.Sleeves.Add(new SleeveEntry { Id = 721141010 }); - if (await db.Emblems.FindAsync(721141010) is null) - db.Emblems.Add(new EmblemEntry { Id = 721141010 }); - if (await db.Degrees.FindAsync(120021) is null) - db.Degrees.Add(new DegreeEntry { Id = 120021 }); - if (await db.MyPageBackgrounds.FindAsync(721141010) is null) - db.MyPageBackgrounds.Add(new MyPageBackgroundEntry { Id = 721141010 }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 721141010L }); - - Assert.Multiple(() => - { - Assert.That(result.RewardList.Any(r => r.RewardType == 6), Is.True, "Sleeve"); - Assert.That(result.RewardList.Any(r => r.RewardType == 7), Is.True, "Emblem"); - Assert.That(result.RewardList.Any(r => r.RewardType == 8), Is.True, "Degree"); - Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.True, "Skin"); - Assert.That(result.RewardList.Any(r => r.RewardType == 15), Is.True, "MyPageBG"); - }); - } - - [Test] - public async Task BackfillCosmeticsAsync_DoesNotIncrementCardCount() - { - using var factory = new SVSimTestFactory(); - // Pre-seed viewer with card 704741010 count=5, no skin - var viewerId = await SeedViewerWithCards(factory, new() { [704741010L] = 5 }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); - if (await db.LeaderSkins.FindAsync(407) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.BackfillCosmeticsAsync(viewerId); - - using var scope2 = factory.Services.CreateScope(); - var db2 = scope2.ServiceProvider.GetRequiredService(); - var viewer = await db2.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); - var owned = viewer.Cards.Single(c => c.Card.Id == 704741010L); - - Assert.That(owned.Count, Is.EqualTo(5), "card count should be unchanged in backfill mode"); - Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True, "skin should be backfilled"); - Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 407L), Is.True, - "skin entry returned even in backfill mode"); - Assert.That(result.RewardList.Any(r => r.RewardType == 5), Is.False, - "no type=5 card entries in backfill mode"); - } - - [Test] - public async Task BackfillCosmeticsAsync_CalledTwice_SecondCallIsNoOp() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { [704741010L] = 1 }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); - if (await db.LeaderSkins.FindAsync(407) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var first = await service.BackfillCosmeticsAsync(viewerId); - var second = await service.BackfillCosmeticsAsync(viewerId); - - Assert.That(first.RewardList, Is.Not.Empty, "first call should grant cosmetics"); - Assert.That(second.RewardList, Is.Empty, "second call should be a no-op"); - } - - [Test] - public async Task GrantManyAsync_LeaderCardWithMissingMapping_GrantsCardSilently() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 701141010L }); - - // NO CardCosmeticReward rows inserted for this card — simulates the 83 missing-mapping cases. - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 701141010L }); - - Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 701141010L), Is.True); - Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.False); - // No exception means it handled the missing mapping gracefully. - } - - [Test] - public async Task GrantManyAsync_OrphanCosmeticReward_LogsWarningAndSkips() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - // Real skin association - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); - if (await db.LeaderSkins.FindAsync(407) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); - - // ORPHAN: points to non-existent skin_id - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 9999999L, Quantity = 1 }); - - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 704741010L }); - - Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 704741010L), Is.True); - Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 407L), Is.True, - "real skin should still be granted"); - Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 9999999L), Is.False, - "orphan cosmetic should not appear in reward_list"); - } -} diff --git a/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs b/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs deleted file mode 100644 index f506b66..0000000 --- a/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.Database.Services; - -namespace SVSim.UnitTests.Services; - -public class CurrencySpendServiceTests -{ - private sealed class FakeEntitlements : IViewerEntitlements - { - public bool IsFreeplay { get; init; } - public long FreeplayAmount { get; init; } = 99999; - - public long EffectiveBalance(Viewer viewer, SpendCurrency currency) - { - if (IsFreeplay && currency != SpendCurrency.SpotPoint) return FreeplayAmount; - return currency switch - { - SpendCurrency.Crystal => (long)viewer.Currency.Crystals, - SpendCurrency.Rupee => (long)viewer.Currency.Rupees, - SpendCurrency.RedEther => (long)viewer.Currency.RedEther, - SpendCurrency.SpotPoint => (long)viewer.Currency.SpotPoints, - _ => 0, - }; - } - public bool OwnsCard(Viewer viewer, long cardId) => IsFreeplay; - public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id) => IsFreeplay; - public Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default) - => Task.FromResult>(new List()); - public Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) - => throw new NotSupportedException(); - } - - private static Viewer NewViewer() => new() { Currency = new ViewerCurrency() }; - - [Test] - public async Task Normal_deducts_and_returns_post_state() - { - var v = NewViewer(); - v.Currency.Crystals = 250; - var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = false }); - - var r = await svc.TrySpendAsync(v, SpendCurrency.Crystal, 100); - - Assert.That(r.Success, Is.True); - Assert.That(r.PostStateTotal, Is.EqualTo(150)); - Assert.That(v.Currency.Crystals, Is.EqualTo(150UL)); - } - - [Test] - public async Task Normal_insufficient_does_not_deduct() - { - var v = NewViewer(); - v.Currency.Rupees = 50; - var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = false }); - - var r = await svc.TrySpendAsync(v, SpendCurrency.Rupee, 100); - - Assert.That(r.Success, Is.False); - Assert.That(r.Outcome, Is.EqualTo(SpendOutcome.Insufficient)); - Assert.That(v.Currency.Rupees, Is.EqualTo(50UL), "no deduction on insufficient funds"); - } - - [Test] - public async Task Freeplay_main_currency_succeeds_without_deducting() - { - var v = NewViewer(); - v.Currency.Crystals = 10; - var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = true }); - - var r = await svc.TrySpendAsync(v, SpendCurrency.Crystal, 100000); - - Assert.That(r.Success, Is.True, "freeplay never blocks on affordability"); - Assert.That(r.PostStateTotal, Is.EqualTo(99999), "post-state shows the freeplay balance"); - Assert.That(v.Currency.Crystals, Is.EqualTo(10UL), "DB balance untouched in freeplay"); - } - - [Test] - public async Task Freeplay_spot_points_still_deduct() - { - var v = NewViewer(); - v.Currency.SpotPoints = 300; - var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = true }); - - var r = await svc.TrySpendAsync(v, SpendCurrency.SpotPoint, 100); - - Assert.That(r.Success, Is.True); - Assert.That(r.PostStateTotal, Is.EqualTo(200)); - Assert.That(v.Currency.SpotPoints, Is.EqualTo(200UL), "spot points are real even in freeplay"); - } -} diff --git a/SVSim.UnitTests/Services/RewardGrantServiceTests.cs b/SVSim.UnitTests/Services/RewardGrantServiceTests.cs deleted file mode 100644 index 707df28..0000000 --- a/SVSim.UnitTests/Services/RewardGrantServiceTests.cs +++ /dev/null @@ -1,280 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using SVSim.Database; -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.Database.Services; -using SVSim.UnitTests.Infrastructure; - -namespace SVSim.UnitTests.Services; - -public class RewardGrantServiceTests -{ - [Test] - public async Task Sleeve_added_to_viewer_collection() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const int testSleeveId = 2_000_000_000; - var sleeve = new SleeveEntry { Id = testSleeveId }; - ctx.Sleeves.Add(sleeve); - await ctx.SaveChangesAsync(); - - var viewer = await ctx.Viewers.Include(v => v.Sleeves).FirstAsync(v => v.Id == viewerId); - var svc = scope.ServiceProvider.GetRequiredService(); - - var result = await svc.ApplyAsync(viewer, UserGoodsType.Sleeve, detailId: testSleeveId, num: 1); - await ctx.SaveChangesAsync(); - - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(viewer.Sleeves.Any(s => s.Id == testSleeveId), Is.True); - Assert.That(result[0].RewardType, Is.EqualTo((int)UserGoodsType.Sleeve)); - Assert.That(result[0].RewardId, Is.EqualTo((long)testSleeveId)); - Assert.That(result[0].RewardNum, Is.EqualTo(1)); - } - - [Test] - public async Task Rupy_sets_currency_post_state_total() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - var viewer = await ctx.Viewers.FirstAsync(v => v.Id == viewerId); - viewer.Currency.Rupees = 100UL; - await ctx.SaveChangesAsync(); - - var svc = scope.ServiceProvider.GetRequiredService(); - - var result = await svc.ApplyAsync(viewer, UserGoodsType.Rupy, detailId: 0, num: 50); - await ctx.SaveChangesAsync(); - - Assert.That(viewer.Currency.Rupees, Is.EqualTo(150UL)); - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].RewardNum, Is.EqualTo(150)); - } - - [Test] - public async Task LeaderSkin_added_idempotently() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const int testSkinId = 9_999_999; - ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "Round1Reward" }); - await ctx.SaveChangesAsync(); - - var viewer = await ctx.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); - var svc = scope.ServiceProvider.GetRequiredService(); - - await svc.ApplyAsync(viewer, UserGoodsType.Skin, testSkinId, 1); - await svc.ApplyAsync(viewer, UserGoodsType.Skin, testSkinId, 1); - await ctx.SaveChangesAsync(); - - Assert.That(viewer.LeaderSkins.Count(s => s.Id == testSkinId), Is.EqualTo(1)); - } - - [Test] - public async Task Card_fresh_grant_inserts_owned_entry_and_returns_post_state_count() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const long testCardId = 999_001_001L; - ctx.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "RGSTestCard", Rarity = Rarity.Bronze }); - await ctx.SaveChangesAsync(); - - var viewer = await ctx.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); - var svc = scope.ServiceProvider.GetRequiredService(); - - var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1); - await ctx.SaveChangesAsync(); - - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].RewardType, Is.EqualTo((int)UserGoodsType.Card)); - Assert.That(result[0].RewardId, Is.EqualTo(testCardId)); - Assert.That(result[0].RewardNum, Is.EqualTo(1)); - Assert.That(viewer.Cards.Single(c => c.Card.Id == testCardId).Count, Is.EqualTo(1)); - } - - [Test] - public async Task Card_existing_grant_increments_count() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const long testCardId = 999_001_002L; - var card = new ShadowverseCardEntry { Id = testCardId, Name = "RGSTestCard2", Rarity = Rarity.Bronze }; - ctx.Cards.Add(card); - var viewer = await ctx.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); - viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = 2, IsProtected = false }); - await ctx.SaveChangesAsync(); - - var svc = scope.ServiceProvider.GetRequiredService(); - var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1); - await ctx.SaveChangesAsync(); - - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].RewardNum, Is.EqualTo(3)); - Assert.That(viewer.Cards.Single(c => c.Card.Id == testCardId).Count, Is.EqualTo(3)); - } - - [Test] - public async Task Card_with_cascade_rows_emits_card_plus_cosmetics() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const long testCardId = 999_002_010L; - const int testSkinId = 999_002_011; - ctx.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "CascadeTestCard", Rarity = Rarity.Gold }); - ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "CascadeTestSkin" }); - ctx.CardCosmeticRewards.Add(new CardCosmeticReward - { - CardId = testCardId, Type = CosmeticType.Skin, CosmeticId = testSkinId, Quantity = 1, - }); - await ctx.SaveChangesAsync(); - - var viewer = await ctx.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.LeaderSkins) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); - - var svc = scope.ServiceProvider.GetRequiredService(); - var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1); - await ctx.SaveChangesAsync(); - - Assert.That(result, Has.Count.EqualTo(2)); - Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == testCardId), Is.True); - Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Skin && r.RewardId == testSkinId), Is.True); - Assert.That(viewer.LeaderSkins.Any(s => s.Id == testSkinId), Is.True); - } - - [Test] - public async Task Card_cascade_skips_already_owned_cosmetic() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const long testCardId = 999_002_020L; - const int testSkinId = 999_002_021; - ctx.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "CascadeOwnedTestCard", Rarity = Rarity.Gold }); - var skin = new LeaderSkinEntry { Id = testSkinId, Name = "CascadeOwnedTestSkin" }; - ctx.LeaderSkins.Add(skin); - ctx.CardCosmeticRewards.Add(new CardCosmeticReward - { - CardId = testCardId, Type = CosmeticType.Skin, CosmeticId = testSkinId, Quantity = 1, - }); - await ctx.SaveChangesAsync(); - - var viewer = await ctx.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.LeaderSkins) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); - viewer.LeaderSkins.Add(skin); - await ctx.SaveChangesAsync(); - - var svc = scope.ServiceProvider.GetRequiredService(); - var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1); - await ctx.SaveChangesAsync(); - - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].RewardType, Is.EqualTo((int)UserGoodsType.Card)); - Assert.That(result[0].RewardId, Is.EqualTo(testCardId)); - } - - [Test] - public async Task Card_foil_grant_resolves_cascade_to_non_foil_id() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const long nonFoilId = 999_002_030L; - const long foilId = 999_002_031L; - const int testSkinId = 999_002_032; - - ctx.Cards.Add(new ShadowverseCardEntry { Id = nonFoilId, Name = "FoilCascadeBase", Rarity = Rarity.Gold }); - ctx.Cards.Add(new ShadowverseCardEntry { Id = foilId, Name = "FoilCascadeFoil", Rarity = Rarity.Gold, IsFoil = true }); - ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "FoilCascadeSkin" }); - ctx.CardCosmeticRewards.Add(new CardCosmeticReward - { - CardId = nonFoilId, Type = CosmeticType.Skin, CosmeticId = testSkinId, Quantity = 1, - }); - await ctx.SaveChangesAsync(); - - var viewer = await ctx.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.LeaderSkins) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); - - var svc = scope.ServiceProvider.GetRequiredService(); - var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, foilId, 1); - await ctx.SaveChangesAsync(); - - Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == foilId), Is.True); - Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Skin && r.RewardId == testSkinId), Is.True); - } - - [Test] - public async Task SpotCard_still_throws_NotSupported() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - var viewer = await ctx.Viewers.FirstAsync(v => v.Id == viewerId); - var svc = scope.ServiceProvider.GetRequiredService(); - - Assert.ThrowsAsync(async () => - await svc.ApplyAsync(viewer, UserGoodsType.SpotCard, 1L, 1)); - Assert.ThrowsAsync(async () => - await svc.ApplyAsync(viewer, UserGoodsType.SpotCardOnlyLatestCardPack, 1L, 1)); - } - - [Test] - public async Task OwnedCardEntry_unique_index_blocks_duplicate_viewer_card_row() - { - // Schema-level safety net: any code that forgets to .Include(v => v.Cards) before doing - // a find-or-add OwnedCardEntry would silently insert a duplicate row otherwise. The - // unique index on (ViewerId, CardId) makes that crash loudly at SaveChanges instead. - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const long testCardId = 999_003_001L; - var card = new ShadowverseCardEntry { Id = testCardId, Name = "UniqueIdxTest", Rarity = Rarity.Bronze }; - ctx.Cards.Add(card); - var viewer = await ctx.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); - viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = 1, IsProtected = false }); - await ctx.SaveChangesAsync(); - - // Simulate the bug: a fresh viewer load WITHOUT .Include(v => v.Cards), then a manual - // Add of a second row for the same (Viewer, Card). The unique index must reject this. - using var scope2 = factory.Services.CreateScope(); - var ctx2 = scope2.ServiceProvider.GetRequiredService(); - var unloadedViewer = await ctx2.Viewers.FirstAsync(v => v.Id == viewerId); - var sameCard = await ctx2.Cards.FirstAsync(c => c.Id == testCardId); - unloadedViewer.Cards.Add(new OwnedCardEntry { Card = sameCard, Count = 1, IsProtected = false }); - - Assert.ThrowsAsync(async () => await ctx2.SaveChangesAsync()); - } -} diff --git a/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs b/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs deleted file mode 100644 index 91665a0..0000000 --- a/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using SVSim.Database; -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.Database.Models.Config; -using SVSim.Database.Repositories.Card; -using SVSim.Database.Repositories.Collectibles; -using SVSim.Database.Services; -using SVSim.EmulatedEntrypoint.Services; -using SVSim.UnitTests.Infrastructure; - -namespace SVSim.UnitTests.Services; - -public class ViewerEntitlementsTests -{ - /// - /// FreeplayConfig is in SVSim.Database so EnsureSeedDataAsync seeds a DB row with - /// Enabled=false (ShippedDefaults). Since tier 1 (DB) wins, we mutate the seeded row - /// to activate freeplay rather than relying on an IConfiguration override. - /// - private static void SetFreeplayEnabled(SVSimDbContext db, bool enabled, ulong currencyAmount = 99999, int cardCopies = 3) - { - var row = db.GameConfigs.First(s => s.SectionName == "Freeplay"); - var cfg = JsonSerializer.Deserialize(row.ValueJson)!; - cfg.Enabled = enabled; - cfg.CurrencyAmount = currencyAmount; - cfg.CardCopies = cardCopies; - row.ValueJson = JsonSerializer.Serialize(cfg); - db.SaveChanges(); - } - - private static ViewerEntitlements Build(IServiceScope scope) - { - var db = scope.ServiceProvider.GetRequiredService(); - return new ViewerEntitlements( - new GameConfigService(db, new ConfigurationBuilder().Build()), - new CardRepository(db), - new CollectionRepository(db)); - } - - [Test] - public async Task Freeplay_off_reflects_real_balance_and_ownership() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - // Freeplay is seeded as Enabled=false by default — no mutation needed. - var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); - viewer.Currency.Crystals = 7; - - var ent = Build(scope); - - Assert.That(ent.IsFreeplay, Is.False); - Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.Crystal), Is.EqualTo(7)); - Assert.That(ent.OwnsCard(viewer, 99_999_999L), Is.False); - Assert.That(ent.OwnsCosmetic(viewer, CosmeticType.Skin, 99_999), Is.False); - } - - [Test] - public async Task Freeplay_on_inflates_main_currencies_but_not_spot_points() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - SetFreeplayEnabled(db, enabled: true, currencyAmount: 99999); - var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId); - viewer.Currency.SpotPoints = 5; - - var ent = Build(scope); - - Assert.That(ent.IsFreeplay, Is.True); - Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.Crystal), Is.EqualTo(99999)); - Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.Rupee), Is.EqualTo(99999)); - Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.RedEther), Is.EqualTo(99999)); - Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.SpotPoint), Is.EqualTo(5), - "spot points are not a freeplay-inflated currency"); - } - - [Test] - public async Task Freeplay_on_treats_all_cards_and_cosmetics_as_owned() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - SetFreeplayEnabled(db, enabled: true); - var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); - - var ent = Build(scope); - - Assert.That(ent.OwnsCard(viewer, 99_999_999L), Is.True); - Assert.That(ent.OwnsCosmetic(viewer, CosmeticType.Skin, 99_999), Is.True); - } - - // ------------------------------------------------------------------------- - // EffectiveOwnedCardsAsync - // ------------------------------------------------------------------------- - - [Test] - public async Task EffectiveOwnedCards_freeplay_on_returns_all_collectible_cards_at_card_copies() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - // Seed one collectible card owned by this viewer (gives it a CollectionInfo). - await factory.SeedOwnedCardAsync(viewerId, 50001001L, count: 1); - - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - SetFreeplayEnabled(db, enabled: true, cardCopies: 3); - - var viewer = await db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .FirstAsync(v => v.Id == viewerId); - - var ent = Build(scope); - var result = await ent.EffectiveOwnedCardsAsync(viewer); - - // Freeplay returns the whole collectible catalog — card 50001001 must be present. - Assert.That(result.Any(e => e.Card.Id == 50001001L), Is.True, - "seeded collectible card must appear in freeplay result"); - - // Every returned entry must have Count == CardCopies (3). - Assert.That(result.All(e => e.Count == 3), Is.True, - "every freeplay entry should have Count == CardCopies (3)"); - - // The full set == every collectible card in the DB. - int collectibleCount = db.Cards.Count(c => c.CollectionInfo != null); - Assert.That(result.Count, Is.EqualTo(collectibleCount), - "freeplay result should contain exactly all collectible cards"); - } - - [Test] - public async Task EffectiveOwnedCards_freeplay_off_returns_only_owned() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - // Seed card 50001002 owned at count 2. - await factory.SeedOwnedCardAsync(viewerId, 50001002L, count: 2); - - // Seed a second collectible card (50001003) NOT owned by the viewer — insert card row - // only (with CollectionInfo so it's collectible) but do not link it to the viewer. - using (var setupScope = factory.Services.CreateScope()) - { - var setupDb = setupScope.ServiceProvider.GetRequiredService(); - if (!setupDb.Cards.Any(c => c.Id == 50001003L)) - { - setupDb.Cards.Add(new ShadowverseCardEntry - { - Id = 50001003L, - Name = "UnownedCollectible", - Rarity = SVSim.Database.Enums.Rarity.Bronze, - CollectionInfo = new SVSim.Database.Models.CardCollectionInfo { CraftCost = 200, DustReward = 50 }, - }); - await setupDb.SaveChangesAsync(); - } - } - - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - // Freeplay is off by default — no mutation needed. - - var viewer = await db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .FirstAsync(v => v.Id == viewerId); - - var ent = Build(scope); - var result = await ent.EffectiveOwnedCardsAsync(viewer); - - // The owned card must be present at the right count. - var owned = result.FirstOrDefault(e => e.Card.Id == 50001002L); - Assert.That(owned, Is.Not.Null, "owned card should appear in result"); - Assert.That(owned!.Count, Is.EqualTo(2)); - - // The unowned collectible card must NOT appear. - Assert.That(result.Any(e => e.Card.Id == 50001003L), Is.False, - "card not owned by viewer must not appear when freeplay is off"); - } - - // ------------------------------------------------------------------------- - // EffectiveCosmeticsAsync - // ------------------------------------------------------------------------- - - [Test] - public async Task EffectiveCosmetics_leader_skins_always_full_catalog_owned_set_differs() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - - // --- freeplay OFF: fresh viewer owns no cosmetics --- - { - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - // Freeplay off by default. - - var viewer = await db.Viewers - .Include(v => v.LeaderSkins) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .FirstAsync(v => v.Id == viewerId); - - var ent = Build(scope); - var cosmetics = await ent.EffectiveCosmeticsAsync(viewer); - - int masterSkinCount = db.LeaderSkins.Count(); - Assert.That(masterSkinCount, Is.GreaterThan(0), - "leaderskins.csv must have been imported — master table must be non-empty"); - Assert.That(cosmetics.AllLeaderSkins.Count, Is.EqualTo(masterSkinCount), - "AllLeaderSkins should always be the full catalog regardless of freeplay"); - - // A fresh viewer owns one default skin per class (granted at registration). - // Assert the owned set matches what the viewer actually has — don't assume empty. - var expectedOwnedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet(); - Assert.That(cosmetics.OwnedLeaderSkinIds, Is.EquivalentTo(expectedOwnedSkinIds), - "OwnedLeaderSkinIds should match the viewer's actual owned skins when freeplay is off"); - - // OwnedLeaderSkinIds must be a strict subset of AllLeaderSkins (not all of them). - Assert.That(cosmetics.OwnedLeaderSkinIds.Count, Is.LessThan(masterSkinCount), - "fresh viewer should own fewer skins than the full catalog"); - - // The four id-lists reflect what the viewer actually owns. - Assert.That(cosmetics.SleeveIds.Count, Is.EqualTo(viewer.Sleeves.Count)); - Assert.That(cosmetics.EmblemIds.Count, Is.EqualTo(viewer.Emblems.Count)); - Assert.That(cosmetics.DegreeIds.Count, Is.EqualTo(viewer.Degrees.Count)); - Assert.That(cosmetics.MyPageBackgroundIds.Count, Is.EqualTo(viewer.MyPageBackgrounds.Count)); - } - - // --- freeplay ON: all catalogs become owned --- - { - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - SetFreeplayEnabled(db, enabled: true); - - var viewer = await db.Viewers - .Include(v => v.LeaderSkins) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .FirstAsync(v => v.Id == viewerId); - - var ent = Build(scope); - var cosmetics = await ent.EffectiveCosmeticsAsync(viewer); - - int masterSkinCount = db.LeaderSkins.Count(); - Assert.That(cosmetics.AllLeaderSkins.Count, Is.EqualTo(masterSkinCount), - "AllLeaderSkins count unchanged when freeplay is on"); - Assert.That(cosmetics.OwnedLeaderSkinIds.Count, Is.EqualTo(masterSkinCount), - "freeplay: every skin id must be in OwnedLeaderSkinIds"); - - // All four id-lists should equal the full catalog counts. - Assert.That(cosmetics.SleeveIds.Count, Is.EqualTo(db.Sleeves.Count()), - "freeplay: SleeveIds should equal full sleeve catalog"); - Assert.That(cosmetics.EmblemIds.Count, Is.EqualTo(db.Emblems.Count()), - "freeplay: EmblemIds should equal full emblem catalog"); - Assert.That(cosmetics.DegreeIds.Count, Is.EqualTo(db.Degrees.Count()), - "freeplay: DegreeIds should equal full degree catalog"); - Assert.That(cosmetics.MyPageBackgroundIds.Count, Is.EqualTo(db.MyPageBackgrounds.Count()), - "freeplay: MyPageBackgroundIds should equal full my-page-background catalog"); - } - } -} From 2ee40c6df7674c88c58cc9bdd96205e6930504b9 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 17:12:16 -0400 Subject: [PATCH 31/32] test(inventory): wire-shape regression for spend+grant+cascade Serializes result.RewardList with snake_case+WhenWritingNull options and asserts the three entries come out in expected first-touch order: Crystal post-state (500), Card post-state count (3), Sleeve cascade (1). Also verifies snake_case key names are actually emitted. Co-Authored-By: Claude Opus 4.7 --- .../Wire/InventoryRewardListWireShape.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 SVSim.UnitTests/Wire/InventoryRewardListWireShape.cs diff --git a/SVSim.UnitTests/Wire/InventoryRewardListWireShape.cs b/SVSim.UnitTests/Wire/InventoryRewardListWireShape.cs new file mode 100644 index 0000000..a34e0c4 --- /dev/null +++ b/SVSim.UnitTests/Wire/InventoryRewardListWireShape.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Wire; + +public class InventoryRewardListWireShape +{ + [Test] + public async Task Spend_crystal_plus_grant_card_with_cascade_matches_fixture() + { + 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(); + var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 1000; + const int sleeveId = 2_000_040_000; + ctx.Sleeves.Add(new SleeveEntry { Id = sleeveId }); + ctx.CardCosmeticRewards.Add(new 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.TrySpendAsync(SpendCurrency.Crystal, 500); + await tx.GrantAsync(UserGoodsType.Card, cardId, 3); + var result = await tx.CommitAsync(); + + var opts = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + var json = JsonSerializer.Serialize(result.RewardList, opts); + + // Expected order: currency entries in first-touch order, then non-currency in first-touch order. + // Crystal spend comes first (post-state 500), then Card grant (post-state count 3), then + // Sleeve cascade (always 1). + var doc = JsonDocument.Parse(json); + var arr = doc.RootElement.EnumerateArray().ToList(); + Assert.That(arr, Has.Count.EqualTo(3), $"Expected 3 reward entries, got {arr.Count}. JSON: {json}"); + + Assert.That(arr[0].GetProperty("reward_type").GetInt32(), Is.EqualTo((int)UserGoodsType.Crystal), + "First entry should be Crystal (spend post-state)"); + Assert.That(arr[0].GetProperty("reward_num").GetInt32(), Is.EqualTo(500), + "Crystal post-state after spending 500 from 1000 should be 500"); + + Assert.That(arr[1].GetProperty("reward_type").GetInt32(), Is.EqualTo((int)UserGoodsType.Card), + "Second entry should be Card"); + Assert.That(arr[1].GetProperty("reward_num").GetInt32(), Is.EqualTo(3), + "Card post-state count for fresh grant of 3 should be 3"); + + Assert.That(arr[2].GetProperty("reward_type").GetInt32(), Is.EqualTo((int)UserGoodsType.Sleeve), + "Third entry should be Sleeve (cascade from card grant)"); + Assert.That(arr[2].GetProperty("reward_id").GetInt32(), Is.EqualTo(sleeveId), + "Sleeve reward_id should match the seeded sleeve"); + + // Verify snake_case keys are present (not PascalCase) + Assert.That(arr[0].TryGetProperty("reward_type", out _), Is.True, "Key must be reward_type not RewardType"); + Assert.That(arr[0].TryGetProperty("reward_id", out _), Is.True, "Key must be reward_id not RewardId"); + Assert.That(arr[0].TryGetProperty("reward_num", out _), Is.True, "Key must be reward_num not RewardNum"); + } +} From a033bf361a5e97cd9cb8a6d3bbdbceed76a3c4ca Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 18:48:26 -0400 Subject: [PATCH 32/32] fix(battle-pass): remove redundant SaveChanges after CommitAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommitAsync's inner SaveChangesAsync already flushes the AddClaim rows + progress.IsPremium mutation alongside the inventory grants (same scoped DbContext). The trailing _db.SaveChangesAsync was a no-op in BuyPremium and only meaningful in AddPoints when no level crossed (no tx opened) — restructured to an else branch. Co-Authored-By: Claude Opus 4.7 --- .../Services/BattlePassService.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs index d2ad0c3..15df87c 100644 --- a/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs +++ b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs @@ -191,8 +191,9 @@ public sealed class BattlePassService : IBattlePassService // op, any grants override the post-state. result.RewardList carries the final // post-state including the deducted crystal balance. result.Deltas carries the raw // grant amounts for achieved_info (no spend entry in Deltas, only GrantOps). + // CommitAsync's SaveChangesAsync flushes the AddClaim rows + the progress.IsPremium + // mutation alongside the inventory grants — all tracked on the same scoped DbContext. var result = await tx.CommitAsync(ct); - await _db.SaveChangesAsync(ct); // flush claim rows added via _viewerBp.AddClaim return new BattlePassBuyOutcome(1, result.Deltas, result.RewardList); } @@ -246,8 +247,12 @@ public sealed class BattlePassService : IBattlePassService var result = await tx.CommitAsync(ct); newlyClaimed = result.Deltas; } - - await _db.SaveChangesAsync(ct); + else + { + // No level crossed → no tx opened → still need to persist the progress mutation + // (CurrentPoint/WeeklyPoints/WeeklyPeriodStart) tracked on the scoped DbContext. + await _db.SaveChangesAsync(ct); + } return new BattlePassPointGrant( BeforePoint: beforePoint,