diff --git a/SVSim.Database/Services/CurrencySpendService.cs b/SVSim.Database/Services/CurrencySpendService.cs deleted file mode 100644 index 1d3ef5e..0000000 --- a/SVSim.Database/Services/CurrencySpendService.cs +++ /dev/null @@ -1,51 +0,0 @@ -using SVSim.Database.Models; - -namespace SVSim.Database.Services; - -public class CurrencySpendService : ICurrencySpendService -{ - private readonly IViewerEntitlements _entitlements; - - public CurrencySpendService(IViewerEntitlements entitlements) => _entitlements = entitlements; - - public Task TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default) - { - if (cost < 0) cost = 0; - - // Freeplay bypass applies only to the three main currencies; SpotPoint always real. - if (_entitlements.IsFreeplay && currency != SpendCurrency.SpotPoint) - { - return Task.FromResult(new SpendResult( - SpendOutcome.Success, _entitlements.EffectiveBalance(viewer, currency))); - } - - ulong current = GetBalance(viewer, currency); - if (current < (ulong)cost) - return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current)); - - ulong post = current - (ulong)cost; - SetBalance(viewer, currency, post); - return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post)); - } - - private static ulong GetBalance(Viewer v, SpendCurrency c) => c switch - { - SpendCurrency.Crystal => v.Currency.Crystals, - SpendCurrency.Rupee => v.Currency.Rupees, - SpendCurrency.RedEther => v.Currency.RedEther, - SpendCurrency.SpotPoint => v.Currency.SpotPoints, - _ => throw new ArgumentOutOfRangeException(nameof(c)), - }; - - private static void SetBalance(Viewer v, SpendCurrency c, ulong value) - { - switch (c) - { - case SpendCurrency.Crystal: v.Currency.Crystals = value; break; - case SpendCurrency.Rupee: v.Currency.Rupees = value; break; - case SpendCurrency.RedEther: v.Currency.RedEther = value; break; - case SpendCurrency.SpotPoint: v.Currency.SpotPoints = value; break; - default: throw new ArgumentOutOfRangeException(nameof(c)); - } - } -} diff --git a/SVSim.Database/Services/ICurrencySpendService.cs b/SVSim.Database/Services/ICurrencySpendService.cs deleted file mode 100644 index 6aa27c1..0000000 --- a/SVSim.Database/Services/ICurrencySpendService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SVSim.Database.Models; - -namespace SVSim.Database.Services; - -/// -/// Centralized debit primitive — the symmetric twin of RewardGrantService.ApplyAsync. -/// Encapsulates the affordability-check + deduction + post-state-total pattern that was inlined -/// across the shop/pack controllers. Does NOT call SaveChangesAsync; the caller saves. -/// Freeplay (for Crystal/Rupee/RedEther) makes spends always succeed without deducting. -/// -public interface ICurrencySpendService -{ - Task TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default); -} diff --git a/SVSim.Database/Services/IViewerEntitlements.cs b/SVSim.Database/Services/IViewerEntitlements.cs deleted file mode 100644 index 7b277f9..0000000 --- a/SVSim.Database/Services/IViewerEntitlements.cs +++ /dev/null @@ -1,42 +0,0 @@ -using SVSim.Database.Enums; -using SVSim.Database.Models; - -namespace SVSim.Database.Services; - -/// -/// The single read/ownership authority for what a viewer is *treated as* owning. Knows the -/// Freeplay flag; all freeplay read-side behavior lives here. See -/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md. -/// -/// -/// Include precondition: methods that inspect the viewer's collections require the -/// viewer to have been loaded with .Include(v => v.Cards).ThenInclude(c => c.Card) -/// and the cosmetic collections -/// (Sleeves, Emblems, Degrees, LeaderSkins, MyPageBackgrounds) -/// included. Without those includes the EF owned-collection nav refs are null or zero-filled -/// (see the EF owned-collection nav-include pitfall in MEMORY.md). -/// -public interface IViewerEntitlements -{ - /// True when the global Freeplay config section is enabled. - bool IsFreeplay { get; } - - /// - /// The balance the viewer is treated as having: the configured freeplay amount for - /// Crystal/Rupee/RedEther when freeplay is on, otherwise (and always for SpotPoint) the real - /// viewer.Currency field. - /// - long EffectiveBalance(Viewer viewer, SpendCurrency currency); - - bool OwnsCard(Viewer viewer, long cardId); - - /// uses (Skin == leader skin). - bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id); - - /// The full owned-card projection for /load/index's user_card_list. - Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default); - - /// The cosmetic id-lists + leader-skin catalog/owned-set for /load/index. - Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default); -} - diff --git a/SVSim.Database/Services/RewardGrantService.cs b/SVSim.Database/Services/RewardGrantService.cs deleted file mode 100644 index 95da3d2..0000000 --- a/SVSim.Database/Services/RewardGrantService.cs +++ /dev/null @@ -1,213 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using SVSim.Database.Enums; -using SVSim.Database.Models; - -namespace SVSim.Database.Services; - -/// -/// Single canonical grant primitive for every the server hands to a -/// viewer. Switch on the type, mutate the appropriate viewer collection / -/// field, return the wire-shape entries to embed in the response's reward_list. -/// -/// -/// DO NOT reimplement reward dispatch in a controller or new helper. This service handles -/// RedEther, Crystal, SpotCardPoint, Item, Card (with cascade), -/// Sleeve, Emblem, Degree, Rupy, Skin, MyPageBG — everything except the dead-letter SpotCard / -/// SpotCardOnlyLatestCardPack slots (use Card=5 instead). Endpoint code that takes a -/// list of (type, id, num) tuples should iterate and call -/// per tuple — never switch on type yourself, never filter to "only card-typed rewards", never -/// build a second dispatch table. Past duplicate implementations (ICardAcquisitionService in the -/// EmulatedEntrypoint project, the first pass at /build_deck/buy) all silently dropped subsets of -/// types and produced the same bug: wire reward visible but viewer's collection unchanged. When a -/// new reward type comes up, add a case here. See feedback_reward_grant_service memory. -/// -/// -/// Card grants additionally run the cascade: any cosmetic -/// associated with the granted card that the viewer doesn't yet own is granted too, and produces -/// an additional entry in the returned list. That's why the return type is a list: most types -/// produce one entry, Card produces 1 + N. -/// -/// Caller is responsible for — -/// this service only mutates the in-memory graph so a controller can stack several grants in -/// a single transaction. -/// -public sealed class RewardGrantService -{ - private readonly SVSimDbContext _db; - private readonly ILogger _log; - - public RewardGrantService(SVSimDbContext db, ILogger log) - { - _db = db; - _log = log; - } - - public async Task> ApplyAsync( - Viewer viewer, UserGoodsType type, long detailId, int num, CancellationToken ct = default) - { - switch (type) - { - case UserGoodsType.Sleeve: - AddCosmeticIfMissing(viewer.Sleeves, detailId, _db.Sleeves); - return Single(type, detailId, 1); - - case UserGoodsType.Emblem: - AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems); - return Single(type, detailId, 1); - - case UserGoodsType.Skin: // LeaderSkin in our schema - AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins); - return Single(type, detailId, 1); - - case UserGoodsType.Degree: - AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees); - return Single(type, detailId, 1); - - case UserGoodsType.MyPageBG: - AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds); - return Single(type, detailId, 1); - - case UserGoodsType.Rupy: - viewer.Currency.Rupees += (ulong)num; - return Single(type, detailId, checked((int)viewer.Currency.Rupees)); - - case UserGoodsType.Crystal: - viewer.Currency.Crystals += (ulong)num; - return Single(type, detailId, checked((int)viewer.Currency.Crystals)); - - case UserGoodsType.RedEther: - viewer.Currency.RedEther += (ulong)num; - return Single(type, detailId, checked((int)viewer.Currency.RedEther)); - - case UserGoodsType.SpotCardPoint: - viewer.Currency.SpotPoints += (ulong)num; - return Single(type, detailId, checked((int)viewer.Currency.SpotPoints)); - - case UserGoodsType.Item: - { - var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId); - if (owned is null) - { - var item = _db.Items.Find((int)detailId) - ?? throw new InvalidOperationException($"Item {detailId} not in catalog"); - viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = viewer }); - return Single(type, detailId, num); - } - owned.Count += num; - return Single(type, detailId, owned.Count); - } - - case UserGoodsType.Card: - return await ApplyCardAsync(viewer, detailId, num, ct); - - case UserGoodsType.SpotCard: - case UserGoodsType.SpotCardOnlyLatestCardPack: - // Spot-card-typed grants don't appear in captures — emitters always use Card=5 - // with the spot-card-specific id. These two enum slots remain unimplemented; if a - // capture ever shows one in a reward_list we'll know to wire them up here. - throw new NotSupportedException( - $"{type} rewards are not yet supported — emitters use Card=5 instead."); - - default: - throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService"); - } - } - - private async Task> ApplyCardAsync( - Viewer viewer, long cardId, int num, CancellationToken ct) - { - // Find-or-add OwnedCardEntry. Mirrors the primitive that used to live in - // IPackRepository.GrantCardsToViewer — now inline so we own the in-memory-only contract. - var owned = viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId); - int postCount; - if (owned is null) - { - var card = await _db.Cards.FirstOrDefaultAsync(c => c.Id == cardId, ct) - ?? throw new InvalidOperationException($"Card {cardId} not in catalog"); - owned = new OwnedCardEntry { Card = card, Count = num, IsProtected = false }; - viewer.Cards.Add(owned); - postCount = num; - } - else - { - owned.Count += num; - postCount = owned.Count; - } - - var results = new List - { - new((int)UserGoodsType.Card, cardId, postCount), - }; - - // Cascade: cosmetic mappings live on the non-foil row. If the granted card is foil - // (card_id ends in 1, IsFoil=true), look up cascade against cardId - 1. - long lookupId = owned.Card.IsFoil ? cardId - 1 : cardId; - - var cascade = await _db.CardCosmeticRewards - .Where(r => r.CardId == lookupId) - .ToListAsync(ct); - - foreach (var reward in cascade) - { - if (TryAddCascadeCosmetic(viewer, reward, lookupId)) - { - // CosmeticType numeric values are identical to UserGoodsType — direct cast is safe. - results.Add(new GrantedReward((int)reward.Type, reward.CosmeticId, 1)); - } - } - - return results; - } - - private static IReadOnlyList Single(UserGoodsType type, long id, int num) - => new[] { new GrantedReward((int)type, id, num) }; - - private bool TryAddCascadeCosmetic(Viewer viewer, CardCosmeticReward reward, long forCardId) - { - try - { - return reward.Type switch - { - CosmeticType.Sleeve => AddCosmeticIfMissing(viewer.Sleeves, reward.CosmeticId, _db.Sleeves), - CosmeticType.Emblem => AddCosmeticIfMissing(viewer.Emblems, reward.CosmeticId, _db.Emblems), - CosmeticType.Skin => AddCosmeticIfMissing(viewer.LeaderSkins, reward.CosmeticId, _db.LeaderSkins), - CosmeticType.Degree => AddCosmeticIfMissing(viewer.Degrees, reward.CosmeticId, _db.Degrees), - CosmeticType.MyPageBG => AddCosmeticIfMissing(viewer.MyPageBackgrounds, reward.CosmeticId, _db.MyPageBackgrounds), - _ => false, - }; - } - catch (InvalidOperationException ex) - { - _log.LogWarning(ex, - "Card cascade: cosmetic {Type} {Id} for card {CardId} skipped (master row missing)", - reward.Type, reward.CosmeticId, forCardId); - return false; - } - } - - private static bool AddCosmeticIfMissing(List collection, long detailId, DbSet catalog) where T : class - { - bool alreadyOwned = collection.Any(e => GetId(e) == detailId); - if (alreadyOwned) return false; - - var entity = catalog.Find(checked((int)detailId)) - ?? throw new InvalidOperationException( - $"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}"); - collection.Add(entity); - return true; - } - - /// - /// Reflectively reads an entity's Id property — works for both BaseEntity<int> - /// (cosmetics) and BaseEntity<long> (e.g. Viewer/Card) without forcing two - /// non-generic overloads of . - /// - private static long GetId(T e) - { - var prop = typeof(T).GetProperty("Id") - ?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property"); - var val = prop.GetValue(e); - return val switch { long l => l, int i => i, _ => 0 }; - } -} diff --git a/SVSim.Database/Services/ViewerEntitlements.cs b/SVSim.Database/Services/ViewerEntitlements.cs deleted file mode 100644 index 4e0a137..0000000 --- a/SVSim.Database/Services/ViewerEntitlements.cs +++ /dev/null @@ -1,107 +0,0 @@ -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.Database.Models.Config; -using SVSim.Database.Repositories.Card; -using SVSim.Database.Repositories.Collectibles; - -namespace SVSim.Database.Services; - -public class ViewerEntitlements : IViewerEntitlements -{ - private readonly IGameConfigService _config; - private readonly ICardRepository _cards; - private readonly ICollectionRepository _collection; - - public ViewerEntitlements(IGameConfigService config, ICardRepository cards, ICollectionRepository collection) - { - _config = config; - _cards = cards; - _collection = collection; - } - - private FreeplayConfig Cfg => _config.Get(); - - public bool IsFreeplay => Cfg.Enabled; - - public long EffectiveBalance(Viewer viewer, SpendCurrency currency) - { - var cfg = Cfg; - if (cfg.Enabled && currency != SpendCurrency.SpotPoint) - return checked((long)cfg.CurrencyAmount); - - return currency switch - { - SpendCurrency.Crystal => (long)viewer.Currency.Crystals, - SpendCurrency.Rupee => (long)viewer.Currency.Rupees, - SpendCurrency.RedEther => (long)viewer.Currency.RedEther, - SpendCurrency.SpotPoint => (long)viewer.Currency.SpotPoints, - _ => throw new ArgumentOutOfRangeException(nameof(currency)), - }; - } - - public bool OwnsCard(Viewer viewer, long cardId) - => Cfg.Enabled || viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0); - - public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id) - { - if (Cfg.Enabled) return true; - return type switch - { - CosmeticType.Sleeve => viewer.Sleeves.Any(s => s.Id == id), - CosmeticType.Emblem => viewer.Emblems.Any(e => e.Id == id), - CosmeticType.Degree => viewer.Degrees.Any(d => d.Id == id), - CosmeticType.Skin => viewer.LeaderSkins.Any(s => s.Id == id), - CosmeticType.MyPageBG => viewer.MyPageBackgrounds.Any(m => m.Id == id), - _ => false, - }; - } - - public async Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default) - { - var defaults = await _cards.GetDefaultCards(); - var defaultIds = defaults.Select(c => c.Id).ToHashSet(); - var cfg = Cfg; - - if (cfg.Enabled) - { - var all = await _cards.GetAll(onlyCollectible: true); - return all - .Select(c => new OwnedCardEntry - { - Card = c, - Count = cfg.CardCopies, - IsProtected = defaultIds.Contains(c.Id), - }) - .ToList(); - } - - var owned = viewer.Cards.Where(c => c.Count > 0 && !defaultIds.Contains(c.Card.Id)); - return owned - .Concat(defaults.Select(bc => new OwnedCardEntry { Card = bc, Count = 3, IsProtected = true })) - .ToList(); - } - - public async Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) - { - var allSkins = await _collection.GetLeaderSkins(); - - if (Cfg.Enabled) - { - return new EffectiveCosmetics( - await _collection.GetAllSleeveIds(), - await _collection.GetAllEmblemIds(), - await _collection.GetAllDegreeIds(), - await _collection.GetAllMyPageBackgroundIds(), - allSkins, - allSkins.Select(s => s.Id).ToHashSet()); - } - - return new EffectiveCosmetics( - viewer.Sleeves.Select(s => s.Id).ToList(), - viewer.Emblems.Select(e => e.Id).ToList(), - viewer.Degrees.Select(d => d.Id).ToList(), - viewer.MyPageBackgrounds.Select(m => m.Id).ToList(), - allSkins, - viewer.LeaderSkins.Select(s => s.Id).ToHashSet()); - } -} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 28d0733..f890e53 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -84,12 +84,8 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped GrantManyAsync(long viewerId, IEnumerable newCardIds) - { - var viewer = await LoadViewerWithGraph(viewerId); - var rewardList = new List(); - - // Bucket the input by id so multi-copy grants increment count once but cascade fires once. - foreach (var grp in newCardIds.GroupBy(id => id)) - { - int count = grp.Count(); - var granted = await _rewards.ApplyAsync(viewer, UserGoodsType.Card, grp.Key, count); - foreach (var g in granted) - { - rewardList.Add(new RewardListEntry - { - RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum, - }); - } - } - - await _db.SaveChangesAsync(); - return new CardGrantResult(rewardList); - } - - public async Task BackfillCosmeticsAsync(long viewerId) - { - var viewer = await LoadViewerWithGraph(viewerId); - var rewardList = new List(); - - // Foil resolution: cascade rows live on non-foil ids. Apply the +1 convention. - var lookupCardIds = viewer.Cards - .Select(c => c.Card.IsFoil ? c.Card.Id - 1 : c.Card.Id) - .Distinct() - .ToList(); - - var cascade = await _db.CardCosmeticRewards - .Where(r => lookupCardIds.Contains(r.CardId)) - .ToListAsync(); - - foreach (var reward in cascade) - { - // Skip if the viewer already owns this cosmetic. ApplyAsync's cosmetic branches - // unconditionally return a wire entry (top-level grant semantics), so we must - // filter at the caller side to avoid emitting "+0 received" lines for cosmetics - // the viewer has owned for ages. - if (AlreadyOwnsCosmetic(viewer, reward.Type, reward.CosmeticId)) continue; - - var goodsType = (UserGoodsType)(int)reward.Type; - var granted = await _rewards.ApplyAsync(viewer, goodsType, reward.CosmeticId, 1); - foreach (var g in granted) - { - rewardList.Add(new RewardListEntry - { - RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum, - }); - } - } - - await _db.SaveChangesAsync(); - return new CardGrantResult(rewardList); - } - - private static bool AlreadyOwnsCosmetic(Viewer viewer, CosmeticType type, long id) => type switch - { - CosmeticType.Sleeve => viewer.Sleeves.Any(s => s.Id == id), - CosmeticType.Emblem => viewer.Emblems.Any(e => e.Id == id), - CosmeticType.Skin => viewer.LeaderSkins.Any(s => s.Id == id), - CosmeticType.Degree => viewer.Degrees.Any(d => d.Id == id), - CosmeticType.MyPageBG => viewer.MyPageBackgrounds.Any(b => b.Id == id), - _ => false, - }; - - private Task LoadViewerWithGraph(long viewerId) => _db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.LeaderSkins) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); -} diff --git a/SVSim.EmulatedEntrypoint/Services/CardGrantResult.cs b/SVSim.EmulatedEntrypoint/Services/CardGrantResult.cs deleted file mode 100644 index b1564b5..0000000 --- a/SVSim.EmulatedEntrypoint/Services/CardGrantResult.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SVSim.EmulatedEntrypoint.Models.Dtos; - -namespace SVSim.EmulatedEntrypoint.Services; - -/// -/// Output of . The RewardList is wire-shape: -/// pass directly into a /pack/open or similar response's data.reward_list field. -/// -/// In grant mode, contains one type=5 (Card) entry per distinct newCardId with post-state -/// count, plus one entry per newly-granted cosmetic. -/// In backfill mode, contains only cosmetic entries (no card-count entries). -/// -public record CardGrantResult(IReadOnlyList RewardList); diff --git a/SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs b/SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs deleted file mode 100644 index 6d02bc0..0000000 --- a/SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace SVSim.EmulatedEntrypoint.Services; - -public interface ICardAcquisitionService -{ - /// - /// Grant N cards + their CardCosmeticReward cascades in a single transaction. - /// Used by /pack/open and any future endpoint that grants new cards in bulk. - /// Returns wire-shape reward_list entries (post-state counts for cards, single-grant - /// entries for any newly-added cosmetics). - /// - Task GrantManyAsync(long viewerId, IEnumerable newCardIds); - - /// - /// Scan all owned cards for missing CardCosmeticReward cosmetics; grant any not yet owned. - /// Used by /load/index for retroactive cosmetic reconciliation. Card counts are NOT mutated. - /// - Task BackfillCosmeticsAsync(long viewerId); -} diff --git a/SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs b/SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs deleted file mode 100644 index 9d01bb0..0000000 --- a/SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs +++ /dev/null @@ -1,365 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using SVSim.Database; -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.EmulatedEntrypoint.Services; -using SVSim.UnitTests.Infrastructure; - -namespace SVSim.UnitTests.Services; - -public class CardAcquisitionServiceTests -{ - /// - /// Seeds a viewer (via the factory's real RegisterViewer-backed helper) and gives it the - /// given owned cards (key = card_id, value = count). Card rows are created on-demand if - /// the test's card_id isn't already in the minimal seeded card set (matches the pattern - /// used by SVSimTestFactory.SeedOwnedCardAsync, but inlined so multiple cards can be - /// seeded in one viewer in one call). Returns the viewer's Id. - /// - private static async Task SeedViewerWithCards( - SVSimTestFactory factory, - Dictionary ownedCards, - IEnumerable? grantableCardIds = null) - { - long viewerId = await factory.SeedViewerAsync(); - - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card) - .FirstAsync(v => v.Id == viewerId); - - foreach (var (cardId, count) in ownedCards) - { - var card = await EnsureCardAsync(db, cardId); - viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = count, IsProtected = false }); - } - // Pre-seed bare Cards rows (no ownership) for any cardIds the test plans to grant via - // the service. RewardGrantService.ApplyAsync does FirstOrDefaultAsync on _db.Cards; - // without the row the grant throws InvalidOperationException("Card {id} not in catalog"). - if (grantableCardIds is not null) - { - foreach (var cardId in grantableCardIds) - { - await EnsureCardAsync(db, cardId); - } - } - await db.SaveChangesAsync(); - return viewerId; - } - - private static async Task EnsureCardAsync(SVSimDbContext db, long cardId) - { - var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == cardId); - if (card is null) - { - // Foil twins follow the universal +1 convention (card_id ends in 1). Marking - // IsFoil here keeps test setup tidy so foil-resolution tests don't have to - // hand-patch the card row. - var isFoil = cardId % 10 == 1; - card = new ShadowverseCardEntry { Id = cardId, Name = $"SeededCard{cardId}", Rarity = Database.Enums.Rarity.Bronze, IsFoil = isFoil }; - db.Cards.Add(card); - await db.SaveChangesAsync(); - } - return card; - } - - private static ICardAcquisitionService GetService(SVSimTestFactory factory) - { - var scope = factory.Services.CreateScope(); - return scope.ServiceProvider.GetRequiredService(); - } - - [Test] - public async Task GrantManyAsync_NewBronzeCard_GrantsCardOnly() - { - // 101111010 is a synthetic test card (inserted ad-hoc via grantableCardIds) with no - // CardCosmeticReward associations. Expectation: grant returns only the type=5 entry. - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 101111010L }); - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 101111010L }); - - Assert.That(result.RewardList, Has.Count.EqualTo(1)); - Assert.That(result.RewardList[0].RewardType, Is.EqualTo(5)); // Card - Assert.That(result.RewardList[0].RewardId, Is.EqualTo(101111010L)); - Assert.That(result.RewardList[0].RewardNum, Is.EqualTo(1)); // post-state count - } - - [Test] - public async Task GrantManyAsync_LeaderCard_GrantsCardAndSkin() - { - // Card 704741010 (Aria leader-card variant) has 3 cosmetic associations in the seed: - // skin 407, sleeve 704741010, emblem 704741010. - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); - - // Since SqliteFriendlyModelCustomizer strips CardCosmeticReward seed in tests, insert - // the specific mappings we need for this test. - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.CardCosmeticRewards.AddRange( - new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }, - new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Sleeve, CosmeticId = 704741010L, Quantity = 1 }, - new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Emblem, CosmeticId = 704741010L, Quantity = 1 } - ); - // Ensure master rows exist for the cosmetics we'll grant - if (await db.LeaderSkins.FindAsync(407) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); - if (await db.Sleeves.FindAsync(704741010) is null) - db.Sleeves.Add(new SleeveEntry { Id = 704741010 }); - if (await db.Emblems.FindAsync(704741010) is null) - db.Emblems.Add(new EmblemEntry { Id = 704741010 }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 704741010L }); - - var skinEntry = result.RewardList.SingleOrDefault(r => r.RewardType == 10); - Assert.That(skinEntry, Is.Not.Null, "expected a Skin reward entry"); - Assert.That(skinEntry!.RewardId, Is.EqualTo(407L)); - - // Verify viewer ownership was actually written to DB - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - var viewer = await db.Viewers - .Include(v => v.LeaderSkins) - .FirstAsync(v => v.Id == viewerId); - Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True); - } - } - - [Test] - public async Task GrantManyAsync_AlreadyOwnedSkin_OmitsFromRewardList() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - // Pre-grant the skin to this viewer - var viewer = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); - var skin = await db.LeaderSkins.FindAsync(407) ?? db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }).Entity; - if (!viewer.LeaderSkins.Any(s => s.Id == 407)) - viewer.LeaderSkins.Add(skin); - // Seed the card→skin mapping - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 704741010L }); - - Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.False, - "skin entry should be omitted since viewer already owns it"); - Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 704741010L), Is.True, - "card grant entry should still be emitted"); - } - - [Test] - public async Task GrantManyAsync_FoilLeaderCard_ResolvesToNonFoilCosmetics() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741011L }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - // CardCosmeticReward.CardId has a FK→Cards.Id; ensure the non-foil row exists - // even though we never grant it directly (the foil twin is the granted card). - await EnsureCardAsync(db, 704741010L); - // Map cosmetics to the NON-FOIL card_id (704741010), as the seed convention requires - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); - if (await db.LeaderSkins.FindAsync(407) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 704741011L }); - - var skinEntry = result.RewardList.SingleOrDefault(r => r.RewardType == 10); - Assert.That(skinEntry, Is.Not.Null, "expected skin entry via foil resolution"); - Assert.That(skinEntry!.RewardId, Is.EqualTo(407L)); - - using var scope2 = factory.Services.CreateScope(); - var db2 = scope2.ServiceProvider.GetRequiredService(); - var viewer = await db2.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); - Assert.That(viewer.Cards.Any(c => c.Card.Id == 704741011L), Is.True, "card is the foil"); - Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True); - } - - [Test] - public async Task GrantManyAsync_MultipleCopiesOfSameLeader_GrantsCosmeticOnce() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); - if (await db.LeaderSkins.FindAsync(407) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 704741010L, 704741010L, 704741010L }); - - Assert.That(result.RewardList.Count(r => r.RewardType == 10), Is.EqualTo(1), - "skin should appear exactly once in reward_list"); - var cardEntry = result.RewardList.Single(r => r.RewardType == 5 && r.RewardId == 704741010L); - Assert.That(cardEntry.RewardNum, Is.EqualTo(3), "card count should reflect all 3 copies"); - } - - [Test] - public async Task GrantManyAsync_RecentLeaderCard_GrantsAllFiveCosmeticTypes() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 721141010L }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - // All 5 cosmetic types for this card. Exact ids: from data_dumps captures. - db.CardCosmeticRewards.AddRange( - new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Sleeve, CosmeticId = 721141010L, Quantity = 1 }, - new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Emblem, CosmeticId = 721141010L, Quantity = 1 }, - new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Degree, CosmeticId = 120021L, Quantity = 1 }, - new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Skin, CosmeticId = 4601L, Quantity = 1 }, - new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.MyPageBG, CosmeticId = 721141010L, Quantity = 1 } - ); - // Ensure master rows - if (await db.LeaderSkins.FindAsync(4601) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 4601, Name = "TestSkin4601" }); - if (await db.Sleeves.FindAsync(721141010) is null) - db.Sleeves.Add(new SleeveEntry { Id = 721141010 }); - if (await db.Emblems.FindAsync(721141010) is null) - db.Emblems.Add(new EmblemEntry { Id = 721141010 }); - if (await db.Degrees.FindAsync(120021) is null) - db.Degrees.Add(new DegreeEntry { Id = 120021 }); - if (await db.MyPageBackgrounds.FindAsync(721141010) is null) - db.MyPageBackgrounds.Add(new MyPageBackgroundEntry { Id = 721141010 }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 721141010L }); - - Assert.Multiple(() => - { - Assert.That(result.RewardList.Any(r => r.RewardType == 6), Is.True, "Sleeve"); - Assert.That(result.RewardList.Any(r => r.RewardType == 7), Is.True, "Emblem"); - Assert.That(result.RewardList.Any(r => r.RewardType == 8), Is.True, "Degree"); - Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.True, "Skin"); - Assert.That(result.RewardList.Any(r => r.RewardType == 15), Is.True, "MyPageBG"); - }); - } - - [Test] - public async Task BackfillCosmeticsAsync_DoesNotIncrementCardCount() - { - using var factory = new SVSimTestFactory(); - // Pre-seed viewer with card 704741010 count=5, no skin - var viewerId = await SeedViewerWithCards(factory, new() { [704741010L] = 5 }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); - if (await db.LeaderSkins.FindAsync(407) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.BackfillCosmeticsAsync(viewerId); - - using var scope2 = factory.Services.CreateScope(); - var db2 = scope2.ServiceProvider.GetRequiredService(); - var viewer = await db2.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); - var owned = viewer.Cards.Single(c => c.Card.Id == 704741010L); - - Assert.That(owned.Count, Is.EqualTo(5), "card count should be unchanged in backfill mode"); - Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True, "skin should be backfilled"); - Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 407L), Is.True, - "skin entry returned even in backfill mode"); - Assert.That(result.RewardList.Any(r => r.RewardType == 5), Is.False, - "no type=5 card entries in backfill mode"); - } - - [Test] - public async Task BackfillCosmeticsAsync_CalledTwice_SecondCallIsNoOp() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { [704741010L] = 1 }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); - if (await db.LeaderSkins.FindAsync(407) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var first = await service.BackfillCosmeticsAsync(viewerId); - var second = await service.BackfillCosmeticsAsync(viewerId); - - Assert.That(first.RewardList, Is.Not.Empty, "first call should grant cosmetics"); - Assert.That(second.RewardList, Is.Empty, "second call should be a no-op"); - } - - [Test] - public async Task GrantManyAsync_LeaderCardWithMissingMapping_GrantsCardSilently() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 701141010L }); - - // NO CardCosmeticReward rows inserted for this card — simulates the 83 missing-mapping cases. - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 701141010L }); - - Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 701141010L), Is.True); - Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.False); - // No exception means it handled the missing mapping gracefully. - } - - [Test] - public async Task GrantManyAsync_OrphanCosmeticReward_LogsWarningAndSkips() - { - using var factory = new SVSimTestFactory(); - var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - // Real skin association - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); - if (await db.LeaderSkins.FindAsync(407) is null) - db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); - - // ORPHAN: points to non-existent skin_id - db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 9999999L, Quantity = 1 }); - - await db.SaveChangesAsync(); - } - - var service = GetService(factory); - var result = await service.GrantManyAsync(viewerId, new[] { 704741010L }); - - Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 704741010L), Is.True); - Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 407L), Is.True, - "real skin should still be granted"); - Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 9999999L), Is.False, - "orphan cosmetic should not appear in reward_list"); - } -} diff --git a/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs b/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs deleted file mode 100644 index f506b66..0000000 --- a/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.Database.Services; - -namespace SVSim.UnitTests.Services; - -public class CurrencySpendServiceTests -{ - private sealed class FakeEntitlements : IViewerEntitlements - { - public bool IsFreeplay { get; init; } - public long FreeplayAmount { get; init; } = 99999; - - public long EffectiveBalance(Viewer viewer, SpendCurrency currency) - { - if (IsFreeplay && currency != SpendCurrency.SpotPoint) return FreeplayAmount; - return currency switch - { - SpendCurrency.Crystal => (long)viewer.Currency.Crystals, - SpendCurrency.Rupee => (long)viewer.Currency.Rupees, - SpendCurrency.RedEther => (long)viewer.Currency.RedEther, - SpendCurrency.SpotPoint => (long)viewer.Currency.SpotPoints, - _ => 0, - }; - } - public bool OwnsCard(Viewer viewer, long cardId) => IsFreeplay; - public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id) => IsFreeplay; - public Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default) - => Task.FromResult>(new List()); - public Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) - => throw new NotSupportedException(); - } - - private static Viewer NewViewer() => new() { Currency = new ViewerCurrency() }; - - [Test] - public async Task Normal_deducts_and_returns_post_state() - { - var v = NewViewer(); - v.Currency.Crystals = 250; - var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = false }); - - var r = await svc.TrySpendAsync(v, SpendCurrency.Crystal, 100); - - Assert.That(r.Success, Is.True); - Assert.That(r.PostStateTotal, Is.EqualTo(150)); - Assert.That(v.Currency.Crystals, Is.EqualTo(150UL)); - } - - [Test] - public async Task Normal_insufficient_does_not_deduct() - { - var v = NewViewer(); - v.Currency.Rupees = 50; - var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = false }); - - var r = await svc.TrySpendAsync(v, SpendCurrency.Rupee, 100); - - Assert.That(r.Success, Is.False); - Assert.That(r.Outcome, Is.EqualTo(SpendOutcome.Insufficient)); - Assert.That(v.Currency.Rupees, Is.EqualTo(50UL), "no deduction on insufficient funds"); - } - - [Test] - public async Task Freeplay_main_currency_succeeds_without_deducting() - { - var v = NewViewer(); - v.Currency.Crystals = 10; - var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = true }); - - var r = await svc.TrySpendAsync(v, SpendCurrency.Crystal, 100000); - - Assert.That(r.Success, Is.True, "freeplay never blocks on affordability"); - Assert.That(r.PostStateTotal, Is.EqualTo(99999), "post-state shows the freeplay balance"); - Assert.That(v.Currency.Crystals, Is.EqualTo(10UL), "DB balance untouched in freeplay"); - } - - [Test] - public async Task Freeplay_spot_points_still_deduct() - { - var v = NewViewer(); - v.Currency.SpotPoints = 300; - var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = true }); - - var r = await svc.TrySpendAsync(v, SpendCurrency.SpotPoint, 100); - - Assert.That(r.Success, Is.True); - Assert.That(r.PostStateTotal, Is.EqualTo(200)); - Assert.That(v.Currency.SpotPoints, Is.EqualTo(200UL), "spot points are real even in freeplay"); - } -} diff --git a/SVSim.UnitTests/Services/RewardGrantServiceTests.cs b/SVSim.UnitTests/Services/RewardGrantServiceTests.cs deleted file mode 100644 index 707df28..0000000 --- a/SVSim.UnitTests/Services/RewardGrantServiceTests.cs +++ /dev/null @@ -1,280 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using SVSim.Database; -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.Database.Services; -using SVSim.UnitTests.Infrastructure; - -namespace SVSim.UnitTests.Services; - -public class RewardGrantServiceTests -{ - [Test] - public async Task Sleeve_added_to_viewer_collection() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const int testSleeveId = 2_000_000_000; - var sleeve = new SleeveEntry { Id = testSleeveId }; - ctx.Sleeves.Add(sleeve); - await ctx.SaveChangesAsync(); - - var viewer = await ctx.Viewers.Include(v => v.Sleeves).FirstAsync(v => v.Id == viewerId); - var svc = scope.ServiceProvider.GetRequiredService(); - - var result = await svc.ApplyAsync(viewer, UserGoodsType.Sleeve, detailId: testSleeveId, num: 1); - await ctx.SaveChangesAsync(); - - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(viewer.Sleeves.Any(s => s.Id == testSleeveId), Is.True); - Assert.That(result[0].RewardType, Is.EqualTo((int)UserGoodsType.Sleeve)); - Assert.That(result[0].RewardId, Is.EqualTo((long)testSleeveId)); - Assert.That(result[0].RewardNum, Is.EqualTo(1)); - } - - [Test] - public async Task Rupy_sets_currency_post_state_total() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - var viewer = await ctx.Viewers.FirstAsync(v => v.Id == viewerId); - viewer.Currency.Rupees = 100UL; - await ctx.SaveChangesAsync(); - - var svc = scope.ServiceProvider.GetRequiredService(); - - var result = await svc.ApplyAsync(viewer, UserGoodsType.Rupy, detailId: 0, num: 50); - await ctx.SaveChangesAsync(); - - Assert.That(viewer.Currency.Rupees, Is.EqualTo(150UL)); - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].RewardNum, Is.EqualTo(150)); - } - - [Test] - public async Task LeaderSkin_added_idempotently() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const int testSkinId = 9_999_999; - ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "Round1Reward" }); - await ctx.SaveChangesAsync(); - - var viewer = await ctx.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); - var svc = scope.ServiceProvider.GetRequiredService(); - - await svc.ApplyAsync(viewer, UserGoodsType.Skin, testSkinId, 1); - await svc.ApplyAsync(viewer, UserGoodsType.Skin, testSkinId, 1); - await ctx.SaveChangesAsync(); - - Assert.That(viewer.LeaderSkins.Count(s => s.Id == testSkinId), Is.EqualTo(1)); - } - - [Test] - public async Task Card_fresh_grant_inserts_owned_entry_and_returns_post_state_count() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const long testCardId = 999_001_001L; - ctx.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "RGSTestCard", Rarity = Rarity.Bronze }); - await ctx.SaveChangesAsync(); - - var viewer = await ctx.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); - var svc = scope.ServiceProvider.GetRequiredService(); - - var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1); - await ctx.SaveChangesAsync(); - - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].RewardType, Is.EqualTo((int)UserGoodsType.Card)); - Assert.That(result[0].RewardId, Is.EqualTo(testCardId)); - Assert.That(result[0].RewardNum, Is.EqualTo(1)); - Assert.That(viewer.Cards.Single(c => c.Card.Id == testCardId).Count, Is.EqualTo(1)); - } - - [Test] - public async Task Card_existing_grant_increments_count() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const long testCardId = 999_001_002L; - var card = new ShadowverseCardEntry { Id = testCardId, Name = "RGSTestCard2", Rarity = Rarity.Bronze }; - ctx.Cards.Add(card); - var viewer = await ctx.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); - viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = 2, IsProtected = false }); - await ctx.SaveChangesAsync(); - - var svc = scope.ServiceProvider.GetRequiredService(); - var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1); - await ctx.SaveChangesAsync(); - - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].RewardNum, Is.EqualTo(3)); - Assert.That(viewer.Cards.Single(c => c.Card.Id == testCardId).Count, Is.EqualTo(3)); - } - - [Test] - public async Task Card_with_cascade_rows_emits_card_plus_cosmetics() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const long testCardId = 999_002_010L; - const int testSkinId = 999_002_011; - ctx.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "CascadeTestCard", Rarity = Rarity.Gold }); - ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "CascadeTestSkin" }); - ctx.CardCosmeticRewards.Add(new CardCosmeticReward - { - CardId = testCardId, Type = CosmeticType.Skin, CosmeticId = testSkinId, Quantity = 1, - }); - await ctx.SaveChangesAsync(); - - var viewer = await ctx.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.LeaderSkins) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); - - var svc = scope.ServiceProvider.GetRequiredService(); - var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1); - await ctx.SaveChangesAsync(); - - Assert.That(result, Has.Count.EqualTo(2)); - Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == testCardId), Is.True); - Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Skin && r.RewardId == testSkinId), Is.True); - Assert.That(viewer.LeaderSkins.Any(s => s.Id == testSkinId), Is.True); - } - - [Test] - public async Task Card_cascade_skips_already_owned_cosmetic() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const long testCardId = 999_002_020L; - const int testSkinId = 999_002_021; - ctx.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "CascadeOwnedTestCard", Rarity = Rarity.Gold }); - var skin = new LeaderSkinEntry { Id = testSkinId, Name = "CascadeOwnedTestSkin" }; - ctx.LeaderSkins.Add(skin); - ctx.CardCosmeticRewards.Add(new CardCosmeticReward - { - CardId = testCardId, Type = CosmeticType.Skin, CosmeticId = testSkinId, Quantity = 1, - }); - await ctx.SaveChangesAsync(); - - var viewer = await ctx.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.LeaderSkins) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); - viewer.LeaderSkins.Add(skin); - await ctx.SaveChangesAsync(); - - var svc = scope.ServiceProvider.GetRequiredService(); - var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1); - await ctx.SaveChangesAsync(); - - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].RewardType, Is.EqualTo((int)UserGoodsType.Card)); - Assert.That(result[0].RewardId, Is.EqualTo(testCardId)); - } - - [Test] - public async Task Card_foil_grant_resolves_cascade_to_non_foil_id() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const long nonFoilId = 999_002_030L; - const long foilId = 999_002_031L; - const int testSkinId = 999_002_032; - - ctx.Cards.Add(new ShadowverseCardEntry { Id = nonFoilId, Name = "FoilCascadeBase", Rarity = Rarity.Gold }); - ctx.Cards.Add(new ShadowverseCardEntry { Id = foilId, Name = "FoilCascadeFoil", Rarity = Rarity.Gold, IsFoil = true }); - ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "FoilCascadeSkin" }); - ctx.CardCosmeticRewards.Add(new CardCosmeticReward - { - CardId = nonFoilId, Type = CosmeticType.Skin, CosmeticId = testSkinId, Quantity = 1, - }); - await ctx.SaveChangesAsync(); - - var viewer = await ctx.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.LeaderSkins) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); - - var svc = scope.ServiceProvider.GetRequiredService(); - var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, foilId, 1); - await ctx.SaveChangesAsync(); - - Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == foilId), Is.True); - Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Skin && r.RewardId == testSkinId), Is.True); - } - - [Test] - public async Task SpotCard_still_throws_NotSupported() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - var viewer = await ctx.Viewers.FirstAsync(v => v.Id == viewerId); - var svc = scope.ServiceProvider.GetRequiredService(); - - Assert.ThrowsAsync(async () => - await svc.ApplyAsync(viewer, UserGoodsType.SpotCard, 1L, 1)); - Assert.ThrowsAsync(async () => - await svc.ApplyAsync(viewer, UserGoodsType.SpotCardOnlyLatestCardPack, 1L, 1)); - } - - [Test] - public async Task OwnedCardEntry_unique_index_blocks_duplicate_viewer_card_row() - { - // Schema-level safety net: any code that forgets to .Include(v => v.Cards) before doing - // a find-or-add OwnedCardEntry would silently insert a duplicate row otherwise. The - // unique index on (ViewerId, CardId) makes that crash loudly at SaveChanges instead. - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var ctx = scope.ServiceProvider.GetRequiredService(); - - const long testCardId = 999_003_001L; - var card = new ShadowverseCardEntry { Id = testCardId, Name = "UniqueIdxTest", Rarity = Rarity.Bronze }; - ctx.Cards.Add(card); - var viewer = await ctx.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); - viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = 1, IsProtected = false }); - await ctx.SaveChangesAsync(); - - // Simulate the bug: a fresh viewer load WITHOUT .Include(v => v.Cards), then a manual - // Add of a second row for the same (Viewer, Card). The unique index must reject this. - using var scope2 = factory.Services.CreateScope(); - var ctx2 = scope2.ServiceProvider.GetRequiredService(); - var unloadedViewer = await ctx2.Viewers.FirstAsync(v => v.Id == viewerId); - var sameCard = await ctx2.Cards.FirstAsync(c => c.Id == testCardId); - unloadedViewer.Cards.Add(new OwnedCardEntry { Card = sameCard, Count = 1, IsProtected = false }); - - Assert.ThrowsAsync(async () => await ctx2.SaveChangesAsync()); - } -} diff --git a/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs b/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs deleted file mode 100644 index 91665a0..0000000 --- a/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using SVSim.Database; -using SVSim.Database.Enums; -using SVSim.Database.Models; -using SVSim.Database.Models.Config; -using SVSim.Database.Repositories.Card; -using SVSim.Database.Repositories.Collectibles; -using SVSim.Database.Services; -using SVSim.EmulatedEntrypoint.Services; -using SVSim.UnitTests.Infrastructure; - -namespace SVSim.UnitTests.Services; - -public class ViewerEntitlementsTests -{ - /// - /// FreeplayConfig is in SVSim.Database so EnsureSeedDataAsync seeds a DB row with - /// Enabled=false (ShippedDefaults). Since tier 1 (DB) wins, we mutate the seeded row - /// to activate freeplay rather than relying on an IConfiguration override. - /// - private static void SetFreeplayEnabled(SVSimDbContext db, bool enabled, ulong currencyAmount = 99999, int cardCopies = 3) - { - var row = db.GameConfigs.First(s => s.SectionName == "Freeplay"); - var cfg = JsonSerializer.Deserialize(row.ValueJson)!; - cfg.Enabled = enabled; - cfg.CurrencyAmount = currencyAmount; - cfg.CardCopies = cardCopies; - row.ValueJson = JsonSerializer.Serialize(cfg); - db.SaveChanges(); - } - - private static ViewerEntitlements Build(IServiceScope scope) - { - var db = scope.ServiceProvider.GetRequiredService(); - return new ViewerEntitlements( - new GameConfigService(db, new ConfigurationBuilder().Build()), - new CardRepository(db), - new CollectionRepository(db)); - } - - [Test] - public async Task Freeplay_off_reflects_real_balance_and_ownership() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - // Freeplay is seeded as Enabled=false by default — no mutation needed. - var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); - viewer.Currency.Crystals = 7; - - var ent = Build(scope); - - Assert.That(ent.IsFreeplay, Is.False); - Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.Crystal), Is.EqualTo(7)); - Assert.That(ent.OwnsCard(viewer, 99_999_999L), Is.False); - Assert.That(ent.OwnsCosmetic(viewer, CosmeticType.Skin, 99_999), Is.False); - } - - [Test] - public async Task Freeplay_on_inflates_main_currencies_but_not_spot_points() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - SetFreeplayEnabled(db, enabled: true, currencyAmount: 99999); - var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId); - viewer.Currency.SpotPoints = 5; - - var ent = Build(scope); - - Assert.That(ent.IsFreeplay, Is.True); - Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.Crystal), Is.EqualTo(99999)); - Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.Rupee), Is.EqualTo(99999)); - Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.RedEther), Is.EqualTo(99999)); - Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.SpotPoint), Is.EqualTo(5), - "spot points are not a freeplay-inflated currency"); - } - - [Test] - public async Task Freeplay_on_treats_all_cards_and_cosmetics_as_owned() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - SetFreeplayEnabled(db, enabled: true); - var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); - - var ent = Build(scope); - - Assert.That(ent.OwnsCard(viewer, 99_999_999L), Is.True); - Assert.That(ent.OwnsCosmetic(viewer, CosmeticType.Skin, 99_999), Is.True); - } - - // ------------------------------------------------------------------------- - // EffectiveOwnedCardsAsync - // ------------------------------------------------------------------------- - - [Test] - public async Task EffectiveOwnedCards_freeplay_on_returns_all_collectible_cards_at_card_copies() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - // Seed one collectible card owned by this viewer (gives it a CollectionInfo). - await factory.SeedOwnedCardAsync(viewerId, 50001001L, count: 1); - - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - SetFreeplayEnabled(db, enabled: true, cardCopies: 3); - - var viewer = await db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .FirstAsync(v => v.Id == viewerId); - - var ent = Build(scope); - var result = await ent.EffectiveOwnedCardsAsync(viewer); - - // Freeplay returns the whole collectible catalog — card 50001001 must be present. - Assert.That(result.Any(e => e.Card.Id == 50001001L), Is.True, - "seeded collectible card must appear in freeplay result"); - - // Every returned entry must have Count == CardCopies (3). - Assert.That(result.All(e => e.Count == 3), Is.True, - "every freeplay entry should have Count == CardCopies (3)"); - - // The full set == every collectible card in the DB. - int collectibleCount = db.Cards.Count(c => c.CollectionInfo != null); - Assert.That(result.Count, Is.EqualTo(collectibleCount), - "freeplay result should contain exactly all collectible cards"); - } - - [Test] - public async Task EffectiveOwnedCards_freeplay_off_returns_only_owned() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - // Seed card 50001002 owned at count 2. - await factory.SeedOwnedCardAsync(viewerId, 50001002L, count: 2); - - // Seed a second collectible card (50001003) NOT owned by the viewer — insert card row - // only (with CollectionInfo so it's collectible) but do not link it to the viewer. - using (var setupScope = factory.Services.CreateScope()) - { - var setupDb = setupScope.ServiceProvider.GetRequiredService(); - if (!setupDb.Cards.Any(c => c.Id == 50001003L)) - { - setupDb.Cards.Add(new ShadowverseCardEntry - { - Id = 50001003L, - Name = "UnownedCollectible", - Rarity = SVSim.Database.Enums.Rarity.Bronze, - CollectionInfo = new SVSim.Database.Models.CardCollectionInfo { CraftCost = 200, DustReward = 50 }, - }); - await setupDb.SaveChangesAsync(); - } - } - - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - // Freeplay is off by default — no mutation needed. - - var viewer = await db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .FirstAsync(v => v.Id == viewerId); - - var ent = Build(scope); - var result = await ent.EffectiveOwnedCardsAsync(viewer); - - // The owned card must be present at the right count. - var owned = result.FirstOrDefault(e => e.Card.Id == 50001002L); - Assert.That(owned, Is.Not.Null, "owned card should appear in result"); - Assert.That(owned!.Count, Is.EqualTo(2)); - - // The unowned collectible card must NOT appear. - Assert.That(result.Any(e => e.Card.Id == 50001003L), Is.False, - "card not owned by viewer must not appear when freeplay is off"); - } - - // ------------------------------------------------------------------------- - // EffectiveCosmeticsAsync - // ------------------------------------------------------------------------- - - [Test] - public async Task EffectiveCosmetics_leader_skins_always_full_catalog_owned_set_differs() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - - // --- freeplay OFF: fresh viewer owns no cosmetics --- - { - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - // Freeplay off by default. - - var viewer = await db.Viewers - .Include(v => v.LeaderSkins) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .FirstAsync(v => v.Id == viewerId); - - var ent = Build(scope); - var cosmetics = await ent.EffectiveCosmeticsAsync(viewer); - - int masterSkinCount = db.LeaderSkins.Count(); - Assert.That(masterSkinCount, Is.GreaterThan(0), - "leaderskins.csv must have been imported — master table must be non-empty"); - Assert.That(cosmetics.AllLeaderSkins.Count, Is.EqualTo(masterSkinCount), - "AllLeaderSkins should always be the full catalog regardless of freeplay"); - - // A fresh viewer owns one default skin per class (granted at registration). - // Assert the owned set matches what the viewer actually has — don't assume empty. - var expectedOwnedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet(); - Assert.That(cosmetics.OwnedLeaderSkinIds, Is.EquivalentTo(expectedOwnedSkinIds), - "OwnedLeaderSkinIds should match the viewer's actual owned skins when freeplay is off"); - - // OwnedLeaderSkinIds must be a strict subset of AllLeaderSkins (not all of them). - Assert.That(cosmetics.OwnedLeaderSkinIds.Count, Is.LessThan(masterSkinCount), - "fresh viewer should own fewer skins than the full catalog"); - - // The four id-lists reflect what the viewer actually owns. - Assert.That(cosmetics.SleeveIds.Count, Is.EqualTo(viewer.Sleeves.Count)); - Assert.That(cosmetics.EmblemIds.Count, Is.EqualTo(viewer.Emblems.Count)); - Assert.That(cosmetics.DegreeIds.Count, Is.EqualTo(viewer.Degrees.Count)); - Assert.That(cosmetics.MyPageBackgroundIds.Count, Is.EqualTo(viewer.MyPageBackgrounds.Count)); - } - - // --- freeplay ON: all catalogs become owned --- - { - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - SetFreeplayEnabled(db, enabled: true); - - var viewer = await db.Viewers - .Include(v => v.LeaderSkins) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .FirstAsync(v => v.Id == viewerId); - - var ent = Build(scope); - var cosmetics = await ent.EffectiveCosmeticsAsync(viewer); - - int masterSkinCount = db.LeaderSkins.Count(); - Assert.That(cosmetics.AllLeaderSkins.Count, Is.EqualTo(masterSkinCount), - "AllLeaderSkins count unchanged when freeplay is on"); - Assert.That(cosmetics.OwnedLeaderSkinIds.Count, Is.EqualTo(masterSkinCount), - "freeplay: every skin id must be in OwnedLeaderSkinIds"); - - // All four id-lists should equal the full catalog counts. - Assert.That(cosmetics.SleeveIds.Count, Is.EqualTo(db.Sleeves.Count()), - "freeplay: SleeveIds should equal full sleeve catalog"); - Assert.That(cosmetics.EmblemIds.Count, Is.EqualTo(db.Emblems.Count()), - "freeplay: EmblemIds should equal full emblem catalog"); - Assert.That(cosmetics.DegreeIds.Count, Is.EqualTo(db.Degrees.Count()), - "freeplay: DegreeIds should equal full degree catalog"); - Assert.That(cosmetics.MyPageBackgroundIds.Count, Is.EqualTo(db.MyPageBackgrounds.Count()), - "freeplay: MyPageBackgroundIds should equal full my-page-background catalog"); - } - } -}