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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user