Merge branch 'inventory-service'

InventoryService consolidation: replaces RewardGrantService,
CurrencySpendService, ViewerEntitlements, and CardAcquisitionService
with a single scoped-transaction facade IInventoryService.

- BeginAsync loads viewer with canonical inventory graph + extras
- TrySpendAsync/TryDebitAsync/GrantAsync queue ops; CommitAsync saves
- Result carries RewardList (post-state, currency-collision-resolved)
  + Deltas (verbatim queued) for distinct wire fields
- Freeplay logic folded into the tx surface
- 14 callers ported (Load, BuildDeck, Pack, LeaderSkin, Sleeve,
  ItemPurchase, SpotCardExchange, Gift, Achievement, Puzzle, Story,
  BattlePass, ArenaTwoPick, GachaPoint); CardInventoryRepository.Create
  ported, Destruct deferred
- 8 old service files + 4 test files deleted
- 713/713 tests pass

Spec: docs/superpowers/specs/2026-05-31-inventory-service-design.md
Plan: docs/superpowers/plans/2026-05-31-inventory-service.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 19:25:43 -04:00
56 changed files with 2060 additions and 2406 deletions

View File

@@ -2,18 +2,19 @@ using Microsoft.EntityFrameworkCore;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
namespace SVSim.Database.Repositories.Card; namespace SVSim.Database.Repositories.Card;
public class CardInventoryRepository : ICardInventoryRepository public class CardInventoryRepository : ICardInventoryRepository
{ {
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly RewardGrantService _grants; private readonly IInventoryService _inv;
public CardInventoryRepository(SVSimDbContext db, RewardGrantService grants) public CardInventoryRepository(SVSimDbContext db, IInventoryService inv)
{ {
_db = db; _db = db;
_grants = grants; _inv = inv;
} }
public async Task<DestructOutcome> DestructCards(long viewerId, IReadOnlyDictionary<long, int> destructCounts) public async Task<DestructOutcome> DestructCards(long viewerId, IReadOnlyDictionary<long, int> destructCounts)
@@ -129,30 +130,27 @@ public class CardInventoryRepository : ICardInventoryRepository
totalCost += (ulong)catalogCard.CollectionInfo.CraftCost * (ulong)num; totalCost += (ulong)catalogCard.CollectionInfo.CraftCost * (ulong)num;
} }
// insufficient_vials checked after summing the full batch — all-or-nothing // insufficient_vials pre-check (validation-before-mutation atomicity, keeps same error ordering)
if (viewer.Currency.RedEther < totalCost) if (viewer.Currency.RedEther < totalCost)
return CreateOutcome.Fail(CreateError.InsufficientVials); return CreateOutcome.Fail(CreateError.InsufficientVials);
using var tx = await _db.Database.BeginTransactionAsync(); // Mutation phase via InventoryService transaction — freeplay-aware RedEther debit,
// card grants with cosmetic cascade.
await using var tx = await _inv.BeginAsync(viewerId);
// Debit RedEther directly. ApplyAsync only credits — debit-pair operations live in this var spendResult = await tx.TrySpendAsync(SpendCurrency.RedEther, (long)totalCost);
// repo, symmetric with destruct. if (!spendResult.Success)
viewer.Currency.RedEther -= totalCost; return CreateOutcome.Fail(CreateError.InsufficientVials);
// Per-card grant via RewardGrantService — single source of truth for Card-typed grants,
// and fires the CardCosmeticReward cascade for first-time owners. See
// feedback_reward_grant_service memory.
var allGrants = new List<GrantedReward>(); var allGrants = new List<GrantedReward>();
foreach (var (cardId, num) in createCounts) foreach (var (cardId, num) in createCounts)
{ {
var granted = await _grants.ApplyAsync(viewer, UserGoodsType.Card, cardId, num); var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, num);
allGrants.AddRange(granted); allGrants.AddRange(granted);
} }
await _db.SaveChangesAsync();
await tx.CommitAsync(); await tx.CommitAsync();
return CreateOutcome.Ok(new CreateResult(tx.Viewer.Currency.RedEther, allGrants));
return CreateOutcome.Ok(new CreateResult(viewer.Currency.RedEther, allGrants));
} }
public async Task<ProtectOutcome> SetProtected(long viewerId, long cardId, bool isProtected) public async Task<ProtectOutcome> SetProtected(long viewerId, long cardId, bool isProtected)

View File

@@ -1,51 +0,0 @@
using SVSim.Database.Models;
namespace SVSim.Database.Services;
public class CurrencySpendService : ICurrencySpendService
{
private readonly IViewerEntitlements _entitlements;
public CurrencySpendService(IViewerEntitlements entitlements) => _entitlements = entitlements;
public Task<SpendResult> TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default)
{
if (cost < 0) cost = 0;
// Freeplay bypass applies only to the three main currencies; SpotPoint always real.
if (_entitlements.IsFreeplay && currency != SpendCurrency.SpotPoint)
{
return Task.FromResult(new SpendResult(
SpendOutcome.Success, _entitlements.EffectiveBalance(viewer, currency)));
}
ulong current = GetBalance(viewer, currency);
if (current < (ulong)cost)
return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current));
ulong post = current - (ulong)cost;
SetBalance(viewer, currency, post);
return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post));
}
private static ulong GetBalance(Viewer v, SpendCurrency c) => c switch
{
SpendCurrency.Crystal => v.Currency.Crystals,
SpendCurrency.Rupee => v.Currency.Rupees,
SpendCurrency.RedEther => v.Currency.RedEther,
SpendCurrency.SpotPoint => v.Currency.SpotPoints,
_ => throw new ArgumentOutOfRangeException(nameof(c)),
};
private static void SetBalance(Viewer v, SpendCurrency c, ulong value)
{
switch (c)
{
case SpendCurrency.Crystal: v.Currency.Crystals = value; break;
case SpendCurrency.Rupee: v.Currency.Rupees = value; break;
case SpendCurrency.RedEther: v.Currency.RedEther = value; break;
case SpendCurrency.SpotPoint: v.Currency.SpotPoints = value; break;
default: throw new ArgumentOutOfRangeException(nameof(c));
}
}
}

View File

@@ -1,14 +0,0 @@
using SVSim.Database.Models;
namespace SVSim.Database.Services;
/// <summary>
/// Centralized debit primitive — the symmetric twin of <c>RewardGrantService.ApplyAsync</c>.
/// Encapsulates the affordability-check + deduction + post-state-total pattern that was inlined
/// across the shop/pack controllers. Does NOT call <c>SaveChangesAsync</c>; the caller saves.
/// Freeplay (for Crystal/Rupee/RedEther) makes spends always succeed without deducting.
/// </summary>
public interface ICurrencySpendService
{
Task<SpendResult> TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default);
}

View File

@@ -1,54 +0,0 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Database.Services;
/// <summary>
/// The single read/ownership authority for what a viewer is *treated as* owning. Knows the
/// Freeplay flag; all freeplay read-side behavior lives here. See
/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md.
/// </summary>
/// <remarks>
/// <b>Include precondition:</b> methods that inspect the viewer's collections require the
/// viewer to have been loaded with <c>.Include(v =&gt; v.Cards).ThenInclude(c =&gt; c.Card)</c>
/// and the cosmetic collections
/// (<c>Sleeves</c>, <c>Emblems</c>, <c>Degrees</c>, <c>LeaderSkins</c>, <c>MyPageBackgrounds</c>)
/// included. Without those includes the EF owned-collection nav refs are null or zero-filled
/// (see the EF owned-collection nav-include pitfall in MEMORY.md).
/// </remarks>
public interface IViewerEntitlements
{
/// <summary>True when the global Freeplay config section is enabled.</summary>
bool IsFreeplay { get; }
/// <summary>
/// The balance the viewer is treated as having: the configured freeplay amount for
/// Crystal/Rupee/RedEther when freeplay is on, otherwise (and always for SpotPoint) the real
/// <c>viewer.Currency</c> field.
/// </summary>
long EffectiveBalance(Viewer viewer, SpendCurrency currency);
bool OwnsCard(Viewer viewer, long cardId);
/// <summary><paramref name="type"/> uses <see cref="CosmeticType"/> (Skin == leader skin).</summary>
bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id);
/// <summary>The full owned-card projection for /load/index's user_card_list.</summary>
Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default);
/// <summary>The cosmetic id-lists + leader-skin catalog/owned-set for /load/index.</summary>
Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default);
}
/// <summary>
/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns"
/// (all of them in freeplay). Leader skins are always the full catalog with a per-skin owned flag;
/// <see cref="OwnedLeaderSkinIds"/> is every skin id in freeplay.
/// </summary>
public sealed record EffectiveCosmetics(
IReadOnlyList<int> SleeveIds,
IReadOnlyList<int> EmblemIds,
IReadOnlyList<int> DegreeIds,
IReadOnlyList<int> MyPageBackgroundIds,
IReadOnlyList<LeaderSkinEntry> AllLeaderSkins,
IReadOnlySet<int> OwnedLeaderSkinIds);

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,49 @@
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; }
/// <summary>
/// Debits one of the four scalar wallets. Freeplay-aware for Crystal/Rupee/RedEther
/// (returns Success with the configured freeplay amount, balance unchanged); SpotPoint
/// always real. Returns <see cref="SpendOutcome.Insufficient"/> with current balance on
/// failure; viewer state is not mutated on failure.
/// </summary>
Task<SpendResult> TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default);
/// <summary>
/// Type-dispatched debit. Currencies (RedEther/Crystal/Rupy/SpotCardPoint) route to
/// <see cref="TrySpendAsync"/>; Item decrements <c>OwnedItemEntry.Count</c>. Returns
/// <see cref="SpendResult"/> whose <c>PostStateTotal</c> is the new wallet balance for
/// currencies and the remaining item count for Item.
/// </summary>
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);
/// <summary>
/// Freeplay-aware balance read against the live viewer; reflects any spends queued in
/// this transaction. Inside a transaction, use this; outside, use
/// <see cref="IInventoryService.EffectiveBalance"/>.
/// </summary>
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,27 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Database.Services;
/// <summary>
/// Wire-shape entry returned by <see cref="Inventory.IInventoryTransaction.GrantAsync"/> and
/// collected in <see cref="Inventory.InventoryCommitResult.RewardList"/> /
/// <see cref="Inventory.InventoryCommitResult.Deltas"/>. Field names match the
/// <c>reward_list</c> entries used by <c>/pack/open</c>, <c>/basic_puzzle/finish</c>, and
/// <c>/story/*/finish</c>. reward_num is a POST-STATE TOTAL for currencies and a count for
/// collection grants — see <see cref="Models.RewardListEntry"/>.
/// </summary>
public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum);
/// <summary>
/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns"
/// (all of them in freeplay). Leader skins are always the full catalog with a per-skin owned flag;
/// <see cref="OwnedLeaderSkinIds"/> is every skin id in freeplay.
/// </summary>
public sealed record EffectiveCosmetics(
IReadOnlyList<int> SleeveIds,
IReadOnlyList<int> EmblemIds,
IReadOnlyList<int> DegreeIds,
IReadOnlyList<int> MyPageBackgroundIds,
IReadOnlyList<LeaderSkinEntry> AllLeaderSkins,
IReadOnlySet<int> OwnedLeaderSkinIds);

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;
}
}

View File

@@ -1,31 +1,68 @@
using SVSim.Database.Enums; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Models.Config; using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Card; using SVSim.Database.Repositories.Card;
using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Repositories.Collectibles;
namespace SVSim.Database.Services; namespace SVSim.Database.Services.Inventory;
public class ViewerEntitlements : IViewerEntitlements public sealed class InventoryService : IInventoryService
{ {
private readonly SVSimDbContext _db;
private readonly IGameConfigService _config; private readonly IGameConfigService _config;
private readonly ICardRepository _cards; private readonly ICardRepository _cards;
private readonly ICollectionRepository _collection; private readonly ICollectionRepository _collection;
private readonly ILogger<InventoryService> _log;
public ViewerEntitlements(IGameConfigService config, ICardRepository cards, ICollectionRepository collection) public InventoryService(
SVSimDbContext db,
IGameConfigService config,
ICardRepository cards,
ICollectionRepository collection,
ILogger<InventoryService> log)
{ {
_db = db;
_config = config; _config = config;
_cards = cards; _cards = cards;
_collection = collection; _collection = collection;
_log = log;
} }
private FreeplayConfig Cfg => _config.Get<FreeplayConfig>(); public async Task<IInventoryTransaction> BeginAsync(
long viewerId,
CancellationToken ct = default,
Action<InventoryLoadConfig>? configure = null)
{
var loadCfg = new InventoryLoadConfig();
configure?.Invoke(loadCfg);
public bool IsFreeplay => Cfg.Enabled; 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);
}
public long EffectiveBalance(Viewer viewer, SpendCurrency currency) public long EffectiveBalance(Viewer viewer, SpendCurrency currency)
{ {
var cfg = Cfg; var cfg = _config.Get<FreeplayConfig>();
if (cfg.Enabled && currency != SpendCurrency.SpotPoint) if (cfg.Enabled && currency != SpendCurrency.SpotPoint)
return checked((long)cfg.CurrencyAmount); return checked((long)cfg.CurrencyAmount);
@@ -39,28 +76,12 @@ public class ViewerEntitlements : IViewerEntitlements
}; };
} }
public bool OwnsCard(Viewer viewer, long cardId) public async Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(
=> Cfg.Enabled || viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0); Viewer viewer, CancellationToken ct = default)
public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id)
{
if (Cfg.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<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default)
{ {
var defaults = await _cards.GetDefaultCards(); var defaults = await _cards.GetDefaultCards();
var defaultIds = defaults.Select(c => c.Id).ToHashSet(); var defaultIds = defaults.Select(c => c.Id).ToHashSet();
var cfg = Cfg; var cfg = _config.Get<FreeplayConfig>();
if (cfg.Enabled) if (cfg.Enabled)
{ {
@@ -81,11 +102,13 @@ public class ViewerEntitlements : IViewerEntitlements
.ToList(); .ToList();
} }
public async Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) public async Task<EffectiveCosmetics> EffectiveCosmeticsAsync(
Viewer viewer, CancellationToken ct = default)
{ {
var allSkins = await _collection.GetLeaderSkins(); var allSkins = await _collection.GetLeaderSkins();
var cfg = _config.Get<FreeplayConfig>();
if (Cfg.Enabled) if (cfg.Enabled)
{ {
return new EffectiveCosmetics( return new EffectiveCosmetics(
await _collection.GetAllSleeveIds(), await _collection.GetAllSleeveIds(),

View File

@@ -0,0 +1,455 @@
using Microsoft.EntityFrameworkCore;
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;
private readonly List<InventoryOp> _ops = new();
internal abstract record InventoryOp;
internal sealed record SpendOp(SpendCurrency Currency, long Cost, long PostState) : InventoryOp;
internal sealed record GrantOp(UserGoodsType Type, long DetailId, int Num, int PostStateOrCount, bool IsCascade) : InventoryOp;
public InventoryTransaction(
SVSimDbContext db,
IDbContextTransaction dbTx,
Viewer viewer,
FreeplayConfig freeplay,
ILogger log)
{
_db = db;
_dbTx = dbTx;
Viewer = viewer;
_freeplay = freeplay;
_log = log;
}
public Task<SpendResult> TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default)
{
ThrowIfCommitted();
if (cost < 0) cost = 0;
if (_freeplay.Enabled && currency != SpendCurrency.SpotPoint)
{
long amount = checked((long)_freeplay.CurrencyAmount);
_ops.Add(new SpendOp(currency, cost, amount));
return Task.FromResult(new SpendResult(SpendOutcome.Success, amount));
}
ulong current = ReadBalance(currency);
if (current < (ulong)cost)
return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current));
ulong post = current - (ulong)cost;
WriteBalance(currency, post);
_ops.Add(new SpendOp(currency, cost, (long)post));
return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post));
}
private ulong ReadBalance(SpendCurrency c) => c switch
{
SpendCurrency.Crystal => Viewer.Currency.Crystals,
SpendCurrency.Rupee => Viewer.Currency.Rupees,
SpendCurrency.RedEther => Viewer.Currency.RedEther,
SpendCurrency.SpotPoint => Viewer.Currency.SpotPoints,
_ => throw new ArgumentOutOfRangeException(nameof(c)),
};
private void WriteBalance(SpendCurrency c, ulong value)
{
switch (c)
{
case SpendCurrency.Crystal: Viewer.Currency.Crystals = value; break;
case SpendCurrency.Rupee: Viewer.Currency.Rupees = value; break;
case SpendCurrency.RedEther: Viewer.Currency.RedEther = value; break;
case SpendCurrency.SpotPoint: Viewer.Currency.SpotPoints = value; break;
default: throw new ArgumentOutOfRangeException(nameof(c));
}
}
public Task<SpendResult> TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default)
{
ThrowIfCommitted();
return type switch
{
UserGoodsType.Crystal => TrySpendAsync(SpendCurrency.Crystal, num, ct),
UserGoodsType.Rupy => TrySpendAsync(SpendCurrency.Rupee, num, ct),
UserGoodsType.RedEther => TrySpendAsync(SpendCurrency.RedEther, num, ct),
UserGoodsType.SpotCardPoint => TrySpendAsync(SpendCurrency.SpotPoint, num, ct),
UserGoodsType.Item => Task.FromResult(DebitItem(detailId, num)),
_ => throw new NotSupportedException($"Debit not supported for {type}"),
};
}
private SpendResult DebitItem(long detailId, int num)
{
var owned = Viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
if (owned is null)
throw new InventoryCatalogException($"Item {detailId} not owned by viewer");
if (owned.Count < num)
return new SpendResult(SpendOutcome.Insufficient, owned.Count);
owned.Count -= num;
// Item debit logged as a synthetic SpendOp so CommitAsync can track it.
// Sentinel currency (int)-1 is filtered out by CommitAsync's currency-collision loop.
_ops.Add(new SpendOp((SpendCurrency)(-1) /* sentinel */, num, owned.Count));
// IsCascade: true so this GrantOp is excluded from BuildDeltas output.
_ops.Add(new GrantOp(UserGoodsType.Item, detailId, 0, owned.Count, IsCascade: true));
return new SpendResult(SpendOutcome.Success, owned.Count);
}
public async Task<IReadOnlyList<GrantedReward>> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default)
{
ThrowIfCommitted();
switch (type)
{
case UserGoodsType.Rupy:
Viewer.Currency.Rupees += (ulong)num;
var rupy = checked((int)Viewer.Currency.Rupees);
_ops.Add(new GrantOp(type, detailId, num, rupy, false));
return Single(type, detailId, rupy);
case UserGoodsType.Crystal:
Viewer.Currency.Crystals += (ulong)num;
var crystal = checked((int)Viewer.Currency.Crystals);
_ops.Add(new GrantOp(type, detailId, num, crystal, false));
return Single(type, detailId, crystal);
case UserGoodsType.RedEther:
Viewer.Currency.RedEther += (ulong)num;
var red = checked((int)Viewer.Currency.RedEther);
_ops.Add(new GrantOp(type, detailId, num, red, false));
return Single(type, detailId, red);
case UserGoodsType.SpotCardPoint:
Viewer.Currency.SpotPoints += (ulong)num;
var spot = checked((int)Viewer.Currency.SpotPoints);
_ops.Add(new GrantOp(type, detailId, num, spot, false));
return Single(type, detailId, spot);
case UserGoodsType.Sleeve:
AddCosmeticIfMissing(Viewer.Sleeves, detailId, _db.Sleeves);
_ops.Add(new GrantOp(type, detailId, num, 1, false));
return Single(type, detailId, 1);
case UserGoodsType.Emblem:
AddCosmeticIfMissing(Viewer.Emblems, detailId, _db.Emblems);
_ops.Add(new GrantOp(type, detailId, num, 1, false));
return Single(type, detailId, 1);
case UserGoodsType.Skin:
AddCosmeticIfMissing(Viewer.LeaderSkins, detailId, _db.LeaderSkins);
_ops.Add(new GrantOp(type, detailId, num, 1, false));
return Single(type, detailId, 1);
case UserGoodsType.Degree:
AddCosmeticIfMissing(Viewer.Degrees, detailId, _db.Degrees);
_ops.Add(new GrantOp(type, detailId, num, 1, false));
return Single(type, detailId, 1);
case UserGoodsType.MyPageBG:
AddCosmeticIfMissing(Viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds);
_ops.Add(new GrantOp(type, detailId, num, 1, false));
return Single(type, detailId, 1);
case UserGoodsType.Item:
{
var owned = Viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
int post;
if (owned is null)
{
var item = _db.Items.Find((int)detailId)
?? throw new InventoryCatalogException($"Item {detailId} not in catalog");
Viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = Viewer });
post = num;
}
else
{
owned.Count += num;
post = owned.Count;
}
_ops.Add(new GrantOp(type, detailId, num, post, false));
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");
}
}
public async Task<int> BackfillCardCosmeticsAsync(CancellationToken ct = default)
{
ThrowIfCommitted();
var lookupIds = Viewer.Cards
.Select(c => c.Card.IsFoil ? c.Card.Id - 1 : c.Card.Id)
.Distinct()
.ToList();
var cascade = await _db.CardCosmeticRewards
.Where(r => lookupIds.Contains(r.CardId))
.ToListAsync(ct);
int granted = 0;
foreach (var reward in cascade)
{
if (AlreadyOwnsCosmetic(reward.Type, reward.CosmeticId)) continue;
if (TryAddCascadeCosmetic(reward, reward.CardId))
{
granted++;
_ops.Add(new GrantOp((UserGoodsType)(int)reward.Type, reward.CosmeticId, 1, 1, true));
}
}
return granted;
}
private bool AlreadyOwnsCosmetic(CosmeticType type, long id) => type switch
{
CosmeticType.Sleeve => Viewer.Sleeves.Any(s => s.Id == id),
CosmeticType.Emblem => Viewer.Emblems.Any(e => e.Id == id),
CosmeticType.Skin => Viewer.LeaderSkins.Any(s => s.Id == id),
CosmeticType.Degree => Viewer.Degrees.Any(d => d.Id == id),
CosmeticType.MyPageBG => Viewer.MyPageBackgrounds.Any(b => b.Id == id),
_ => false,
};
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)
{
ThrowIfCommitted();
await _db.SaveChangesAsync(ct);
await _dbTx.CommitAsync(ct);
_committed = true;
var rewardList = BuildRewardList();
var deltas = BuildDeltas();
return new InventoryCommitResult(rewardList, deltas);
}
private IReadOnlyList<GrantedReward> BuildRewardList()
{
// Pass 1 — for each currency type, find the last op (spend OR grant) that touched it
// and emit a single entry with its post-state. Skip the sentinel item-debit currency.
var lastCurrencyPost = new Dictionary<UserGoodsType, int>();
var orderedTouches = new List<UserGoodsType>(); // preserve first-touch order for stable output
foreach (var op in _ops)
{
switch (op)
{
case SpendOp s when (int)s.Currency >= 0:
var goodsForSpend = SpendCurrencyToGoodsType(s.Currency);
if (!lastCurrencyPost.ContainsKey(goodsForSpend)) orderedTouches.Add(goodsForSpend);
lastCurrencyPost[goodsForSpend] = checked((int)s.PostState);
break;
case GrantOp g when IsCurrency(g.Type):
if (!lastCurrencyPost.ContainsKey(g.Type)) orderedTouches.Add(g.Type);
lastCurrencyPost[g.Type] = g.PostStateOrCount;
break;
}
}
var output = new List<GrantedReward>();
foreach (var type in orderedTouches)
{
output.Add(new GrantedReward((int)type, 0, lastCurrencyPost[type]));
}
// Pass 2 — non-currency grants: one entry per (type, id) using LAST post-state for items
// and cards (collapses multi-add to final count) and 1 for cosmetics.
var nonCurrencyKey = new Dictionary<(UserGoodsType, long), int>();
var nonCurrencyOrder = new List<(UserGoodsType, long)>();
foreach (var op in _ops.OfType<GrantOp>())
{
if (IsCurrency(op.Type)) continue;
var key = (op.Type, op.DetailId);
if (!nonCurrencyKey.ContainsKey(key)) nonCurrencyOrder.Add(key);
nonCurrencyKey[key] = op.PostStateOrCount;
}
foreach (var (type, id) in nonCurrencyOrder)
{
output.Add(new GrantedReward((int)type, id, nonCurrencyKey[(type, id)]));
}
return output;
}
private IReadOnlyList<GrantedReward> BuildDeltas()
=> _ops.OfType<GrantOp>()
.Where(o => !o.IsCascade)
.Select(o => new GrantedReward((int)o.Type, o.DetailId, o.Num))
.ToList();
private static bool IsCurrency(UserGoodsType t) =>
t is UserGoodsType.Crystal
or UserGoodsType.Rupy
or UserGoodsType.RedEther
or UserGoodsType.SpotCardPoint;
private static UserGoodsType SpendCurrencyToGoodsType(SpendCurrency c) => c switch
{
SpendCurrency.Crystal => UserGoodsType.Crystal,
SpendCurrency.Rupee => UserGoodsType.Rupy,
SpendCurrency.RedEther => UserGoodsType.RedEther,
SpendCurrency.SpotPoint => UserGoodsType.SpotCardPoint,
_ => throw new ArgumentOutOfRangeException(nameof(c)),
};
private static IReadOnlyList<GrantedReward> Single(UserGoodsType type, long id, int num)
=> new[] { new GrantedReward((int)type, id, num) };
private void ThrowIfCommitted()
{
if (_committed)
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;
var entity = catalog.Find(checked((int)detailId))
?? throw new InventoryCatalogException(
$"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}");
collection.Add(entity);
return true;
}
private static long GetId<T>(T e)
{
var prop = typeof(T).GetProperty("Id")
?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property");
var val = prop.GetValue(e);
return val switch { long l => l, int i => i, _ => 0 };
}
public async ValueTask DisposeAsync()
{
if (!_committed)
{
await _dbTx.RollbackAsync();
_db.ChangeTracker.Clear();
}
await _dbTx.DisposeAsync();
}
}

