feat(inventory): GrantAsync handles Card + cosmetic cascade

Card grants produce a post-state-total entry and run the CardCosmeticReward
cascade (foil twin → id-1 lookup). Cascade additions are skipped when the
viewer already owns the cosmetic; missing-master-row failures are logged
and dropped without failing the parent grant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 15:54:36 -04:00
parent 1f3f81d878
commit a821b7f6b4
3 changed files with 165 additions and 0 deletions

View File

@@ -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<IReadOnlyList<GrantedReward>> 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<GrantedReward>
{
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<T>(List<T> collection, long detailId, Microsoft.EntityFrameworkCore.DbSet<T> catalog) where T : class
{
if (collection.Any(e => GetId(e) == detailId)) return false;

View File

@@ -427,6 +427,23 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
await db.SaveChangesAsync();
}
/// <summary>
/// Seeds a bare <see cref="ShadowverseCardEntry"/> (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.
/// </summary>
public async Task<long> SeedCardAsync(bool isFoil = false)
{
using var scope = Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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;
}
/// <summary>
/// Sets the viewer's RedEther balance to <paramref name="amount"/>. Call this AFTER
/// <see cref="SeedOwnedCardAsync"/>, which resets RedEther to 0. Create tests use this

View File

@@ -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<IInventoryService>();
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<SVSimDbContext>();
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<IInventoryService>();
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<SVSimDbContext>();
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<IInventoryService>();
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");
}
}