From 91909c57556b2d8f492d27b4461c1b8274bc2e3d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:05:35 -0400 Subject: [PATCH] feat(inventory): read-side methods on IInventoryService + tx EffectiveBalance/OwnsCard/OwnsCosmetic on the tx are freeplay-aware against the live viewer. EffectiveOwnedCardsAsync/EffectiveCosmeticsAsync on the service mirror today's ViewerEntitlements projections (used by /load/index). Co-Authored-By: Claude Opus 4.7 --- .../Services/Inventory/InventoryService.cs | 74 ++++++++-- .../Inventory/InventoryTransaction.cs | 34 ++++- .../Inventory/InventoryReadSideTests.cs | 128 ++++++++++++++++++ 3 files changed, 225 insertions(+), 11 deletions(-) create mode 100644 SVSim.UnitTests/Services/Inventory/InventoryReadSideTests.cs diff --git a/SVSim.Database/Services/Inventory/InventoryService.cs b/SVSim.Database/Services/Inventory/InventoryService.cs index d48998e..cc5ea67 100644 --- a/SVSim.Database/Services/Inventory/InventoryService.cs +++ b/SVSim.Database/Services/Inventory/InventoryService.cs @@ -60,13 +60,71 @@ public sealed class InventoryService : IInventoryService return new InventoryTransaction(_db, dbTx, viewer, freeplay, _log); } - // Stubs for later tasks. - public Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default) - => throw new NotImplementedException(); - - public Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) - => throw new NotImplementedException(); - public long EffectiveBalance(Viewer viewer, SpendCurrency currency) - => throw new NotImplementedException(); + { + var cfg = _config.Get(); + if (cfg.Enabled && currency != SpendCurrency.SpotPoint) + return checked((long)cfg.CurrencyAmount); + + return currency switch + { + SpendCurrency.Crystal => (long)viewer.Currency.Crystals, + SpendCurrency.Rupee => (long)viewer.Currency.Rupees, + SpendCurrency.RedEther => (long)viewer.Currency.RedEther, + SpendCurrency.SpotPoint => (long)viewer.Currency.SpotPoints, + _ => throw new ArgumentOutOfRangeException(nameof(currency)), + }; + } + + public async Task> EffectiveOwnedCardsAsync( + Viewer viewer, CancellationToken ct = default) + { + var defaults = await _cards.GetDefaultCards(); + var defaultIds = defaults.Select(c => c.Id).ToHashSet(); + var cfg = _config.Get(); + + if (cfg.Enabled) + { + var all = await _cards.GetAll(onlyCollectible: true); + return all + .Select(c => new OwnedCardEntry + { + Card = c, + Count = cfg.CardCopies, + IsProtected = defaultIds.Contains(c.Id), + }) + .ToList(); + } + + var owned = viewer.Cards.Where(c => c.Count > 0 && !defaultIds.Contains(c.Card.Id)); + return owned + .Concat(defaults.Select(bc => new OwnedCardEntry { Card = bc, Count = 3, IsProtected = true })) + .ToList(); + } + + public async Task EffectiveCosmeticsAsync( + Viewer viewer, CancellationToken ct = default) + { + var allSkins = await _collection.GetLeaderSkins(); + var cfg = _config.Get(); + + if (cfg.Enabled) + { + return new EffectiveCosmetics( + await _collection.GetAllSleeveIds(), + await _collection.GetAllEmblemIds(), + await _collection.GetAllDegreeIds(), + await _collection.GetAllMyPageBackgroundIds(), + allSkins, + allSkins.Select(s => s.Id).ToHashSet()); + } + + return new EffectiveCosmetics( + viewer.Sleeves.Select(s => s.Id).ToList(), + viewer.Emblems.Select(e => e.Id).ToList(), + viewer.Degrees.Select(d => d.Id).ToList(), + viewer.MyPageBackgrounds.Select(m => m.Id).ToList(), + allSkins, + viewer.LeaderSkins.Select(s => s.Id).ToHashSet()); + } } diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index 2ec341a..f36c2c0 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -237,9 +237,37 @@ internal sealed class InventoryTransaction : IInventoryTransaction _ => false, }; - public long EffectiveBalance(SpendCurrency currency) => throw new NotImplementedException(); - public bool OwnsCard(long cardId) => throw new NotImplementedException(); - public bool OwnsCosmetic(CosmeticType type, int id) => throw new NotImplementedException(); + public long EffectiveBalance(SpendCurrency currency) + { + if (_freeplay.Enabled && currency != SpendCurrency.SpotPoint) + return checked((long)_freeplay.CurrencyAmount); + + return currency switch + { + SpendCurrency.Crystal => (long)Viewer.Currency.Crystals, + SpendCurrency.Rupee => (long)Viewer.Currency.Rupees, + SpendCurrency.RedEther => (long)Viewer.Currency.RedEther, + SpendCurrency.SpotPoint => (long)Viewer.Currency.SpotPoints, + _ => throw new ArgumentOutOfRangeException(nameof(currency)), + }; + } + + public bool OwnsCard(long cardId) + => _freeplay.Enabled || Viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0); + + public bool OwnsCosmetic(CosmeticType type, int id) + { + if (_freeplay.Enabled) return true; + return type switch + { + CosmeticType.Sleeve => Viewer.Sleeves.Any(s => s.Id == id), + CosmeticType.Emblem => Viewer.Emblems.Any(e => e.Id == id), + CosmeticType.Degree => Viewer.Degrees.Any(d => d.Id == id), + CosmeticType.Skin => Viewer.LeaderSkins.Any(s => s.Id == id), + CosmeticType.MyPageBG => Viewer.MyPageBackgrounds.Any(m => m.Id == id), + _ => false, + }; + } public async Task CommitAsync(CancellationToken ct = default) { diff --git a/SVSim.UnitTests/Services/Inventory/InventoryReadSideTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryReadSideTests.cs new file mode 100644 index 0000000..fa5bc0a --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventoryReadSideTests.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Inventory; + +public class InventoryReadSideTests +{ + [Test] + public async Task EffectiveBalance_returns_viewer_currency_when_not_freeplay() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 1234; + await ctx.SaveChangesAsync(); + + // Re-load viewer with inventory graph for the read-side call + var v2 = await ctx.Viewers.AsNoTracking().FirstAsync(x => x.Id == viewerId); + var inv = scope.ServiceProvider.GetRequiredService(); + Assert.That(inv.EffectiveBalance(v2, SpendCurrency.Crystal), Is.EqualTo(1234)); + } + + [Test] + public async Task EffectiveOwnedCardsAsync_returns_non_null_collection() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var v = await ctx.Viewers + .Include(x => x.Cards).ThenInclude(c => c.Card) + .FirstAsync(x => x.Id == viewerId); + + var inv = scope.ServiceProvider.GetRequiredService(); + var owned = await inv.EffectiveOwnedCardsAsync(v); + + Assert.That(owned, Is.Not.Null); + // If there are basic cards seeded (IsBasic=true) they should be protected; + // if none are seeded the collection may be empty — just confirm it doesn't throw. + } + + [Test] + public async Task EffectiveBalance_returns_freeplay_amount_when_freeplay_enabled() + { + using var factory = new SVSimTestFactory(freeplayEnabled: true); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var v = await ctx.Viewers.AsNoTracking().FirstAsync(x => x.Id == viewerId); + + var inv = scope.ServiceProvider.GetRequiredService(); + var freeCfg = scope.ServiceProvider.GetRequiredService().Get(); + Assert.That(inv.EffectiveBalance(v, SpendCurrency.Crystal), Is.EqualTo(checked((long)freeCfg.CurrencyAmount))); + } + + [Test] + public async Task Transaction_EffectiveBalance_matches_viewer_balance() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 5678; + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + + Assert.That(tx.EffectiveBalance(SpendCurrency.Crystal), Is.EqualTo(5678)); + } + + [Test] + public async Task Transaction_OwnsCard_returns_true_when_card_owned() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + long cardId = await factory.SeedCardAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId, 1); + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + + Assert.That(tx.OwnsCard(cardId), Is.True); + } + + [Test] + public async Task Transaction_OwnsCard_returns_false_when_card_not_owned() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + long cardId = await factory.SeedCardAsync(); + // Do NOT seed owned card + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + + Assert.That(tx.OwnsCard(cardId), Is.False); + } + + [Test] + public async Task Transaction_OwnsCosmetic_returns_true_when_owned() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + const int sleeveId = 2_000_040_000; + var sleeve = new SleeveEntry { Id = sleeveId }; + ctx.Sleeves.Add(sleeve); + var v = await ctx.Viewers.Include(x => x.Sleeves).FirstAsync(x => x.Id == viewerId); + v.Sleeves.Add(sleeve); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + + Assert.That(tx.OwnsCosmetic(CosmeticType.Sleeve, sleeveId), Is.True); + } +}