View File

@@ -1,221 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Database.Services;
/// <summary>
/// Wire-shape entry returned by <see cref="RewardGrantService.ApplyAsync"/>. Field names match
/// the <c>reward_list</c> entries used by <c>/pack/open</c>, <c>/basic_puzzle/finish</c>, and
/// <c>/story/*/finish</c>. reward_num is a POST-STATE TOTAL for currencies and a count for
/// collection grants — see <see cref="Models.RewardListEntry"/>.
/// </summary>
public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum);
/// <summary>
/// Single canonical grant primitive for every <see cref="UserGoodsType"/> the server hands to a
/// viewer. Switch on the type, mutate the appropriate viewer collection / <see cref="ViewerCurrency"/>
/// field, return the wire-shape entries to embed in the response's <c>reward_list</c>.
///
/// <para>
/// <b>DO NOT reimplement reward dispatch in a controller or new helper.</b> This service handles
/// RedEther, Crystal, SpotCardPoint, Item, Card (with <see cref="CardCosmeticReward"/> cascade),
/// Sleeve, Emblem, Degree, Rupy, Skin, MyPageBG — everything except the dead-letter SpotCard /
/// SpotCardOnlyLatestCardPack slots (use Card=5 instead). Endpoint code that takes a
/// list of <c>(type, id, num)</c> tuples should iterate and call <see cref="ApplyAsync"/>
/// per tuple — never switch on type yourself, never filter to "only card-typed rewards", never
/// build a second dispatch table. Past duplicate implementations (ICardAcquisitionService in the
/// EmulatedEntrypoint project, the first pass at /build_deck/buy) all silently dropped subsets of
/// types and produced the same bug: wire reward visible but viewer's collection unchanged. When a
/// new reward type comes up, add a case here. See <c>feedback_reward_grant_service</c> memory.
/// </para>
///
/// Card grants additionally run the <see cref="CardCosmeticReward"/> cascade: any cosmetic
/// associated with the granted card that the viewer doesn't yet own is granted too, and produces
/// an additional entry in the returned list. That's why the return type is a list: most types
/// produce one entry, Card produces 1 + N.
///
/// Caller is responsible for <see cref="SVSimDbContext.SaveChangesAsync(System.Threading.CancellationToken)"/> —
/// this service only mutates the in-memory graph so a controller can stack several grants in
/// a single transaction.
/// </summary>
public sealed class RewardGrantService
{
private readonly SVSimDbContext _db;
private readonly ILogger<RewardGrantService> _log;
public RewardGrantService(SVSimDbContext db, ILogger<RewardGrantService> log)
{
_db = db;
_log = log;
}
public async Task<IReadOnlyList<GrantedReward>> ApplyAsync(
Viewer viewer, UserGoodsType type, long detailId, int num, CancellationToken ct = default)
{
switch (type)
{
case UserGoodsType.Sleeve:
AddCosmeticIfMissing(viewer.Sleeves, detailId, _db.Sleeves);
return Single(type, detailId, 1);
case UserGoodsType.Emblem:
AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems);
return Single(type, detailId, 1);
case UserGoodsType.Skin: // LeaderSkin in our schema
AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins);
return Single(type, detailId, 1);
case UserGoodsType.Degree:
AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees);
return Single(type, detailId, 1);
case UserGoodsType.MyPageBG:
AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds);
return Single(type, detailId, 1);
case UserGoodsType.Rupy:
viewer.Currency.Rupees += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.Rupees));
case UserGoodsType.Crystal:
viewer.Currency.Crystals += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.Crystals));
case UserGoodsType.RedEther:
viewer.Currency.RedEther += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.RedEther));
case UserGoodsType.SpotCardPoint:
viewer.Currency.SpotPoints += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.SpotPoints));
case UserGoodsType.Item:
{
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
if (owned is null)
{
var item = _db.Items.Find((int)detailId)
?? throw new InvalidOperationException($"Item {detailId} not in catalog");
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = viewer });
return Single(type, detailId, num);
}
owned.Count += num;
return Single(type, detailId, owned.Count);
}
case UserGoodsType.Card:
return await ApplyCardAsync(viewer, detailId, num, ct);
case UserGoodsType.SpotCard:
case UserGoodsType.SpotCardOnlyLatestCardPack:
// Spot-card-typed grants don't appear in captures — emitters always use Card=5
// with the spot-card-specific id. These two enum slots remain unimplemented; if a
// capture ever shows one in a reward_list we'll know to wire them up here.
throw new NotSupportedException(
$"{type} rewards are not yet supported — emitters use Card=5 instead.");
default:
throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService");
}
}
private async Task<IReadOnlyList<GrantedReward>> ApplyCardAsync(
Viewer viewer, long cardId, int num, CancellationToken ct)
{
// Find-or-add OwnedCardEntry. Mirrors the primitive that used to live in
// IPackRepository.GrantCardsToViewer — now inline so we own the in-memory-only contract.
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 InvalidOperationException($"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),
};
// Cascade: cosmetic mappings live on the non-foil row. If the granted card is foil
// (card_id ends in 1, IsFoil=true), look up cascade against cardId - 1.
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(viewer, reward, lookupId))
{
// CosmeticType numeric values are identical to UserGoodsType — direct cast is safe.
results.Add(new GrantedReward((int)reward.Type, reward.CosmeticId, 1));
}
}
return results;
}
private static IReadOnlyList<GrantedReward> Single(UserGoodsType type, long id, int num)
=> new[] { new GrantedReward((int)type, id, num) };
private bool TryAddCascadeCosmetic(Viewer viewer, 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 (InvalidOperationException 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, DbSet<T> catalog) where T : class
{
bool alreadyOwned = collection.Any(e => GetId(e) == detailId);
if (alreadyOwned) return false;
var entity = catalog.Find(checked((int)detailId))
?? throw new InvalidOperationException(
$"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}");
collection.Add(entity);
return true;
}
/// <summary>
/// Reflectively reads an entity's Id property — works for both <c>BaseEntity&lt;int&gt;</c>
/// (cosmetics) and <c>BaseEntity&lt;long&gt;</c> (e.g. Viewer/Card) without forcing two
/// non-generic overloads of <see cref="AddCosmeticIfMissing"/>.
/// </summary>
private static long GetId<T>(T e)
{
var prop = typeof(T).GetProperty("Id")
?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property");
var val = prop.GetValue(e);
return val switch { long l => l, int i => i, _ => 0 };
}
}

View File

@@ -2,9 +2,8 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SVSim.Database; using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Mission; using SVSim.Database.Repositories.Mission;
using SVSim.Database.Services; using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Achievement; using SVSim.EmulatedEntrypoint.Models.Dtos.Achievement;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
@@ -22,20 +21,20 @@ public class AchievementController : SVSimController
private readonly IMissionCatalogRepository _catalog; private readonly IMissionCatalogRepository _catalog;
private readonly IViewerMissionStateService _state; private readonly IViewerMissionStateService _state;
private readonly IMissionAssembler _assembler; private readonly IMissionAssembler _assembler;
private readonly RewardGrantService _grantService; private readonly IInventoryService _inv;
public AchievementController( public AchievementController(
SVSimDbContext db, SVSimDbContext db,
IMissionCatalogRepository catalog, IMissionCatalogRepository catalog,
IViewerMissionStateService state, IViewerMissionStateService state,
IMissionAssembler assembler, IMissionAssembler assembler,
RewardGrantService grantService) IInventoryService inv)
{ {
_db = db; _db = db;
_catalog = catalog; _catalog = catalog;
_state = state; _state = state;
_assembler = assembler; _assembler = assembler;
_grantService = grantService; _inv = inv;
} }
[HttpPost("receive_reward")] [HttpPost("receive_reward")]
@@ -44,21 +43,15 @@ public class AchievementController : SVSimController
{ {
if (!TryGetViewerId(out long viewerId)) return Unauthorized(); if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// Load viewer with all the collections RewardGrantService may need to mutate. // EnsureCurrentAsync needs a viewer id — use a lightweight pre-check load then
var viewer = await _db.Viewers // materialize state before opening the inventory tx.
.Include(v => v.MissionData) var viewerIdCheck = await _db.Viewers
.Include(v => v.Currency) .Where(v => v.Id == viewerId)
.Include(v => v.Cards) .Select(v => v.Id)
.Include(v => v.Items) .FirstOrDefaultAsync(ct);
.Include(v => v.Sleeves) if (viewerIdCheck == 0) return Unauthorized();
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.LeaderSkins)
.Include(v => v.MyPageBackgrounds)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId, ct);
await _state.EnsureCurrentAsync(viewer.Id, ct); await _state.EnsureCurrentAsync(viewerId, ct);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
// Re-read viewer's achievement for this type after state-service materialization. // Re-read viewer's achievement for this type after state-service materialization.
@@ -75,9 +68,10 @@ public class AchievementController : SVSimController
return Ok(new { result_code = FailureResultCode }); return Ok(new { result_code = FailureResultCode });
} }
// Grant via the canonical RewardGrantService primitive. // Open inventory tx and grant via InventoryService.
var granted = await _grantService.ApplyAsync( await using var tx = await _inv.BeginAsync(viewerId, ct);
viewer,
var granted = await tx.GrantAsync(
(UserGoodsType)catalogRow.RewardType, (UserGoodsType)catalogRow.RewardType,
catalogRow.RewardDetailId, catalogRow.RewardDetailId,
catalogRow.RewardNumber, catalogRow.RewardNumber,
@@ -99,9 +93,9 @@ public class AchievementController : SVSimController
} }
ach.NowAchievedLevel = request.Level; ach.NowAchievedLevel = request.Level;
await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct);
var dto = await _assembler.BuildAsync(viewer, ct); var dto = await _assembler.BuildAsync(tx.Viewer, ct);
var resp = new AchievementReceiveRewardResponse var resp = new AchievementReceiveRewardResponse
{ {
UserMissionList = dto.UserMissionList, UserMissionList = dto.UserMissionList,

View File

@@ -1,10 +1,9 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Repositories.BuildDeck; using SVSim.Database.Repositories.BuildDeck;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck;
@@ -20,39 +19,16 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class BuildDeckController : SVSimController public class BuildDeckController : SVSimController
{ {
private readonly IBuildDeckRepository _repo; private readonly IBuildDeckRepository _repo;
private readonly SVSimDbContext _db; private readonly IInventoryService _inv;
private readonly RewardGrantService _rewards;
private readonly ICurrencySpendService _spend;
public BuildDeckController( public BuildDeckController(
IBuildDeckRepository repo, IBuildDeckRepository repo,
SVSimDbContext db, IInventoryService inv)
RewardGrantService rewards,
ICurrencySpendService spend)
{ {
_repo = repo; _repo = repo;
_db = db; _inv = inv;
_rewards = rewards;
_spend = spend;
} }
/// <summary>
/// Loads the viewer with the full cosmetic / inventory graph + BuildDeckPurchases. This is
/// the single load /build_deck/buy makes; every subsequent mutation operates on the returned
/// instance and the controller saves once at the end.
/// </summary>
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.LeaderSkins)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.BuildDeckPurchases)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// The wire shape for /build_deck/info has `data` as a bare collection of series, not a // The wire shape for /build_deck/info has `data` as a bare collection of series, not a
// DTO with a `series_list` field. The client (BuildDeckPurchaseInfoTask.Parse) iterates // DTO with a `series_list` field. The client (BuildDeckPurchaseInfoTask.Parse) iterates
// `data` directly via numeric indexer: // `data` directly via numeric indexer:
@@ -194,60 +170,45 @@ public class BuildDeckController : SVSimController
break; break;
} }
// Single viewer load with the full graph — every subsequent mutation (currency debit, // Open the inventory transaction — loads canonical graph + BuildDeckPurchases.
// purchase counter, card grants, cosmetic grants) operates on this one in-memory instance await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted,
// so we can save once at the end. cfg => cfg.WithInclude(v => v.BuildDeckPurchases));
var viewer = await LoadViewerGraphAsync(viewerId); var viewer = tx.Viewer;
var rewardList = new List<RewardListEntry>();
// Debit + post-state currency entry // Debit currency
if (request.SalesType == 1) if (request.SalesType == 1)
{ {
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, priceCrystal!.Value); var r = await tx.TrySpendAsync(SpendCurrency.Crystal, priceCrystal!.Value);
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal });
} }
else if (request.SalesType == 2) else if (request.SalesType == 2)
{ {
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, priceRupy!.Value); var r = await tx.TrySpendAsync(SpendCurrency.Rupee, priceRupy!.Value);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal });
} }
// sales_type == 0 (free): no debit, no currency entry // sales_type == 0 (free): no debit
// Compute series purchase total BEFORE this buy // Compute series purchase total BEFORE this buy
int prevSeriesCount = product.Series!.Products int prevSeriesCount = product.Series!.Products
.Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0); .Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0);
int newSeriesCount = prevSeriesCount + 1; int newSeriesCount = prevSeriesCount + 1;
// Increment purchase counter directly on the tracked viewer (we already loaded // Increment purchase counter on tx.Viewer (tx loaded BuildDeckPurchases via WithInclude).
// BuildDeckPurchases via LoadViewerGraphAsync). The repo's IncrementPurchaseCount would
// re-attach to the same instance and trigger an extra save — inlining keeps the
// controller's single-save model intact.
var purchaseRow = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == product.Id); var purchaseRow = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == product.Id);
if (purchaseRow is null) if (purchaseRow is null)
viewer.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = product.Id, PurchaseCount = 1 }); viewer.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = product.Id, PurchaseCount = 1 });
else else
purchaseRow.PurchaseCount += 1; purchaseRow.PurchaseCount += 1;
// Grant the 40 deck cards. Bucket by CardId so duplicate (CardId, IsSpot) rows don't // Grant deck cards (grouped by CardId)
// emit redundant reward_list entries — ApplyAsync(Card, ...) runs the cosmetic cascade foreach (var grp in product.Cards.GroupBy(c => c.CardId))
// and returns a post-state-total entry per call. await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Sum(c => c.Number));
var deckGrants = product.Cards
.GroupBy(c => c.CardId)
.Select(g => (UserGoodsType.Card, g.Key, g.Sum(c => c.Number)));
await ApplyRewardsAsync(viewer, deckGrants, rewardList);
// Per-buy rewards from the product catalog: sleeve, emblem, skin, sometimes extra cards // Per-buy rewards
// (Set 4 grants 3 copies of the featured card as a type=5 reward). foreach (var r in product.Rewards.OrderBy(r => r.RewardIndex))
await ApplyRewardsAsync(viewer, product.Rewards await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
.OrderBy(r => r.RewardIndex)
.Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)),
rewardList);
// Series-reward tier crossings: tiers where prevSeriesCount < TierIndex <= newSeriesCount. // Series-reward tier crossings
// Captured tiers include type 4 (Item), 5 (Card), 6 (Sleeve), 7 (Emblem) — granting them
// all uniformly avoids the earlier card-only path that dropped non-card tier rewards.
var crossedTiers = product.Series.SeriesRewards var crossedTiers = product.Series.SeriesRewards
.Where(r => r.TierIndex > prevSeriesCount && r.TierIndex <= newSeriesCount) .Where(r => r.TierIndex > prevSeriesCount && r.TierIndex <= newSeriesCount)
.GroupBy(r => r.TierIndex) .GroupBy(r => r.TierIndex)
@@ -257,13 +218,9 @@ public class BuildDeckController : SVSimController
var seriesRewards = new List<BuildDeckProductRewardDto>(); var seriesRewards = new List<BuildDeckProductRewardDto>();
foreach (var tier in crossedTiers) foreach (var tier in crossedTiers)
{ {
await ApplyRewardsAsync(viewer, tier
.OrderBy(r => r.ItemIndex)
.Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)),
rewardList);
foreach (var item in tier.OrderBy(r => r.ItemIndex)) foreach (var item in tier.OrderBy(r => r.ItemIndex))
{ {
await tx.GrantAsync((UserGoodsType)item.RewardType, item.RewardDetailId, item.RewardNumber);
seriesRewards.Add(new BuildDeckProductRewardDto seriesRewards.Add(new BuildDeckProductRewardDto
{ {
RewardType = item.RewardType, RewardType = item.RewardType,
@@ -274,39 +231,17 @@ public class BuildDeckController : SVSimController
} }
} }
await _db.SaveChangesAsync(); var result = await tx.CommitAsync(HttpContext.RequestAborted);
return new BuildDeckBuyResponse return new BuildDeckBuyResponse
{ {
RewardList = rewardList, RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
SeriesRewards = seriesRewards, SeriesRewards = seriesRewards,
}; };
} }
/// <summary>
/// Dispatches each (type, id, num) tuple through <see cref="RewardGrantService.ApplyAsync"/>
/// and appends the resulting wire entries to <paramref name="rewardList"/>. Caller saves.
/// </summary>
private async Task ApplyRewardsAsync(
Viewer viewer,
IEnumerable<(UserGoodsType Type, long DetailId, int Number)> rewards,
List<RewardListEntry> rewardList)
{
foreach (var (type, detailId, number) in rewards)
{
var granted = await _rewards.ApplyAsync(viewer, type, detailId, number);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
}
}
[HttpPost("get_purchase_count")] [HttpPost("get_purchase_count")]
public async Task<ActionResult<BuildDeckGetPurchaseCountResponse>> GetPurchaseCount( public async Task<ActionResult<BuildDeckGetPurchaseCountResponse>> GetPurchaseCount(
BuildDeckGetPurchaseCountRequest request) BuildDeckGetPurchaseCountRequest request)

View File

