From 8e913578ffd79c1eb8a77a9c5f9e0c02ee9284e1 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 25 May 2026 16:34:24 -0400 Subject: [PATCH] Consolidation --- .../Card/CardInventoryRepository.cs | 3 + .../Repositories/Pack/IPackRepository.cs | 1 - .../Repositories/Pack/PackRepository.cs | 24 --- SVSim.Database/Services/RewardGrantService.cs | 148 +++++++++++--- .../Controllers/LoadController.cs | 2 +- .../Controllers/PackController.cs | 2 +- .../Controllers/PuzzleController.cs | 41 ++-- .../Services/CardAcquisitionService.cs | 174 ++++++---------- .../Services/ICardAcquisitionService.cs | 22 +-- .../Services/StoryService.cs | 27 +-- .../Repositories/PackRepositoryTests.cs | 37 ---- .../Services/CardAcquisitionServiceTests.cs | 46 ++--- .../Services/RewardGrantServiceTests.cs | 186 ++++++++++++++++-- SVSim.UnitTests/Story/StoryServiceTests.cs | 133 ++++++++++++- 14 files changed, 566 insertions(+), 280 deletions(-) diff --git a/SVSim.Database/Repositories/Card/CardInventoryRepository.cs b/SVSim.Database/Repositories/Card/CardInventoryRepository.cs index a944472..6d1efe2 100644 --- a/SVSim.Database/Repositories/Card/CardInventoryRepository.cs +++ b/SVSim.Database/Repositories/Card/CardInventoryRepository.cs @@ -59,6 +59,9 @@ public class CardInventoryRepository : ICardInventoryRepository totalVials += (ulong)owned.Card.CollectionInfo!.DustReward * (ulong)num; postCounts[cardId] = owned.Count; } + // Direct credit (not via RewardGrantService.ApplyAsync) because destruct is a debit-pair + // operation (destroy cards + credit vials) handled atomically here. ApplyAsync is the + // standard path for one-shot reward grants — see RewardGrantService for that pattern. viewer.Currency.RedEther += totalVials; // Deck auto-strip: any deck holding more copies of a destructed card than the viewer now owns diff --git a/SVSim.Database/Repositories/Pack/IPackRepository.cs b/SVSim.Database/Repositories/Pack/IPackRepository.cs index 438338d..0c6d7ec 100644 --- a/SVSim.Database/Repositories/Pack/IPackRepository.cs +++ b/SVSim.Database/Repositories/Pack/IPackRepository.cs @@ -9,5 +9,4 @@ public interface IPackRepository Task> GetOpenCountsForViewer(long viewerId); Task IncrementOpenCount(long viewerId, int parentGachaId, int by); Task MarkDailyFreeUsed(long viewerId, int parentGachaId, DateTime when); - Task GrantCardsToViewer(long viewerId, IEnumerable cardIds); } diff --git a/SVSim.Database/Repositories/Pack/PackRepository.cs b/SVSim.Database/Repositories/Pack/PackRepository.cs index 385633d..51f41e1 100644 --- a/SVSim.Database/Repositories/Pack/PackRepository.cs +++ b/SVSim.Database/Repositories/Pack/PackRepository.cs @@ -63,28 +63,4 @@ public class PackRepository : IPackRepository await _db.SaveChangesAsync(); } - public async Task GrantCardsToViewer(long viewerId, IEnumerable cardIds) - { - var viewer = await _db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .FirstAsync(v => v.Id == viewerId); - - var byId = viewer.Cards.ToDictionary(c => c.Card.Id); - foreach (var grp in cardIds.GroupBy(id => id)) - { - if (byId.TryGetValue(grp.Key, out var existing)) - { - existing.Count += grp.Count(); - } - else - { - // Look up the card by id and attach it so we don't insert a phantom Card row. - var card = await _db.Cards.FirstAsync(c => c.Id == grp.Key); - var owned = new OwnedCardEntry { Card = card, Count = grp.Count(), IsProtected = false }; - viewer.Cards.Add(owned); - byId[grp.Key] = owned; - } - } - await _db.SaveChangesAsync(); - } } diff --git a/SVSim.Database/Services/RewardGrantService.cs b/SVSim.Database/Services/RewardGrantService.cs index ff8527c..3694010 100644 --- a/SVSim.Database/Services/RewardGrantService.cs +++ b/SVSim.Database/Services/RewardGrantService.cs @@ -1,66 +1,79 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using SVSim.Database.Enums; using SVSim.Database.Models; namespace SVSim.Database.Services; /// -/// Wire-shape returned by . Field names match the -/// reward_list entries used by /pack/open and /basic_puzzle/finish. -/// reward_num is a POST-STATE TOTAL for currencies and a count for collection grants — see -/// ... see SVSim.EmulatedEntrypoint.Models.Dtos.RewardListEntry -/// for the on-the-wire DTO and the rationale. +/// 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); /// -/// General reward-grant primitive. Switches on , mutates the -/// appropriate viewer collection or field, and returns the -/// wire-shape entry the caller should embed in its response's reward_list. +/// Single canonical grant primitive. Switch on , mutate the +/// appropriate viewer collection / field, and return the +/// wire-shape entries the caller should embed in its response's reward_list. /// -/// Caller is responsible for SaveChangesAsync — this service only mutates the in-memory -/// graph so a controller can stack several grants in a single transaction. +/// 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; - public RewardGrantService(SVSimDbContext db) => _db = db; + private readonly ILogger _log; - public GrantedReward Apply(Viewer viewer, UserGoodsType type, long detailId, int num) + 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 new GrantedReward((int)type, detailId, 1); + return Single(type, detailId, 1); case UserGoodsType.Emblem: AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems); - return new GrantedReward((int)type, detailId, 1); + return Single(type, detailId, 1); case UserGoodsType.Skin: // LeaderSkin in our schema AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins); - return new GrantedReward((int)type, detailId, 1); + return Single(type, detailId, 1); case UserGoodsType.Degree: AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees); - return new GrantedReward((int)type, detailId, 1); + return Single(type, detailId, 1); case UserGoodsType.MyPageBG: AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds); - return new GrantedReward((int)type, detailId, 1); + return Single(type, detailId, 1); case UserGoodsType.Rupy: viewer.Currency.Rupees += (ulong)num; - return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.Rupees)); + return Single(type, detailId, checked((int)viewer.Currency.Rupees)); case UserGoodsType.Crystal: viewer.Currency.Crystals += (ulong)num; - return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.Crystals)); + return Single(type, detailId, checked((int)viewer.Currency.Crystals)); case UserGoodsType.RedEther: viewer.Currency.RedEther += (ulong)num; - return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.RedEther)); + return Single(type, detailId, checked((int)viewer.Currency.RedEther)); case UserGoodsType.Item: { @@ -70,37 +83,110 @@ public sealed class RewardGrantService 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 new GrantedReward((int)type, detailId, num); + return Single(type, detailId, num); } owned.Count += num; - return new GrantedReward((int)type, detailId, owned.Count); + return Single(type, detailId, owned.Count); } case UserGoodsType.Card: + return await ApplyCardAsync(viewer, detailId, num, ct); + case UserGoodsType.SpotCard: case UserGoodsType.SpotCardOnlyLatestCardPack: + // TODO: spot cards are currently global in our seed data; the existence of these + // reward types suggests there's a mix of global + per-player spot cards. Revisit + // when per-player spot-card infrastructure lands. throw new NotSupportedException( - $"{type} rewards are out of Phase 1 scope — extend RewardGrantService when /pack/open or similar needs them."); + $"{type} rewards are not yet supported — see SpotCard TODO in RewardGrantService."); default: throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService"); } } - private static void AddCosmeticIfMissing(List collection, long detailId, DbSet catalog) where T : class + private async Task> ApplyCardAsync( + Viewer viewer, long cardId, int num, CancellationToken ct) { - // Cosmetic ownership is binary — if the viewer already owns it, the grant is a no-op - // (matches client UpdateHaveUserGoodsNum behaviour which just calls .Acquired() each time). - bool alreadyOwned = collection.Any(e => GetId(e) == detailId); - if (alreadyOwned) return; + // 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; - // Wire reward_detail_id is long, but every cosmetic catalog in this codebase uses - // BaseEntity; downcast for Find. The checked() throws OverflowException if a - // future capture ships a real long id rather than silently truncating it. 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; } /// diff --git a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs index 4b5148c..649adfc 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs @@ -79,7 +79,7 @@ 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.GrantAsync(viewer.Id, newCardIds: null); + await _acquisition.BackfillCosmeticsAsync(viewer.Id); viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid); if (viewer is null) { diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index da63ba9..26496ff 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -196,7 +196,7 @@ public class PackController : SVSimController // Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open). int drawCount = child.IsDailySingle ? 1 : packNumber; var draw = _opener.Draw(pack, _pools, drawCount, request.ExcludeCardIds, _rng); - var grant = await _acquisition.GrantAsync(viewerId, draw.Cards.Select(c => c.CardId)); + var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId)); // 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 diff --git a/SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs b/SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs index d93d231..aad054c 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs @@ -2,6 +2,7 @@ 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.Repositories.Globals; using SVSim.Database.Repositories.Viewer; @@ -26,17 +27,20 @@ public class PuzzleController : SVSimController private readonly IPuzzleClearRepository _clears; private readonly PuzzleMissionEvaluator _evaluator; private readonly RewardGrantService _rewards; + private readonly ILogger _logger; public PuzzleController( IPuzzleCatalogRepository catalog, IPuzzleClearRepository clears, PuzzleMissionEvaluator evaluator, - RewardGrantService rewards) + RewardGrantService rewards, + ILogger logger) { _catalog = catalog; _clears = clears; _evaluator = evaluator; _rewards = rewards; + _logger = logger; } /// /basic_puzzle/info — full catalog of groups + per-viewer clear flags. @@ -175,6 +179,7 @@ public class PuzzleController : SVSimController // 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) @@ -186,11 +191,22 @@ public class PuzzleController : SVSimController foreach (var status in fresh) { - var granted = _rewards.Apply( - viewer, - (SVSim.Database.Enums.UserGoodsType)status.Mission.RewardType, - status.Mission.RewardDetailId, - status.Mission.RewardNumber); + IReadOnlyList granted; + try + { + granted = await _rewards.ApplyAsync( + viewer, + (SVSim.Database.Enums.UserGoodsType)status.Mission.RewardType, + status.Mission.RewardDetailId, + status.Mission.RewardNumber); + } + catch (NotSupportedException ex) + { + _logger.LogWarning(ex, + "PuzzleController: skipping unsupported reward_type={Type} detail={Detail} num={Num} for mission={MissionId}", + status.Mission.RewardType, status.Mission.RewardDetailId, status.Mission.RewardNumber, status.Mission.Id); + continue; + } response.AchievedInfo.AchievedMissionList.Add(new PuzzleAchievedMissionEntry { @@ -202,12 +218,15 @@ public class PuzzleController : SVSimController MissionRewardDetailId = status.Mission.RewardDetailId, MissionRewardNumber = status.Mission.RewardNumber, }); - response.RewardList.Add(new TreasureRewardResponse + foreach (var g in granted) { - RewardType = granted.RewardType, - RewardId = granted.RewardId, - RewardNum = granted.RewardNum, - }); + response.RewardList.Add(new TreasureRewardResponse + { + RewardType = g.RewardType, + RewardId = g.RewardId, + RewardNum = g.RewardNum, + }); + } } await ctx.SaveChangesAsync(); diff --git a/SVSim.EmulatedEntrypoint/Services/CardAcquisitionService.cs b/SVSim.EmulatedEntrypoint/Services/CardAcquisitionService.cs index d036115..10ce933 100644 --- a/SVSim.EmulatedEntrypoint/Services/CardAcquisitionService.cs +++ b/SVSim.EmulatedEntrypoint/Services/CardAcquisitionService.cs @@ -1,9 +1,8 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; -using SVSim.Database.Repositories.Pack; +using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos; namespace SVSim.EmulatedEntrypoint.Services; @@ -11,78 +10,29 @@ namespace SVSim.EmulatedEntrypoint.Services; public class CardAcquisitionService : ICardAcquisitionService { private readonly SVSimDbContext _db; - private readonly IPackRepository _packs; - private readonly ILogger _log; + private readonly RewardGrantService _rewards; - public CardAcquisitionService(SVSimDbContext db, IPackRepository packs, ILogger log) + public CardAcquisitionService(SVSimDbContext db, RewardGrantService rewards) { - _db = db; _packs = packs; _log = log; + _db = db; + _rewards = rewards; } - public async Task GrantAsync(long viewerId, IEnumerable? newCardIds = null) + public async Task GrantManyAsync(long viewerId, IEnumerable newCardIds) { + var viewer = await LoadViewerWithGraph(viewerId); var rewardList = new List(); - var viewer = await _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); - - List lookupSourceCardIds; - if (newCardIds is not null) + // Bucket the input by id so multi-copy grants increment count once but cascade fires once. + foreach (var grp in newCardIds.GroupBy(id => id)) { - var newIds = newCardIds.ToList(); - await _packs.GrantCardsToViewer(viewerId, newIds); - // GrantCardsToViewer mutates OwnedCardEntry rows on the same scoped SVSimDbContext - // AND commits them via its own SaveChangesAsync. The change tracker already exposes - // the post-state via viewer.Cards. Note this means GrantAsync performs two saves total - // (one inside the repo call, one at the end for cosmetic grants) — accepted because - // any inconsistency in the failure window is self-healing via the next /load/index - // backfill. - - lookupSourceCardIds = newIds.Distinct().ToList(); - - foreach (var distinctId in lookupSourceCardIds) - { - var owned = viewer.Cards.First(c => c.Card.Id == distinctId); - rewardList.Add(new RewardListEntry { RewardType = 5, RewardId = distinctId, RewardNum = owned.Count }); - } - } - else - { - // Backfill mode: scan all owned cards for missing cosmetics. No card-count mutation. - lookupSourceCardIds = viewer.Cards.Select(c => c.Card.Id).Distinct().ToList(); - } - - // Foil resolution: cosmetic mappings are recorded on the non-foil card row. - // Foil twins (card_id + 1) inherit via the universal +1 convention. - var lookupCardIds = lookupSourceCardIds - .Select(id => - { - var card = viewer.Cards.FirstOrDefault(c => c.Card.Id == id)?.Card; - return (card?.IsFoil == true) ? id - 1 : id; - }) - .Distinct() - .ToList(); - - var rewards = await _db.CardCosmeticRewards - .Where(r => lookupCardIds.Contains(r.CardId)) - .ToListAsync(); - - foreach (var reward in rewards) - { - if (await TryGrant(viewer, reward)) + 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 = (int)reward.Type, - RewardId = reward.CosmeticId, - RewardNum = reward.Quantity, + RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum, }); } } @@ -91,59 +41,61 @@ public class CardAcquisitionService : ICardAcquisitionService return new CardGrantResult(rewardList); } - /// - /// Returns true if the cosmetic was newly granted (caller should emit a reward_list entry). - /// Returns false if the viewer already owned it or the master row is missing (defensive log). - /// - private async Task TryGrant(Viewer viewer, CardCosmeticReward reward) + public async Task BackfillCosmeticsAsync(long viewerId) { - var id = (int)reward.CosmeticId; // master tables use int Id + var viewer = await LoadViewerWithGraph(viewerId); + var rewardList = new List(); - switch (reward.Type) + // 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) { - case CosmeticType.Skin: + // 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) { - if (viewer.LeaderSkins.Any(s => s.Id == id)) return false; - var master = await _db.LeaderSkins.FindAsync(id); - if (master is null) { _log.LogWarning("Skin master row missing for cosmetic_id={Id}", id); return false; } - viewer.LeaderSkins.Add(master); - return true; + rewardList.Add(new RewardListEntry + { + RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum, + }); } - case CosmeticType.Sleeve: - { - if (viewer.Sleeves.Any(s => s.Id == id)) return false; - var master = await _db.Sleeves.FindAsync(id); - if (master is null) { _log.LogWarning("Sleeve master row missing for cosmetic_id={Id}", id); return false; } - viewer.Sleeves.Add(master); - return true; - } - case CosmeticType.Emblem: - { - if (viewer.Emblems.Any(e => e.Id == id)) return false; - var master = await _db.Emblems.FindAsync(id); - if (master is null) { _log.LogWarning("Emblem master row missing for cosmetic_id={Id}", id); return false; } - viewer.Emblems.Add(master); - return true; - } - case CosmeticType.Degree: - { - if (viewer.Degrees.Any(d => d.Id == id)) return false; - var master = await _db.Degrees.FindAsync(id); - if (master is null) { _log.LogWarning("Degree master row missing for cosmetic_id={Id}", id); return false; } - viewer.Degrees.Add(master); - return true; - } - case CosmeticType.MyPageBG: - { - if (viewer.MyPageBackgrounds.Any(b => b.Id == id)) return false; - var master = await _db.MyPageBackgrounds.FindAsync(id); - if (master is null) { _log.LogWarning("MyPageBG master row missing for cosmetic_id={Id}", id); return false; } - viewer.MyPageBackgrounds.Add(master); - return true; - } - default: - _log.LogWarning("Unknown CosmeticType {Type} for card {CardId}", reward.Type, reward.CardId); - return false; } + + 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/ICardAcquisitionService.cs b/SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs index a356de1..6d02bc0 100644 --- a/SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs +++ b/SVSim.EmulatedEntrypoint/Services/ICardAcquisitionService.cs @@ -3,16 +3,16 @@ namespace SVSim.EmulatedEntrypoint.Services; public interface ICardAcquisitionService { /// - /// Grant cards + associated cosmetics in one transaction. - /// - /// • non-null → increments OwnedCardEntry for each via - /// the existing IPackRepository.GrantCardsToViewer primitive, then grants any - /// cosmetics associated with those cards that the viewer doesn't yet own. - /// • null → backfill mode: skips card mutation, - /// scans viewer.Cards, grants missing cosmetics. - /// - /// Returns wire-shape RewardList in both modes. Backfill callers typically discard. - /// All ownership writes happen in a single SaveChangesAsync. + /// 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 GrantAsync(long viewerId, IEnumerable? newCardIds = null); + 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/StoryService.cs b/SVSim.EmulatedEntrypoint/Services/StoryService.cs index bd9f4e2..5a345ba 100644 --- a/SVSim.EmulatedEntrypoint/Services/StoryService.cs +++ b/SVSim.EmulatedEntrypoint/Services/StoryService.cs @@ -377,6 +377,7 @@ public class StoryService : IStoryService // Load viewer with all collections RewardGrantService might mutate. Split-query // to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query"). 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) @@ -388,10 +389,11 @@ public class StoryService : IStoryService foreach (var r in chapter.Rewards) { - GrantedReward granted; + IReadOnlyList granted; try { - granted = _rewards.Apply(viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); + granted = await _rewards.ApplyAsync( + viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); } catch (NotSupportedException ex) { @@ -407,16 +409,19 @@ public class StoryService : IStoryService // balances (e.g. UserRupyCount = num). // - story_reward_list: deltas. Client (ResultAnimationAgent // .HandleStoryAndMissionRewards) feeds each entry to - // AddReward(item) which draws a "+N received" line in - // the rewards popup. - // Same reward_id, different reward_num. For cosmetics (binary owned/not-owned) - // both happen to be 1, so the bug only surfaces on currency rewards. - resp.RewardList.Add(new RewardGrant + // 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) { - RewardType = granted.RewardType.ToString(), - RewardId = granted.RewardId.ToString(), - RewardNum = granted.RewardNum.ToString(), - }); + resp.RewardList.Add(new RewardGrant + { + RewardType = g.RewardType.ToString(), + RewardId = g.RewardId.ToString(), + RewardNum = g.RewardNum.ToString(), + }); + } resp.StoryRewardList.Add(new RewardGrant { RewardType = ((int)r.RewardType).ToString(), diff --git a/SVSim.UnitTests/Repositories/PackRepositoryTests.cs b/SVSim.UnitTests/Repositories/PackRepositoryTests.cs index c3394b0..83d823b 100644 --- a/SVSim.UnitTests/Repositories/PackRepositoryTests.cs +++ b/SVSim.UnitTests/Repositories/PackRepositoryTests.cs @@ -79,41 +79,4 @@ public class PackRepositoryTests } } - [Test] - public async Task GrantCardsToViewer_inserts_new_and_increments_existing() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - - // Cards are not seeded by BaseDataSeeder (they come from CardImport). Insert one directly. - const long seedCardId = 100000001L; - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.Cards.Add(new ShadowverseCardEntry { Id = seedCardId, Name = "Test Card", Rarity = Rarity.Bronze }); - await db.SaveChangesAsync(); - } - - long sampleCardId; - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - sampleCardId = await db.Cards.OrderBy(c => c.Id).Select(c => c.Id).FirstAsync(); - } - - using (var scope = factory.Services.CreateScope()) - { - var repo = scope.ServiceProvider.GetRequiredService(); - await repo.GrantCardsToViewer(viewerId, new[] { sampleCardId, sampleCardId }); - } - - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - var v = await db.Viewers.Include(x => x.Cards).ThenInclude(c => c.Card) - .FirstAsync(x => x.Id == viewerId); - var owned = v.Cards.Single(c => c.Card.Id == sampleCardId); - Assert.That(owned.Count, Is.EqualTo(2)); - } - } } diff --git a/SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs b/SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs index 1847dc9..9d01bb0 100644 --- a/SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs +++ b/SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs @@ -35,8 +35,8 @@ public class CardAcquisitionServiceTests 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. GrantCardsToViewer does a FirstAsync(c => c.Id == grpKey) lookup; without - // these the production code throws "Sequence contains no elements". + // 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) @@ -71,7 +71,7 @@ public class CardAcquisitionServiceTests } [Test] - public async Task GrantAsync_NewBronzeCard_GrantsCardOnly() + 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. @@ -79,7 +79,7 @@ public class CardAcquisitionServiceTests var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 101111010L }); var service = GetService(factory); - var result = await service.GrantAsync(viewerId, new[] { 101111010L }); + 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 @@ -88,7 +88,7 @@ public class CardAcquisitionServiceTests } [Test] - public async Task GrantAsync_LeaderCard_GrantsCardAndSkin() + 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. @@ -116,7 +116,7 @@ public class CardAcquisitionServiceTests } var service = GetService(factory); - var result = await service.GrantAsync(viewerId, new[] { 704741010L }); + 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"); @@ -134,7 +134,7 @@ public class CardAcquisitionServiceTests } [Test] - public async Task GrantAsync_AlreadyOwnedSkin_OmitsFromRewardList() + public async Task GrantManyAsync_AlreadyOwnedSkin_OmitsFromRewardList() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); @@ -153,7 +153,7 @@ public class CardAcquisitionServiceTests } var service = GetService(factory); - var result = await service.GrantAsync(viewerId, new[] { 704741010L }); + 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"); @@ -162,7 +162,7 @@ public class CardAcquisitionServiceTests } [Test] - public async Task GrantAsync_FoilLeaderCard_ResolvesToNonFoilCosmetics() + public async Task GrantManyAsync_FoilLeaderCard_ResolvesToNonFoilCosmetics() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741011L }); @@ -181,7 +181,7 @@ public class CardAcquisitionServiceTests } var service = GetService(factory); - var result = await service.GrantAsync(viewerId, new[] { 704741011L }); + 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"); @@ -195,7 +195,7 @@ public class CardAcquisitionServiceTests } [Test] - public async Task GrantAsync_MultipleCopiesOfSameLeader_GrantsCosmeticOnce() + public async Task GrantManyAsync_MultipleCopiesOfSameLeader_GrantsCosmeticOnce() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); @@ -210,7 +210,7 @@ public class CardAcquisitionServiceTests } var service = GetService(factory); - var result = await service.GrantAsync(viewerId, new[] { 704741010L, 704741010L, 704741010L }); + 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"); @@ -219,7 +219,7 @@ public class CardAcquisitionServiceTests } [Test] - public async Task GrantAsync_RecentLeaderCard_GrantsAllFiveCosmeticTypes() + public async Task GrantManyAsync_RecentLeaderCard_GrantsAllFiveCosmeticTypes() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 721141010L }); @@ -250,7 +250,7 @@ public class CardAcquisitionServiceTests } var service = GetService(factory); - var result = await service.GrantAsync(viewerId, new[] { 721141010L }); + var result = await service.GrantManyAsync(viewerId, new[] { 721141010L }); Assert.Multiple(() => { @@ -263,7 +263,7 @@ public class CardAcquisitionServiceTests } [Test] - public async Task GrantAsync_BackfillMode_DoesNotIncrementCardCount() + public async Task BackfillCosmeticsAsync_DoesNotIncrementCardCount() { using var factory = new SVSimTestFactory(); // Pre-seed viewer with card 704741010 count=5, no skin @@ -279,7 +279,7 @@ public class CardAcquisitionServiceTests } var service = GetService(factory); - var result = await service.GrantAsync(viewerId, newCardIds: null); + var result = await service.BackfillCosmeticsAsync(viewerId); using var scope2 = factory.Services.CreateScope(); var db2 = scope2.ServiceProvider.GetRequiredService(); @@ -295,7 +295,7 @@ public class CardAcquisitionServiceTests } [Test] - public async Task GrantAsync_BackfillCalledTwice_SecondCallIsNoOp() + public async Task BackfillCosmeticsAsync_CalledTwice_SecondCallIsNoOp() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { [704741010L] = 1 }); @@ -310,15 +310,15 @@ public class CardAcquisitionServiceTests } var service = GetService(factory); - var first = await service.GrantAsync(viewerId, newCardIds: null); - var second = await service.GrantAsync(viewerId, newCardIds: null); + 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 GrantAsync_LeaderCardWithMissingMapping_GrantsCardSilently() + public async Task GrantManyAsync_LeaderCardWithMissingMapping_GrantsCardSilently() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 701141010L }); @@ -326,7 +326,7 @@ public class CardAcquisitionServiceTests // NO CardCosmeticReward rows inserted for this card — simulates the 83 missing-mapping cases. var service = GetService(factory); - var result = await service.GrantAsync(viewerId, new[] { 701141010L }); + 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); @@ -334,7 +334,7 @@ public class CardAcquisitionServiceTests } [Test] - public async Task GrantAsync_OrphanCosmeticReward_LogsWarningAndSkips() + public async Task GrantManyAsync_OrphanCosmeticReward_LogsWarningAndSkips() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); @@ -354,7 +354,7 @@ public class CardAcquisitionServiceTests } var service = GetService(factory); - var result = await service.GrantAsync(viewerId, new[] { 704741010L }); + 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, diff --git a/SVSim.UnitTests/Services/RewardGrantServiceTests.cs b/SVSim.UnitTests/Services/RewardGrantServiceTests.cs index 601136f..21b4d35 100644 --- a/SVSim.UnitTests/Services/RewardGrantServiceTests.cs +++ b/SVSim.UnitTests/Services/RewardGrantServiceTests.cs @@ -18,23 +18,22 @@ public class RewardGrantServiceTests using var scope = factory.Services.CreateScope(); var ctx = scope.ServiceProvider.GetRequiredService(); - // Pick an Id above the seeded sleeves.csv range so this test doesn't collide with the - // reference-CSV importer SVSimTestFactory runs at host construction. const int testSleeveId = 2_000_000_000; - var sleeve = new SleeveEntry { Id = testSleeveId }; // SleeveEntry has no Name field; Id only + 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 entry = svc.Apply(viewer, UserGoodsType.Sleeve, detailId: testSleeveId, num: 1); + 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(entry.RewardType, Is.EqualTo((int)UserGoodsType.Sleeve)); - Assert.That(entry.RewardId, Is.EqualTo((long)testSleeveId)); - Assert.That(entry.RewardNum, Is.EqualTo(1)); + 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] @@ -50,12 +49,12 @@ public class RewardGrantServiceTests var svc = scope.ServiceProvider.GetRequiredService(); - // Reward grants 50; final balance becomes 150 and reward_num on the wire is the new total. - var entry = svc.Apply(viewer, UserGoodsType.Rupy, detailId: 0, num: 50); + 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(entry.RewardNum, Is.EqualTo(150)); + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].RewardNum, Is.EqualTo(150)); } [Test] @@ -66,8 +65,6 @@ public class RewardGrantServiceTests using var scope = factory.Services.CreateScope(); var ctx = scope.ServiceProvider.GetRequiredService(); - // Pick an Id above the seeded leaderskins.csv range so this test doesn't collide with - // the reference-CSV importer SVSimTestFactory runs at host construction. const int testSkinId = 9_999_999; ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "Round1Reward" }); await ctx.SaveChangesAsync(); @@ -75,25 +72,180 @@ public class RewardGrantServiceTests var viewer = await ctx.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); var svc = scope.ServiceProvider.GetRequiredService(); - svc.Apply(viewer, UserGoodsType.Skin, testSkinId, 1); - svc.Apply(viewer, UserGoodsType.Skin, testSkinId, 1); // second grant is a no-op on collection size + 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_reward_throws_NotSupported() + 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.Throws(() => - svc.Apply(viewer, UserGoodsType.Card, 10001001L, 1)); + Assert.ThrowsAsync(async () => + await svc.ApplyAsync(viewer, UserGoodsType.SpotCard, 1L, 1)); + Assert.ThrowsAsync(async () => + await svc.ApplyAsync(viewer, UserGoodsType.SpotCardOnlyLatestCardPack, 1L, 1)); } } diff --git a/SVSim.UnitTests/Story/StoryServiceTests.cs b/SVSim.UnitTests/Story/StoryServiceTests.cs index 4947a8b..054f858 100644 --- a/SVSim.UnitTests/Story/StoryServiceTests.cs +++ b/SVSim.UnitTests/Story/StoryServiceTests.cs @@ -28,7 +28,7 @@ public class StoryServiceTests _viewer = new Mock(); // Non-reward tests never exercise the DB/reward path; use a stub InMemory context. var db = StoryServiceTestHelpers.NewInMemoryDb(nameof(SetUp)); - var rewards = new RewardGrantService(db); + var rewards = new RewardGrantService(db, NullLogger.Instance); _service = new StoryService( _master.Object, _viewer.Object, rewards: rewards, @@ -394,6 +394,137 @@ public class StoryServiceTests Assert.That(freshViewer.Currency.RedEther, Is.EqualTo(100UL)); } } + + [Test] + public async Task FinishAsync_play_shape_first_clear_grants_card_and_cascades_cosmetic() + { + using var factory = new SVSimTestFactory(); + + const long testCardId = 998_001_010L; + const int testSkinId = 998_001_011; + const int testStoryId = 998_001_500; + + using (var seedScope = factory.Services.CreateScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + db.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "StoryCascadeCard", Rarity = SVSim.Database.Enums.Rarity.Gold }); + db.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "StoryCascadeSkin" }); + db.CardCosmeticRewards.Add(new CardCosmeticReward + { + CardId = testCardId, + Type = SVSim.Database.Enums.CosmeticType.Skin, + CosmeticId = testSkinId, + Quantity = 1, + }); + await db.SaveChangesAsync(); + } + + var svc = NewServiceWithSeededViewer(factory, out var scope, out var viewerId); + using (scope) + { + var chapter = Ch(testStoryId, 1, 2, "1", "2"); + chapter.Rewards.Add(new StoryChapterReward + { + RewardType = 5, // UserGoodsType.Card + RewardDetailId = testCardId, + RewardNumber = 1, + }); + _master.Setup(m => m.GetChapterByIdAsync(testStoryId)).ReturnsAsync(chapter); + _viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny>())) + .ReturnsAsync(new Dictionary()); + + var req = new FinishRequest { StoryId = testStoryId, IsFinish = 1, ClassId = 2 }; + var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); + + // reward_list (post-state) gets BOTH the Card entry AND the cascaded Skin entry. + Assert.That(resp.RewardList.Any(r => r.RewardType == "5" && r.RewardId == testCardId.ToString()), Is.True, + "card reward should appear in reward_list"); + Assert.That(resp.RewardList.Any(r => r.RewardType == "10" && r.RewardId == testSkinId.ToString()), Is.True, + "cascade skin should appear in reward_list"); + + // story_reward_list (deltas) only carries the top-level chapter reward. + Assert.That(resp.StoryRewardList.Count(r => r.RewardType == "5"), Is.EqualTo(1)); + Assert.That(resp.StoryRewardList.Any(r => r.RewardType == "10"), Is.False, + "cascade cosmetics should not appear in story_reward_list deltas"); + } + + // Verify viewer ownership was persisted. + using var verifyScope = factory.Services.CreateScope(); + var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + var viewer = await verifyDb.Viewers + .Include(v => v.Cards).ThenInclude(c => c.Card) + .Include(v => v.LeaderSkins) + .AsSplitQuery() + .FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Cards.Any(c => c.Card.Id == testCardId), Is.True); + Assert.That(viewer.LeaderSkins.Any(s => s.Id == testSkinId), Is.True); + } + + [Test] + public async Task FinishAsync_card_grant_for_already_owned_card_increments_not_duplicates() + { + using var factory = new SVSimTestFactory(); + + const long testCardId = 998_002_010L; + const int testStoryId = 998_002_500; + + // Pre-seed the card in the catalog AND give the viewer 2 copies of it before the story finish. + using (var seedScope = factory.Services.CreateScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + db.Cards.Add(new ShadowverseCardEntry + { + Id = testCardId, + Name = "ExistingOwnedCard", + Rarity = SVSim.Database.Enums.Rarity.Silver, + }); + await db.SaveChangesAsync(); + } + + var svc = NewServiceWithSeededViewer(factory, out var scope, out var viewerId); + using (scope) + { + // Seed 2 owned copies of the card under the same viewer used by NewServiceWithSeededViewer. + var scopeDb = scope.ServiceProvider.GetRequiredService(); + var seedViewer = await scopeDb.Viewers + .Include(v => v.Cards).ThenInclude(c => c.Card) + .FirstAsync(v => v.Id == viewerId); + var card = await scopeDb.Cards.FirstAsync(c => c.Id == testCardId); + seedViewer.Cards.Add(new OwnedCardEntry { Card = card, Count = 2, IsProtected = false }); + await scopeDb.SaveChangesAsync(); + + // Configure a chapter that grants 1 copy of the same card. + var chapter = Ch(testStoryId, 1, 2, "1", "2"); + chapter.Rewards.Add(new StoryChapterReward + { + RewardType = 5, // UserGoodsType.Card + RewardDetailId = testCardId, + RewardNumber = 1, + }); + _master.Setup(m => m.GetChapterByIdAsync(testStoryId)).ReturnsAsync(chapter); + _viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny>())) + .ReturnsAsync(new Dictionary()); + + var req = new FinishRequest { StoryId = testStoryId, IsFinish = 1, ClassId = 2 }; + var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); + + // Post-state count on the wire should be 3 (2 owned + 1 granted). + var cardEntry = resp.RewardList.SingleOrDefault(r => r.RewardType == "5" && r.RewardId == testCardId.ToString()); + Assert.That(cardEntry, Is.Not.Null, "card reward should appear in reward_list"); + Assert.That(cardEntry!.RewardNum, Is.EqualTo("3"), "post-state count should be incremented, not reset to 1"); + } + + // Verify the viewer has exactly ONE OwnedCardEntry row for this card, with Count=3. + using var verifyScope = factory.Services.CreateScope(); + var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + var viewer = await verifyDb.Viewers + .Include(v => v.Cards).ThenInclude(c => c.Card) + .AsSplitQuery() + .FirstAsync(v => v.Id == viewerId); + var ownedRows = viewer.Cards.Where(c => c.Card.Id == testCardId).ToList(); + Assert.That(ownedRows, Has.Count.EqualTo(1), "exactly one OwnedCardEntry row should exist (no duplicates)"); + Assert.That(ownedRows[0].Count, Is.EqualTo(3)); + } } internal static class StoryServiceTestHelpers