feat(inventory): scaffold InventoryService namespace types

Empty interfaces + records for IInventoryService, IInventoryTransaction,
InventoryCommitResult, InventoryLoadConfig, InventoryCatalogException.
Implementation lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 15:38:51 -04:00
parent fc504af496
commit 220e5699cd
5 changed files with 119 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
using SVSim.Database.Models;
using SVSim.Database.Services;
namespace SVSim.Database.Services.Inventory;
public interface IInventoryService
{
/// <summary>
/// Loads the viewer with the canonical inventory graph (Cards.Card, Sleeves, Emblems,
/// LeaderSkins, Degrees, MyPageBackgrounds, Items.Item under AsSplitQuery), opens a DB
/// transaction, and returns a builder for queueing operations. Throws
/// <see cref="InventoryViewerNotFoundException"/> if the viewer does not exist.
/// </summary>
Task<IInventoryTransaction> BeginAsync(
long viewerId,
CancellationToken ct = default,
Action<InventoryLoadConfig>? configure = null);
Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default);
Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default);
long EffectiveBalance(Viewer viewer, SpendCurrency currency);
}
public sealed class InventoryViewerNotFoundException : Exception
{
public InventoryViewerNotFoundException(long viewerId)
: base($"Viewer {viewerId} not found") { }
}

View File

@@ -0,0 +1,30 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
namespace SVSim.Database.Services.Inventory;
/// <summary>
/// Scoped builder returned by <see cref="IInventoryService.BeginAsync"/>. Queue spend +
/// grant operations; commit to save and assemble the <see cref="InventoryCommitResult"/>.
/// <para>
/// Dispose without committing rolls back the underlying DB transaction and detaches any
/// in-memory mutations. <b>Always</b> wrap in <c>await using</c>.
/// </para>
/// </summary>
public interface IInventoryTransaction : IAsyncDisposable
{
Viewer Viewer { get; }
bool IsFreeplay { get; }
Task<SpendResult> TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default);
Task<SpendResult> TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default);
Task<IReadOnlyList<GrantedReward>> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default);
Task<int> BackfillCardCosmeticsAsync(CancellationToken ct = default);
long EffectiveBalance(SpendCurrency currency);
bool OwnsCard(long cardId);
bool OwnsCosmetic(CosmeticType type, int id);
Task<InventoryCommitResult> CommitAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,10 @@
namespace SVSim.Database.Services.Inventory;
/// <summary>
/// Thrown when an inventory operation references a catalog id that doesn't exist
/// (unknown card / item / cosmetic). Programmer error — bubbles to the global error handler.
/// </summary>
public sealed class InventoryCatalogException : Exception
{
public InventoryCatalogException(string message) : base(message) { }
}

View File

@@ -0,0 +1,20 @@
using SVSim.Database.Services;
namespace SVSim.Database.Services.Inventory;
/// <summary>
/// Result of <see cref="IInventoryTransaction.CommitAsync"/>.
/// <para>
/// <see cref="RewardList"/> — wire-shape entries with currency-collision resolved (one entry per
/// (type, id); for currencies that were both spent and granted, the last post-state in op order
/// wins). Use this for response <c>reward_list</c> fields.
/// </para>
/// <para>
/// <see cref="Deltas"/> — verbatim ordered (type, id, num) sequence the caller queued. No
/// collapse, no cosmetic-cascade entries. Use this for BP <c>achieved_info</c> and Story
/// <c>story_reward_list</c> popups.
/// </para>
/// </summary>
public sealed record InventoryCommitResult(
IReadOnlyList<GrantedReward> RewardList,
IReadOnlyList<GrantedReward> Deltas);

View File

@@ -0,0 +1,31 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using SVSim.Database.Models;
namespace SVSim.Database.Services.Inventory;
/// <summary>
/// Caller-supplied extra <c>.Include</c> chains on top of the canonical viewer-inventory query
/// in <see cref="IInventoryService.BeginAsync"/>. Use to bring in extra collections needed by
/// the calling controller (e.g. <c>MissionData</c>, <c>BuildDeckPurchases</c>).
/// </summary>
public sealed class InventoryLoadConfig
{
internal List<Func<IQueryable<Viewer>, IQueryable<Viewer>>> Includes { get; } = new();
public InventoryLoadConfig WithInclude<TProperty>(
Expression<Func<Viewer, TProperty>> path)
{
Includes.Add(q => q.Include(path));
return this;
}
public InventoryLoadConfig WithInclude<TProperty, TThen>(
Expression<Func<Viewer, IEnumerable<TProperty>>> collectionPath,
Expression<Func<TProperty, TThen>> thenPath)
{
Includes.Add(q => q.Include(collectionPath).ThenInclude(thenPath));
return this;
}
}