@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SVSim.Database; using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Services; using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
@@ -27,12 +27,12 @@ public class GiftController : SVSimController
}; };
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards; private readonly IInventoryService _inv;
public GiftController(SVSimDbContext db, RewardGrantService rewards) public GiftController(SVSimDbContext db, IInventoryService inv)
{ {
_db = db; _db = db;
_rewards = rewards; _inv = inv;
} }
[HttpPost("/tutorial/gift_top")] [HttpPost("/tutorial/gift_top")]
@@ -71,25 +71,7 @@ public class GiftController : SVSimController
var requestedIds = request.PresentIdArray.ToHashSet(); var requestedIds = request.PresentIdArray.ToHashSet();
// Load viewer with the collections RewardGrantService mutates. Crystals/Rupees live on // Resolve which of the requested ids are still claimable for this viewer before opening tx.
// viewer.Currency (owned, auto-loads); Items live on viewer.Items (owned collection).
// MissionData is an owned type and auto-loads, but Include is listed explicitly to match
// the pattern in TutorialController.Update and to make the intent clear.
// AsSplitQuery is the default-safe pattern when including viewer collections
// (project memory: project_ef_split_query).
//
// ThenInclude(i => i.Item) is load-bearing: OwnedItemEntry.Item is a separate non-owned
// entity whose default initialiser is `new ItemEntry()` (Id=0). Without the explicit
// ThenInclude, RewardGrantService.ApplyAsync's `FirstOrDefault(i => i.Item.Id == ...)`
// never matches a pre-existing row → falls through to add a duplicate → (ViewerId, ItemId)
// unique index throws on SaveChanges (project_ef_nav_include_pitfall).
var viewer = await _db.Viewers
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.MissionData)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// Resolve which of the requested ids are still claimable for this viewer.
var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts
.Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId)) .Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId))
.Select(g => g.PresentId) .Select(g => g.PresentId)
@@ -100,23 +82,43 @@ public class GiftController : SVSimController
.Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId)) .Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId))
.ToList(); .ToList();
// Apply grants via the canonical primitive. Pass the loaded viewer, NOT viewerId. // Open inventory tx with MissionData loaded for tutorial-step advance.
await using var tx = await _inv.BeginAsync(viewerId, configure:
cfg => cfg.WithInclude(v => v.MissionData));
// Apply grants via tx. Collect post-state per (type, detailId) for reward_list.
// Each GrantAsync returns a list of GrantedReward with post-state totals; for currencies
// only one entry is returned; for cards the cascade may return more entries (card + cosmetics).
// reward_list must carry post-state totals (client does direct assignment).
var rewardListEntries = new List<GiftRewardListEntry>();
foreach (var p in toClaim) foreach (var p in toClaim)
{ {
var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType)); var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType));
await _rewards.ApplyAsync(viewer, goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount)); var granted = await tx.GrantAsync(goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount));
// Use the first granted entry's post-state for the top-level gift reward_list entry.
// Gift rewards are currencies and items only (no cards in TutorialGifts), so granted
// always has exactly one element. The post-state total is already correct from tx.
if (granted.Count > 0)
{
rewardListEntries.Add(new GiftRewardListEntry
{
RewardType = p.RewardType,
RewardId = p.RewardDetailId,
RewardNum = granted[0].RewardNum.ToString(System.Globalization.CultureInfo.InvariantCulture),
});
}
} }
// Advance tutorial state from 31 → 41 as a side-effect of this claim (no separate // Advance tutorial state from 31 → 41 as a side-effect of this claim (no separate
// /tutorial/update → 41 call appears in the prod capture). Preserve max — don't downgrade // /tutorial/update → 41 call appears in the prod capture). Preserve max — don't downgrade
// viewers who are already past step 41. // viewers who are already past step 41.
const int GiftReceiveTutorialStep = 41; const int GiftReceiveTutorialStep = 41;
if (viewer.MissionData.TutorialState < GiftReceiveTutorialStep) if (tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
{ {
viewer.MissionData.TutorialState = GiftReceiveTutorialStep; tx.Viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
} }
// Persist claim receipts in the same transaction. // Persist claim receipts inside the same tx.
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
foreach (var p in toClaim) foreach (var p in toClaim)
{ {
@@ -127,7 +129,7 @@ public class GiftController : SVSimController
ClaimedAt = now, ClaimedAt = now,
}); });
} }
await _db.SaveChangesAsync(); await tx.CommitAsync();
var nowString = now.ToString("yyyy-MM-dd HH:mm:ss"); var nowString = now.ToString("yyyy-MM-dd HH:mm:ss");
var allClaimedList = await _db.ViewerClaimedTutorialGifts var allClaimedList = await _db.ViewerClaimedTutorialGifts
@@ -176,54 +178,18 @@ public class GiftController : SVSimController
// Hardcoding false hid the badge after partial claims even though present_list still // Hardcoding false hid the badge after partial claims even though present_list still
// carried unclaimed entries. // carried unclaimed entries.
IsUnreceivedPresent = unclaimedPresents.Count > 0, IsUnreceivedPresent = unclaimedPresents.Count > 0,
// reward_list entries must carry POST-STATE TOTALS, not gift deltas. // reward_list entries carry POST-STATE TOTALS (from tx.GrantAsync).
// The client's PlayerStaticData.UpdateHaveUserGoodsNumByJsonData does direct
// assignment on each entry's reward_num — emitting the delta would clobber
// the client-side cached balance down to the gift amount until the next /load/index.
// See project memory: project_wire_reward_list_post_state. // See project memory: project_wire_reward_list_post_state.
// // Iterate toClaim so idempotent re-receive doesn't re-emit post-state entries.
// Iterate `toClaim` so idempotent re-receive doesn't re-emit post-state entries RewardList = rewardListEntries,
// the client would direct-assign again (no-op on currency, but redundant traffic
// and risk of misinterpretation on item counts).
RewardList = toClaim
.Select(p => new GiftRewardListEntry
{
RewardType = p.RewardType,
RewardId = p.RewardDetailId,
RewardNum = ResolvePostStateRewardNum(p, viewer),
})
.ToList(),
// Echo the persisted state, not a hardcoded 41. The state may already be past 41 // Echo the persisted state, not a hardcoded 41. The state may already be past 41
// for replay/edge-case calls (the Math.Max-preserve block above keeps it stable); // for replay/edge-case calls (the Math.Max-preserve block above keeps it stable);
// emitting 41 anyway would surface a regressed step to the client and desync the // emitting 41 anyway would surface a regressed step to the client and desync the
// tutorial-state machine. // tutorial-state machine.
TutorialStep = viewer.MissionData.TutorialState, TutorialStep = tx.Viewer.MissionData.TutorialState,
}; };
} }
/// <summary>
/// Returns the post-grant viewer balance for the given gift entry, not the gift delta.
/// reward_list on wire carries post-state totals (client does direct assignment).
/// </summary>
private static string ResolvePostStateRewardNum(PresentDto gift, SVSim.Database.Models.Viewer viewer)
{
switch (gift.RewardType)
{
case "1": // Crystal
return ((long)viewer.Currency.Crystals).ToString(System.Globalization.CultureInfo.InvariantCulture);
case "9": // Rupy
return ((long)viewer.Currency.Rupees).ToString(System.Globalization.CultureInfo.InvariantCulture);
case "4": // Item
{
int itemId = int.Parse(gift.RewardDetailId, System.Globalization.CultureInfo.InvariantCulture);
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == itemId);
return ((long)(owned?.Count ?? 0)).ToString(System.Globalization.CultureInfo.InvariantCulture);
}
default:
return gift.RewardCount; // unknown type — fall back to gift count (better than 0)
}
}
private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch
{ {
1 => UserGoodsType.Crystal, 1 => UserGoodsType.Crystal,

View File

@@ -4,6 +4,7 @@ using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
@@ -21,16 +22,14 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class ItemPurchaseController : SVSimController public class ItemPurchaseController : SVSimController
{ {
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards; private readonly IInventoryService _inv;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly ICurrencySpendService _spend;
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend) public ItemPurchaseController(SVSimDbContext db, IInventoryService inv, TimeProvider time)
{ {
_db = db; _db = db;
_rewards = rewards; _inv = inv;
_time = time; _time = time;
_spend = spend;
} }
[HttpPost("info")] [HttpPost("info")]
@@ -115,28 +114,17 @@ public class ItemPurchaseController : SVSimController
if (rest <= 0) if (rest <= 0)
return BadRequest(new { error = "sold_out" }); return BadRequest(new { error = "sold_out" });
var viewer = await LoadViewerGraphAsync(viewerId); await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
var rewardList = new List<RewardListEntry>();
// Debit the require side. RewardGrantService is grant-only, so handle this inline. // Debit the require side via the tx.
var debit = await TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum); var debit = await tx.TryDebitAsync(
if (debit.Error is not null) return BadRequest(new { error = debit.Error }); (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
if (debit.PostState is not null) rewardList.Add(debit.PostState); if (!debit.Success) return BadRequest(new { error = MapDebitError(entry.RequireItemType) });
// Grant the purchase side through the central dispatcher. // Grant the purchase side.
var granted = await _rewards.ApplyAsync(viewer, await tx.GrantAsync((UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum);
(UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
// Increment the per-period counter. // Increment the per-period counter (tracked via _db, outside the inventory tx).
if (counter is null) if (counter is null)
{ {
_db.ViewerEventCounters.Add(new ViewerEventCounter _db.ViewerEventCounters.Add(new ViewerEventCounter
@@ -151,52 +139,27 @@ public class ItemPurchaseController : SVSimController
{ {
counter.Count++; counter.Count++;
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return new ItemPurchasePurchaseResponse { RewardList = rewardList }; var result = await tx.CommitAsync(HttpContext.RequestAborted);
}
/// <summary> return new ItemPurchasePurchaseResponse
/// Debit <paramref name="num"/> of (<paramref name="type"/>, <paramref name="detailId"/>)
/// from the viewer, returning a post-state-aware <see cref="RewardListEntry"/> the client
/// uses to refresh its cached count. Returns an error string on insufficient balance.
/// </summary>
private async Task<(RewardListEntry? PostState, string? Error)> TryDebit(
Viewer viewer, UserGoodsType type, long detailId, int num)
{
switch (type)
{ {
case UserGoodsType.RedEther: RewardList = result.RewardList
{ .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.RedEther, num); .ToList(),
if (!r.Success) return (null, "insufficient_red_ether"); };
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
case UserGoodsType.Crystal:
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, num);
if (!r.Success) return (null, "insufficient_crystals");
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
case UserGoodsType.Rupy:
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, num);
if (!r.Success) return (null, "insufficient_rupees");
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
case UserGoodsType.Item:
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
if (owned is null || owned.Count < num)
return (null, "insufficient_item");
owned.Count -= num;
return (new RewardListEntry { RewardType = 4, RewardId = detailId, RewardNum = owned.Count }, null);
default:
return (null, $"debit_type_not_supported:{type}");
}
} }
private static string MapDebitError(int requireType) => requireType switch
{
(int)UserGoodsType.RedEther => "insufficient_red_ether",
(int)UserGoodsType.Crystal => "insufficient_crystals",
(int)UserGoodsType.Rupy => "insufficient_rupees",
(int)UserGoodsType.Item => "insufficient_item",
_ => "debit_type_not_supported",
};
private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}"; private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}";
private static int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey) private static int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey)
@@ -204,15 +167,4 @@ public class ItemPurchaseController : SVSimController
var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime; var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime;
return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0; return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0;
} }
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.Items).ThenInclude(i => i.Item)
.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)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
} }

View File

@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
@@ -29,19 +30,15 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class LeaderSkinController : SVSimController public class LeaderSkinController : SVSimController
{ {
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards; private readonly IInventoryService _inv;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
private readonly ICollectionRepository _collection; private readonly ICollectionRepository _collection;
public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection) public LeaderSkinController(SVSimDbContext db, IInventoryService inv, TimeProvider time, ICollectionRepository collection)
{ {
_db = db; _db = db;
_rewards = rewards; _inv = inv;
_time = time; _time = time;
_spend = spend;
_entitlements = entitlements;
_collection = collection; _collection = collection;
} }
@@ -69,7 +66,8 @@ public class LeaderSkinController : SVSimController
var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId); var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId);
if (skin is null) return BadRequest(new { error = "unknown_skin" }); if (skin is null) return BadRequest(new { error = "unknown_skin" });
if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" }); if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" });
if (!_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, skin.Id)) var cosmeticsForSet = await _inv.EffectiveCosmeticsAsync(viewer);
if (!cosmeticsForSet.OwnedLeaderSkinIds.Contains(skin.Id))
return BadRequest(new { error = "skin_not_owned" }); return BadRequest(new { error = "skin_not_owned" });
classData.LeaderSkin = skin; classData.LeaderSkin = skin;
@@ -88,18 +86,13 @@ public class LeaderSkinController : SVSimController
{ {
if (!TryGetViewerId(out long viewerId)) return Unauthorized(); if (!TryGetViewerId(out long viewerId)) return Unauthorized();
if (_entitlements.IsFreeplay) var viewer = await _db.Viewers
{ .Include(v => v.LeaderSkins)
var all = (await _collection.GetLeaderSkins()).Select(s => s.Id).OrderBy(id => id).ToList(); .FirstOrDefaultAsync(v => v.Id == viewerId);
return new LeaderSkinIdsResponse { UserLeaderSkinIds = all }; if (viewer is null) return Unauthorized();
}
var ids = await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
.OrderBy(id => id)
.ToListAsync();
var cosmetics = await _inv.EffectiveCosmeticsAsync(viewer);
var ids = cosmetics.OwnedLeaderSkinIds.OrderBy(id => id).ToList();
return new LeaderSkinIdsResponse { UserLeaderSkinIds = ids }; return new LeaderSkinIdsResponse { UserLeaderSkinIds = ids };
} }
@@ -108,12 +101,13 @@ public class LeaderSkinController : SVSimController
{ {
if (!TryGetViewerId(out long viewerId)) return Unauthorized(); if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var ownedSkinIds = _entitlements.IsFreeplay var viewerForProducts = await _db.Viewers
? (await _collection.GetLeaderSkins()).Select(s => s.Id).ToHashSet() .Include(v => v.LeaderSkins)
: (await _db.Viewers .FirstOrDefaultAsync(v => v.Id == viewerId);
.Where(v => v.Id == viewerId) if (viewerForProducts is null) return Unauthorized();
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
.ToListAsync()).ToHashSet(); var cosmeticsForProducts = await _inv.EffectiveCosmeticsAsync(viewerForProducts);
var ownedSkinIds = cosmeticsForProducts.OwnedLeaderSkinIds;
var claimedSeries = (await _db.ViewerLeaderSkinSetClaims var claimedSeries = (await _db.ViewerLeaderSkinSetClaims
.Where(c => c.ViewerId == viewerId) .Where(c => c.ViewerId == viewerId)
@@ -183,21 +177,41 @@ public class LeaderSkinController : SVSimController
if (!product.IsEnabled || product.Series is not { IsEnabled: true }) if (!product.IsEnabled || product.Series is not { IsEnabled: true })
return BadRequest(new { error = "product_not_available" }); return BadRequest(new { error = "product_not_available" });
var viewer = await LoadViewerGraphAsync(viewerId); await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
// Already-purchased = viewer owns the leader_skin this product grants. // Already-purchased = viewer owns the leader_skin this product grants.
if (_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, product.LeaderSkinId)) if (tx.OwnsCosmetic(CosmeticType.Skin, product.LeaderSkinId))
return BadRequest(new { error = "already_purchased" }); return BadRequest(new { error = "already_purchased" });
var rewardList = new List<RewardListEntry>(); // Debit currency
var debit = await DebitProductPrice(viewer, product, request.SalesType); switch (request.SalesType)
if (debit.Error is not null) return BadRequest(new { error = debit.Error }); {
if (debit.PostState is not null) rewardList.Add(debit.PostState); case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0:
break; // free
case 0:
return BadRequest(new { error = "price_not_available_for_currency" });
case 1:
if (product.SinglePriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" });
{ var r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.SinglePriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
break;
case 2:
if (product.SinglePriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" });
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.SinglePriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
break;
default:
return BadRequest(new { error = "invalid_sales_type" });
}
await ApplyRewardsAsync(viewer, product.Rewards, rewardList); foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
await _db.SaveChangesAsync(); var result = await tx.CommitAsync(HttpContext.RequestAborted);
return new LeaderSkinBuyResponse { RewardList = rewardList }; return new LeaderSkinBuyResponse
{
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
};
} }
[HttpPost("buy_set")] [HttpPost("buy_set")]
@@ -218,25 +232,44 @@ public class LeaderSkinController : SVSimController
if (!series.IsEnabled || series.SetSalesStatus == 0) if (!series.IsEnabled || series.SetSalesStatus == 0)
return BadRequest(new { error = "set_sale_not_active" }); return BadRequest(new { error = "set_sale_not_active" });
var viewer = await LoadViewerGraphAsync(viewerId); await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
if (_entitlements.IsFreeplay) if (tx.IsFreeplay)
return BadRequest(new { error = "already_purchased" }); return BadRequest(new { error = "already_purchased" });
var rewardList = new List<RewardListEntry>(); // Debit set price
var debit = await DebitSetPrice(viewer, series, request.SalesType); switch (request.SalesType)
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState);
// Grant every product's rewards; RewardGrantService is idempotent on already-owned
// cosmetics, so partial-set buyers don't double-add.
foreach (var p in series.Products.OrderBy(p => p.Id))
{ {
await ApplyRewardsAsync(viewer, p.Rewards, rewardList); case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0:
break; // free
case 0:
return BadRequest(new { error = "price_not_available_for_currency" });
case 1:
if (series.SetPriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" });
{ var r = await tx.TrySpendAsync(SpendCurrency.Crystal, series.SetPriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
break;
case 2:
if (series.SetPriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" });
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, series.SetPriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
break;
default:
return BadRequest(new { error = "invalid_sales_type" });
} }
await _db.SaveChangesAsync(); // Grant every product's rewards; tx.GrantAsync is idempotent on already-owned cosmetics.
return new LeaderSkinBuyResponse { RewardList = rewardList }; foreach (var p in series.Products.OrderBy(p => p.Id))
{
foreach (var r in p.Rewards.OrderBy(r => r.OrderIndex))
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
}
var result = await tx.CommitAsync(HttpContext.RequestAborted);
return new LeaderSkinBuyResponse
{
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
};
} }
[HttpPost("buy_set_item")] [HttpPost("buy_set_item")]
@@ -257,16 +290,15 @@ public class LeaderSkinController : SVSimController
if (existingClaim is not null) if (existingClaim is not null)
return new LeaderSkinBuyResponse { RewardList = new() }; return new LeaderSkinBuyResponse { RewardList = new() };
var viewer = await LoadViewerGraphAsync(viewerId); await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
// Must own every skin in the series to claim the bonus. // Must own every skin in the series to claim the bonus.
var ownedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet(); bool ownsAll = series.Products.Count > 0 && series.Products.All(p => tx.OwnsCosmetic(CosmeticType.Skin, p.LeaderSkinId));
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => ownedSkinIds.Contains(p.LeaderSkinId));
if (!ownsAll) if (!ownsAll)
return BadRequest(new { error = "series_not_completed" }); return BadRequest(new { error = "series_not_completed" });
var rewardList = new List<RewardListEntry>(); foreach (var r in series.SetCompletionRewards.OrderBy(r => r.OrderIndex))
await ApplyRewardsAsync(viewer, series.SetCompletionRewards, rewardList); await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
_db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim _db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim
{ {
@@ -275,8 +307,13 @@ public class LeaderSkinController : SVSimController
ClaimedAt = _time.GetUtcNow().UtcDateTime, ClaimedAt = _time.GetUtcNow().UtcDateTime,
}); });
await _db.SaveChangesAsync(); var result = await tx.CommitAsync(HttpContext.RequestAborted);
return new LeaderSkinBuyResponse { RewardList = rewardList }; return new LeaderSkinBuyResponse
{
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
};
} }
/// <summary> /// <summary>
@@ -304,7 +341,7 @@ public class LeaderSkinController : SVSimController
return 1; return 1;
} }
private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, HashSet<int> ownedSkinIds) private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, IReadOnlySet<int> ownedSkinIds)
{ {
bool isPurchased = ownedSkinIds.Contains(p.LeaderSkinId); bool isPurchased = ownedSkinIds.Contains(p.LeaderSkinId);
return new SkinProductDto return new SkinProductDto
@@ -339,7 +376,7 @@ public class LeaderSkinController : SVSimController
/// emblem/sleeve typically come with the skin, so the heuristic is "skin owned → all three /// emblem/sleeve typically come with the skin, so the heuristic is "skin owned → all three
/// bundle items are de-facto owned." Refine later if a capture shows independent state. /// bundle items are de-facto owned." Refine later if a capture shows independent state.
/// </summary> /// </summary>
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, HashSet<int> ownedSkinIds) private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, IReadOnlySet<int> ownedSkinIds)
{ {
// Skin reward: direct check. // Skin reward: direct check.
if (r.RewardType == (int)UserGoodsType.Skin) if (r.RewardType == (int)UserGoodsType.Skin)
@@ -350,94 +387,4 @@ public class LeaderSkinController : SVSimController
return false; return false;
} }
private async Task<(RewardListEntry? PostState, string? Error)> DebitProductPrice(
Viewer viewer, LeaderSkinShopProductEntry product, int salesType)
{
switch (salesType)
{
case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0:
return (null, null);
case 0:
return (null, "price_not_available_for_currency");
case 1:
if (product.SinglePriceCrystal is null) return (null, "price_not_available_for_currency");
return await DebitCrystal(viewer, product.SinglePriceCrystal.Value);
case 2:
if (product.SinglePriceRupy is null) return (null, "price_not_available_for_currency");
return await DebitRupy(viewer, product.SinglePriceRupy.Value);
default:
return (null, "invalid_sales_type");
}
}
private async Task<(RewardListEntry? PostState, string? Error)> DebitSetPrice(
Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType)
{
switch (salesType)
{
case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0:
return (null, null);
case 0:
return (null, "price_not_available_for_currency");
case 1:
if (series.SetPriceCrystal is null) return (null, "price_not_available_for_currency");
return await DebitCrystal(viewer, series.SetPriceCrystal.Value);
case 2:
if (series.SetPriceRupy is null) return (null, "price_not_available_for_currency");
return await DebitRupy(viewer, series.SetPriceRupy.Value);
default:
return (null, "invalid_sales_type");
}
}
private async Task<(RewardListEntry?, string?)> DebitCrystal(Viewer viewer, int amount)
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, amount);
if (!r.Success) return (null, "insufficient_crystals");
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
private async Task<(RewardListEntry?, string?)> DebitRupy(Viewer viewer, int amount)
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, amount);
if (!r.Success) return (null, "insufficient_rupees");
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
private async Task ApplyRewardsAsync<T>(
Viewer viewer, IEnumerable<T> rewards, List<RewardListEntry> rewardList) where T : notnull
{
foreach (var r in rewards)
{
var (type, detailId, number) = ExtractTuple(r);
var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)type, detailId, number);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
}
}
private static (int Type, long Id, int Num) ExtractTuple(object reward) => reward switch
{
LeaderSkinShopProductRewardEntry p => (p.RewardType, p.RewardDetailId, p.RewardNumber),
LeaderSkinShopSeriesRewardEntry s => (s.RewardType, s.RewardDetailId, s.RewardNumber),
_ => throw new InvalidOperationException($"unexpected reward type {reward.GetType().Name}"),
};
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.LeaderSkins)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
} }

View File

@@ -10,6 +10,7 @@ using PreReleaseInfoDto = SVSim.EmulatedEntrypoint.Models.Dtos.PreReleaseInfo;
using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer; using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Infrastructure; using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
@@ -42,26 +43,24 @@ public class LoadController : SVSimController
private readonly IViewerRepository _viewerRepository; private readonly IViewerRepository _viewerRepository;
private readonly IGlobalsRepository _globalsRepository; private readonly IGlobalsRepository _globalsRepository;
private readonly ICardAcquisitionService _acquisition;
private readonly IGameConfigService _config; private readonly IGameConfigService _config;
private readonly IBattlePassService _battlePass; private readonly IBattlePassService _battlePass;
private readonly IViewerMissionStateService _missionState; private readonly IViewerMissionStateService _missionState;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly IViewerEntitlements _entitlements; private readonly IInventoryService _inv;
public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
ICardAcquisitionService acquisition, IGameConfigService config, IGameConfigService config,
IBattlePassService battlePass, IViewerMissionStateService missionState, IBattlePassService battlePass, IViewerMissionStateService missionState,
SVSimDbContext db, IViewerEntitlements entitlements) SVSimDbContext db, IInventoryService inv)
{ {
_viewerRepository = viewerRepository; _viewerRepository = viewerRepository;
_globalsRepository = globalsRepository; _globalsRepository = globalsRepository;
_acquisition = acquisition;
_config = config; _config = config;
_battlePass = battlePass; _battlePass = battlePass;
_missionState = missionState; _missionState = missionState;
_db = db; _db = db;
_entitlements = entitlements; _inv = inv;
} }
[HttpPost("index")] [HttpPost("index")]
@@ -84,7 +83,9 @@ public class LoadController : SVSimController
// .AsNoTracking() — the local `viewer` instance is detached, and the service's writes // .AsNoTracking() — the local `viewer` instance is detached, and the service's writes
// (on a separate tracked instance) won't appear on this snapshot. Without the re-fetch, // (on a separate tracked instance) won't appear on this snapshot. Without the re-fetch,
// the response payload would be one /load/index behind on newly-granted cosmetics. // the response payload would be one /load/index behind on newly-granted cosmetics.
await _acquisition.BackfillCosmeticsAsync(viewer.Id); await using var tx = await _inv.BeginAsync(viewer.Id, ct);
await tx.BackfillCardCosmeticsAsync(ct);
await tx.CommitAsync(ct);
// Lazy-materialize mission/achievement state. Idempotent — safe to call every /load/index. // Lazy-materialize mission/achievement state. Idempotent — safe to call every /load/index.
await _missionState.EnsureCurrentAsync(viewer.Id); await _missionState.EnsureCurrentAsync(viewer.Id);
@@ -125,9 +126,9 @@ public class LoadController : SVSimController
// re-confirm the filter if we later move to Option B and start iterating card-sets. // re-confirm the filter if we later move to Option B and start iterating card-sets.
// Owned-card projection (incl. the freeplay "all cards" path) lives in the entitlements // Owned-card projection (incl. the freeplay "all cards" path) lives in the entitlements
// service so both modes share one definition. // service so both modes share one definition.
var allCardsAsOwned = await _entitlements.EffectiveOwnedCardsAsync(viewer, ct); var allCardsAsOwned = await _inv.EffectiveOwnedCardsAsync(viewer, ct);
var cosmetics = await _entitlements.EffectiveCosmeticsAsync(viewer, ct); var cosmetics = await _inv.EffectiveCosmeticsAsync(viewer, ct);
var classExpCurve = await _globalsRepository.GetClassExpCurve(); var classExpCurve = await _globalsRepository.GetClassExpCurve();
List<ClassExp> classExps = new(); List<ClassExp> classExps = new();
@@ -168,10 +169,10 @@ public class LoadController : SVSimController
UserInfo = new UserInfo(deviceType, viewer), UserInfo = new UserInfo(deviceType, viewer),
UserCurrency = new UserCurrency(viewer) UserCurrency = new UserCurrency(viewer)
{ {
Crystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal), Crystals = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Crystal),
TotalCrystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal), TotalCrystals = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Crystal),
Rupees = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee), Rupees = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Rupee),
RedEther = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.RedEther), RedEther = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.RedEther),
}, },
UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(), UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
SpotPoint = checked((int)viewer.Currency.SpotPoints), SpotPoint = checked((int)viewer.Currency.SpotPoints),

View File

