diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index 4f1bbe7..b357ea7 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Logging; using SVSim.Database.Enums; @@ -119,6 +120,14 @@ internal sealed class InventoryTransaction : IInventoryTransaction return Single(type, detailId, post); } + case UserGoodsType.Card: + return await ApplyCardAsync(detailId, num, ct); + + case UserGoodsType.SpotCard: + case UserGoodsType.SpotCardOnlyLatestCardPack: + throw new NotSupportedException( + $"{type} rewards are not yet supported — emitters use Card=5 instead."); + default: throw new NotImplementedException( $"UserGoodsType {type} grant lands in a subsequent task"); @@ -144,6 +153,70 @@ internal sealed class InventoryTransaction : IInventoryTransaction throw new InvalidOperationException("Inventory transaction already committed"); } + private async Task> ApplyCardAsync(long cardId, int num, CancellationToken ct) + { + var owned = Viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId); + int postCount; + if (owned is null) + { + var card = await _db.Cards.FirstOrDefaultAsync(c => c.Id == cardId, ct) + ?? throw new InventoryCatalogException($"Card {cardId} not in catalog"); + owned = new OwnedCardEntry { Card = card, Count = num, IsProtected = false }; + Viewer.Cards.Add(owned); + postCount = num; + } + else + { + owned.Count += num; + postCount = owned.Count; + } + + var results = new List + { + new((int)UserGoodsType.Card, cardId, postCount), + }; + _ops.Add(new GrantOp(UserGoodsType.Card, cardId, num, postCount, false)); + + long lookupId = owned.Card.IsFoil ? cardId - 1 : cardId; + var cascade = await _db.CardCosmeticRewards + .Where(r => r.CardId == lookupId) + .ToListAsync(ct); + + foreach (var reward in cascade) + { + if (TryAddCascadeCosmetic(reward, lookupId)) + { + results.Add(new GrantedReward((int)reward.Type, reward.CosmeticId, 1)); + _ops.Add(new GrantOp((UserGoodsType)(int)reward.Type, reward.CosmeticId, 1, 1, true)); + } + } + + return results; + } + + private bool TryAddCascadeCosmetic(CardCosmeticReward reward, long forCardId) + { + try + { + return reward.Type switch + { + CosmeticType.Sleeve => AddCosmeticIfMissing(Viewer.Sleeves, reward.CosmeticId, _db.Sleeves), + CosmeticType.Emblem => AddCosmeticIfMissing(Viewer.Emblems, reward.CosmeticId, _db.Emblems), + CosmeticType.Skin => AddCosmeticIfMissing(Viewer.LeaderSkins, reward.CosmeticId, _db.LeaderSkins), + CosmeticType.Degree => AddCosmeticIfMissing(Viewer.Degrees, reward.CosmeticId, _db.Degrees), + CosmeticType.MyPageBG => AddCosmeticIfMissing(Viewer.MyPageBackgrounds, reward.CosmeticId, _db.MyPageBackgrounds), + _ => false, + }; + } + catch (InventoryCatalogException ex) + { + _log.LogWarning(ex, + "Card cascade: cosmetic {Type} {Id} for card {CardId} skipped (master row missing)", + reward.Type, reward.CosmeticId, forCardId); + return false; + } + } + private static bool AddCosmeticIfMissing(List collection, long detailId, Microsoft.EntityFrameworkCore.DbSet catalog) where T : class { if (collection.Any(e => GetId(e) == detailId)) return false; diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index f22b797..ada7002 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -427,6 +427,23 @@ internal sealed class SVSimTestFactory : WebApplicationFactory await db.SaveChangesAsync(); } + /// + /// Seeds a bare (no viewer ownership) and returns its id. + /// Used by InventoryGrantCardTests to get a valid card id without also seeding owned state. + /// Ids start at 800_000_000 (non-foil) or 800_000_001 (foil) and increment by 2 per call to + /// keep foil twins aligned. + /// + public async Task SeedCardAsync(bool isFoil = false) + { + using var scope = Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + long id = isFoil ? 800_000_001L : 800_000_000L; + while (await ctx.Cards.AnyAsync(c => c.Id == id)) id += 2; + ctx.Cards.Add(new ShadowverseCardEntry { Id = id, IsFoil = isFoil, Name = $"SeedCard{id}" }); + await ctx.SaveChangesAsync(); + return id; + } + /// /// Sets the viewer's RedEther balance to . Call this AFTER /// , which resets RedEther to 0. Create tests use this diff --git a/SVSim.UnitTests/Services/Inventory/InventoryGrantCardTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryGrantCardTests.cs new file mode 100644 index 0000000..4710c30 --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventoryGrantCardTests.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Inventory; + +public class InventoryGrantCardTests +{ + [Test] + public async Task Card_first_grant_creates_owned_with_post_state_count() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + long cardId = await factory.SeedCardAsync(); // helper added below if missing + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + + var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 2); + + Assert.That(granted, Has.Count.EqualTo(1)); + Assert.That(granted[0].RewardType, Is.EqualTo((int)UserGoodsType.Card)); + Assert.That(granted[0].RewardId, Is.EqualTo(cardId)); + Assert.That(granted[0].RewardNum, Is.EqualTo(2)); + } + + [Test] + public async Task Card_cascade_grants_associated_cosmetic_and_appends_entry() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + long cardId = await factory.SeedCardAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + const int sleeveId = 2_000_010_000; + ctx.Sleeves.Add(new SleeveEntry { Id = sleeveId }); + ctx.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = cardId, CosmeticId = sleeveId, Type = CosmeticType.Sleeve }); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 1); + + Assert.That(granted, Has.Count.EqualTo(2)); + Assert.That(granted[1].RewardType, Is.EqualTo((int)UserGoodsType.Sleeve)); + Assert.That(granted[1].RewardId, Is.EqualTo(sleeveId)); + } + + [Test] + public async Task Card_cascade_skips_already_owned_cosmetic() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + long cardId = await factory.SeedCardAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + const int sleeveId = 2_000_010_001; + var sleeve = new SleeveEntry { Id = sleeveId }; + ctx.Sleeves.Add(sleeve); + ctx.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = cardId, CosmeticId = sleeveId, Type = CosmeticType.Sleeve }); + var v = await ctx.Viewers.Include(x => x.Sleeves).FirstAsync(x => x.Id == viewerId); + v.Sleeves.Add(sleeve); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 1); + + Assert.That(granted, Has.Count.EqualTo(1), "owned cosmetic skipped from cascade"); + } +}