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) 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 fd5d23d..0000000 --- a/SVSim.Database/Services/IViewerEntitlements.cs +++ /dev/null @@ -1,54 +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); -} - -/// -/// 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/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..b3b70f7 --- /dev/null +++ b/SVSim.Database/Services/Inventory/IInventoryTransaction.cs @@ -0,0 +1,49 @@ +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; } + + /// + /// 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); + + 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/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/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; + } +} diff --git a/SVSim.Database/Services/ViewerEntitlements.cs b/SVSim.Database/Services/Inventory/InventoryService.cs similarity index 58% rename from SVSim.Database/Services/ViewerEntitlements.cs rename to SVSim.Database/Services/Inventory/InventoryService.cs index 4e0a137..cc5ea67 100644 --- a/SVSim.Database/Services/ViewerEntitlements.cs +++ b/SVSim.Database/Services/Inventory/InventoryService.cs @@ -1,31 +1,68 @@ -using SVSim.Database.Enums; +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; +namespace SVSim.Database.Services.Inventory; -public class ViewerEntitlements : IViewerEntitlements +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 ViewerEntitlements(IGameConfigService config, ICardRepository cards, ICollectionRepository collection) + public InventoryService( + SVSimDbContext db, + IGameConfigService config, + ICardRepository cards, + ICollectionRepository collection, + ILogger log) { + _db = db; _config = config; _cards = cards; _collection = collection; + _log = log; } - private FreeplayConfig Cfg => _config.Get(); + public async Task BeginAsync( + long viewerId, + CancellationToken ct = default, + Action? configure = null) + { + var loadCfg = new InventoryLoadConfig(); + configure?.Invoke(loadCfg); - public bool IsFreeplay => Cfg.Enabled; + 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); + } public long EffectiveBalance(Viewer viewer, SpendCurrency currency) { - var cfg = Cfg; + var cfg = _config.Get(); if (cfg.Enabled && currency != SpendCurrency.SpotPoint) return checked((long)cfg.CurrencyAmount); @@ -39,28 +76,12 @@ public class ViewerEntitlements : IViewerEntitlements }; } - 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) + 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; + var cfg = _config.Get(); if (cfg.Enabled) { @@ -81,11 +102,13 @@ public class ViewerEntitlements : IViewerEntitlements .ToList(); } - public async Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) + public async Task EffectiveCosmeticsAsync( + Viewer viewer, CancellationToken ct = default) { var allSkins = await _collection.GetLeaderSkins(); + var cfg = _config.Get(); - if (Cfg.Enabled) + if (cfg.Enabled) { return new EffectiveCosmetics( await _collection.GetAllSleeveIds(), diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs new file mode 100644 index 0000000..f36c2c0 --- /dev/null +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -0,0 +1,455 @@ +using Microsoft.EntityFrameworkCore; +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; + + 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, + Viewer viewer, + FreeplayConfig freeplay, + ILogger log) + { + _db = db; + _dbTx = dbTx; + Viewer = viewer; + _freeplay = freeplay; + _log = log; + } + + public Task TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default) + { + 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) + { + 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) + { + 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); + + 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); + + 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); + } + + 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"); + } + } + + 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) + { + 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) + { + 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) }; + + private void ThrowIfCommitted() + { + if (_committed) + 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; + 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) + { + await _dbTx.RollbackAsync(); + _db.ChangeTracker.Clear(); + } + await _dbTx.DisposeAsync(); + } +} diff --git a/SVSim.Database/Services/RewardGrantService.cs b/SVSim.Database/Services/RewardGrantService.cs deleted file mode 100644 index 4d4c6d5..0000000 --- a/SVSim.Database/Services/RewardGrantService.cs +++ /dev/null @@ -1,221 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SVSim.Database.Enums; -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 / -/// 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.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, 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) 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, 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); } 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); } 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), diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 8a588af..06fbf4c 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")] @@ -207,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 { @@ -287,13 +274,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 +300,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 +308,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 +322,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 +333,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 +376,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 +398,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 +412,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 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"; 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); } 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); } diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index f93bc55..f890e53 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -84,10 +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 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.EmulatedEntrypoint/Services/BattlePassService.cs b/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs index 2a07fcc..15df87c 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,22 @@ 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). + // 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); - // 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 +209,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 +224,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,14 +240,19 @@ 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); } } - } - await _db.SaveChangesAsync(ct); + var result = await tx.CommitAsync(ct); + newlyClaimed = result.Deltas; + } + 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, diff --git a/SVSim.EmulatedEntrypoint/Services/CardAcquisitionService.cs b/SVSim.EmulatedEntrypoint/Services/CardAcquisitionService.cs deleted file mode 100644 index 10ce933..0000000 --- a/SVSim.EmulatedEntrypoint/Services/CardAcquisitionService.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using SVSim.Database; -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.Database.Services; -using SVSim.EmulatedEntrypoint.Models.Dtos; - -namespace SVSim.EmulatedEntrypoint.Services; - -public class CardAcquisitionService : ICardAcquisitionService -{ - private readonly SVSimDbContext _db; - private readonly RewardGrantService _rewards; - - public CardAcquisitionService(SVSimDbContext db, RewardGrantService rewards) - { - _db = db; - _rewards = rewards; - } - - public async Task 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/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/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.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.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/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index f22b797..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. @@ -427,6 +442,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/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); } 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/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"); 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)); + } +} 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"); + } +} 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)); + } +} 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"); + } +} 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); }); + } +} 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)); + } +} 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)); + } +} 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); }); + } +} 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); + } +} diff --git a/SVSim.UnitTests/Services/Inventory/InventoryServiceBeginTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryServiceBeginTests.cs new file mode 100644 index 0000000..bcb2ef3 --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventoryServiceBeginTests.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Inventory; + +public class InventoryServiceBeginTests +{ + [Test] + public async Task BeginAsync_loads_viewer_with_canonical_graph() + { + 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.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); + } +} 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"); + } +} 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"); - } - } -} 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, 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"); + } +}