@@ -11,6 +11,7 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers; namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -30,10 +31,8 @@ public class PackController : SVSimController
private readonly ICardFoilLookup _foils; private readonly ICardFoilLookup _foils;
private readonly IRandom _rng; private readonly IRandom _rng;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly ICardAcquisitionService _acquisition; private readonly IInventoryService _inv;
private readonly IGachaPointService _gachaPoint; private readonly IGachaPointService _gachaPoint;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
public PackController( public PackController(
IPackRepository packs, IPackRepository packs,
@@ -42,10 +41,8 @@ public class PackController : SVSimController
ICardFoilLookup foils, ICardFoilLookup foils,
IRandom rng, IRandom rng,
SVSimDbContext db, SVSimDbContext db,
ICardAcquisitionService acquisition, IInventoryService inv,
IGachaPointService gachaPoint, IGachaPointService gachaPoint)
ICurrencySpendService spend,
IViewerEntitlements entitlements)
{ {
_packs = packs; _packs = packs;
_opener = opener; _opener = opener;
@@ -53,10 +50,8 @@ public class PackController : SVSimController
_foils = foils; _foils = foils;
_rng = rng; _rng = rng;
_db = db; _db = db;
_acquisition = acquisition; _inv = inv;
_gachaPoint = gachaPoint; _gachaPoint = gachaPoint;
_spend = spend;
_entitlements = entitlements;
} }
[HttpPost("info")] [HttpPost("info")]
@@ -207,26 +202,18 @@ public class PackController : SVSimController
{ {
if (!TryGetViewerId(out long viewerId)) return Unauthorized(); if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// Load the viewer with the collections the service mutates (balances, received marker, // Open inventory tx with extra includes for GachaPointBalances + GachaPointReceived
// cards, cosmetics). AsSplitQuery per project_ef_split_query memory. // (needed by TryExchangeAsync to validate balance and already-received guard).
var viewer = await _db.Viewers await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg
.Include(v => v.GachaPointBalances) .WithInclude(v => v.GachaPointBalances)
.Include(v => v.GachaPointReceived) .WithInclude(v => v.GachaPointReceived));
.Include(v => v.Cards)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.LeaderSkins)
.Include(v => v.MyPageBackgrounds)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// Use odds_gacha_id (the seasonal pack id) — that's where the balance / received marker // Use odds_gacha_id (the seasonal pack id) — that's where the balance / received marker
// live. Mirrors the GetGachaPointRewards fix. // live. Mirrors the GetGachaPointRewards fix.
var outcome = await _gachaPoint.TryExchangeAsync(viewer, request.OddsGachaId, request.CardId); var outcome = await _gachaPoint.TryExchangeAsync(tx, request.OddsGachaId, request.CardId);
if (!outcome.Success) return BadRequest(new { error = outcome.Error }); if (!outcome.Success) return BadRequest(new { error = outcome.Error });
await _db.SaveChangesAsync(); await tx.CommitAsync();
return new ExchangeGachaPointResponse return new ExchangeGachaPointResponse
{ {
@@ -287,13 +274,12 @@ public class PackController : SVSimController
if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7)) if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7))
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" }); return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
var viewer = await _db.Viewers // Load viewer via InventoryService transaction with extra includes for pack-open needs.
.Include(v => v.PackOpenCounts) await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg
.Include(v => v.GachaPointBalances) .WithInclude(v => v.PackOpenCounts)
.Include(v => v.MissionData) .WithInclude(v => v.GachaPointBalances)
.Include(v => v.Items).ThenInclude(i => i.Item) .WithInclude(v => v.MissionData));
.AsSplitQuery() var viewer = tx.Viewer;
.FirstAsync(v => v.Id == viewerId);
// Tutorial alias is only valid pre-END. After state>=100 the viewer has already // Tutorial alias is only valid pre-END. After state>=100 the viewer has already
// completed the tutorial — re-running the path would re-consume the ticket they // completed the tutorial — re-running the path would re-consume the ticket they
@@ -314,7 +300,7 @@ public class PackController : SVSimController
case 2: // CRYSTAL_MULTI (10-pack) case 2: // CRYSTAL_MULTI (10-pack)
{ {
long cost = (long)child.Cost * packNumber; long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, cost); var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
break; break;
} }
@@ -322,7 +308,7 @@ public class PackController : SVSimController
case 7: // RUPY_MULTI (10-pack) case 7: // RUPY_MULTI (10-pack)
{ {
long cost = (long)child.Cost * packNumber; long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost); var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break; break;
} }
@@ -336,7 +322,7 @@ public class PackController : SVSimController
return BadRequest(new { error = "daily_free_already_claimed" }); return BadRequest(new { error = "daily_free_already_claimed" });
long cost = (long)child.Cost * packNumber; long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost); var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break; break;
} }
@@ -347,15 +333,11 @@ public class PackController : SVSimController
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" }); return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
int ticketsNeeded = child.Cost * packNumber; int ticketsNeeded = child.Cost * packNumber;
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded);
if (owned is null || owned.Count < ticketsNeeded) if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" });
return BadRequest(new { error = "insufficient_tickets" });
owned.Count -= ticketsNeeded;
break; break;
} }
} }
await _db.SaveChangesAsync();
} }
// Increment open count + mark daily-free timestamp where relevant. // Increment open count + mark daily-free timestamp where relevant.
@@ -394,48 +376,17 @@ public class PackController : SVSimController
ownedCardIds, ownedCardIds,
_foils, _foils,
_rng); _rng);
var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId));
// Grant drawn cards through the transaction — cosmetic cascade fires on first-time owners.
foreach (var grp in draw.Cards.GroupBy(c => c.CardId))
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count());
// Accrue gacha points (skip tutorial path — the starter pack isn't a real open). // Accrue gacha points (skip tutorial path — the starter pack isn't a real open).
if (!isTutorialPath) if (!isTutorialPath)
{ {
_gachaPoint.Accrue(viewer, pack, child, drawCount); _gachaPoint.Accrue(viewer, pack, child, drawCount);
await _db.SaveChangesAsync();
} }
// Build reward_list. The service produces the type=5 (Card) entries with post-state counts
// plus any cosmetic grants. Currency entry (type=2 Crystals or type=9 Rupy) stays in the
// controller — it's a pack-purchase concern, not a card-grant concern. The client's
// PlayerStaticData.UpdateHaveUserGoodsNum does direct assignment, so currency/card counts
// must be the new TOTAL — emitting deltas would leave the on-screen balances stale.
var rewardList = new List<RewardListEntry>();
// Currency reward entries only apply to purchasable packs; tutorial path omits them.
if (!isTutorialPath)
{
if (child.TypeDetail is 1 or 2)
{
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) });
}
else if (child.TypeDetail is 3 or 6 or 7)
{
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) });
}
else if (child.TypeDetail is 4 or 5 && child.ItemId is long ticketItemId)
{
// Item post-state count for the ticket we just consumed — client direct-assigns
// _userItemDict, so this must be the new total (project_wire_reward_list_post_state).
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
rewardList.Add(new RewardListEntry
{
RewardType = 4, // Item
RewardId = ticketItemId,
RewardNum = owned?.Count ?? 0, // post-state total
});
}
}
rewardList.AddRange(grant.RewardList);
// Tutorial path consumes the granted ticket (same item_id used to gate display) so the // Tutorial path consumes the granted ticket (same item_id used to gate display) so the
// pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still // pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still
// shows item_number=1 after the tutorial pack-open, the client lets the user re-click // shows item_number=1 after the tutorial pack-open, the client lets the user re-click
@@ -447,19 +398,12 @@ public class PackController : SVSimController
int? responseTutorialStep = null; int? responseTutorialStep = null;
if (isTutorialPath) if (isTutorialPath)
{ {
if (child.ItemId is long ticketItemId) if (child.ItemId is long tutorialTicketItemId)
{ {
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); int ticketsToConsume = packNumber;
if (owned is not null) var debit = await tx.TryDebitAsync(UserGoodsType.Item, tutorialTicketItemId, ticketsToConsume);
{ // Silently accept if the viewer doesn't have the ticket (already consumed or never granted)
owned.Count = Math.Max(0, owned.Count - packNumber); _ = debit;
rewardList.Add(new RewardListEntry
{
RewardType = 4, // Item
RewardId = ticketItemId,
RewardNum = owned.Count, // POST-STATE total
});
}
} }
// Max-preserve: never regress the persisted state, even though Gate B already // Max-preserve: never regress the persisted state, even though Gate B already
@@ -468,10 +412,16 @@ public class PackController : SVSimController
// the tutorial-END signal the client expects. // the tutorial-END signal the client expects.
if (viewer.MissionData.TutorialState < TutorialEndStep) if (viewer.MissionData.TutorialState < TutorialEndStep)
viewer.MissionData.TutorialState = TutorialEndStep; viewer.MissionData.TutorialState = TutorialEndStep;
await _db.SaveChangesAsync();
responseTutorialStep = TutorialEndStep; responseTutorialStep = TutorialEndStep;
} }
// CommitAsync saves all mutations and produces reward_list with currency-collision resolved.
// Tutorial path never calls TrySpendAsync so no currency op is in the log — correct.
var result = await tx.CommitAsync(HttpContext.RequestAborted);
var rewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList();
return new PackOpenResponse return new PackOpenResponse
{ {
PackList = draw.Cards.Select(c => new CardPackEntryDto PackList = draw.Cards.Select(c => new CardPackEntryDto

View File

@@ -1,12 +1,11 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SVSim.Database; using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer; using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services; using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle; using SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle;
@@ -26,20 +25,20 @@ public class PuzzleController : SVSimController
private readonly IPuzzleCatalogRepository _catalog; private readonly IPuzzleCatalogRepository _catalog;
private readonly IPuzzleClearRepository _clears; private readonly IPuzzleClearRepository _clears;
private readonly PuzzleMissionEvaluator _evaluator; private readonly PuzzleMissionEvaluator _evaluator;
private readonly RewardGrantService _rewards; private readonly IInventoryService _inv;
private readonly ILogger<PuzzleController> _logger; private readonly ILogger<PuzzleController> _logger;
public PuzzleController( public PuzzleController(
IPuzzleCatalogRepository catalog, IPuzzleCatalogRepository catalog,
IPuzzleClearRepository clears, IPuzzleClearRepository clears,
PuzzleMissionEvaluator evaluator, PuzzleMissionEvaluator evaluator,
RewardGrantService rewards, IInventoryService inv,
ILogger<PuzzleController> logger) ILogger<PuzzleController> logger)
{ {
_catalog = catalog; _catalog = catalog;
_clears = clears; _clears = clears;
_evaluator = evaluator; _evaluator = evaluator;
_rewards = rewards; _inv = inv;
_logger = logger; _logger = logger;
} }
@@ -175,28 +174,15 @@ public class PuzzleController : SVSimController
if (fresh.Count > 0) if (fresh.Count > 0)
{ {
// Load viewer with all the collections RewardGrantService might mutate. Split-query await using var tx = await _inv.BeginAsync(viewerId);
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query").
var ctx = HttpContext.RequestServices.GetRequiredService<SVSimDbContext>();
var viewer = await ctx.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)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
foreach (var status in fresh) foreach (var status in fresh)
{ {
IReadOnlyList<GrantedReward> granted; IReadOnlyList<SVSim.Database.Services.GrantedReward> granted;
try try
{ {
granted = await _rewards.ApplyAsync( granted = await tx.GrantAsync(
viewer, (UserGoodsType)status.Mission.RewardType,
(SVSim.Database.Enums.UserGoodsType)status.Mission.RewardType,
status.Mission.RewardDetailId, status.Mission.RewardDetailId,
status.Mission.RewardNumber); status.Mission.RewardNumber);
} }
@@ -229,7 +215,7 @@ public class PuzzleController : SVSimController
} }
} }
await ctx.SaveChangesAsync(); await tx.CommitAsync();
} }
response.WinCount = "1"; response.WinCount = "1";

View File

@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Sleeve; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Sleeve;
@@ -20,17 +21,13 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class SleeveController : SVSimController public class SleeveController : SVSimController
{ {
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards; private readonly IInventoryService _inv;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
private readonly ICollectionRepository _collection; private readonly ICollectionRepository _collection;
public SleeveController(SVSimDbContext db, RewardGrantService rewards, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection) public SleeveController(SVSimDbContext db, IInventoryService inv, ICollectionRepository collection)
{ {
_db = db; _db = db;
_rewards = rewards; _inv = inv;
_spend = spend;
_entitlements = entitlements;
_collection = collection; _collection = collection;
} }
@@ -42,12 +39,13 @@ public class SleeveController : SVSimController
// is_purchased_product is "viewer owns at least one sleeve granted by this product". // is_purchased_product is "viewer owns at least one sleeve granted by this product".
// Loading the viewer's sleeve-id set once and checking each product against it avoids // Loading the viewer's sleeve-id set once and checking each product against it avoids
// an N+1 over products. // an N+1 over products.
var ownedSleeveIds = _entitlements.IsFreeplay var viewerForInfo = await _db.Viewers
? (await _collection.GetAllSleeveIds()).Select(id => (long)id).ToHashSet() .Include(v => v.Sleeves)
: (await _db.Viewers .FirstOrDefaultAsync(v => v.Id == viewerId);
.Where(v => v.Id == viewerId) if (viewerForInfo is null) return Unauthorized();
.SelectMany(v => v.Sleeves.Select(s => (long)s.Id))
.ToListAsync()).ToHashSet(); var cosmeticsForInfo = await _inv.EffectiveCosmeticsAsync(viewerForInfo);
var ownedSleeveIds = cosmeticsForInfo.SleeveIds.Select(id => (long)id).ToHashSet();
var series = await _db.SleeveShopSeries var series = await _db.SleeveShopSeries
.Where(s => s.IsEnabled) .Where(s => s.IsEnabled)
@@ -113,18 +111,17 @@ public class SleeveController : SVSimController
if (product.SeriesId != request.SeriesId) if (product.SeriesId != request.SeriesId)
return BadRequest(new { error = "series_product_mismatch" }); return BadRequest(new { error = "series_product_mismatch" });
var viewer = await LoadViewerGraphAsync(viewerId); await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
if (_entitlements.IsFreeplay) if (tx.IsFreeplay)
return BadRequest(new { error = "already_purchased" }); return BadRequest(new { error = "already_purchased" });
if (IsProductPurchased(product, viewer.Sleeves.Select(s => (long)s.Id).ToHashSet())) if (IsProductPurchased(product, tx.Viewer.Sleeves.Select(s => (long)s.Id).ToHashSet()))
return BadRequest(new { error = "already_purchased" }); return BadRequest(new { error = "already_purchased" });
// Pricing: capture-confirmed shape is single-price-per-currency (no intro/regular tiers // Pricing: capture-confirmed shape is single-price-per-currency (no intro/regular tiers
// like BuildDeck). At least one of crystal/rupy must match the chosen sales_type; // like BuildDeck). At least one of crystal/rupy must match the chosen sales_type;
// sales_type==0 means "free", which requires both prices == 0. // sales_type==0 means "free", which requires both prices == 0.
var rewardList = new List<RewardListEntry>();
switch (request.SalesType) switch (request.SalesType)
{ {
case 0: // free case 0: // free
@@ -134,39 +131,27 @@ public class SleeveController : SVSimController
case 1: // crystal case 1: // crystal
if (product.PriceCrystal is null) if (product.PriceCrystal is null)
return BadRequest(new { error = "price_not_available_for_currency" }); return BadRequest(new { error = "price_not_available_for_currency" });
var crystalRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, product.PriceCrystal.Value); { var r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.PriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
if (!crystalRes.Success) return BadRequest(new { error = "insufficient_crystals" });
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)crystalRes.PostStateTotal });
break; break;
case 2: // rupy case 2: // rupy
if (product.PriceRupy is null) if (product.PriceRupy is null)
return BadRequest(new { error = "price_not_available_for_currency" }); return BadRequest(new { error = "price_not_available_for_currency" });
var rupyRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, product.PriceRupy.Value); { var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.PriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
if (!rupyRes.Success) return BadRequest(new { error = "insufficient_rupees" });
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)rupyRes.PostStateTotal });
break; break;
} }
// Grant each catalog reward through the central dispatcher — covers sleeve (6), emblem // Grant each catalog reward through the central dispatcher.
// (7), and any future bundled grants. ApplyAsync returns post-state-aware reward entries
// suitable for emission as-is.
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex)) foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
var result = await tx.CommitAsync(HttpContext.RequestAborted);
return new SleeveBuyResponse
{ {
var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); RewardList = result.RewardList
foreach (var g in granted) .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
{ .ToList(),
rewardList.Add(new RewardListEntry };
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
}
await _db.SaveChangesAsync();
return new SleeveBuyResponse { RewardList = rewardList };
} }
/// <summary> /// <summary>
@@ -185,14 +170,4 @@ public class SleeveController : SVSimController
return false; return false;
} }
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.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)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
} }

View File

@@ -4,6 +4,7 @@ using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.SpotCardExchange; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.SpotCardExchange;
@@ -14,8 +15,7 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary> /// <summary>
/// /spot_card_exchange/* — trade spot points for individual cards from the rotating exchange /// /spot_card_exchange/* — trade spot points for individual cards from the rotating exchange
/// pool. Spot points are earned from battles/missions (not implemented here — earners live in /// pool. Spot points are earned from battles/missions (not implemented here — earners live in
/// battle/mission finish reward emitters via <see cref="RewardGrantService"/> + /// battle/mission finish reward emitters via <see cref="UserGoodsType.SpotCardPoint"/>).
/// <see cref="UserGoodsType.SpotCardPoint"/>).
/// </summary> /// </summary>
[Route("spot_card_exchange")] [Route("spot_card_exchange")]
public class SpotCardExchangeController : SVSimController public class SpotCardExchangeController : SVSimController
@@ -28,16 +28,14 @@ public class SpotCardExchangeController : SVSimController
private const int PreReleaseLimit = 2; private const int PreReleaseLimit = 2;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards; private readonly IInventoryService _inv;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly ICurrencySpendService _spend;
public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend) public SpotCardExchangeController(SVSimDbContext db, IInventoryService inv, TimeProvider time)
{ {
_db = db; _db = db;
_rewards = rewards; _inv = inv;
_time = time; _time = time;
_spend = spend;
} }
[HttpPost("top")] [HttpPost("top")]
@@ -126,14 +124,14 @@ public class SpotCardExchangeController : SVSimController
return BadRequest(new { error = "pre_release_limit_reached" }); return BadRequest(new { error = "pre_release_limit_reached" });
} }
var viewer = await LoadViewerGraphAsync(viewerId); await using var tx = await _inv.BeginAsync(viewerId);
var rewardList = new List<RewardListEntry>(); var rewardList = new List<RewardListEntry>();
// Debit spot points. Client-supplied exchange_point isn't authoritative — server uses // Debit spot points. Client-supplied exchange_point isn't authoritative — server uses
// catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry // catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry
// first, then grants. // first, then grants.
var spotRes = await _spend.TrySpendAsync(viewer, SpendCurrency.SpotPoint, entry.ExchangePoint); var spotRes = await tx.TrySpendAsync(SpendCurrency.SpotPoint, entry.ExchangePoint);
if (!spotRes.Success) if (!spotRes.Success)
return BadRequest(new { error = "insufficient_spot_points" }); return BadRequest(new { error = "insufficient_spot_points" });
rewardList.Add(new RewardListEntry rewardList.Add(new RewardListEntry
@@ -143,8 +141,8 @@ public class SpotCardExchangeController : SVSimController
RewardNum = checked((int)spotRes.PostStateTotal), RewardNum = checked((int)spotRes.PostStateTotal),
}); });
// Grant the card itself via the existing card dispatcher (handles cosmetic cascade). // Grant the card itself via the inventory tx (handles cosmetic cascade).
var granted = await _rewards.ApplyAsync(viewer, UserGoodsType.Card, entry.Id, 1); var granted = await tx.GrantAsync(UserGoodsType.Card, entry.Id, 1);
foreach (var g in granted) foreach (var g in granted)
{ {
rewardList.Add(new RewardListEntry rewardList.Add(new RewardListEntry
@@ -163,7 +161,7 @@ public class SpotCardExchangeController : SVSimController
ExchangedAt = _time.GetUtcNow().UtcDateTime, ExchangedAt = _time.GetUtcNow().UtcDateTime,
}); });
await _db.SaveChangesAsync(); await tx.CommitAsync();
return new SpotCardExchangeResponse { RewardList = rewardList }; return new SpotCardExchangeResponse { RewardList = rewardList };
} }
@@ -182,14 +180,4 @@ public class SpotCardExchangeController : SVSimController
return 0; return 0;
} }
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _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)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
} }

View File

@@ -84,10 +84,8 @@ public class Program
builder.Services.AddScoped<ICardFoilLookup, DbCardFoilLookup>(); builder.Services.AddScoped<ICardFoilLookup, DbCardFoilLookup>();
builder.Services.AddScoped<PackOpenService>(); builder.Services.AddScoped<PackOpenService>();
builder.Services.AddScoped<IGachaPointService, GachaPointService>(); builder.Services.AddScoped<IGachaPointService, GachaPointService>();
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>(); builder.Services.AddScoped<SVSim.Database.Services.Inventory.IInventoryService,
builder.Services.AddScoped<RewardGrantService>(); 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, builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IBattlePassRepository,
SVSim.Database.Repositories.BattlePass.BattlePassRepository>(); SVSim.Database.Repositories.BattlePass.BattlePassRepository>();
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IViewerBattlePassRepository, builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IViewerBattlePassRepository,

View File

