feat(inventory): read-side methods on IInventoryService + tx
EffectiveBalance/OwnsCard/OwnsCosmetic on the tx are freeplay-aware against the live viewer. EffectiveOwnedCardsAsync/EffectiveCosmeticsAsync on the service mirror today's ViewerEntitlements projections (used by /load/index). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -60,13 +60,71 @@ public sealed class InventoryService : IInventoryService
|
||||
return new InventoryTransaction(_db, dbTx, viewer, freeplay, _log);
|
||||
}
|
||||
|
||||
// Stubs for later tasks.
|
||||
public Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public long EffectiveBalance(Viewer viewer, SpendCurrency currency)
|
||||
=> throw new NotImplementedException();
|
||||
{
|
||||
var cfg = _config.Get<FreeplayConfig>();
|
||||
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 async Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(
|
||||
Viewer viewer, CancellationToken ct = default)
|
||||
{
|
||||
var defaults = await _cards.GetDefaultCards();
|
||||
var defaultIds = defaults.Select(c => c.Id).ToHashSet();
|
||||
var cfg = _config.Get<FreeplayConfig>();
|
||||
|
||||
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<EffectiveCosmetics> EffectiveCosmeticsAsync(
|
||||
Viewer viewer, CancellationToken ct = default)
|
||||
{
|
||||
var allSkins = await _collection.GetLeaderSkins();
|
||||
var cfg = _config.Get<FreeplayConfig>();
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,9 +237,37 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
_ => false,
|
||||
};
|
||||
|
||||
public long EffectiveBalance(SpendCurrency currency) => throw new NotImplementedException();
|
||||
public bool OwnsCard(long cardId) => throw new NotImplementedException();
|
||||
public bool OwnsCosmetic(CosmeticType type, int id) => throw new NotImplementedException();
|
||||
public long EffectiveBalance(SpendCurrency currency)
|
||||
{
|
||||
if (_freeplay.Enabled && currency != SpendCurrency.SpotPoint)
|
||||
return checked((long)_freeplay.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(long cardId)
|
||||
=> _freeplay.Enabled || Viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0);
|
||||
|
||||
public bool OwnsCosmetic(CosmeticType type, int id)
|
||||
{
|
||||
if (_freeplay.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<InventoryCommitResult> CommitAsync(CancellationToken ct = default)
|
||||
{
|
||||
|
||||
128
SVSim.UnitTests/Services/Inventory/InventoryReadSideTests.cs
Normal file
128
SVSim.UnitTests/Services/Inventory/InventoryReadSideTests.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Services.Inventory;
|
||||
|
||||
public class InventoryReadSideTests
|
||||
{
|
||||
[Test]
|
||||
public async Task EffectiveBalance_returns_viewer_currency_when_not_freeplay()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.Currency.Crystals = 1234;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
// Re-load viewer with inventory graph for the read-side call
|
||||
var v2 = await ctx.Viewers.AsNoTracking().FirstAsync(x => x.Id == viewerId);
|
||||
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
|
||||
Assert.That(inv.EffectiveBalance(v2, SpendCurrency.Crystal), Is.EqualTo(1234));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EffectiveOwnedCardsAsync_returns_non_null_collection()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await ctx.Viewers
|
||||
.Include(x => x.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(x => x.Id == viewerId);
|
||||
|
||||
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
|
||||
var owned = await inv.EffectiveOwnedCardsAsync(v);
|
||||
|
||||
Assert.That(owned, Is.Not.Null);
|
||||
// If there are basic cards seeded (IsBasic=true) they should be protected;
|
||||
// if none are seeded the collection may be empty — just confirm it doesn't throw.
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EffectiveBalance_returns_freeplay_amount_when_freeplay_enabled()
|
||||
{
|
||||
using var factory = new SVSimTestFactory(freeplayEnabled: true);
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await ctx.Viewers.AsNoTracking().FirstAsync(x => x.Id == viewerId);
|
||||
|
||||
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
|
||||
var freeCfg = scope.ServiceProvider.GetRequiredService<IGameConfigService>().Get<SVSim.Database.Models.Config.FreeplayConfig>();
|
||||
Assert.That(inv.EffectiveBalance(v, SpendCurrency.Crystal), Is.EqualTo(checked((long)freeCfg.CurrencyAmount)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Transaction_EffectiveBalance_matches_viewer_balance()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.Currency.Crystals = 5678;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
|
||||
await using var tx = await inv.BeginAsync(viewerId);
|
||||
|
||||
Assert.That(tx.EffectiveBalance(SpendCurrency.Crystal), Is.EqualTo(5678));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Transaction_OwnsCard_returns_true_when_card_owned()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
long cardId = await factory.SeedCardAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId, 1);
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
|
||||
await using var tx = await inv.BeginAsync(viewerId);
|
||||
|
||||
Assert.That(tx.OwnsCard(cardId), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Transaction_OwnsCard_returns_false_when_card_not_owned()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
long cardId = await factory.SeedCardAsync();
|
||||
// Do NOT seed owned card
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
|
||||
await using var tx = await inv.BeginAsync(viewerId);
|
||||
|
||||
Assert.That(tx.OwnsCard(cardId), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Transaction_OwnsCosmetic_returns_true_when_owned()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
const int sleeveId = 2_000_040_000;
|
||||
var sleeve = new SleeveEntry { Id = sleeveId };
|
||||
ctx.Sleeves.Add(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);
|
||||
|
||||
Assert.That(tx.OwnsCosmetic(CosmeticType.Sleeve, sleeveId), Is.True);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user