From 02e86cf16c1f601c6d1816566097c8f85dded372 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 15:46:20 -0400 Subject: [PATCH] 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 --- .../Services/Inventory/InventoryService.cs | 72 +++++++++++++++++++ .../Inventory/InventoryTransaction.cs | 63 ++++++++++++++++ SVSim.EmulatedEntrypoint/Program.cs | 2 + .../Inventory/InventoryServiceBeginTests.cs | 50 +++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 SVSim.Database/Services/Inventory/InventoryService.cs create mode 100644 SVSim.Database/Services/Inventory/InventoryTransaction.cs create mode 100644 SVSim.UnitTests/Services/Inventory/InventoryServiceBeginTests.cs diff --git a/SVSim.Database/Services/Inventory/InventoryService.cs b/SVSim.Database/Services/Inventory/InventoryService.cs new file mode 100644 index 0000000..d48998e --- /dev/null +++ b/SVSim.Database/Services/Inventory/InventoryService.cs @@ -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 _log; + + public InventoryService( + SVSimDbContext db, + IGameConfigService config, + ICardRepository cards, + ICollectionRepository collection, + ILogger log) + { + _db = db; + _config = config; + _cards = cards; + _collection = collection; + _log = log; + } + + public async Task BeginAsync( + long viewerId, + CancellationToken ct = default, + Action? configure = null) + { + var loadCfg = new InventoryLoadConfig(); + configure?.Invoke(loadCfg); + + IQueryable 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(); + var dbTx = await _db.Database.BeginTransactionAsync(ct); + + return new InventoryTransaction(_db, dbTx, viewer, freeplay, _log); + } + + // Stubs for later tasks. + public Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) + => throw new NotImplementedException(); + + public long EffectiveBalance(Viewer viewer, SpendCurrency currency) + => throw new NotImplementedException(); +} diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs new file mode 100644 index 0000000..4cd9c79 --- /dev/null +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -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 TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task 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 CommitAsync(CancellationToken ct = default) + => throw new NotImplementedException(); + + public async ValueTask DisposeAsync() + { + if (!_committed) + { + await _dbTx.RollbackAsync(); + _db.ChangeTracker.Clear(); + } + await _dbTx.DisposeAsync(); + } +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index f93bc55..28d0733 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -86,6 +86,8 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + + 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(); + + Assert.ThrowsAsync( + 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(); + + await using var tx = await inv.BeginAsync(viewerId, configure: + cfg => cfg.WithInclude(v => v.MissionData)); + + Assert.That(tx.Viewer.MissionData, Is.Not.Null); + } +}