@@ -1,10 +1,12 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SVSim.Database; using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer; using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick; using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
@@ -17,11 +19,9 @@ public class ArenaTwoPickService : IArenaTwoPickService
private readonly IArenaTwoPickCardPoolService _pool; private readonly IArenaTwoPickCardPoolService _pool;
private readonly IGameConfigService _config; private readonly IGameConfigService _config;
private readonly IViewerRepository _viewers; private readonly IViewerRepository _viewers;
private readonly RewardGrantService _grants; private readonly IInventoryService _inv;
private readonly IViewerEntitlements _entitlements;
private readonly IRandom _rng; private readonly IRandom _rng;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly ICurrencySpendService _spend;
public ArenaTwoPickService( public ArenaTwoPickService(
IArenaTwoPickRunRepository runs, IArenaTwoPickRunRepository runs,
@@ -29,15 +29,12 @@ public class ArenaTwoPickService : IArenaTwoPickService
IArenaTwoPickCardPoolService pool, IArenaTwoPickCardPoolService pool,
IGameConfigService config, IGameConfigService config,
IViewerRepository viewers, IViewerRepository viewers,
RewardGrantService grants, IInventoryService inv,
IViewerEntitlements entitlements,
IRandom rng, IRandom rng,
SVSimDbContext db, SVSimDbContext db)
ICurrencySpendService spend)
{ {
_runs = runs; _rewards = rewards; _pool = pool; _config = config; _runs = runs; _rewards = rewards; _pool = pool; _config = config;
_viewers = viewers; _grants = grants; _entitlements = entitlements; _rng = rng; _db = db; _viewers = viewers; _inv = inv; _rng = rng; _db = db;
_spend = spend;
} }
public async Task<TopResponseDto> GetTopAsync(long viewerId) public async Task<TopResponseDto> GetTopAsync(long viewerId)
@@ -66,14 +63,16 @@ public class ArenaTwoPickService : IArenaTwoPickService
throw new ArenaTwoPickException("arena_two_pick_already_in_progress"); throw new ArenaTwoPickException("arena_two_pick_already_in_progress");
var aCfg = _config.Get<SVSim.Database.Models.Config.ArenaTwoPickConfig>(); var aCfg = _config.Get<SVSim.Database.Models.Config.ArenaTwoPickConfig>();
var viewer = await LoadViewerForGrantsAsync(viewerId);
// Open inventory tx for currency/item debit.
await using var tx = await _inv.BeginAsync(viewerId);
// Dispatch on the client's chosen payment method (ArenaData.eARENA_PAY). // Dispatch on the client's chosen payment method (ArenaData.eARENA_PAY).
RewardEntryDto? feeEntry = consumeItemType switch RewardEntryDto? feeEntry = consumeItemType switch
{ {
1 => await DebitCrystalsAsync(viewer, aCfg.CrystalCost), 1 => await DebitCrystalsAsync(tx, aCfg.CrystalCost),
3 => DebitTicket(viewer, aCfg.TicketItemId, aCfg.TicketCost), 3 => await DebitTicketAsync(tx, aCfg.TicketItemId, aCfg.TicketCost),
4 => await DebitRupiesAsync(viewer, aCfg.RupyCost), 4 => await DebitRupiesAsync(tx, aCfg.RupyCost),
5 => null, // Free entry — no fee. 5 => null, // Free entry — no fee.
_ => throw new ArenaTwoPickException("invalid_consume_item_type"), _ => throw new ArenaTwoPickException("invalid_consume_item_type"),
}; };
@@ -102,9 +101,12 @@ public class ArenaTwoPickService : IArenaTwoPickService
IsRetire = false, IsRetire = false,
}; };
await _runs.UpsertAsync(run); await _runs.UpsertAsync(run);
// Save to get auto-generated Id before CommitAsync.
await _db.SaveChangesAsync();
run.EntryId = run.Id; run.EntryId = run.Id;
await _runs.UpsertAsync(run); await _runs.UpsertAsync(run);
await _db.SaveChangesAsync(); // CommitAsync saves all pending changes (including run update) and commits the db tx.
await tx.CommitAsync();
var rewardList = feeEntry is null ? new List<RewardEntryDto>() : new List<RewardEntryDto> { feeEntry }; var rewardList = feeEntry is null ? new List<RewardEntryDto>() : new List<RewardEntryDto> { feeEntry };
@@ -117,50 +119,50 @@ public class ArenaTwoPickService : IArenaTwoPickService
}; };
} }
private RewardEntryDto DebitTicket(SVSim.Database.Models.Viewer viewer, int ticketItemId, int ticketCost) private async Task<RewardEntryDto> DebitTicketAsync(IInventoryTransaction tx, int ticketItemId, int ticketCost)
{ {
var ticket = viewer.Items.FirstOrDefault(i => i.Item.Id == ticketItemId); if (tx.IsFreeplay)
int postStateCount;
if (_entitlements.IsFreeplay)
{ {
postStateCount = ticket?.Count ?? 0; var ticket = tx.Viewer.Items.FirstOrDefault(i => i.Item.Id == ticketItemId);
} return new RewardEntryDto
else {
{ RewardType = (int)UserGoodsType.Item,
if (ticket is null || ticket.Count < ticketCost) RewardId = ticketItemId,
throw new ArenaTwoPickException("insufficient_ticket"); RewardNum = ticket?.Count ?? 0,
ticket.Count -= ticketCost; };
postStateCount = ticket.Count;
} }
var debitResult = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketCost);
if (!debitResult.Success)
throw new ArenaTwoPickException("insufficient_ticket");
return new RewardEntryDto return new RewardEntryDto
{ {
RewardType = (int)SVSim.Database.Enums.UserGoodsType.Item, RewardType = (int)UserGoodsType.Item,
RewardId = ticketItemId, RewardId = ticketItemId,
RewardNum = postStateCount, RewardNum = (int)debitResult.PostStateTotal,
}; };
} }
private async Task<RewardEntryDto> DebitCrystalsAsync(SVSim.Database.Models.Viewer viewer, int cost) private async Task<RewardEntryDto> DebitCrystalsAsync(IInventoryTransaction tx, int cost)
{ {
var result = await _spend.TrySpendAsync(viewer, SVSim.Database.Services.SpendCurrency.Crystal, cost); var result = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
if (!result.Success) if (!result.Success)
throw new ArenaTwoPickException("insufficient_crystal"); throw new ArenaTwoPickException("insufficient_crystal");
return new RewardEntryDto return new RewardEntryDto
{ {
RewardType = (int)SVSim.Database.Enums.UserGoodsType.Crystal, RewardType = (int)UserGoodsType.Crystal,
RewardId = 0, RewardId = 0,
RewardNum = (int)result.PostStateTotal, RewardNum = (int)result.PostStateTotal,
}; };
} }
private async Task<RewardEntryDto> DebitRupiesAsync(SVSim.Database.Models.Viewer viewer, int cost) private async Task<RewardEntryDto> DebitRupiesAsync(IInventoryTransaction tx, int cost)
{ {
var result = await _spend.TrySpendAsync(viewer, SVSim.Database.Services.SpendCurrency.Rupee, cost); var result = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!result.Success) if (!result.Success)
throw new ArenaTwoPickException("insufficient_rupy"); throw new ArenaTwoPickException("insufficient_rupy");
return new RewardEntryDto return new RewardEntryDto
{ {
RewardType = (int)SVSim.Database.Enums.UserGoodsType.Rupy, RewardType = (int)UserGoodsType.Rupy,
RewardId = 0, RewardId = 0,
RewardNum = (int)result.PostStateTotal, RewardNum = (int)result.PostStateTotal,
}; };
@@ -295,12 +297,11 @@ public class ArenaTwoPickService : IArenaTwoPickService
throw new ArenaTwoPickException("arena_two_pick_run_not_complete"); throw new ArenaTwoPickException("arena_two_pick_run_not_complete");
var rewardRows = await _rewards.GetRewardsByWinCountAsync(run.WinCount); var rewardRows = await _rewards.GetRewardsByWinCountAsync(run.WinCount);
var viewer = await LoadViewerForGrantsAsync(viewerId);
// Pre-load item_type for any Item-typed reward so we can populate it on the // Pre-load item_type for any Item-typed reward so we can populate it on the
// per-grant delta entries. Currencies don't need a lookup (item_type stays 0). // per-grant delta entries. Currencies don't need a lookup (item_type stays 0).
var itemRewardIds = rewardRows var itemRewardIds = rewardRows
.Where(r => r.RewardType == (int)SVSim.Database.Enums.UserGoodsType.Item) .Where(r => r.RewardType == (int)UserGoodsType.Item)
.Select(r => (int)r.RewardId) .Select(r => (int)r.RewardId)
.Distinct() .Distinct()
.ToList(); .ToList();
@@ -310,7 +311,9 @@ public class ArenaTwoPickService : IArenaTwoPickService
.ToDictionaryAsync(i => i.Id, i => i.Type); .ToDictionaryAsync(i => i.Id, i => i.Type);
var deltas = new List<TwoPickRewardReceivedDto>(); var deltas = new List<TwoPickRewardReceivedDto>();
var picks = new List<SVSim.Database.Models.ArenaTwoPickReward>();
// Open inventory tx for grants.
await using var tx = await _inv.BeginAsync(viewerId);
// Group by RewardGroup, weighted-pick one row per group (Weight==0 excluded). // Group by RewardGroup, weighted-pick one row per group (Weight==0 excluded).
foreach (var group in rewardRows.GroupBy(r => r.RewardGroup)) foreach (var group in rewardRows.GroupBy(r => r.RewardGroup))
@@ -318,13 +321,11 @@ public class ArenaTwoPickService : IArenaTwoPickService
var pickable = group.Where(r => r.Weight > 0).ToList(); var pickable = group.Where(r => r.Weight > 0).ToList();
if (pickable.Count == 0) continue; if (pickable.Count == 0) continue;
var pick = WeightedPick(pickable, _rng); var pick = WeightedPick(pickable, _rng);
picks.Add(pick);
// Skip when the rolled outcome is "nothing" (RewardNum == 0). // Skip when the rolled outcome is "nothing" (RewardNum == 0).
if (pick.RewardNum <= 0) continue; if (pick.RewardNum <= 0) continue;
var goodsType = (SVSim.Database.Enums.UserGoodsType)pick.RewardType; await tx.GrantAsync((UserGoodsType)pick.RewardType, pick.RewardId, pick.RewardNum);
await _grants.ApplyAsync(viewer, goodsType, pick.RewardId, pick.RewardNum);
deltas.Add(new TwoPickRewardReceivedDto deltas.Add(new TwoPickRewardReceivedDto
{ {
RewardType = pick.RewardType, RewardType = pick.RewardType,
@@ -334,11 +335,12 @@ public class ArenaTwoPickService : IArenaTwoPickService
IsUsable = true, IsUsable = true,
}); });
} }
await _db.SaveChangesAsync();
// ComputePostStateRewardList reads from the picked rows only — same set the var result = await tx.CommitAsync();
// grants were applied for — so the post-state list mirrors the deltas exactly.
var postStates = ComputePostStateRewardList(picks.Where(p => p.RewardNum > 0).ToList(), viewer); var postStates = result.RewardList
.Select(g => new RewardEntryDto { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList();
await _runs.DeleteAsync(viewerId); await _runs.DeleteAsync(viewerId);
return new FinishResponseDto { Rewards = deltas, RewardList = postStates }; return new FinishResponseDto { Rewards = deltas, RewardList = postStates };
@@ -358,25 +360,6 @@ public class ArenaTwoPickService : IArenaTwoPickService
return rows[^1]; return rows[^1];
} }
private static List<RewardEntryDto> ComputePostStateRewardList(
IReadOnlyList<SVSim.Database.Models.ArenaTwoPickReward> rows, SVSim.Database.Models.Viewer viewer)
{
var entries = new List<RewardEntryDto>();
foreach (var r in rows)
{
int postState = r.RewardType switch
{
(int)SVSim.Database.Enums.UserGoodsType.Rupy => (int)viewer.Currency!.Rupees,
(int)SVSim.Database.Enums.UserGoodsType.Crystal => (int)viewer.Currency!.Crystals,
(int)SVSim.Database.Enums.UserGoodsType.RedEther => (int)viewer.Currency!.RedEther,
(int)SVSim.Database.Enums.UserGoodsType.Item => viewer.Items.FirstOrDefault(i => i.Item.Id == (int)r.RewardId)?.Count ?? r.RewardNum,
_ => r.RewardNum,
};
entries.Add(new RewardEntryDto { RewardType = r.RewardType, RewardId = r.RewardId, RewardNum = postState });
}
return entries;
}
public async Task<BattleFinishResultDto> RecordBattleResultAsync(long viewerId, bool isWin) public async Task<BattleFinishResultDto> RecordBattleResultAsync(long viewerId, bool isWin)
{ {
var run = await _runs.GetByViewerIdAsync(viewerId) var run = await _runs.GetByViewerIdAsync(viewerId)

View File

@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Repositories.BattlePass; using SVSim.Database.Repositories.BattlePass;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass; using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
@@ -22,23 +23,20 @@ public sealed class BattlePassService : IBattlePassService
private readonly IViewerBattlePassRepository _viewerBp; private readonly IViewerBattlePassRepository _viewerBp;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards; private readonly IInventoryService _inv;
private readonly ICurrencySpendService _spend;
public BattlePassService( public BattlePassService(
IBattlePassRepository bp, IBattlePassRepository bp,
IViewerBattlePassRepository viewerBp, IViewerBattlePassRepository viewerBp,
TimeProvider time, TimeProvider time,
SVSimDbContext db, SVSimDbContext db,
RewardGrantService rewards, IInventoryService inv)
ICurrencySpendService spend)
{ {
_bp = bp; _bp = bp;
_viewerBp = viewerBp; _viewerBp = viewerBp;
_time = time; _time = time;
_db = db; _db = db;
_rewards = rewards; _inv = inv;
_spend = spend;
} }
public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct) public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
@@ -156,26 +154,22 @@ public sealed class BattlePassService : IBattlePassService
if (productId != season.Id * 1000) if (productId != season.Id * 1000)
return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>()); return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
var viewer = await _db.Viewers // Guard: viewer must exist (BeginAsync throws InventoryViewerNotFoundException otherwise).
.Include(v => v.Cards).ThenInclude(c => c.Card) var viewerExists = await _db.Viewers.AnyAsync(v => v.Id == viewerId, ct);
.Include(v => v.Sleeves).Include(v => v.Emblems).Include(v => v.LeaderSkins) if (!viewerExists)
.Include(v => v.Degrees).Include(v => v.MyPageBackgrounds).Include(v => v.Items).ThenInclude(i => i.Item)
.AsSplitQuery() // per memory project_ef_split_query
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
if (viewer is null)
return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>()); return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct); var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct);
if (progress.IsPremium) if (progress.IsPremium)
return new BattlePassBuyOutcome(23, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>()); return new BattlePassBuyOutcome(23, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
var spendResult = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, season.PriceCrystal, ct); // Open inventory tx — loads viewer + opens DB tx.
await using var tx = await _inv.BeginAsync(viewerId, ct);
var spendResult = await tx.TrySpendAsync(SpendCurrency.Crystal, season.PriceCrystal, ct);
if (!spendResult.Success) if (!spendResult.Success)
return new BattlePassBuyOutcome(22, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>()); return new BattlePassBuyOutcome(22, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
// BeginTransactionAsync is a no-op on the SQLite in-memory test DB but is safe to call.
await using var tx = await _db.Database.BeginTransactionAsync(ct);
progress.IsPremium = true; progress.IsPremium = true;
// Retroactive grants: every premium reward at level <= current_level not already claimed. // Retroactive grants: every premium reward at level <= current_level not already claimed.
@@ -186,32 +180,22 @@ public sealed class BattlePassService : IBattlePassService
var curve = await _bp.GetLevelCurveAsync(ct); var curve = await _bp.GetLevelCurveAsync(ct);
int currentLevel = ComputeLevel(curve, progress.CurrentPoint); int currentLevel = ComputeLevel(curve, progress.CurrentPoint);
// achieved = delta list (the original reward spec amounts — what was just granted).
// postState = post-state totals from RewardGrantService (what goes in reward_list).
var achieved = new List<GrantedReward>();
var postState = new List<GrantedReward>();
foreach (var r in rewards.Where(r => r.Track == BattlePassTrack.Premium && r.Level <= currentLevel)) foreach (var r in rewards.Where(r => r.Track == BattlePassTrack.Premium && r.Level <= currentLevel))
{ {
if (claimSet.Contains((r.Track, r.Level))) continue; if (claimSet.Contains((r.Track, r.Level))) continue;
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now); _viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
var granted = await _rewards.ApplyAsync( await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
// achieved_info uses the original reward spec (delta), not post-state.
achieved.Add(new GrantedReward(r.RewardType, r.RewardDetailId, r.RewardNumber));
postState.AddRange(granted);
} }
await _db.SaveChangesAsync(ct); // CommitAsync handles DB save + currency-collision rule. Crystal spend is the first
await tx.CommitAsync(ct); // op, any grants override the post-state. result.RewardList carries the final
// post-state including the deducted crystal balance. result.Deltas carries the raw
// grant amounts for achieved_info (no spend entry in Deltas, only GrantOps).
// CommitAsync's SaveChangesAsync flushes the AddClaim rows + the progress.IsPremium
// mutation alongside the inventory grants — all tracked on the same scoped DbContext.
var result = await tx.CommitAsync(ct);
// Post-state reward_list must always include the crystal balance after the deduction. return new BattlePassBuyOutcome(1, result.Deltas, result.RewardList);
// Unconditionally overwrite: remove any crystal entry ApplyAsync may have added, then
// append the post-deduction total so the client gets the correct final balance.
postState.RemoveAll(r => r.RewardType == (int)UserGoodsType.Crystal);
postState.Add(new GrantedReward(
(int)UserGoodsType.Crystal, 0, (int)spendResult.PostStateTotal));
return new BattlePassBuyOutcome(1, achieved, postState);
} }
public async Task<BattlePassPointGrant> AddPointsAsync( public async Task<BattlePassPointGrant> AddPointsAsync(
@@ -225,14 +209,6 @@ public sealed class BattlePassService : IBattlePassService
Array.Empty<SVSim.Database.Services.GrantedReward>()); Array.Empty<SVSim.Database.Services.GrantedReward>());
} }
var viewer = await _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)
.AsSplitQuery()
.FirstOrDefaultAsync(v => v.Id == viewerId, ct)
?? throw new InvalidOperationException($"viewer {viewerId} not found");
var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct); var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct);
int beforePoint = progress.CurrentPoint; int beforePoint = progress.CurrentPoint;
@@ -248,13 +224,15 @@ public sealed class BattlePassService : IBattlePassService
int afterLevel = ComputeLevel(curve, progress.CurrentPoint); int afterLevel = ComputeLevel(curve, progress.CurrentPoint);
var newlyClaimed = new List<SVSim.Database.Services.GrantedReward>(); IReadOnlyList<SVSim.Database.Services.GrantedReward> newlyClaimed = Array.Empty<SVSim.Database.Services.GrantedReward>();
if (afterLevel > beforeLevel) if (afterLevel > beforeLevel)
{ {
var rewards = await _bp.GetSeasonRewardsAsync(season.Id, ct); var rewards = await _bp.GetSeasonRewardsAsync(season.Id, ct);
var claims = await _viewerBp.GetClaimsAsync(viewerId, season.Id, ct); var claims = await _viewerBp.GetClaimsAsync(viewerId, season.Id, ct);
var claimSet = claims.Select(c => (c.Track, c.Level)).ToHashSet(); var claimSet = claims.Select(c => (c.Track, c.Level)).ToHashSet();
await using var tx = await _inv.BeginAsync(viewerId, ct);
for (int level = beforeLevel + 1; level <= afterLevel; level++) for (int level = beforeLevel + 1; level <= afterLevel; level++)
{ {
foreach (var r in rewards.Where(r => r.Level == level)) foreach (var r in rewards.Where(r => r.Level == level))
@@ -262,14 +240,19 @@ public sealed class BattlePassService : IBattlePassService
if (r.Track == BattlePassTrack.Premium && !progress.IsPremium) continue; if (r.Track == BattlePassTrack.Premium && !progress.IsPremium) continue;
if (claimSet.Contains((r.Track, r.Level))) continue; if (claimSet.Contains((r.Track, r.Level))) continue;
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now); _viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
var granted = await _rewards.ApplyAsync( await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
newlyClaimed.AddRange(granted);
} }
} }
}
await _db.SaveChangesAsync(ct); var result = await tx.CommitAsync(ct);
newlyClaimed = result.Deltas;
}
else
{
// No level crossed → no tx opened → still need to persist the progress mutation
// (CurrentPoint/WeeklyPoints/WeeklyPeriodStart) tracked on the scoped DbContext.
await _db.SaveChangesAsync(ct);
}
return new BattlePassPointGrant( return new BattlePassPointGrant(
BeforePoint: beforePoint, BeforePoint: beforePoint,

View File

@@ -1,101 +0,0 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
namespace SVSim.EmulatedEntrypoint.Services;
public class CardAcquisitionService : ICardAcquisitionService
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
public CardAcquisitionService(SVSimDbContext db, RewardGrantService rewards)
{
_db = db;
_rewards = rewards;
}
public async Task<CardGrantResult> GrantManyAsync(long viewerId, IEnumerable<long> newCardIds)
{
var viewer = await LoadViewerWithGraph(viewerId);
var rewardList = new List<RewardListEntry>();
// Bucket the input by id so multi-copy grants increment count once but cascade fires once.
foreach (var grp in newCardIds.GroupBy(id => id))
{
int count = grp.Count();
var granted = await _rewards.ApplyAsync(viewer, UserGoodsType.Card, grp.Key, count);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum,
});
}
}
await _db.SaveChangesAsync();
return new CardGrantResult(rewardList);
}
public async Task<CardGrantResult> BackfillCosmeticsAsync(long viewerId)
{
var viewer = await LoadViewerWithGraph(viewerId);
var rewardList = new List<RewardListEntry>();
// Foil resolution: cascade rows live on non-foil ids. Apply the +1 convention.
var lookupCardIds = viewer.Cards
.Select(c => c.Card.IsFoil ? c.Card.Id - 1 : c.Card.Id)
.Distinct()
.ToList();
var cascade = await _db.CardCosmeticRewards
.Where(r => lookupCardIds.Contains(r.CardId))
.ToListAsync();
foreach (var reward in cascade)
{
// Skip if the viewer already owns this cosmetic. ApplyAsync's cosmetic branches
// unconditionally return a wire entry (top-level grant semantics), so we must
// filter at the caller side to avoid emitting "+0 received" lines for cosmetics
// the viewer has owned for ages.
if (AlreadyOwnsCosmetic(viewer, reward.Type, reward.CosmeticId)) continue;
var goodsType = (UserGoodsType)(int)reward.Type;
var granted = await _rewards.ApplyAsync(viewer, goodsType, reward.CosmeticId, 1);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum,
});
}
}
await _db.SaveChangesAsync();
return new CardGrantResult(rewardList);
}
private static bool AlreadyOwnsCosmetic(Viewer viewer, CosmeticType type, long id) => type switch
{
CosmeticType.Sleeve => viewer.Sleeves.Any(s => s.Id == id),
CosmeticType.Emblem => viewer.Emblems.Any(e => e.Id == id),
CosmeticType.Skin => viewer.LeaderSkins.Any(s => s.Id == id),
CosmeticType.Degree => viewer.Degrees.Any(d => d.Id == id),
CosmeticType.MyPageBG => viewer.MyPageBackgrounds.Any(b => b.Id == id),
_ => false,
};
private Task<Viewer> LoadViewerWithGraph(long viewerId) => _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.LeaderSkins)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}

View File

@@ -1,13 +0,0 @@
using SVSim.EmulatedEntrypoint.Models.Dtos;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Output of <see cref="ICardAcquisitionService.GrantAsync"/>. The RewardList is wire-shape:
/// pass directly into a /pack/open or similar response's <c>data.reward_list</c> field.
///
/// In grant mode, contains one type=5 (Card) entry per distinct newCardId with post-state
/// count, plus one entry per newly-granted cosmetic.
/// In backfill mode, contains only cosmetic entries (no card-count entries).
/// </summary>
public record CardGrantResult(IReadOnlyList<RewardListEntry> RewardList);

View File

@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Repositories.PackDrawTables; using SVSim.Database.Repositories.PackDrawTables;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
namespace SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Services;
@@ -13,13 +14,11 @@ public sealed class GachaPointService : IGachaPointService
{ {
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly IPackDrawTableRepository _drawTables; private readonly IPackDrawTableRepository _drawTables;
private readonly RewardGrantService _grants;
public GachaPointService(SVSimDbContext db, IPackDrawTableRepository drawTables, RewardGrantService grants) public GachaPointService(SVSimDbContext db, IPackDrawTableRepository drawTables)
{ {
_db = db; _db = db;
_drawTables = drawTables; _drawTables = drawTables;
_grants = grants;
} }
public async Task<IReadOnlyList<GachaPointRewardDto>> GetRewardsAsync(int packId, long viewerId) public async Task<IReadOnlyList<GachaPointRewardDto>> GetRewardsAsync(int packId, long viewerId)
@@ -176,8 +175,9 @@ public sealed class GachaPointService : IGachaPointService
} }
} }
public async Task<ExchangeOutcome> TryExchangeAsync(Viewer viewer, int packId, long cardId) public async Task<ExchangeOutcome> TryExchangeAsync(IInventoryTransaction tx, int packId, long cardId)
{ {
var viewer = tx.Viewer;
var pack = await _db.Packs.FirstOrDefaultAsync(p => p.Id == packId); var pack = await _db.Packs.FirstOrDefaultAsync(p => p.Id == packId);
if (pack?.GachaPointConfig is null) if (pack?.GachaPointConfig is null)
return ExchangeOutcome.Fail("pack_not_exchangeable"); return ExchangeOutcome.Fail("pack_not_exchangeable");
@@ -206,23 +206,13 @@ public sealed class GachaPointService : IGachaPointService
PackId = packId, CardId = cardId, ReceivedAt = DateTime.UtcNow, PackId = packId, CardId = cardId, ReceivedAt = DateTime.UtcNow,
}); });
// Grant the card itself through RewardGrantService — its CardCosmeticReward cascade // Grant the card via the inventory tx — its CardCosmeticReward cascade covers the
// covers the Emblem (standard legendary) or Skin+Emblem (leader) the catalog // Emblem (standard legendary) or Skin+Emblem (leader). Convert at the wire boundary
// advertised. The catalog's reward_list is a wire-shape *display* (what the player // so ExchangeOutcome still carries RewardListEntry for the controller response.
// sees on /pack/get_gacha_point_rewards) — the actual grant uses the canonical var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 1);
// primitive per feedback_reward_grant_service. For leader-card exchanges the catalog var rewardList = granted
// also advertises a synthetic Sleeve(=card_id) entry, but that's not in .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
// CardCosmeticRewards; if a capture ever shows leader exchanges granting a sleeve .ToList();
// row, add that here. Today no leader exchange has been captured.
var granted = await _grants.ApplyAsync(viewer, UserGoodsType.Card, cardId, 1);
var rewardList = new List<RewardListEntry>();
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum,
});
}
return ExchangeOutcome.Ok(rewardList); return ExchangeOutcome.Ok(rewardList);
} }

