feat(inventory): BeginAsync loads viewer with canonical graph

Includes Cards/Sleeves/Emblems/LeaderSkins/Degrees/MyPageBackgrounds/Items
under AsSplitQuery, plus caller-supplied extras via InventoryLoadConfig.
Opens a DB transaction and returns an InventoryTransaction shell. All
mutation methods throw NotImplementedException until subsequent tasks
land them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 15:46:20 -04:00
parent b181257aaa
commit 02e86cf16c
4 changed files with 187 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Card;
using SVSim.Database.Repositories.Collectibles;
namespace SVSim.Database.Services.Inventory;
public sealed class InventoryService : IInventoryService
{
private readonly SVSimDbContext _db;
private readonly IGameConfigService _config;
private readonly ICardRepository _cards;
private readonly ICollectionRepository _collection;
private readonly ILogger<InventoryService> _log;
public InventoryService(
SVSimDbContext db,
IGameConfigService config,
ICardRepository cards,
ICollectionRepository collection,
ILogger<InventoryService> log)
{
_db = db;
_config = config;
_cards = cards;
_collection = collection;
_log = log;
}
public async Task<IInventoryTransaction> BeginAsync(
long viewerId,
CancellationToken ct = default,
Action<InventoryLoadConfig>? configure = null)
{
var loadCfg = new InventoryLoadConfig();
configure?.Invoke(loadCfg);
IQueryable<Viewer> query = _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item);
foreach (var include in loadCfg.Includes)
query = include(query);
var viewer = await query
.AsSplitQuery()
.FirstOrDefaultAsync(v => v.Id == viewerId, ct)
?? throw new InventoryViewerNotFoundException(viewerId);
var freeplay = _config.Get<FreeplayConfig>();
var dbTx = await _db.Database.BeginTransactionAsync(ct);
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();
}

View File

@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Logging;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
namespace SVSim.Database.Services.Inventory;
internal sealed class InventoryTransaction : IInventoryTransaction
{
private readonly SVSimDbContext _db;
private readonly IDbContextTransaction _dbTx;
private readonly ILogger _log;
private readonly FreeplayConfig _freeplay;
private bool _committed;
public Viewer Viewer { get; }
public bool IsFreeplay => _freeplay.Enabled;
public InventoryTransaction(
SVSimDbContext db,
IDbContextTransaction dbTx,
Viewer viewer,
FreeplayConfig freeplay,
ILogger log)
{
_db = db;
_dbTx = dbTx;
Viewer = viewer;
_freeplay = freeplay;
_log = log;
}
// Implementations land in later tasks. Throw NotImplementedException to keep the build green.
public Task<SpendResult> TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<SpendResult> TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<GrantedReward>> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<int> BackfillCardCosmeticsAsync(CancellationToken ct = default)
=> throw new NotImplementedException();
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 Task<InventoryCommitResult> CommitAsync(CancellationToken ct = default)
=> throw new NotImplementedException();
public async ValueTask DisposeAsync()
{
if (!_committed)
{
await _dbTx.RollbackAsync();
_db.ChangeTracker.Clear();
}
await _dbTx.DisposeAsync();
}
}

View File

@@ -86,6 +86,8 @@ public class Program
builder.Services.AddScoped<IGachaPointService, GachaPointService>();
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();
builder.Services.AddScoped<RewardGrantService>();
builder.Services.AddScoped<SVSim.Database.Services.Inventory.IInventoryService,
SVSim.Database.Services.Inventory.InventoryService>();
builder.Services.AddScoped<SVSim.Database.Services.IViewerEntitlements, SVSim.Database.Services.ViewerEntitlements>();
builder.Services.AddScoped<SVSim.Database.Services.ICurrencySpendService, SVSim.Database.Services.CurrencySpendService>();
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IBattlePassRepository,

View File

@@ -0,0 +1,50 @@
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database.Services.Inventory;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Services.Inventory;
public class InventoryServiceBeginTests
{
[Test]
public async Task BeginAsync_loads_viewer_with_canonical_graph()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
Assert.That(tx.Viewer, Is.Not.Null);
Assert.That(tx.Viewer.Id, Is.EqualTo(viewerId));
Assert.That(tx.Viewer.Cards, Is.Not.Null, "Cards collection must be loaded");
Assert.That(tx.Viewer.Sleeves, Is.Not.Null, "Sleeves collection must be loaded");
Assert.That(tx.Viewer.Items, Is.Not.Null, "Items collection must be loaded");
}
[Test]
public async Task BeginAsync_throws_when_viewer_missing()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
Assert.ThrowsAsync<InventoryViewerNotFoundException>(
async () => { await inv.BeginAsync(viewerId: 9999); });
}
[Test]
public async Task BeginAsync_applies_extra_includes_via_configure()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId, configure:
cfg => cfg.WithInclude(v => v.MissionData));
Assert.That(tx.Viewer.MissionData, Is.Not.Null);
}
}