View File

@@ -1,18 +0,0 @@
namespace SVSim.EmulatedEntrypoint.Services;
public interface ICardAcquisitionService
{
/// <summary>
/// Grant N cards + their CardCosmeticReward cascades in a single transaction.
/// Used by /pack/open and any future endpoint that grants new cards in bulk.
/// Returns wire-shape reward_list entries (post-state counts for cards, single-grant
/// entries for any newly-added cosmetics).
/// </summary>
Task<CardGrantResult> GrantManyAsync(long viewerId, IEnumerable<long> newCardIds);
/// <summary>
/// Scan all owned cards for missing CardCosmeticReward cosmetics; grant any not yet owned.
/// Used by /load/index for retroactive cosmetic reconciliation. Card counts are NOT mutated.
/// </summary>
Task<CardGrantResult> BackfillCosmeticsAsync(long viewerId);
}

View File

@@ -1,4 +1,5 @@
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
namespace SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Services;
@@ -23,11 +24,14 @@ public interface IGachaPointService
void Accrue(Viewer viewer, PackConfigEntry pack, PackChildGachaEntry child, int packNumber); void Accrue(Viewer viewer, PackConfigEntry pack, PackChildGachaEntry child, int packNumber);
/// <summary> /// <summary>
/// Validate + execute an exchange. Returns the grant outcome on success (reward_list /// Validate + execute an exchange using the provided inventory transaction (which must
/// entries the controller will return in <see cref="Dtos.Responses.Pack.ExchangeGachaPointResponse"/>), /// have <c>GachaPointBalances</c> and <c>GachaPointReceived</c> loaded on <c>tx.Viewer</c>
/// or a failure result describing why. Mutates the in-memory graph; caller saves. /// via <see cref="IInventoryService.BeginAsync"/> extra includes). Grants the card via
/// the tx. Returns the grant outcome on success (reward_list entries already converted to
/// <see cref="RewardListEntry"/>), or a failure result describing why. Caller commits
/// the tx on success.
/// </summary> /// </summary>
Task<ExchangeOutcome> TryExchangeAsync(Viewer viewer, int packId, long cardId); Task<ExchangeOutcome> TryExchangeAsync(IInventoryTransaction tx, int packId, long cardId);
} }
public sealed record ExchangeOutcome(bool Success, string? Error, IReadOnlyList<RewardListEntry> RewardList) public sealed record ExchangeOutcome(bool Success, string? Error, IReadOnlyList<RewardListEntry> RewardList)

View File

@@ -8,6 +8,7 @@ using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.Deck;
using SVSim.Database.Repositories.BuildDeck; using SVSim.Database.Repositories.BuildDeck;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.Database.Repositories.Story; using SVSim.Database.Repositories.Story;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
@@ -19,7 +20,7 @@ public class StoryService : IStoryService
{ {
private readonly IStoryMasterRepository _master; private readonly IStoryMasterRepository _master;
private readonly IViewerStoryProgressRepository _viewer; private readonly IViewerStoryProgressRepository _viewer;
private readonly RewardGrantService _rewards; private readonly IInventoryService _inv;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly IGameConfigService _configService; private readonly IGameConfigService _configService;
private readonly IDeckRepository _deckRepository; private readonly IDeckRepository _deckRepository;
@@ -29,7 +30,7 @@ public class StoryService : IStoryService
public StoryService( public StoryService(
IStoryMasterRepository master, IStoryMasterRepository master,
IViewerStoryProgressRepository viewer, IViewerStoryProgressRepository viewer,
RewardGrantService rewards, IInventoryService inv,
SVSimDbContext db, SVSimDbContext db,
IGameConfigService configService, IGameConfigService configService,
IDeckRepository deckRepository, IDeckRepository deckRepository,
@@ -38,7 +39,7 @@ public class StoryService : IStoryService
{ {
_master = master; _master = master;
_viewer = viewer; _viewer = viewer;
_rewards = rewards; _inv = inv;
_db = db; _db = db;
_configService = configService; _configService = configService;
_deckRepository = deckRepository; _deckRepository = deckRepository;
@@ -519,28 +520,26 @@ public class StoryService : IStoryService
if (firstClear && chapter.Rewards.Count > 0) if (firstClear && chapter.Rewards.Count > 0)
{ {
// Load viewer with all collections RewardGrantService might mutate. Split-query // Open inventory tx — skip the load entirely when no rewards (narrative-only
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query"). Skip the // chapters where the only side effect is the progress upsert).
// load entirely when the chapter has no rewards — common for narrative-only await using var tx = await _inv.BeginAsync(viewerId);
// chapters (limited/event story) where the only side effect is the progress upsert.
var viewer = await _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)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// reward_list and story_reward_list have DIFFERENT semantics for reward_num:
// - reward_list: post-state totals. Client (PlayerStaticData
// .UpdateHaveUserGoodsNum) direct-assigns to in-memory
// balances (e.g. UserRupyCount = num).
// - story_reward_list: deltas. Client (ResultAnimationAgent
// .HandleStoryAndMissionRewards) feeds each entry to
// AddReward(item) which draws a "+N received" popup line.
// GrantAsync may return 1+N entries (Card grants cascade into cosmetics). All
// post-state entries go into reward_list via result.RewardList; story_reward_list
// only gets the top-level mission row's delta (cascade cosmetics have no row).
var storyRewardDeltas = new List<RewardGrant>();
foreach (var r in chapter.Rewards) foreach (var r in chapter.Rewards)
{ {
IReadOnlyList<GrantedReward> granted;
try try
{ {
granted = await _rewards.ApplyAsync( await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
} }
catch (NotSupportedException ex) catch (NotSupportedException ex)
{ {
@@ -549,27 +548,8 @@ public class StoryService : IStoryService
r.RewardType, r.RewardDetailId, r.RewardNumber, req.StoryId); r.RewardType, r.RewardDetailId, r.RewardNumber, req.StoryId);
continue; continue;
} }
// delta for story_reward_list: raw catalog amounts (not post-state)
// reward_list and story_reward_list have DIFFERENT semantics for reward_num: storyRewardDeltas.Add(new RewardGrant
// - reward_list: post-state totals. Client (PlayerStaticData
// .UpdateHaveUserGoodsNum) direct-assigns to in-memory
// balances (e.g. UserRupyCount = num).
// - story_reward_list: deltas. Client (ResultAnimationAgent
// .HandleStoryAndMissionRewards) feeds each entry to
// AddReward(item) which draws a "+N received" popup line.
// ApplyAsync may return 1+N entries (Card grants cascade into cosmetics). All
// post-state entries go into reward_list; story_reward_list only gets the
// top-level mission row's delta (cascade cosmetics have no corresponding row).
foreach (var g in granted)
{
resp.RewardList.Add(new RewardGrant
{
RewardType = g.RewardType.ToString(),
RewardId = g.RewardId.ToString(),
RewardNum = g.RewardNum.ToString(),
});
}
resp.StoryRewardList.Add(new RewardGrant
{ {
RewardType = ((int)r.RewardType).ToString(), RewardType = ((int)r.RewardType).ToString(),
RewardId = r.RewardDetailId.ToString(), RewardId = r.RewardDetailId.ToString(),
@@ -577,7 +557,20 @@ public class StoryService : IStoryService
}); });
} }
await _db.SaveChangesAsync(); var result = await tx.CommitAsync();
// reward_list = post-state totals from tx (includes cosmetic cascade entries)
foreach (var g in result.RewardList)
{
resp.RewardList.Add(new RewardGrant
{
RewardType = g.RewardType.ToString(),
RewardId = g.RewardId.ToString(),
RewardNum = g.RewardNum.ToString(),
});
}
// story_reward_list = deltas accumulated above
resp.StoryRewardList.AddRange(storyRewardDeltas);
} }
if (firstClear && isPlayShape) if (firstClear && isPlayShape)

View File

@@ -28,9 +28,11 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
{ {
private readonly SqliteConnection _connection; private readonly SqliteConnection _connection;
private long _nextSeededShortUdid = 400_000_001; private long _nextSeededShortUdid = 400_000_001;
private readonly bool _freeplayEnabled;
public SVSimTestFactory() public SVSimTestFactory(bool freeplayEnabled = false)
{ {
_freeplayEnabled = freeplayEnabled;
// SQLite :memory: lives only as long as a connection is open — keep ours open for the // SQLite :memory: lives only as long as a connection is open — keep ours open for the
// factory's lifetime so the DbContext can reattach to the same DB across scopes. // factory's lifetime so the DbContext can reattach to the same DB across scopes.
_connection = new SqliteConnection("DataSource=:memory:"); _connection = new SqliteConnection("DataSource=:memory:");
@@ -59,6 +61,19 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
db.Database.EnsureCreated(); db.Database.EnsureCreated();
db.EnsureSeedDataAsync().GetAwaiter().GetResult(); db.EnsureSeedDataAsync().GetAwaiter().GetResult();
if (_freeplayEnabled)
{
using var seedScope = host.Services.CreateScope();
var seedDb = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const string freeplayJson = "{\"Enabled\":true,\"CurrencyAmount\":99999,\"CardCopies\":3}";
var existing = seedDb.GameConfigs.FirstOrDefault(s => s.SectionName == "Freeplay");
if (existing is null)
seedDb.GameConfigs.Add(new SVSim.Database.Models.GameConfigSection { SectionName = "Freeplay", ValueJson = freeplayJson });
else
existing.ValueJson = freeplayJson;
seedDb.SaveChanges();
}
// Reference data is no longer HasData-seeded; load the CSVs via the same importer // Reference data is no longer HasData-seeded; load the CSVs via the same importer
// production uses so tests exercise the same code path. CardCosmeticRewards skipped — // production uses so tests exercise the same code path. CardCosmeticRewards skipped —
// FK to Cards would reject every row against the minimal 3-card test seed below. // FK to Cards would reject every row against the minimal 3-card test seed below.
@@ -427,6 +442,23 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
await db.SaveChangesAsync(); 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> /// <summary>
/// Sets the viewer's RedEther balance to <paramref name="amount"/>. Call this AFTER /// 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 /// <see cref="SeedOwnedCardAsync"/>, which resets RedEther to 0. Create tests use this

View File

@@ -7,6 +7,7 @@ using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer; using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure; using SVSim.UnitTests.Infrastructure;
@@ -25,19 +26,6 @@ public class ArenaTwoPickServiceDraftTests
}; };
} }
private sealed class FakeEntitlements : IViewerEntitlements
{
public bool IsFreeplay { get; init; }
public long EffectiveBalance(SVSim.Database.Models.Viewer viewer, SpendCurrency currency) => 0;
public bool OwnsCard(SVSim.Database.Models.Viewer viewer, long cardId) => IsFreeplay;
public bool OwnsCosmetic(SVSim.Database.Models.Viewer viewer, CosmeticType type, int id) => IsFreeplay;
public Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<OwnedCardEntry>>(new List<OwnedCardEntry>());
public Task<EffectiveCosmetics> EffectiveCosmeticsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default)
=> throw new NotSupportedException();
}
private static async Task<(IArenaTwoPickService, IArenaTwoPickRunRepository, long viewerId)> SetupWithActiveRunAsync(int classChosen = 0) private static async Task<(IArenaTwoPickService, IArenaTwoPickRunRepository, long viewerId)> SetupWithActiveRunAsync(int classChosen = 0)
{ {
var factory = new SVSimTestFactory(); var factory = new SVSimTestFactory();
@@ -73,11 +61,9 @@ public class ArenaTwoPickServiceDraftTests
new FakePool(), new FakePool(),
scope.ServiceProvider.GetRequiredService<IGameConfigService>(), scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
scope.ServiceProvider.GetRequiredService<IViewerRepository>(), scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
scope.ServiceProvider.GetRequiredService<RewardGrantService>(), scope.ServiceProvider.GetRequiredService<IInventoryService>(),
new FakeEntitlements(),
new SystemRandom(seed: 1), new SystemRandom(seed: 1),
db, db);
scope.ServiceProvider.GetRequiredService<ICurrencySpendService>());
return (svc, runs, 7); return (svc, runs, 7);
} }

View File

@@ -7,6 +7,7 @@ using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer; using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure; using SVSim.UnitTests.Infrastructure;
@@ -23,24 +24,10 @@ public class ArenaTwoPickServiceEntryTests
=> throw new NotSupportedException("pool not used in EntryAsync"); => throw new NotSupportedException("pool not used in EntryAsync");
} }
/// <summary>Minimal fake that exposes only <see cref="IsFreeplay"/>.</summary>
private sealed class FakeEntitlements : IViewerEntitlements
{
public bool IsFreeplay { get; init; }
public long EffectiveBalance(SVSim.Database.Models.Viewer viewer, SpendCurrency currency) => 0;
public bool OwnsCard(SVSim.Database.Models.Viewer viewer, long cardId) => IsFreeplay;
public bool OwnsCosmetic(SVSim.Database.Models.Viewer viewer, CosmeticType type, int id) => IsFreeplay;
public Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<OwnedCardEntry>>(new List<OwnedCardEntry>());
public Task<EffectiveCosmetics> EffectiveCosmeticsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default)
=> throw new NotSupportedException();
}
private static async Task<(SVSimDbContext db, IArenaTwoPickService svc, long viewerId)> SetupAsync( private static async Task<(SVSimDbContext db, IArenaTwoPickService svc, long viewerId)> SetupAsync(
int ticketCount, bool freeplay = false, ulong crystals = 0, ulong rupees = 0) int ticketCount, bool freeplay = false, ulong crystals = 0, ulong rupees = 0)
{ {
var factory = new SVSimTestFactory(); var factory = new SVSimTestFactory(freeplayEnabled: freeplay);
var scope = factory.Services.CreateScope(); var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>(); var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
@@ -56,8 +43,8 @@ public class ArenaTwoPickServiceEntryTests
db.Viewers.Add(viewer); db.Viewers.Add(viewer);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var grants = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var config = scope.ServiceProvider.GetRequiredService<IGameConfigService>(); var config = scope.ServiceProvider.GetRequiredService<IGameConfigService>();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
// Seed reward catalog so GetMaxWinCountAsync returns 7. // Seed reward catalog so GetMaxWinCountAsync returns 7.
await new ArenaTwoPickRewardImporter() await new ArenaTwoPickRewardImporter()
@@ -69,11 +56,9 @@ public class ArenaTwoPickServiceEntryTests
new NullCardPoolService(), new NullCardPoolService(),
config, config,
scope.ServiceProvider.GetRequiredService<IViewerRepository>(), scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
grants, inv,
new FakeEntitlements { IsFreeplay = freeplay },
new SystemRandom(seed: 1234), new SystemRandom(seed: 1234),
db, db);
scope.ServiceProvider.GetRequiredService<ICurrencySpendService>());
return (db, svc, viewer.Id); return (db, svc, viewer.Id);
} }

View File

@@ -8,6 +8,7 @@ using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer; using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure; using SVSim.UnitTests.Infrastructure;
@@ -17,19 +18,6 @@ public class ArenaTwoPickServiceFinishTests
{ {
private const long TicketItemId = 80001; private const long TicketItemId = 80001;
private sealed class FakeEntitlements : IViewerEntitlements
{
public bool IsFreeplay { get; init; }
public long EffectiveBalance(SVSim.Database.Models.Viewer viewer, SpendCurrency currency) => 0;
public bool OwnsCard(SVSim.Database.Models.Viewer viewer, long cardId) => IsFreeplay;
public bool OwnsCosmetic(SVSim.Database.Models.Viewer viewer, CosmeticType type, int id) => IsFreeplay;
public Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<OwnedCardEntry>>(new List<OwnedCardEntry>());
public Task<EffectiveCosmetics> EffectiveCosmeticsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default)
=> throw new NotSupportedException();
}
private sealed class FakePool : IArenaTwoPickCardPoolService private sealed class FakePool : IArenaTwoPickCardPoolService
{ {
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new(); public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new();
@@ -90,11 +78,9 @@ public class ArenaTwoPickServiceFinishTests
new FakePool(), new FakePool(),
scope.ServiceProvider.GetRequiredService<IGameConfigService>(), scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
scope.ServiceProvider.GetRequiredService<IViewerRepository>(), scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
scope.ServiceProvider.GetRequiredService<RewardGrantService>(), scope.ServiceProvider.GetRequiredService<IInventoryService>(),
new FakeEntitlements(),
new SystemRandom(seed: 1), new SystemRandom(seed: 1),
db, db);
scope.ServiceProvider.GetRequiredService<ICurrencySpendService>());
return (db, svc, 7L); return (db, svc, 7L);
} }

View File

@@ -81,7 +81,7 @@ public class ArenaTwoPickServiceTopTests
private static IArenaTwoPickService BuildService(SVSimDbContext db, IArenaTwoPickRunRepository runRepo) private static IArenaTwoPickService BuildService(SVSimDbContext db, IArenaTwoPickRunRepository runRepo)
{ {
// GetTopAsync only uses _runs — every other dep can be null! because the test path // GetTopAsync only uses _runs — every other dep can be null! because the test path
// never touches them. The 9th positional arg (db) is required from Task 13 onward. // never touches them.
return new ArenaTwoPickService(runRepo, null!, null!, null!, null!, null!, null!, null!, db, null!); return new ArenaTwoPickService(runRepo, null!, null!, null!, null!, null!, null!, db);
} }
} }

View File

@@ -8,6 +8,7 @@ using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer; using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure; using SVSim.UnitTests.Infrastructure;
@@ -17,19 +18,6 @@ public class ArenaTwoPickServiceWeightedRewardsTests
{ {
private const long TicketItemId = 80001; private const long TicketItemId = 80001;
private sealed class FakeEntitlements : IViewerEntitlements
{
public bool IsFreeplay { get; init; }
public long EffectiveBalance(SVSim.Database.Models.Viewer viewer, SpendCurrency currency) => 0;
public bool OwnsCard(SVSim.Database.Models.Viewer viewer, long cardId) => IsFreeplay;
public bool OwnsCosmetic(SVSim.Database.Models.Viewer viewer, CosmeticType type, int id) => IsFreeplay;
public Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<OwnedCardEntry>>(new List<OwnedCardEntry>());
public Task<EffectiveCosmetics> EffectiveCosmeticsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default)
=> throw new NotSupportedException();
}
private sealed class FakePool : IArenaTwoPickCardPoolService private sealed class FakePool : IArenaTwoPickCardPoolService
{ {
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new(); public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new();
@@ -100,11 +88,9 @@ public class ArenaTwoPickServiceWeightedRewardsTests
new FakePool(), new FakePool(),
scope.ServiceProvider.GetRequiredService<IGameConfigService>(), scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
scope.ServiceProvider.GetRequiredService<IViewerRepository>(), scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
scope.ServiceProvider.GetRequiredService<RewardGrantService>(), scope.ServiceProvider.GetRequiredService<IInventoryService>(),
new FakeEntitlements(),
rng, rng,
db, db);
scope.ServiceProvider.GetRequiredService<ICurrencySpendService>());
return (db, svc, 7L); return (db, svc, 7L);
} }

View File

@@ -1,365 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Services;
public class CardAcquisitionServiceTests
{
/// <summary>
/// Seeds a viewer (via the factory's real RegisterViewer-backed helper) and gives it the
/// given owned cards (key = card_id, value = count). Card rows are created on-demand if
/// the test's card_id isn't already in the minimal seeded card set (matches the pattern
/// used by SVSimTestFactory.SeedOwnedCardAsync, but inlined so multiple cards can be
/// seeded in one viewer in one call). Returns the viewer's Id.
/// </summary>
private static async Task<long> SeedViewerWithCards(
SVSimTestFactory factory,
Dictionary<long, int> ownedCards,
IEnumerable<long>? grantableCardIds = null)
{
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
foreach (var (cardId, count) in ownedCards)
{
var card = await EnsureCardAsync(db, cardId);
viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = count, IsProtected = false });
}
// Pre-seed bare Cards rows (no ownership) for any cardIds the test plans to grant via
// the service. RewardGrantService.ApplyAsync does FirstOrDefaultAsync on _db.Cards;
// without the row the grant throws InvalidOperationException("Card {id} not in catalog").
if (grantableCardIds is not null)
{
foreach (var cardId in grantableCardIds)
{
await EnsureCardAsync(db, cardId);
}
}
await db.SaveChangesAsync();
return viewerId;
}
private static async Task<ShadowverseCardEntry> EnsureCardAsync(SVSimDbContext db, long cardId)
{
var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == cardId);
if (card is null)
{
// Foil twins follow the universal +1 convention (card_id ends in 1). Marking
// IsFoil here keeps test setup tidy so foil-resolution tests don't have to
// hand-patch the card row.
var isFoil = cardId % 10 == 1;
card = new ShadowverseCardEntry { Id = cardId, Name = $"SeededCard{cardId}", Rarity = Database.Enums.Rarity.Bronze, IsFoil = isFoil };
db.Cards.Add(card);
await db.SaveChangesAsync();
}
return card;
}
private static ICardAcquisitionService GetService(SVSimTestFactory factory)
{
var scope = factory.Services.CreateScope();
return scope.ServiceProvider.GetRequiredService<ICardAcquisitionService>();
}
[Test]
public async Task GrantManyAsync_NewBronzeCard_GrantsCardOnly()
{
// 101111010 is a synthetic test card (inserted ad-hoc via grantableCardIds) with no
// CardCosmeticReward associations. Expectation: grant returns only the type=5 entry.
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 101111010L });
var service = GetService(factory);
var result = await service.GrantManyAsync(viewerId, new[] { 101111010L });
Assert.That(result.RewardList, Has.Count.EqualTo(1));
Assert.That(result.RewardList[0].RewardType, Is.EqualTo(5)); // Card
Assert.That(result.RewardList[0].RewardId, Is.EqualTo(101111010L));
Assert.That(result.RewardList[0].RewardNum, Is.EqualTo(1)); // post-state count
}
[Test]
public async Task GrantManyAsync_LeaderCard_GrantsCardAndSkin()
{
// Card 704741010 (Aria leader-card variant) has 3 cosmetic associations in the seed:
// skin 407, sleeve 704741010, emblem 704741010.
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L });
// Since SqliteFriendlyModelCustomizer strips CardCosmeticReward seed in tests, insert
// the specific mappings we need for this test.
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.CardCosmeticRewards.AddRange(
new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 },
new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Sleeve, CosmeticId = 704741010L, Quantity = 1 },
new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Emblem, CosmeticId = 704741010L, Quantity = 1 }
);
// Ensure master rows exist for the cosmetics we'll grant
if (await db.LeaderSkins.FindAsync(407) is null)
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
if (await db.Sleeves.FindAsync(704741010) is null)
db.Sleeves.Add(new SleeveEntry { Id = 704741010 });
if (await db.Emblems.FindAsync(704741010) is null)
db.Emblems.Add(new EmblemEntry { Id = 704741010 });
await db.SaveChangesAsync();
}
var service = GetService(factory);
var result = await service.GrantManyAsync(viewerId, new[] { 704741010L });
var skinEntry = result.RewardList.SingleOrDefault(r => r.RewardType == 10);
Assert.That(skinEntry, Is.Not.Null, "expected a Skin reward entry");
Assert.That(skinEntry!.RewardId, Is.EqualTo(407L));
// Verify viewer ownership was actually written to DB
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers
.Include(v => v.LeaderSkins)
.FirstAsync(v => v.Id == viewerId);
Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True);
}
}
[Test]
public async Task GrantManyAsync_AlreadyOwnedSkin_OmitsFromRewardList()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L });
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Pre-grant the skin to this viewer
var viewer = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
var skin = await db.LeaderSkins.FindAsync(407) ?? db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }).Entity;
if (!viewer.LeaderSkins.Any(s => s.Id == 407))
viewer.LeaderSkins.Add(skin);
// Seed the card→skin mapping
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
await db.SaveChangesAsync();
}
var service = GetService(factory);
var result = await service.GrantManyAsync(viewerId, new[] { 704741010L });
Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.False,
"skin entry should be omitted since viewer already owns it");
Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 704741010L), Is.True,
"card grant entry should still be emitted");
}
[Test]
public async Task GrantManyAsync_FoilLeaderCard_ResolvesToNonFoilCosmetics()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741011L });
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// CardCosmeticReward.CardId has a FK→Cards.Id; ensure the non-foil row exists
// even though we never grant it directly (the foil twin is the granted card).
await EnsureCardAsync(db, 704741010L);
// Map cosmetics to the NON-FOIL card_id (704741010), as the seed convention requires
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
if (await db.LeaderSkins.FindAsync(407) is null)
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
await db.SaveChangesAsync();
}
var service = GetService(factory);
var result = await service.GrantManyAsync(viewerId, new[] { 704741011L });
var skinEntry = result.RewardList.SingleOrDefault(r => r.RewardType == 10);
Assert.That(skinEntry, Is.Not.Null, "expected skin entry via foil resolution");
Assert.That(skinEntry!.RewardId, Is.EqualTo(407L));
using var scope2 = factory.Services.CreateScope();
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db2.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
Assert.That(viewer.Cards.Any(c => c.Card.Id == 704741011L), Is.True, "card is the foil");
Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True);
}
[Test]
public async Task GrantManyAsync_MultipleCopiesOfSameLeader_GrantsCosmeticOnce()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L });
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
if (await db.LeaderSkins.FindAsync(407) is null)
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
await db.SaveChangesAsync();
}
var service = GetService(factory);
var result = await service.GrantManyAsync(viewerId, new[] { 704741010L, 704741010L, 704741010L });
Assert.That(result.RewardList.Count(r => r.RewardType == 10), Is.EqualTo(1),
"skin should appear exactly once in reward_list");
var cardEntry = result.RewardList.Single(r => r.RewardType == 5 && r.RewardId == 704741010L);
Assert.That(cardEntry.RewardNum, Is.EqualTo(3), "card count should reflect all 3 copies");
}
[Test]
public async Task GrantManyAsync_RecentLeaderCard_GrantsAllFiveCosmeticTypes()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 721141010L });
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// All 5 cosmetic types for this card. Exact ids: from data_dumps captures.
db.CardCosmeticRewards.AddRange(
new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Sleeve, CosmeticId = 721141010L, Quantity = 1 },
new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Emblem, CosmeticId = 721141010L, Quantity = 1 },
new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Degree, CosmeticId = 120021L, Quantity = 1 },
new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Skin, CosmeticId = 4601L, Quantity = 1 },
new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.MyPageBG, CosmeticId = 721141010L, Quantity = 1 }
);
// Ensure master rows
if (await db.LeaderSkins.FindAsync(4601) is null)
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 4601, Name = "TestSkin4601" });
if (await db.Sleeves.FindAsync(721141010) is null)
db.Sleeves.Add(new SleeveEntry { Id = 721141010 });
if (await db.Emblems.FindAsync(721141010) is null)
db.Emblems.Add(new EmblemEntry { Id = 721141010 });
if (await db.Degrees.FindAsync(120021) is null)
db.Degrees.Add(new DegreeEntry { Id = 120021 });
if (await db.MyPageBackgrounds.FindAsync(721141010) is null)
db.MyPageBackgrounds.Add(new MyPageBackgroundEntry { Id = 721141010 });
await db.SaveChangesAsync();
}
var service = GetService(factory);
var result = await service.GrantManyAsync(viewerId, new[] { 721141010L });
Assert.Multiple(() =>
{
Assert.That(result.RewardList.Any(r => r.RewardType == 6), Is.True, "Sleeve");
Assert.That(result.RewardList.Any(r => r.RewardType == 7), Is.True, "Emblem");
Assert.That(result.RewardList.Any(r => r.RewardType == 8), Is.True, "Degree");
Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.True, "Skin");
Assert.That(result.RewardList.Any(r => r.RewardType == 15), Is.True, "MyPageBG");
});
}
[Test]
public async Task BackfillCosmeticsAsync_DoesNotIncrementCardCount()
{
using var factory = new SVSimTestFactory();
// Pre-seed viewer with card 704741010 count=5, no skin
var viewerId = await SeedViewerWithCards(factory, new() { [704741010L] = 5 });
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
if (await db.LeaderSkins.FindAsync(407) is null)
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
await db.SaveChangesAsync();
}
var service = GetService(factory);
var result = await service.BackfillCosmeticsAsync(viewerId);
using var scope2 = factory.Services.CreateScope();
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db2.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
var owned = viewer.Cards.Single(c => c.Card.Id == 704741010L);
Assert.That(owned.Count, Is.EqualTo(5), "card count should be unchanged in backfill mode");
Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True, "skin should be backfilled");
Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 407L), Is.True,
"skin entry returned even in backfill mode");
Assert.That(result.RewardList.Any(r => r.RewardType == 5), Is.False,
"no type=5 card entries in backfill mode");
}
[Test]
public async Task BackfillCosmeticsAsync_CalledTwice_SecondCallIsNoOp()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { [704741010L] = 1 });
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
if (await db.LeaderSkins.FindAsync(407) is null)
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
await db.SaveChangesAsync();
}
var service = GetService(factory);
var first = await service.BackfillCosmeticsAsync(viewerId);
var second = await service.BackfillCosmeticsAsync(viewerId);
Assert.That(first.RewardList, Is.Not.Empty, "first call should grant cosmetics");
Assert.That(second.RewardList, Is.Empty, "second call should be a no-op");
}
[Test]
public async Task GrantManyAsync_LeaderCardWithMissingMapping_GrantsCardSilently()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 701141010L });
// NO CardCosmeticReward rows inserted for this card — simulates the 83 missing-mapping cases.
var service = GetService(factory);
var result = await service.GrantManyAsync(viewerId, new[] { 701141010L });
Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 701141010L), Is.True);
Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.False);
// No exception means it handled the missing mapping gracefully.
}
[Test]
public async Task GrantManyAsync_OrphanCosmeticReward_LogsWarningAndSkips()
{
using var factory = new SVSimTestFactory();
var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L });
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Real skin association
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 });
if (await db.LeaderSkins.FindAsync(407) is null)
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" });
// ORPHAN: points to non-existent skin_id
db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 9999999L, Quantity = 1 });
await db.SaveChangesAsync();
}
var service = GetService(factory);
var result = await service.GrantManyAsync(viewerId, new[] { 704741010L });
Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 704741010L), Is.True);
Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 407L), Is.True,
"real skin should still be granted");
Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 9999999L), Is.False,
"orphan cosmetic should not appear in reward_list");
}
}

View File

@@ -1,91 +0,0 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
namespace SVSim.UnitTests.Services;
public class CurrencySpendServiceTests
{
private sealed class FakeEntitlements : IViewerEntitlements
{
public bool IsFreeplay { get; init; }
public long FreeplayAmount { get; init; } = 99999;
public long EffectiveBalance(Viewer viewer, SpendCurrency currency)
{
if (IsFreeplay && currency != SpendCurrency.SpotPoint) return FreeplayAmount;
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,
_ => 0,
};
}
public bool OwnsCard(Viewer viewer, long cardId) => IsFreeplay;
public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id) => IsFreeplay;
public Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<OwnedCardEntry>>(new List<OwnedCardEntry>());
public Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default)
=> throw new NotSupportedException();
}
private static Viewer NewViewer() => new() { Currency = new ViewerCurrency() };
[Test]
public async Task Normal_deducts_and_returns_post_state()
{
var v = NewViewer();
v.Currency.Crystals = 250;
var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = false });
var r = await svc.TrySpendAsync(v, SpendCurrency.Crystal, 100);
Assert.That(r.Success, Is.True);
Assert.That(r.PostStateTotal, Is.EqualTo(150));
Assert.That(v.Currency.Crystals, Is.EqualTo(150UL));
}
[Test]
public async Task Normal_insufficient_does_not_deduct()
{
var v = NewViewer();
v.Currency.Rupees = 50;
var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = false });
var r = await svc.TrySpendAsync(v, SpendCurrency.Rupee, 100);
Assert.That(r.Success, Is.False);
Assert.That(r.Outcome, Is.EqualTo(SpendOutcome.Insufficient));
Assert.That(v.Currency.Rupees, Is.EqualTo(50UL), "no deduction on insufficient funds");
}
[Test]
public async Task Freeplay_main_currency_succeeds_without_deducting()
{
var v = NewViewer();
v.Currency.Crystals = 10;
var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = true });
var r = await svc.TrySpendAsync(v, SpendCurrency.Crystal, 100000);
Assert.That(r.Success, Is.True, "freeplay never blocks on affordability");
Assert.That(r.PostStateTotal, Is.EqualTo(99999), "post-state shows the freeplay balance");
Assert.That(v.Currency.Crystals, Is.EqualTo(10UL), "DB balance untouched in freeplay");
}
[Test]
public async Task Freeplay_spot_points_still_deduct()
{
var v = NewViewer();
v.Currency.SpotPoints = 300;
var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = true });
var r = await svc.TrySpendAsync(v, SpendCurrency.SpotPoint, 100);
Assert.That(r.Success, Is.True);
Assert.That(r.PostStateTotal, Is.EqualTo(200));
Assert.That(v.Currency.SpotPoints, Is.EqualTo(200UL), "spot points are real even in freeplay");
}
}

View File

@@ -4,6 +4,7 @@ using NUnit.Framework;
using SVSim.Database; using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure; using SVSim.UnitTests.Infrastructure;
@@ -380,14 +381,17 @@ public class GachaPointServiceTests
SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); SeedPackWithOneLegendary(db, packId: 10008, threshold: 400);
var viewer = await db.Viewers var viewer = await db.Viewers.Include(v => v.GachaPointBalances).FirstAsync(v => v.Id == viewerId);
.Include(v => v.GachaPointBalances)
.FirstAsync(v => v.Id == viewerId);
viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 399 }); viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 399 });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>(); var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010); var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId, configure: cfg => cfg
.WithInclude(v => v.GachaPointBalances)
.WithInclude(v => v.GachaPointReceived));
var outcome = await svc.TryExchangeAsync(tx, 10008, 108041010);
Assert.That(outcome.Success, Is.False); Assert.That(outcome.Success, Is.False);
Assert.That(outcome.Error, Is.EqualTo("insufficient_gacha_points")); Assert.That(outcome.Error, Is.EqualTo("insufficient_gacha_points"));
@@ -403,14 +407,17 @@ public class GachaPointServiceTests
SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); SeedPackWithOneLegendary(db, packId: 10008, threshold: 400);
var viewer = await db.Viewers var viewer = await db.Viewers.Include(v => v.GachaPointBalances).FirstAsync(v => v.Id == viewerId);
.Include(v => v.GachaPointBalances)
.FirstAsync(v => v.Id == viewerId);
viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 400 }); viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 400 });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>(); var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
var outcome = await svc.TryExchangeAsync(viewer, 10008, cardId: 999999999); // not in pool var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId, configure: cfg => cfg
.WithInclude(v => v.GachaPointBalances)
.WithInclude(v => v.GachaPointReceived));
var outcome = await svc.TryExchangeAsync(tx, 10008, cardId: 999999999); // not in pool
Assert.That(outcome.Success, Is.False); Assert.That(outcome.Success, Is.False);
Assert.That(outcome.Error, Is.EqualTo("card_not_exchangeable")); Assert.That(outcome.Error, Is.EqualTo("card_not_exchangeable"));
@@ -438,7 +445,12 @@ public class GachaPointServiceTests
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>(); var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010); var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId, configure: cfg => cfg
.WithInclude(v => v.GachaPointBalances)
.WithInclude(v => v.GachaPointReceived));
var outcome = await svc.TryExchangeAsync(tx, 10008, 108041010);
Assert.That(outcome.Success, Is.False); Assert.That(outcome.Success, Is.False);
Assert.That(outcome.Error, Is.EqualTo("already_received")); Assert.That(outcome.Error, Is.EqualTo("already_received"));
@@ -454,29 +466,31 @@ public class GachaPointServiceTests
SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); SeedPackWithOneLegendary(db, packId: 10008, threshold: 400);
var viewer = await db.Viewers var preViewer = await db.Viewers
.Include(v => v.GachaPointBalances) .Include(v => v.GachaPointBalances)
.Include(v => v.GachaPointReceived)
.Include(v => v.Cards)
.Include(v => v.Emblems)
.FirstAsync(v => v.Id == viewerId); .FirstAsync(v => v.Id == viewerId);
viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 500 }); preViewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 500 });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>(); var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010); var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await db.SaveChangesAsync(); await using var tx = await inv.BeginAsync(viewerId, configure: cfg => cfg
.WithInclude(v => v.GachaPointBalances)
.WithInclude(v => v.GachaPointReceived));
var outcome = await svc.TryExchangeAsync(tx, 10008, 108041010);
Assert.That(outcome.Success, Is.True); Assert.That(outcome.Success, Is.True);
// Balance debited. await tx.CommitAsync();
Assert.That(viewer.GachaPointBalances.Single().Points, Is.EqualTo(100));
// Balance debited (check via tx.Viewer which is tracked).
Assert.That(tx.Viewer.GachaPointBalances.Single().Points, Is.EqualTo(100));
// Marker written. // Marker written.
Assert.That(viewer.GachaPointReceived Assert.That(tx.Viewer.GachaPointReceived
.Any(r => r.PackId == 10008 && r.CardId == 108041010), Is.True); .Any(r => r.PackId == 10008 && r.CardId == 108041010), Is.True);
// Reward list non-empty: at minimum the card grant and the gacha-point post-state entry. // Reward list non-empty: at minimum the card grant.
Assert.That(outcome.RewardList, Is.Not.Empty); Assert.That(outcome.RewardList, Is.Not.Empty);
Assert.That(outcome.RewardList.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == 108041010), Assert.That(outcome.RewardList.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == 108041010),
Is.True, "card grant missing"); Is.True, "card grant missing");

View File

@@ -0,0 +1,66 @@
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 InventoryBackfillTests
{
[Test]
public async Task Backfill_grants_missing_cosmetic_for_already_owned_card()
{
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_020_000;
ctx.Sleeves.Add(new SleeveEntry { Id = sleeveId });
ctx.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = cardId, CosmeticId = sleeveId, Type = CosmeticType.Sleeve });
var card = await ctx.Cards.FirstAsync(c => c.Id == cardId);
var v = await ctx.Viewers.Include(x => x.Cards).ThenInclude(c => c.Card).FirstAsync(x => x.Id == viewerId);
v.Cards.Add(new OwnedCardEntry { Card = card, Count = 3, IsProtected = false });
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
int granted = await tx.BackfillCardCosmeticsAsync();
Assert.That(granted, Is.EqualTo(1));
Assert.That(tx.Viewer.Sleeves.Any(s => s.Id == sleeveId), Is.True);
}
[Test]
public async Task Backfill_idempotent_on_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_020_001;
var sleeve = new SleeveEntry { Id = sleeveId };
ctx.Sleeves.Add(sleeve);
ctx.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = cardId, CosmeticId = sleeveId, Type = CosmeticType.Sleeve });
var card = await ctx.Cards.FirstAsync(c => c.Id == cardId);
var v = await ctx.Viewers
.Include(x => x.Cards).ThenInclude(c => c.Card)
.Include(x => x.Sleeves)
.FirstAsync(x => x.Id == viewerId);
v.Cards.Add(new OwnedCardEntry { Card = card, Count = 3, IsProtected = false });
v.Sleeves.Add(sleeve);
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
int granted = await tx.BackfillCardCosmeticsAsync();
Assert.That(granted, Is.EqualTo(0));
}
}

View File

@@ -0,0 +1,104 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Services.Inventory;
public class InventoryCommitTests
{
[Test]
public async Task Commit_emits_one_currency_entry_with_grant_post_state_when_spend_then_grant()
{
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 = 1000;
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
await tx.TrySpendAsync(SpendCurrency.Crystal, 500);
await tx.GrantAsync(UserGoodsType.Crystal, 0, 200);
var result = await tx.CommitAsync();
var crystals = result.RewardList.Where(r => r.RewardType == (int)UserGoodsType.Crystal).ToList();
Assert.That(crystals, Has.Count.EqualTo(1));
Assert.That(crystals[0].RewardNum, Is.EqualTo(700), "spend 500 then grant 200 → 1000-500+200=700, grant's post-state wins");
}
[Test]
public async Task Commit_emits_one_currency_entry_with_spend_post_state_when_grant_then_spend()
{
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 = 1000;
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
await tx.GrantAsync(UserGoodsType.Crystal, 0, 200);
await tx.TrySpendAsync(SpendCurrency.Crystal, 500);
var result = await tx.CommitAsync();
var crystals = result.RewardList.Where(r => r.RewardType == (int)UserGoodsType.Crystal).ToList();
Assert.That(crystals, Has.Count.EqualTo(1));
Assert.That(crystals[0].RewardNum, Is.EqualTo(700));
}
[Test]
public async Task Commit_persists_mutations()
{
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.Rupees = 100;
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using (var tx = await inv.BeginAsync(viewerId))
{
await tx.GrantAsync(UserGoodsType.Rupy, 0, 50);
await tx.CommitAsync();
}
using var verifyScope = factory.Services.CreateScope();
var ctx2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v2 = await ctx2.Viewers.AsNoTracking().FirstAsync(x => x.Id == viewerId);
Assert.That(v2.Currency.Rupees, Is.EqualTo(150UL));
}
[Test]
public async Task Deltas_are_verbatim_queued_no_cascade()
{
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_030_000;
ctx.Sleeves.Add(new SVSim.Database.Models.SleeveEntry { Id = sleeveId });
ctx.CardCosmeticRewards.Add(new SVSim.Database.Models.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);
await tx.GrantAsync(UserGoodsType.Card, cardId, 1);
var result = await tx.CommitAsync();
Assert.That(result.Deltas, Has.Count.EqualTo(1), "verbatim — card only, no cascade");
Assert.That(result.Deltas[0].RewardType, Is.EqualTo((int)UserGoodsType.Card));
Assert.That(result.RewardList.Any(e => e.RewardType == (int)UserGoodsType.Sleeve), Is.True,
"cascade appears in RewardList but not Deltas");
}
}

View File

@@ -0,0 +1,76 @@
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 InventoryDebitTests
{
[Test]
public async Task Debit_Crystal_delegates_to_TrySpend()
{
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 = 500;
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
var r = await tx.TryDebitAsync(UserGoodsType.Crystal, 0, 200);
Assert.That(r.Success, Is.True);
Assert.That(r.PostStateTotal, Is.EqualTo(300));
}
[Test]
public async Task Debit_Item_decrements_count_and_returns_post_state()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const int itemId = 32000;
var item = new ItemEntry { Id = itemId };
ctx.Items.Add(item);
var v = await ctx.Viewers.Include(x => x.Items).ThenInclude(i => i.Item).FirstAsync(x => x.Id == viewerId);
v.Items.Add(new OwnedItemEntry { Item = item, Count = 10, Viewer = v });
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
var r = await tx.TryDebitAsync(UserGoodsType.Item, itemId, 3);
Assert.That(r.Success, Is.True);
Assert.That(r.PostStateTotal, Is.EqualTo(7));
}
[Test]
public async Task Debit_Item_insufficient_returns_current_count_and_does_not_decrement()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const int itemId = 32001;
var item = new ItemEntry { Id = itemId };
ctx.Items.Add(item);
var v = await ctx.Viewers.Include(x => x.Items).ThenInclude(i => i.Item).FirstAsync(x => x.Id == viewerId);
v.Items.Add(new OwnedItemEntry { Item = item, Count = 2, Viewer = v });
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
var r = await tx.TryDebitAsync(UserGoodsType.Item, itemId, 5);
Assert.That(r.Outcome, Is.EqualTo(SpendOutcome.Insufficient));
Assert.That(r.PostStateTotal, Is.EqualTo(2));
}
}

View File

@@ -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");
}
}

View File

@@ -0,0 +1,69 @@
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 InventoryGrantCosmeticTests
{
[Test]
public async Task Sleeve_added_when_missing()
{
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_000_001;
ctx.Sleeves.Add(new SleeveEntry { Id = sleeveId });
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
var granted = await tx.GrantAsync(UserGoodsType.Sleeve, sleeveId, 1);
Assert.That(granted, Has.Count.EqualTo(1));
Assert.That(granted[0].RewardType, Is.EqualTo((int)UserGoodsType.Sleeve));
Assert.That(granted[0].RewardId, Is.EqualTo(sleeveId));
Assert.That(granted[0].RewardNum, Is.EqualTo(1));
Assert.That(tx.Viewer.Sleeves.Any(s => s.Id == sleeveId), Is.True);
}
[Test]
public async Task Sleeve_idempotent_when_already_owned_but_still_emits_entry()
{
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_000_002;
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);
var granted = await tx.GrantAsync(UserGoodsType.Sleeve, sleeveId, 1);
Assert.That(granted, Has.Count.EqualTo(1), "top-level cosmetic grant emits even if owned");
Assert.That(tx.Viewer.Sleeves.Count(s => s.Id == sleeveId), Is.EqualTo(1), "no duplicate row");
}
[Test]
public async Task Unknown_cosmetic_id_throws_catalog_exception()
{
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.ThrowsAsync<InventoryCatalogException>(
async () => { await tx.GrantAsync(UserGoodsType.Sleeve, 999_999, 1); });
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Services.Inventory;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Services.Inventory;
public class InventoryGrantCurrencyTests
{
[TestCase(UserGoodsType.Rupy)]
[TestCase(UserGoodsType.Crystal)]
[TestCase(UserGoodsType.RedEther)]
[TestCase(UserGoodsType.SpotCardPoint)]
public async Task Grant_currency_adds_and_emits_post_state(UserGoodsType type)
{
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);
switch (type)
{
case UserGoodsType.Rupy: v.Currency.Rupees = 100; break;
case UserGoodsType.Crystal: v.Currency.Crystals = 100; break;
case UserGoodsType.RedEther: v.Currency.RedEther = 100; break;
case UserGoodsType.SpotCardPoint: v.Currency.SpotPoints = 100; break;
}
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
var granted = await tx.GrantAsync(type, detailId: 0, num: 50);
Assert.That(granted, Has.Count.EqualTo(1));
Assert.That(granted[0].RewardType, Is.EqualTo((int)type));
Assert.That(granted[0].RewardNum, Is.EqualTo(150));
}
}

View File

@@ -0,0 +1,53 @@
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 InventoryGrantItemTests
{
[Test]
public async Task Item_first_grant_creates_owned_entry()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const int itemId = 31000;
ctx.Items.Add(new ItemEntry { Id = itemId });
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
var granted = await tx.GrantAsync(UserGoodsType.Item, itemId, 3);
Assert.That(granted[0].RewardNum, Is.EqualTo(3));
Assert.That(tx.Viewer.Items.Single(i => i.Item.Id == itemId).Count, Is.EqualTo(3));
}
[Test]
public async Task Item_second_grant_accumulates_post_state()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const int itemId = 31001;
var item = new ItemEntry { Id = itemId };
ctx.Items.Add(item);
var v = await ctx.Viewers.Include(x => x.Items).ThenInclude(i => i.Item).FirstAsync(x => x.Id == viewerId);
v.Items.Add(new OwnedItemEntry { Item = item, Count = 5, Viewer = v });
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
var granted = await tx.GrantAsync(UserGoodsType.Item, itemId, 4);
Assert.That(granted[0].RewardNum, Is.EqualTo(9));
Assert.That(tx.Viewer.Items.Single(i => i.Item.Id == itemId).Count, Is.EqualTo(9));
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Services.Inventory;
public class InventoryLifecycleTests
{
[Test]
public async Task Dispose_without_commit_does_not_persist()
{
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.Rupees = 100;
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using (var tx = await inv.BeginAsync(viewerId))
{
await tx.GrantAsync(UserGoodsType.Rupy, 0, 50);
// no commit; dispose runs
}
using var verifyScope = factory.Services.CreateScope();
var ctx2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v2 = await ctx2.Viewers.AsNoTracking().FirstAsync(x => x.Id == viewerId);
Assert.That(v2.Currency.Rupees, Is.EqualTo(100UL), "no persistence without commit");
}
[Test]
public async Task Use_after_commit_throws()
{
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);
await tx.CommitAsync();
Assert.ThrowsAsync<InvalidOperationException>(
async () => { await tx.GrantAsync(UserGoodsType.Rupy, 0, 1); });
}
}

View 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);
}
}

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);
}
}

View File

@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Models.Config;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Services.Inventory;
public class InventorySpendTests
{
[Test]
public async Task Spend_sufficient_returns_post_deduction_total()
{
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 = 1000;
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, 300);
Assert.That(r.Success, Is.True);
Assert.That(r.PostStateTotal, Is.EqualTo(700));
Assert.That(tx.Viewer.Currency.Crystals, Is.EqualTo(700UL));
}
[Test]
public async Task Spend_insufficient_returns_insufficient_with_current_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 = 100;
await ctx.SaveChangesAsync();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
await using var tx = await inv.BeginAsync(viewerId);
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, 300);
Assert.That(r.Outcome, Is.EqualTo(SpendOutcome.Insufficient));
Assert.That(r.PostStateTotal, Is.EqualTo(100));
Assert.That(tx.Viewer.Currency.Crystals, Is.EqualTo(100UL), "balance unchanged");
}
[Test]
public async Task Freeplay_returns_success_with_configured_amount_for_main_currencies()
{
using var factory = new SVSimTestFactory(freeplayEnabled: true);
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);
ulong balanceBefore = tx.Viewer.Currency.Crystals;
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, 99999);
Assert.That(r.Success, Is.True);
var freeCfg = scope.ServiceProvider.GetRequiredService<IGameConfigService>().Get<FreeplayConfig>();
Assert.That(r.PostStateTotal, Is.EqualTo(checked((long)freeCfg.CurrencyAmount)));
Assert.That(tx.Viewer.Currency.Crystals, Is.EqualTo(balanceBefore), "freeplay never deducts");
}
}

View File

@@ -1,280 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Services;
public class RewardGrantServiceTests
{
[Test]
public async Task Sleeve_added_to_viewer_collection()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const int testSleeveId = 2_000_000_000;
var sleeve = new SleeveEntry { Id = testSleeveId };
ctx.Sleeves.Add(sleeve);
await ctx.SaveChangesAsync();
var viewer = await ctx.Viewers.Include(v => v.Sleeves).FirstAsync(v => v.Id == viewerId);
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var result = await svc.ApplyAsync(viewer, UserGoodsType.Sleeve, detailId: testSleeveId, num: 1);
await ctx.SaveChangesAsync();
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(viewer.Sleeves.Any(s => s.Id == testSleeveId), Is.True);
Assert.That(result[0].RewardType, Is.EqualTo((int)UserGoodsType.Sleeve));
Assert.That(result[0].RewardId, Is.EqualTo((long)testSleeveId));
Assert.That(result[0].RewardNum, Is.EqualTo(1));
}
[Test]
public async Task Rupy_sets_currency_post_state_total()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await ctx.Viewers.FirstAsync(v => v.Id == viewerId);
viewer.Currency.Rupees = 100UL;
await ctx.SaveChangesAsync();
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var result = await svc.ApplyAsync(viewer, UserGoodsType.Rupy, detailId: 0, num: 50);
await ctx.SaveChangesAsync();
Assert.That(viewer.Currency.Rupees, Is.EqualTo(150UL));
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(result[0].RewardNum, Is.EqualTo(150));
}
[Test]
public async Task LeaderSkin_added_idempotently()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const int testSkinId = 9_999_999;
ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "Round1Reward" });
await ctx.SaveChangesAsync();
var viewer = await ctx.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
await svc.ApplyAsync(viewer, UserGoodsType.Skin, testSkinId, 1);
await svc.ApplyAsync(viewer, UserGoodsType.Skin, testSkinId, 1);
await ctx.SaveChangesAsync();
Assert.That(viewer.LeaderSkins.Count(s => s.Id == testSkinId), Is.EqualTo(1));
}
[Test]
public async Task Card_fresh_grant_inserts_owned_entry_and_returns_post_state_count()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const long testCardId = 999_001_001L;
ctx.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "RGSTestCard", Rarity = Rarity.Bronze });
await ctx.SaveChangesAsync();
var viewer = await ctx.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1);
await ctx.SaveChangesAsync();
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(result[0].RewardType, Is.EqualTo((int)UserGoodsType.Card));
Assert.That(result[0].RewardId, Is.EqualTo(testCardId));
Assert.That(result[0].RewardNum, Is.EqualTo(1));
Assert.That(viewer.Cards.Single(c => c.Card.Id == testCardId).Count, Is.EqualTo(1));
}
[Test]
public async Task Card_existing_grant_increments_count()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const long testCardId = 999_001_002L;
var card = new ShadowverseCardEntry { Id = testCardId, Name = "RGSTestCard2", Rarity = Rarity.Bronze };
ctx.Cards.Add(card);
var viewer = await ctx.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = 2, IsProtected = false });
await ctx.SaveChangesAsync();
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1);
await ctx.SaveChangesAsync();
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(result[0].RewardNum, Is.EqualTo(3));
Assert.That(viewer.Cards.Single(c => c.Card.Id == testCardId).Count, Is.EqualTo(3));
}
[Test]
public async Task Card_with_cascade_rows_emits_card_plus_cosmetics()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const long testCardId = 999_002_010L;
const int testSkinId = 999_002_011;
ctx.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "CascadeTestCard", Rarity = Rarity.Gold });
ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "CascadeTestSkin" });
ctx.CardCosmeticRewards.Add(new CardCosmeticReward
{
CardId = testCardId, Type = CosmeticType.Skin, CosmeticId = testSkinId, Quantity = 1,
});
await ctx.SaveChangesAsync();
var viewer = await ctx.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.LeaderSkins)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1);
await ctx.SaveChangesAsync();
Assert.That(result, Has.Count.EqualTo(2));
Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == testCardId), Is.True);
Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Skin && r.RewardId == testSkinId), Is.True);
Assert.That(viewer.LeaderSkins.Any(s => s.Id == testSkinId), Is.True);
}
[Test]
public async Task Card_cascade_skips_already_owned_cosmetic()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const long testCardId = 999_002_020L;
const int testSkinId = 999_002_021;
ctx.Cards.Add(new ShadowverseCardEntry { Id = testCardId, Name = "CascadeOwnedTestCard", Rarity = Rarity.Gold });
var skin = new LeaderSkinEntry { Id = testSkinId, Name = "CascadeOwnedTestSkin" };
ctx.LeaderSkins.Add(skin);
ctx.CardCosmeticRewards.Add(new CardCosmeticReward
{
CardId = testCardId, Type = CosmeticType.Skin, CosmeticId = testSkinId, Quantity = 1,
});
await ctx.SaveChangesAsync();
var viewer = await ctx.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.LeaderSkins)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
viewer.LeaderSkins.Add(skin);
await ctx.SaveChangesAsync();
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, testCardId, 1);
await ctx.SaveChangesAsync();
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(result[0].RewardType, Is.EqualTo((int)UserGoodsType.Card));
Assert.That(result[0].RewardId, Is.EqualTo(testCardId));
}
[Test]
public async Task Card_foil_grant_resolves_cascade_to_non_foil_id()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const long nonFoilId = 999_002_030L;
const long foilId = 999_002_031L;
const int testSkinId = 999_002_032;
ctx.Cards.Add(new ShadowverseCardEntry { Id = nonFoilId, Name = "FoilCascadeBase", Rarity = Rarity.Gold });
ctx.Cards.Add(new ShadowverseCardEntry { Id = foilId, Name = "FoilCascadeFoil", Rarity = Rarity.Gold, IsFoil = true });
ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "FoilCascadeSkin" });
ctx.CardCosmeticRewards.Add(new CardCosmeticReward
{
CardId = nonFoilId, Type = CosmeticType.Skin, CosmeticId = testSkinId, Quantity = 1,
});
await ctx.SaveChangesAsync();
var viewer = await ctx.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.LeaderSkins)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var result = await svc.ApplyAsync(viewer, UserGoodsType.Card, foilId, 1);
await ctx.SaveChangesAsync();
Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == foilId), Is.True);
Assert.That(result.Any(r => r.RewardType == (int)UserGoodsType.Skin && r.RewardId == testSkinId), Is.True);
}
[Test]
public async Task SpotCard_still_throws_NotSupported()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await ctx.Viewers.FirstAsync(v => v.Id == viewerId);
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
Assert.ThrowsAsync<NotSupportedException>(async () =>
await svc.ApplyAsync(viewer, UserGoodsType.SpotCard, 1L, 1));
Assert.ThrowsAsync<NotSupportedException>(async () =>
await svc.ApplyAsync(viewer, UserGoodsType.SpotCardOnlyLatestCardPack, 1L, 1));
}
[Test]
public async Task OwnedCardEntry_unique_index_blocks_duplicate_viewer_card_row()
{
// Schema-level safety net: any code that forgets to .Include(v => v.Cards) before doing
// a find-or-add OwnedCardEntry would silently insert a duplicate row otherwise. The
// unique index on (ViewerId, CardId) makes that crash loudly at SaveChanges instead.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const long testCardId = 999_003_001L;
var card = new ShadowverseCardEntry { Id = testCardId, Name = "UniqueIdxTest", Rarity = Rarity.Bronze };
ctx.Cards.Add(card);
var viewer = await ctx.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = 1, IsProtected = false });
await ctx.SaveChangesAsync();
// Simulate the bug: a fresh viewer load WITHOUT .Include(v => v.Cards), then a manual
// Add of a second row for the same (Viewer, Card). The unique index must reject this.
using var scope2 = factory.Services.CreateScope();
var ctx2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
var unloadedViewer = await ctx2.Viewers.FirstAsync(v => v.Id == viewerId);
var sameCard = await ctx2.Cards.FirstAsync(c => c.Id == testCardId);
unloadedViewer.Cards.Add(new OwnedCardEntry { Card = sameCard, Count = 1, IsProtected = false });
Assert.ThrowsAsync<DbUpdateException>(async () => await ctx2.SaveChangesAsync());
}
}

View File

@@ -1,268 +0,0 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Card;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Services;
public class ViewerEntitlementsTests
{
/// <summary>
/// FreeplayConfig is in SVSim.Database so EnsureSeedDataAsync seeds a DB row with
/// Enabled=false (ShippedDefaults). Since tier 1 (DB) wins, we mutate the seeded row
/// to activate freeplay rather than relying on an IConfiguration override.
/// </summary>
private static void SetFreeplayEnabled(SVSimDbContext db, bool enabled, ulong currencyAmount = 99999, int cardCopies = 3)
{
var row = db.GameConfigs.First(s => s.SectionName == "Freeplay");
var cfg = JsonSerializer.Deserialize<FreeplayConfig>(row.ValueJson)!;
cfg.Enabled = enabled;
cfg.CurrencyAmount = currencyAmount;
cfg.CardCopies = cardCopies;
row.ValueJson = JsonSerializer.Serialize(cfg);
db.SaveChanges();
}
private static ViewerEntitlements Build(IServiceScope scope)
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
return new ViewerEntitlements(
new GameConfigService(db, new ConfigurationBuilder().Build()),
new CardRepository(db),
new CollectionRepository(db));
}
[Test]
public async Task Freeplay_off_reflects_real_balance_and_ownership()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Freeplay is seeded as Enabled=false by default — no mutation needed.
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
viewer.Currency.Crystals = 7;
var ent = Build(scope);
Assert.That(ent.IsFreeplay, Is.False);
Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.Crystal), Is.EqualTo(7));
Assert.That(ent.OwnsCard(viewer, 99_999_999L), Is.False);
Assert.That(ent.OwnsCosmetic(viewer, CosmeticType.Skin, 99_999), Is.False);
}
[Test]
public async Task Freeplay_on_inflates_main_currencies_but_not_spot_points()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
SetFreeplayEnabled(db, enabled: true, currencyAmount: 99999);
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
viewer.Currency.SpotPoints = 5;
var ent = Build(scope);
Assert.That(ent.IsFreeplay, Is.True);
Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.Crystal), Is.EqualTo(99999));
Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.Rupee), Is.EqualTo(99999));
Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.RedEther), Is.EqualTo(99999));
Assert.That(ent.EffectiveBalance(viewer, SpendCurrency.SpotPoint), Is.EqualTo(5),
"spot points are not a freeplay-inflated currency");
}
[Test]
public async Task Freeplay_on_treats_all_cards_and_cosmetics_as_owned()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
SetFreeplayEnabled(db, enabled: true);
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
var ent = Build(scope);
Assert.That(ent.OwnsCard(viewer, 99_999_999L), Is.True);
Assert.That(ent.OwnsCosmetic(viewer, CosmeticType.Skin, 99_999), Is.True);
}
// -------------------------------------------------------------------------
// EffectiveOwnedCardsAsync
// -------------------------------------------------------------------------
[Test]
public async Task EffectiveOwnedCards_freeplay_on_returns_all_collectible_cards_at_card_copies()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
// Seed one collectible card owned by this viewer (gives it a CollectionInfo).
await factory.SeedOwnedCardAsync(viewerId, 50001001L, count: 1);
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
SetFreeplayEnabled(db, enabled: true, cardCopies: 3);
var viewer = await db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
var ent = Build(scope);
var result = await ent.EffectiveOwnedCardsAsync(viewer);
// Freeplay returns the whole collectible catalog — card 50001001 must be present.
Assert.That(result.Any(e => e.Card.Id == 50001001L), Is.True,
"seeded collectible card must appear in freeplay result");
// Every returned entry must have Count == CardCopies (3).
Assert.That(result.All(e => e.Count == 3), Is.True,
"every freeplay entry should have Count == CardCopies (3)");
// The full set == every collectible card in the DB.
int collectibleCount = db.Cards.Count(c => c.CollectionInfo != null);
Assert.That(result.Count, Is.EqualTo(collectibleCount),
"freeplay result should contain exactly all collectible cards");
}
[Test]
public async Task EffectiveOwnedCards_freeplay_off_returns_only_owned()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
// Seed card 50001002 owned at count 2.
await factory.SeedOwnedCardAsync(viewerId, 50001002L, count: 2);
// Seed a second collectible card (50001003) NOT owned by the viewer — insert card row
// only (with CollectionInfo so it's collectible) but do not link it to the viewer.
using (var setupScope = factory.Services.CreateScope())
{
var setupDb = setupScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
if (!setupDb.Cards.Any(c => c.Id == 50001003L))
{
setupDb.Cards.Add(new ShadowverseCardEntry
{
Id = 50001003L,
Name = "UnownedCollectible",
Rarity = SVSim.Database.Enums.Rarity.Bronze,
CollectionInfo = new SVSim.Database.Models.CardCollectionInfo { CraftCost = 200, DustReward = 50 },
});
await setupDb.SaveChangesAsync();
}
}
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Freeplay is off by default — no mutation needed.
var viewer = await db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
var ent = Build(scope);
var result = await ent.EffectiveOwnedCardsAsync(viewer);
// The owned card must be present at the right count.
var owned = result.FirstOrDefault(e => e.Card.Id == 50001002L);
Assert.That(owned, Is.Not.Null, "owned card should appear in result");
Assert.That(owned!.Count, Is.EqualTo(2));
// The unowned collectible card must NOT appear.
Assert.That(result.Any(e => e.Card.Id == 50001003L), Is.False,
"card not owned by viewer must not appear when freeplay is off");
}
// -------------------------------------------------------------------------
// EffectiveCosmeticsAsync
// -------------------------------------------------------------------------
[Test]
public async Task EffectiveCosmetics_leader_skins_always_full_catalog_owned_set_differs()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
// --- freeplay OFF: fresh viewer owns no cosmetics ---
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Freeplay off by default.
var viewer = await db.Viewers
.Include(v => v.LeaderSkins)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.FirstAsync(v => v.Id == viewerId);
var ent = Build(scope);
var cosmetics = await ent.EffectiveCosmeticsAsync(viewer);
int masterSkinCount = db.LeaderSkins.Count();
Assert.That(masterSkinCount, Is.GreaterThan(0),
"leaderskins.csv must have been imported — master table must be non-empty");
Assert.That(cosmetics.AllLeaderSkins.Count, Is.EqualTo(masterSkinCount),
"AllLeaderSkins should always be the full catalog regardless of freeplay");
// A fresh viewer owns one default skin per class (granted at registration).
// Assert the owned set matches what the viewer actually has — don't assume empty.
var expectedOwnedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet();
Assert.That(cosmetics.OwnedLeaderSkinIds, Is.EquivalentTo(expectedOwnedSkinIds),
"OwnedLeaderSkinIds should match the viewer's actual owned skins when freeplay is off");
// OwnedLeaderSkinIds must be a strict subset of AllLeaderSkins (not all of them).
Assert.That(cosmetics.OwnedLeaderSkinIds.Count, Is.LessThan(masterSkinCount),
"fresh viewer should own fewer skins than the full catalog");
// The four id-lists reflect what the viewer actually owns.
Assert.That(cosmetics.SleeveIds.Count, Is.EqualTo(viewer.Sleeves.Count));
Assert.That(cosmetics.EmblemIds.Count, Is.EqualTo(viewer.Emblems.Count));
Assert.That(cosmetics.DegreeIds.Count, Is.EqualTo(viewer.Degrees.Count));
Assert.That(cosmetics.MyPageBackgroundIds.Count, Is.EqualTo(viewer.MyPageBackgrounds.Count));
}
// --- freeplay ON: all catalogs become owned ---
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
SetFreeplayEnabled(db, enabled: true);
var viewer = await db.Viewers
.Include(v => v.LeaderSkins)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.FirstAsync(v => v.Id == viewerId);
var ent = Build(scope);
var cosmetics = await ent.EffectiveCosmeticsAsync(viewer);
int masterSkinCount = db.LeaderSkins.Count();
Assert.That(cosmetics.AllLeaderSkins.Count, Is.EqualTo(masterSkinCount),
"AllLeaderSkins count unchanged when freeplay is on");
Assert.That(cosmetics.OwnedLeaderSkinIds.Count, Is.EqualTo(masterSkinCount),
"freeplay: every skin id must be in OwnedLeaderSkinIds");
// All four id-lists should equal the full catalog counts.
Assert.That(cosmetics.SleeveIds.Count, Is.EqualTo(db.Sleeves.Count()),
"freeplay: SleeveIds should equal full sleeve catalog");
Assert.That(cosmetics.EmblemIds.Count, Is.EqualTo(db.Emblems.Count()),
"freeplay: EmblemIds should equal full emblem catalog");
Assert.That(cosmetics.DegreeIds.Count, Is.EqualTo(db.Degrees.Count()),
"freeplay: DegreeIds should equal full degree catalog");
Assert.That(cosmetics.MyPageBackgroundIds.Count, Is.EqualTo(db.MyPageBackgrounds.Count()),
"freeplay: MyPageBackgroundIds should equal full my-page-background catalog");
}
}
}

View File

@@ -8,6 +8,7 @@ using SVSim.Database.Entities.Story;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Repositories.Story; using SVSim.Database.Repositories.Story;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Story; using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure; using SVSim.UnitTests.Infrastructure;
@@ -26,12 +27,12 @@ public class StoryServiceTests
{ {
_master = new Mock<IStoryMasterRepository>(); _master = new Mock<IStoryMasterRepository>();
_viewer = new Mock<IViewerStoryProgressRepository>(); _viewer = new Mock<IViewerStoryProgressRepository>();
// Non-reward tests never exercise the DB/reward path; use a stub InMemory context. // Non-reward tests never exercise the DB/reward path; use a stub InMemory context + null inv.
var db = StoryServiceTestHelpers.NewInMemoryDb(nameof(SetUp)); var db = StoryServiceTestHelpers.NewInMemoryDb(nameof(SetUp));
var rewards = new RewardGrantService(db, NullLogger<RewardGrantService>.Instance); var inv = new Mock<IInventoryService>().Object;
_service = new StoryService( _service = new StoryService(
_master.Object, _viewer.Object, _master.Object, _viewer.Object,
rewards: rewards, inv: inv,
db: db, db: db,
configService: StoryServiceTestHelpers.NewConfigService(), configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object, deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
@@ -64,12 +65,12 @@ public class StoryServiceTests
scope = factory.Services.CreateScope(); scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>(); var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var rewards = scope.ServiceProvider.GetRequiredService<RewardGrantService>(); var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
return new StoryService( return new StoryService(
_master.Object, _master.Object,
_viewer.Object, _viewer.Object,
rewards: rewards, inv: inv,
db: db, db: db,
configService: StoryServiceTestHelpers.NewConfigService(), configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object, deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
@@ -402,7 +403,7 @@ public class StoryServiceTests
db.SaveChanges(); db.SaveChanges();
return new StoryService( return new StoryService(
_master.Object, _viewer.Object, _master.Object, _viewer.Object,
rewards: new RewardGrantService(db, NullLogger<RewardGrantService>.Instance), inv: new Mock<IInventoryService>().Object,
db: db, db: db,
configService: StoryServiceTestHelpers.NewConfigService(), configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object, deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,

View File

@@ -0,0 +1,71 @@
using System.Text.Json;
using System.Text.Json.Serialization;
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.Wire;
public class InventoryRewardListWireShape
{
[Test]
public async Task Spend_crystal_plus_grant_card_with_cascade_matches_fixture()
{
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>();
var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId);
v.Currency.Crystals = 1000;
const int sleeveId = 2_000_040_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);
await tx.TrySpendAsync(SpendCurrency.Crystal, 500);
await tx.GrantAsync(UserGoodsType.Card, cardId, 3);
var result = await tx.CommitAsync();
var opts = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
var json = JsonSerializer.Serialize(result.RewardList, opts);
// Expected order: currency entries in first-touch order, then non-currency in first-touch order.
// Crystal spend comes first (post-state 500), then Card grant (post-state count 3), then
// Sleeve cascade (always 1).
var doc = JsonDocument.Parse(json);
var arr = doc.RootElement.EnumerateArray().ToList();
Assert.That(arr, Has.Count.EqualTo(3), $"Expected 3 reward entries, got {arr.Count}. JSON: {json}");
Assert.That(arr[0].GetProperty("reward_type").GetInt32(), Is.EqualTo((int)UserGoodsType.Crystal),
"First entry should be Crystal (spend post-state)");
Assert.That(arr[0].GetProperty("reward_num").GetInt32(), Is.EqualTo(500),
"Crystal post-state after spending 500 from 1000 should be 500");
Assert.That(arr[1].GetProperty("reward_type").GetInt32(), Is.EqualTo((int)UserGoodsType.Card),
"Second entry should be Card");
Assert.That(arr[1].GetProperty("reward_num").GetInt32(), Is.EqualTo(3),
"Card post-state count for fresh grant of 3 should be 3");
Assert.That(arr[2].GetProperty("reward_type").GetInt32(), Is.EqualTo((int)UserGoodsType.Sleeve),
"Third entry should be Sleeve (cascade from card grant)");
Assert.That(arr[2].GetProperty("reward_id").GetInt32(), Is.EqualTo(sleeveId),
"Sleeve reward_id should match the seeded sleeve");
// Verify snake_case keys are present (not PascalCase)
Assert.That(arr[0].TryGetProperty("reward_type", out _), Is.True, "Key must be reward_type not RewardType");
Assert.That(arr[0].TryGetProperty("reward_id", out _), Is.True, "Key must be reward_id not RewardId");
Assert.That(arr[0].TryGetProperty("reward_num", out _), Is.True, "Key must be reward_num not RewardNum");
}
}