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.Models;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
namespace SVSim.Database.Repositories.Card;
public class CardInventoryRepository : ICardInventoryRepository
{
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;
_grants = grants;
_inv = inv;
}
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;
}
// 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)
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
// repo, symmetric with destruct.
viewer.Currency.RedEther -= totalCost;
var spendResult = await tx.TrySpendAsync(SpendCurrency.RedEther, (long)totalCost);
if (!spendResult.Success)
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>();
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);
}
await _db.SaveChangesAsync();
await tx.CommitAsync();
return CreateOutcome.Ok(new CreateResult(viewer.Currency.RedEther, allGrants));
return CreateOutcome.Ok(new CreateResult(tx.Viewer.Currency.RedEther, allGrants));
}
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.Config;
using SVSim.Database.Repositories.Card;
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 ICardRepository _cards;
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;
_cards = cards;
_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)
{
var cfg = Cfg;
var cfg = _config.Get<FreeplayConfig>();
if (cfg.Enabled && currency != SpendCurrency.SpotPoint)
return checked((long)cfg.CurrencyAmount);
@@ -39,28 +76,12 @@ public class ViewerEntitlements : IViewerEntitlements
};
}
public bool OwnsCard(Viewer viewer, long cardId)
=> Cfg.Enabled || viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0);
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)
public async Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(
Viewer viewer, CancellationToken ct = default)
{
var defaults = await _cards.GetDefaultCards();
var defaultIds = defaults.Select(c => c.Id).ToHashSet();
var cfg = Cfg;
var cfg = _config.Get<FreeplayConfig>();
if (cfg.Enabled)
{
@@ -81,11 +102,13 @@ public class ViewerEntitlements : IViewerEntitlements
.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 cfg = _config.Get<FreeplayConfig>();
if (Cfg.Enabled)
if (cfg.Enabled)
{
return new EffectiveCosmetics(
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 SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Mission;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Achievement;
using SVSim.EmulatedEntrypoint.Services;
@@ -22,20 +21,20 @@ public class AchievementController : SVSimController
private readonly IMissionCatalogRepository _catalog;
private readonly IViewerMissionStateService _state;
private readonly IMissionAssembler _assembler;
private readonly RewardGrantService _grantService;
private readonly IInventoryService _inv;
public AchievementController(
SVSimDbContext db,
IMissionCatalogRepository catalog,
IViewerMissionStateService state,
IMissionAssembler assembler,
RewardGrantService grantService)
IInventoryService inv)
{
_db = db;
_catalog = catalog;
_state = state;
_assembler = assembler;
_grantService = grantService;
_inv = inv;
}
[HttpPost("receive_reward")]
@@ -44,21 +43,15 @@ public class AchievementController : SVSimController
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// Load viewer with all the collections RewardGrantService may need to mutate.
var viewer = await _db.Viewers
.Include(v => v.MissionData)
.Include(v => v.Currency)
.Include(v => v.Cards)
.Include(v => v.Items)
.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, ct);
// EnsureCurrentAsync needs a viewer id — use a lightweight pre-check load then
// materialize state before opening the inventory tx.
var viewerIdCheck = await _db.Viewers
.Where(v => v.Id == viewerId)
.Select(v => v.Id)
.FirstOrDefaultAsync(ct);
if (viewerIdCheck == 0) return Unauthorized();
await _state.EnsureCurrentAsync(viewer.Id, ct);
await _state.EnsureCurrentAsync(viewerId, ct);
await _db.SaveChangesAsync(ct);
// 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 });
}
// Grant via the canonical RewardGrantService primitive.
var granted = await _grantService.ApplyAsync(
viewer,
// Open inventory tx and grant via InventoryService.
await using var tx = await _inv.BeginAsync(viewerId, ct);
var granted = await tx.GrantAsync(
(UserGoodsType)catalogRow.RewardType,
catalogRow.RewardDetailId,
catalogRow.RewardNumber,
@@ -99,9 +93,9 @@ public class AchievementController : SVSimController
}
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
{
UserMissionList = dto.UserMissionList,

View File

@@ -1,10 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.BuildDeck;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck;
@@ -20,39 +19,16 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class BuildDeckController : SVSimController
{
private readonly IBuildDeckRepository _repo;
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly ICurrencySpendService _spend;
private readonly IInventoryService _inv;
public BuildDeckController(
IBuildDeckRepository repo,
SVSimDbContext db,
RewardGrantService rewards,
ICurrencySpendService spend)
IInventoryService inv)
{
_repo = repo;
_db = db;
_rewards = rewards;
_spend = spend;
_inv = inv;
}
/// <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
// DTO with a `series_list` field. The client (BuildDeckPurchaseInfoTask.Parse) iterates
// `data` directly via numeric indexer:
@@ -194,60 +170,45 @@ public class BuildDeckController : SVSimController
break;
}
// Single viewer load with the full graph — every subsequent mutation (currency debit,
// purchase counter, card grants, cosmetic grants) operates on this one in-memory instance
// so we can save once at the end.
var viewer = await LoadViewerGraphAsync(viewerId);
var rewardList = new List<RewardListEntry>();
// Open the inventory transaction — loads canonical graph + BuildDeckPurchases.
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted,
cfg => cfg.WithInclude(v => v.BuildDeckPurchases));
var viewer = tx.Viewer;
// Debit + post-state currency entry
// Debit currency
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" });
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal });
}
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" });
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
int prevSeriesCount = product.Series!.Products
.Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0);
int newSeriesCount = prevSeriesCount + 1;
// Increment purchase counter directly on the tracked viewer (we already loaded
// 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.
// Increment purchase counter on tx.Viewer (tx loaded BuildDeckPurchases via WithInclude).
var purchaseRow = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == product.Id);
if (purchaseRow is null)
viewer.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = product.Id, PurchaseCount = 1 });
else
purchaseRow.PurchaseCount += 1;
// Grant the 40 deck cards. Bucket by CardId so duplicate (CardId, IsSpot) rows don't
// emit redundant reward_list entries — ApplyAsync(Card, ...) runs the cosmetic cascade
// and returns a post-state-total entry per call.
var deckGrants = product.Cards
.GroupBy(c => c.CardId)
.Select(g => (UserGoodsType.Card, g.Key, g.Sum(c => c.Number)));
await ApplyRewardsAsync(viewer, deckGrants, rewardList);
// Grant deck cards (grouped by CardId)
foreach (var grp in product.Cards.GroupBy(c => c.CardId))
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Sum(c => c.Number));
// Per-buy rewards from the product catalog: sleeve, emblem, skin, sometimes extra cards
// (Set 4 grants 3 copies of the featured card as a type=5 reward).
await ApplyRewardsAsync(viewer, product.Rewards
.OrderBy(r => r.RewardIndex)
.Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)),
rewardList);
// Per-buy rewards
foreach (var r in product.Rewards.OrderBy(r => r.RewardIndex))
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
// Series-reward tier crossings: tiers where prevSeriesCount < TierIndex <= newSeriesCount.
// 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.
// Series-reward tier crossings
var crossedTiers = product.Series.SeriesRewards
.Where(r => r.TierIndex > prevSeriesCount && r.TierIndex <= newSeriesCount)
.GroupBy(r => r.TierIndex)
@@ -257,13 +218,9 @@ public class BuildDeckController : SVSimController
var seriesRewards = new List<BuildDeckProductRewardDto>();
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))
{
await tx.GrantAsync((UserGoodsType)item.RewardType, item.RewardDetailId, item.RewardNumber);
seriesRewards.Add(new BuildDeckProductRewardDto
{
RewardType = item.RewardType,
@@ -274,39 +231,17 @@ public class BuildDeckController : SVSimController
}
}
await _db.SaveChangesAsync();
var result = await tx.CommitAsync(HttpContext.RequestAborted);
return new BuildDeckBuyResponse
{
RewardList = rewardList,
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
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")]
public async Task<ActionResult<BuildDeckGetPurchaseCountResponse>> GetPurchaseCount(
BuildDeckGetPurchaseCountRequest request)

View File

@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
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.Responses.Gift;
@@ -27,12 +27,12 @@ public class GiftController : SVSimController
};
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;
_rewards = rewards;
_inv = inv;
}
[HttpPost("/tutorial/gift_top")]
@@ -71,25 +71,7 @@ public class GiftController : SVSimController
var requestedIds = request.PresentIdArray.ToHashSet();
// Load viewer with the collections RewardGrantService mutates. Crystals/Rupees live on
// 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.
// Resolve which of the requested ids are still claimable for this viewer before opening tx.
var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts
.Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId))
.Select(g => g.PresentId)
@@ -100,23 +82,43 @@ public class GiftController : SVSimController
.Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId))
.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)
{
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
// /tutorial/update → 41 call appears in the prod capture). Preserve max — don't downgrade
// viewers who are already past step 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;
foreach (var p in toClaim)
{
@@ -127,7 +129,7 @@ public class GiftController : SVSimController
ClaimedAt = now,
});
}
await _db.SaveChangesAsync();
await tx.CommitAsync();
var nowString = now.ToString("yyyy-MM-dd HH:mm:ss");
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
// carried unclaimed entries.
IsUnreceivedPresent = unclaimedPresents.Count > 0,
// reward_list entries must carry POST-STATE TOTALS, not gift deltas.
// 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.
// reward_list entries carry POST-STATE TOTALS (from tx.GrantAsync).
// See project memory: project_wire_reward_list_post_state.
//
// Iterate `toClaim` so idempotent re-receive doesn't re-emit post-state entries
// 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(),
// Iterate toClaim so idempotent re-receive doesn't re-emit post-state entries.
RewardList = rewardListEntries,
// 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);
// emitting 41 anyway would surface a regressed step to the client and desync the
// 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
{
1 => UserGoodsType.Crystal,

View File

@@ -4,6 +4,7 @@ using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
@@ -21,16 +22,14 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class ItemPurchaseController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly IInventoryService _inv;
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;
_rewards = rewards;
_inv = inv;
_time = time;
_spend = spend;
}
[HttpPost("info")]
@@ -115,28 +114,17 @@ public class ItemPurchaseController : SVSimController
if (rest <= 0)
return BadRequest(new { error = "sold_out" });
var viewer = await LoadViewerGraphAsync(viewerId);
var rewardList = new List<RewardListEntry>();
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
// Debit the require side. RewardGrantService is grant-only, so handle this inline.
var debit = await TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState);
// Debit the require side via the tx.
var debit = await tx.TryDebitAsync(
(UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
if (!debit.Success) return BadRequest(new { error = MapDebitError(entry.RequireItemType) });
// Grant the purchase side through the central dispatcher.
var granted = await _rewards.ApplyAsync(viewer,
(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,
});
}
// Grant the purchase side.
await tx.GrantAsync((UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum);
// Increment the per-period counter.
// Increment the per-period counter (tracked via _db, outside the inventory tx).
if (counter is null)
{
_db.ViewerEventCounters.Add(new ViewerEventCounter
@@ -151,52 +139,27 @@ public class ItemPurchaseController : SVSimController
{
counter.Count++;
}
await _db.SaveChangesAsync();
return new ItemPurchasePurchaseResponse { RewardList = rewardList };
}
var result = await tx.CommitAsync(HttpContext.RequestAborted);
/// <summary>
/// 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)
return new ItemPurchasePurchaseResponse
{
case UserGoodsType.RedEther:
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.RedEther, num);
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}");
}
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
};
}
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 int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey)
@@ -204,15 +167,4 @@ public class ItemPurchaseController : SVSimController
var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime;
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.Repositories.Collectibles;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
@@ -29,19 +30,15 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class LeaderSkinController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly IInventoryService _inv;
private readonly TimeProvider _time;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
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;
_rewards = rewards;
_inv = inv;
_time = time;
_spend = spend;
_entitlements = entitlements;
_collection = collection;
}
@@ -69,7 +66,8 @@ public class LeaderSkinController : SVSimController
var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId);
if (skin is null) return BadRequest(new { error = "unknown_skin" });
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" });
classData.LeaderSkin = skin;
@@ -88,18 +86,13 @@ public class LeaderSkinController : SVSimController
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
if (_entitlements.IsFreeplay)
{
var all = (await _collection.GetLeaderSkins()).Select(s => s.Id).OrderBy(id => id).ToList();
return new LeaderSkinIdsResponse { UserLeaderSkinIds = all };
}
var ids = await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
.OrderBy(id => id)
.ToListAsync();
var viewer = await _db.Viewers
.Include(v => v.LeaderSkins)
.FirstOrDefaultAsync(v => v.Id == viewerId);
if (viewer is null) return Unauthorized();
var cosmetics = await _inv.EffectiveCosmeticsAsync(viewer);
var ids = cosmetics.OwnedLeaderSkinIds.OrderBy(id => id).ToList();
return new LeaderSkinIdsResponse { UserLeaderSkinIds = ids };
}
@@ -108,12 +101,13 @@ public class LeaderSkinController : SVSimController
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var ownedSkinIds = _entitlements.IsFreeplay
? (await _collection.GetLeaderSkins()).Select(s => s.Id).ToHashSet()
: (await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
.ToListAsync()).ToHashSet();
var viewerForProducts = await _db.Viewers
.Include(v => v.LeaderSkins)
.FirstOrDefaultAsync(v => v.Id == viewerId);
if (viewerForProducts is null) return Unauthorized();
var cosmeticsForProducts = await _inv.EffectiveCosmeticsAsync(viewerForProducts);
var ownedSkinIds = cosmeticsForProducts.OwnedLeaderSkinIds;
var claimedSeries = (await _db.ViewerLeaderSkinSetClaims
.Where(c => c.ViewerId == viewerId)
@@ -183,21 +177,41 @@ public class LeaderSkinController : SVSimController
if (!product.IsEnabled || product.Series is not { IsEnabled: true })
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.
if (_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, product.LeaderSkinId))
if (tx.OwnsCosmetic(CosmeticType.Skin, product.LeaderSkinId))
return BadRequest(new { error = "already_purchased" });
var rewardList = new List<RewardListEntry>();
var debit = await DebitProductPrice(viewer, product, request.SalesType);
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState);
// Debit currency
switch (request.SalesType)
{
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();
return new LeaderSkinBuyResponse { RewardList = rewardList };
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")]
@@ -218,25 +232,44 @@ public class LeaderSkinController : SVSimController
if (!series.IsEnabled || series.SetSalesStatus == 0)
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" });
var rewardList = new List<RewardListEntry>();
var debit = await DebitSetPrice(viewer, series, 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))
// Debit set price
switch (request.SalesType)
{
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();
return new LeaderSkinBuyResponse { RewardList = rewardList };
// Grant every product's rewards; tx.GrantAsync is idempotent on already-owned cosmetics.
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")]
@@ -257,16 +290,15 @@ public class LeaderSkinController : SVSimController
if (existingClaim is not null)
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.
var ownedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet();
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => ownedSkinIds.Contains(p.LeaderSkinId));
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => tx.OwnsCosmetic(CosmeticType.Skin, p.LeaderSkinId));
if (!ownsAll)
return BadRequest(new { error = "series_not_completed" });
var rewardList = new List<RewardListEntry>();
await ApplyRewardsAsync(viewer, series.SetCompletionRewards, rewardList);
foreach (var r in series.SetCompletionRewards.OrderBy(r => r.OrderIndex))
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
_db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim
{
@@ -275,8 +307,13 @@ public class LeaderSkinController : SVSimController
ClaimedAt = _time.GetUtcNow().UtcDateTime,
});
await _db.SaveChangesAsync();
return new LeaderSkinBuyResponse { RewardList = rewardList };
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(),
};
}
/// <summary>
@@ -304,7 +341,7 @@ public class LeaderSkinController : SVSimController
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);
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
/// bundle items are de-facto owned." Refine later if a capture shows independent state.
/// </summary>
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, HashSet<int> ownedSkinIds)
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, IReadOnlySet<int> ownedSkinIds)
{
// Skin reward: direct check.
if (r.RewardType == (int)UserGoodsType.Skin)
@@ -350,94 +387,4 @@ public class LeaderSkinController : SVSimController
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.Viewer;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Models.Dtos;
@@ -42,26 +43,24 @@ public class LoadController : SVSimController
private readonly IViewerRepository _viewerRepository;
private readonly IGlobalsRepository _globalsRepository;
private readonly ICardAcquisitionService _acquisition;
private readonly IGameConfigService _config;
private readonly IBattlePassService _battlePass;
private readonly IViewerMissionStateService _missionState;
private readonly SVSimDbContext _db;
private readonly IViewerEntitlements _entitlements;
private readonly IInventoryService _inv;
public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
ICardAcquisitionService acquisition, IGameConfigService config,
IGameConfigService config,
IBattlePassService battlePass, IViewerMissionStateService missionState,
SVSimDbContext db, IViewerEntitlements entitlements)
SVSimDbContext db, IInventoryService inv)
{
_viewerRepository = viewerRepository;
_globalsRepository = globalsRepository;
_acquisition = acquisition;
_config = config;
_battlePass = battlePass;
_missionState = missionState;
_db = db;
_entitlements = entitlements;
_inv = inv;
}
[HttpPost("index")]
@@ -84,7 +83,9 @@ public class LoadController : SVSimController
// .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,
// 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.
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.
// Owned-card projection (incl. the freeplay "all cards" path) lives in the entitlements
// 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();
List<ClassExp> classExps = new();
@@ -168,10 +169,10 @@ public class LoadController : SVSimController
UserInfo = new UserInfo(deviceType, viewer),
UserCurrency = new UserCurrency(viewer)
{
Crystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal),
TotalCrystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal),
Rupees = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee),
RedEther = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.RedEther),
Crystals = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Crystal),
TotalCrystals = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Crystal),
Rupees = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Rupee),
RedEther = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.RedEther),
},
UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
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.Responses.Pack;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -30,10 +31,8 @@ public class PackController : SVSimController
private readonly ICardFoilLookup _foils;
private readonly IRandom _rng;
private readonly SVSimDbContext _db;
private readonly ICardAcquisitionService _acquisition;
private readonly IInventoryService _inv;
private readonly IGachaPointService _gachaPoint;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
public PackController(
IPackRepository packs,
@@ -42,10 +41,8 @@ public class PackController : SVSimController
ICardFoilLookup foils,
IRandom rng,
SVSimDbContext db,
ICardAcquisitionService acquisition,
IGachaPointService gachaPoint,
ICurrencySpendService spend,
IViewerEntitlements entitlements)
IInventoryService inv,
IGachaPointService gachaPoint)
{
_packs = packs;
_opener = opener;
@@ -53,10 +50,8 @@ public class PackController : SVSimController
_foils = foils;
_rng = rng;
_db = db;
_acquisition = acquisition;
_inv = inv;
_gachaPoint = gachaPoint;
_spend = spend;
_entitlements = entitlements;
}
[HttpPost("info")]
@@ -207,26 +202,18 @@ public class PackController : SVSimController
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// Load the viewer with the collections the service mutates (balances, received marker,
// cards, cosmetics). AsSplitQuery per project_ef_split_query memory.
var viewer = await _db.Viewers
.Include(v => v.GachaPointBalances)
.Include(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);
// Open inventory tx with extra includes for GachaPointBalances + GachaPointReceived
// (needed by TryExchangeAsync to validate balance and already-received guard).
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg
.WithInclude(v => v.GachaPointBalances)
.WithInclude(v => v.GachaPointReceived));
// Use odds_gacha_id (the seasonal pack id) — that's where the balance / received marker
// 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 });
await _db.SaveChangesAsync();
await tx.CommitAsync();
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))
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
var viewer = await _db.Viewers
.Include(v => v.PackOpenCounts)
.Include(v => v.GachaPointBalances)
.Include(v => v.MissionData)
.Include(v => v.Items).ThenInclude(i => i.Item)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// Load viewer via InventoryService transaction with extra includes for pack-open needs.
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg
.WithInclude(v => v.PackOpenCounts)
.WithInclude(v => v.GachaPointBalances)
.WithInclude(v => v.MissionData));
var viewer = tx.Viewer;
// 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
@@ -314,7 +300,7 @@ public class PackController : SVSimController
case 2: // CRYSTAL_MULTI (10-pack)
{
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" });
break;
}
@@ -322,7 +308,7 @@ public class PackController : SVSimController
case 7: // RUPY_MULTI (10-pack)
{
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" });
break;
}
@@ -336,7 +322,7 @@ public class PackController : SVSimController
return BadRequest(new { error = "daily_free_already_claimed" });
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" });
break;
}
@@ -347,15 +333,11 @@ public class PackController : SVSimController
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
int ticketsNeeded = child.Cost * packNumber;
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
if (owned is null || owned.Count < ticketsNeeded)
return BadRequest(new { error = "insufficient_tickets" });
owned.Count -= ticketsNeeded;
var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded);
if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" });
break;
}
}
await _db.SaveChangesAsync();
}
// Increment open count + mark daily-free timestamp where relevant.
@@ -394,48 +376,17 @@ public class PackController : SVSimController
ownedCardIds,
_foils,
_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).
if (!isTutorialPath)
{
_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
// 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
@@ -447,19 +398,12 @@ public class PackController : SVSimController
int? responseTutorialStep = null;
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);
if (owned is not null)
{
owned.Count = Math.Max(0, owned.Count - packNumber);
rewardList.Add(new RewardListEntry
{
RewardType = 4, // Item
RewardId = ticketItemId,
RewardNum = owned.Count, // POST-STATE total
});
}
int ticketsToConsume = packNumber;
var debit = await tx.TryDebitAsync(UserGoodsType.Item, tutorialTicketItemId, ticketsToConsume);
// Silently accept if the viewer doesn't have the ticket (already consumed or never granted)
_ = debit;
}
// 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.
if (viewer.MissionData.TutorialState < TutorialEndStep)
viewer.MissionData.TutorialState = TutorialEndStep;
await _db.SaveChangesAsync();
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
{
PackList = draw.Cards.Select(c => new CardPackEntryDto

View File

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

View File

@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Sleeve;
@@ -20,17 +21,13 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class SleeveController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
private readonly IInventoryService _inv;
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;
_rewards = rewards;
_spend = spend;
_entitlements = entitlements;
_inv = inv;
_collection = collection;
}
@@ -42,12 +39,13 @@ public class SleeveController : SVSimController
// 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
// an N+1 over products.
var ownedSleeveIds = _entitlements.IsFreeplay
? (await _collection.GetAllSleeveIds()).Select(id => (long)id).ToHashSet()
: (await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Sleeves.Select(s => (long)s.Id))
.ToListAsync()).ToHashSet();
var viewerForInfo = await _db.Viewers
.Include(v => v.Sleeves)
.FirstOrDefaultAsync(v => v.Id == viewerId);
if (viewerForInfo is null) return Unauthorized();
var cosmeticsForInfo = await _inv.EffectiveCosmeticsAsync(viewerForInfo);
var ownedSleeveIds = cosmeticsForInfo.SleeveIds.Select(id => (long)id).ToHashSet();
var series = await _db.SleeveShopSeries
.Where(s => s.IsEnabled)
@@ -113,18 +111,17 @@ public class SleeveController : SVSimController
if (product.SeriesId != request.SeriesId)
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" });
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" });
// 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;
// sales_type==0 means "free", which requires both prices == 0.
var rewardList = new List<RewardListEntry>();
switch (request.SalesType)
{
case 0: // free
@@ -134,39 +131,27 @@ public class SleeveController : SVSimController
case 1: // crystal
if (product.PriceCrystal is null)
return BadRequest(new { error = "price_not_available_for_currency" });
var crystalRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, product.PriceCrystal.Value);
if (!crystalRes.Success) return BadRequest(new { error = "insufficient_crystals" });
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)crystalRes.PostStateTotal });
{ var r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.PriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
break;
case 2: // rupy
if (product.PriceRupy is null)
return BadRequest(new { error = "price_not_available_for_currency" });
var rupyRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, product.PriceRupy.Value);
if (!rupyRes.Success) return BadRequest(new { error = "insufficient_rupees" });
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)rupyRes.PostStateTotal });
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.PriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
break;
}
// Grant each catalog reward through the central dispatcher — covers sleeve (6), emblem
// (7), and any future bundled grants. ApplyAsync returns post-state-aware reward entries
// suitable for emission as-is.
// Grant each catalog reward through the central dispatcher.
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);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
}
await _db.SaveChangesAsync();
return new SleeveBuyResponse { RewardList = rewardList };
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
};
}
/// <summary>
@@ -185,14 +170,4 @@ public class SleeveController : SVSimController
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.Models;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.SpotCardExchange;
@@ -14,8 +15,7 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /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
/// battle/mission finish reward emitters via <see cref="RewardGrantService"/> +
/// <see cref="UserGoodsType.SpotCardPoint"/>).
/// battle/mission finish reward emitters via <see cref="UserGoodsType.SpotCardPoint"/>).
/// </summary>
[Route("spot_card_exchange")]
public class SpotCardExchangeController : SVSimController
@@ -28,16 +28,14 @@ public class SpotCardExchangeController : SVSimController
private const int PreReleaseLimit = 2;
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly IInventoryService _inv;
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;
_rewards = rewards;
_inv = inv;
_time = time;
_spend = spend;
}
[HttpPost("top")]
@@ -126,14 +124,14 @@ public class SpotCardExchangeController : SVSimController
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>();
// Debit spot points. Client-supplied exchange_point isn't authoritative — server uses
// catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry
// 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)
return BadRequest(new { error = "insufficient_spot_points" });
rewardList.Add(new RewardListEntry
@@ -143,8 +141,8 @@ public class SpotCardExchangeController : SVSimController
RewardNum = checked((int)spotRes.PostStateTotal),
});
// Grant the card itself via the existing card dispatcher (handles cosmetic cascade).
var granted = await _rewards.ApplyAsync(viewer, UserGoodsType.Card, entry.Id, 1);
// Grant the card itself via the inventory tx (handles cosmetic cascade).
var granted = await tx.GrantAsync(UserGoodsType.Card, entry.Id, 1);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
@@ -163,7 +161,7 @@ public class SpotCardExchangeController : SVSimController
ExchangedAt = _time.GetUtcNow().UtcDateTime,
});
await _db.SaveChangesAsync();
await tx.CommitAsync();
return new SpotCardExchangeResponse { RewardList = rewardList };
}
@@ -182,14 +180,4 @@ public class SpotCardExchangeController : SVSimController
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<PackOpenService>();
builder.Services.AddScoped<IGachaPointService, GachaPointService>();
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();
builder.Services.AddScoped<RewardGrantService>();
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.Services.Inventory.IInventoryService,
SVSim.Database.Services.Inventory.InventoryService>();
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IBattlePassRepository,
SVSim.Database.Repositories.BattlePass.BattlePassRepository>();
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IViewerBattlePassRepository,

View File

@@ -1,10 +1,12 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
@@ -17,11 +19,9 @@ public class ArenaTwoPickService : IArenaTwoPickService
private readonly IArenaTwoPickCardPoolService _pool;
private readonly IGameConfigService _config;
private readonly IViewerRepository _viewers;
private readonly RewardGrantService _grants;
private readonly IViewerEntitlements _entitlements;
private readonly IInventoryService _inv;
private readonly IRandom _rng;
private readonly SVSimDbContext _db;
private readonly ICurrencySpendService _spend;
public ArenaTwoPickService(
IArenaTwoPickRunRepository runs,
@@ -29,15 +29,12 @@ public class ArenaTwoPickService : IArenaTwoPickService
IArenaTwoPickCardPoolService pool,
IGameConfigService config,
IViewerRepository viewers,
RewardGrantService grants,
IViewerEntitlements entitlements,
IInventoryService inv,
IRandom rng,
SVSimDbContext db,
ICurrencySpendService spend)
SVSimDbContext db)
{
_runs = runs; _rewards = rewards; _pool = pool; _config = config;
_viewers = viewers; _grants = grants; _entitlements = entitlements; _rng = rng; _db = db;
_spend = spend;
_viewers = viewers; _inv = inv; _rng = rng; _db = db;
}
public async Task<TopResponseDto> GetTopAsync(long viewerId)
@@ -66,14 +63,16 @@ public class ArenaTwoPickService : IArenaTwoPickService
throw new ArenaTwoPickException("arena_two_pick_already_in_progress");
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).
RewardEntryDto? feeEntry = consumeItemType switch
{
1 => await DebitCrystalsAsync(viewer, aCfg.CrystalCost),
3 => DebitTicket(viewer, aCfg.TicketItemId, aCfg.TicketCost),
4 => await DebitRupiesAsync(viewer, aCfg.RupyCost),
1 => await DebitCrystalsAsync(tx, aCfg.CrystalCost),
3 => await DebitTicketAsync(tx, aCfg.TicketItemId, aCfg.TicketCost),
4 => await DebitRupiesAsync(tx, aCfg.RupyCost),
5 => null, // Free entry — no fee.
_ => throw new ArenaTwoPickException("invalid_consume_item_type"),
};
@@ -102,9 +101,12 @@ public class ArenaTwoPickService : IArenaTwoPickService
IsRetire = false,
};
await _runs.UpsertAsync(run);
// Save to get auto-generated Id before CommitAsync.
await _db.SaveChangesAsync();
run.EntryId = run.Id;
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 };
@@ -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);
int postStateCount;
if (_entitlements.IsFreeplay)
if (tx.IsFreeplay)
{
postStateCount = ticket?.Count ?? 0;
}
else
{
if (ticket is null || ticket.Count < ticketCost)
throw new ArenaTwoPickException("insufficient_ticket");
ticket.Count -= ticketCost;
postStateCount = ticket.Count;
var ticket = tx.Viewer.Items.FirstOrDefault(i => i.Item.Id == ticketItemId);
return new RewardEntryDto
{
RewardType = (int)UserGoodsType.Item,
RewardId = ticketItemId,
RewardNum = ticket?.Count ?? 0,
};
}
var debitResult = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketCost);
if (!debitResult.Success)
throw new ArenaTwoPickException("insufficient_ticket");
return new RewardEntryDto
{
RewardType = (int)SVSim.Database.Enums.UserGoodsType.Item,
RewardType = (int)UserGoodsType.Item,
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)
throw new ArenaTwoPickException("insufficient_crystal");
return new RewardEntryDto
{
RewardType = (int)SVSim.Database.Enums.UserGoodsType.Crystal,
RewardType = (int)UserGoodsType.Crystal,
RewardId = 0,
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)
throw new ArenaTwoPickException("insufficient_rupy");
return new RewardEntryDto
{
RewardType = (int)SVSim.Database.Enums.UserGoodsType.Rupy,
RewardType = (int)UserGoodsType.Rupy,
RewardId = 0,
RewardNum = (int)result.PostStateTotal,
};
@@ -295,12 +297,11 @@ public class ArenaTwoPickService : IArenaTwoPickService
throw new ArenaTwoPickException("arena_two_pick_run_not_complete");
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
// per-grant delta entries. Currencies don't need a lookup (item_type stays 0).
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)
.Distinct()
.ToList();
@@ -310,7 +311,9 @@ public class ArenaTwoPickService : IArenaTwoPickService
.ToDictionaryAsync(i => i.Id, i => i.Type);
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).
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();
if (pickable.Count == 0) continue;
var pick = WeightedPick(pickable, _rng);
picks.Add(pick);
// Skip when the rolled outcome is "nothing" (RewardNum == 0).
if (pick.RewardNum <= 0) continue;
var goodsType = (SVSim.Database.Enums.UserGoodsType)pick.RewardType;
await _grants.ApplyAsync(viewer, goodsType, pick.RewardId, pick.RewardNum);
await tx.GrantAsync((UserGoodsType)pick.RewardType, pick.RewardId, pick.RewardNum);
deltas.Add(new TwoPickRewardReceivedDto
{
RewardType = pick.RewardType,
@@ -334,11 +335,12 @@ public class ArenaTwoPickService : IArenaTwoPickService
IsUsable = true,
});
}
await _db.SaveChangesAsync();
// ComputePostStateRewardList reads from the picked rows only — same set the
// 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 result = await tx.CommitAsync();
var postStates = result.RewardList
.Select(g => new RewardEntryDto { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList();
await _runs.DeleteAsync(viewerId);
return new FinishResponseDto { Rewards = deltas, RewardList = postStates };
@@ -358,25 +360,6 @@ public class ArenaTwoPickService : IArenaTwoPickService
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)
{
var run = await _runs.GetByViewerIdAsync(viewerId)

View File

@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.BattlePass;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
@@ -22,23 +23,20 @@ public sealed class BattlePassService : IBattlePassService
private readonly IViewerBattlePassRepository _viewerBp;
private readonly TimeProvider _time;
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly ICurrencySpendService _spend;
private readonly IInventoryService _inv;
public BattlePassService(
IBattlePassRepository bp,
IViewerBattlePassRepository viewerBp,
TimeProvider time,
SVSimDbContext db,
RewardGrantService rewards,
ICurrencySpendService spend)
IInventoryService inv)
{
_bp = bp;
_viewerBp = viewerBp;
_time = time;
_db = db;
_rewards = rewards;
_spend = spend;
_inv = inv;
}
public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
@@ -156,26 +154,22 @@ public sealed class BattlePassService : IBattlePassService
if (productId != season.Id * 1000)
return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<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() // per memory project_ef_split_query
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
if (viewer is null)
// Guard: viewer must exist (BeginAsync throws InventoryViewerNotFoundException otherwise).
var viewerExists = await _db.Viewers.AnyAsync(v => v.Id == viewerId, ct);
if (!viewerExists)
return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct);
if (progress.IsPremium)
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)
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;
// 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);
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))
{
if (claimSet.Contains((r.Track, r.Level))) continue;
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
var granted = await _rewards.ApplyAsync(
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 tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
}
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
// CommitAsync handles DB save + currency-collision rule. Crystal spend is the first
// 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.
// 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);
return new BattlePassBuyOutcome(1, result.Deltas, result.RewardList);
}
public async Task<BattlePassPointGrant> AddPointsAsync(
@@ -225,14 +209,6 @@ public sealed class BattlePassService : IBattlePassService
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);
int beforePoint = progress.CurrentPoint;
@@ -248,13 +224,15 @@ public sealed class BattlePassService : IBattlePassService
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)
{
var rewards = await _bp.GetSeasonRewardsAsync(season.Id, ct);
var claims = await _viewerBp.GetClaimsAsync(viewerId, season.Id, ct);
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++)
{
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 (claimSet.Contains((r.Track, r.Level))) continue;
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
var granted = await _rewards.ApplyAsync(
viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
newlyClaimed.AddRange(granted);
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
}
}
}
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(
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.Repositories.PackDrawTables;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
namespace SVSim.EmulatedEntrypoint.Services;
@@ -13,13 +14,11 @@ public sealed class GachaPointService : IGachaPointService
{
private readonly SVSimDbContext _db;
private readonly IPackDrawTableRepository _drawTables;
private readonly RewardGrantService _grants;
public GachaPointService(SVSimDbContext db, IPackDrawTableRepository drawTables, RewardGrantService grants)
public GachaPointService(SVSimDbContext db, IPackDrawTableRepository drawTables)
{
_db = db;
_drawTables = drawTables;
_grants = grants;
}
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);
if (pack?.GachaPointConfig is null)
return ExchangeOutcome.Fail("pack_not_exchangeable");
@@ -206,23 +206,13 @@ public sealed class GachaPointService : IGachaPointService
PackId = packId, CardId = cardId, ReceivedAt = DateTime.UtcNow,
});
// Grant the card itself through RewardGrantService — its CardCosmeticReward cascade
// covers the Emblem (standard legendary) or Skin+Emblem (leader) the catalog
// advertised. The catalog's reward_list is a wire-shape *display* (what the player
// sees on /pack/get_gacha_point_rewards) — the actual grant uses the canonical
// primitive per feedback_reward_grant_service. For leader-card exchanges the catalog
// also advertises a synthetic Sleeve(=card_id) entry, but that's not in
// CardCosmeticRewards; if a capture ever shows leader exchanges granting a sleeve
// 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,
});
}
// Grant the card via the inventory tx — its CardCosmeticReward cascade covers the
// Emblem (standard legendary) or Skin+Emblem (leader). Convert at the wire boundary
// so ExchangeOutcome still carries RewardListEntry for the controller response.
var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 1);
var rewardList = granted
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList();
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.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
namespace SVSim.EmulatedEntrypoint.Services;
@@ -23,11 +24,14 @@ public interface IGachaPointService
void Accrue(Viewer viewer, PackConfigEntry pack, PackChildGachaEntry child, int packNumber);
/// <summary>
/// Validate + execute an exchange. Returns the grant outcome on success (reward_list
/// entries the controller will return in <see cref="Dtos.Responses.Pack.ExchangeGachaPointResponse"/>),
/// or a failure result describing why. Mutates the in-memory graph; caller saves.
/// Validate + execute an exchange using the provided inventory transaction (which must
/// have <c>GachaPointBalances</c> and <c>GachaPointReceived</c> loaded on <c>tx.Viewer</c>
/// 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>
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)

View File

@@ -8,6 +8,7 @@ using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Deck;
using SVSim.Database.Repositories.BuildDeck;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.Database.Repositories.Story;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
@@ -19,7 +20,7 @@ public class StoryService : IStoryService
{
private readonly IStoryMasterRepository _master;
private readonly IViewerStoryProgressRepository _viewer;
private readonly RewardGrantService _rewards;
private readonly IInventoryService _inv;
private readonly SVSimDbContext _db;
private readonly IGameConfigService _configService;
private readonly IDeckRepository _deckRepository;
@@ -29,7 +30,7 @@ public class StoryService : IStoryService
public StoryService(
IStoryMasterRepository master,
IViewerStoryProgressRepository viewer,
RewardGrantService rewards,
IInventoryService inv,
SVSimDbContext db,
IGameConfigService configService,
IDeckRepository deckRepository,
@@ -38,7 +39,7 @@ public class StoryService : IStoryService
{
_master = master;
_viewer = viewer;
_rewards = rewards;
_inv = inv;
_db = db;
_configService = configService;
_deckRepository = deckRepository;
@@ -519,28 +520,26 @@ public class StoryService : IStoryService
if (firstClear && chapter.Rewards.Count > 0)
{
// Load viewer with all collections RewardGrantService might mutate. Split-query
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query"). Skip the
// load entirely when the chapter has no rewards — common for narrative-only
// 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);
// Open inventory tx — skip the load entirely when no rewards (narrative-only
// chapters where the only side effect is the progress upsert).
await using var tx = await _inv.BeginAsync(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)
{
IReadOnlyList<GrantedReward> granted;
try
{
granted = await _rewards.ApplyAsync(
viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
}
catch (NotSupportedException ex)
{
@@ -549,27 +548,8 @@ public class StoryService : IStoryService
r.RewardType, r.RewardDetailId, r.RewardNumber, req.StoryId);
continue;
}
// 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.
// 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
// delta for story_reward_list: raw catalog amounts (not post-state)
storyRewardDeltas.Add(new RewardGrant
{
RewardType = ((int)r.RewardType).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)

View File

@@ -28,9 +28,11 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
{
private readonly SqliteConnection _connection;
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
// factory's lifetime so the DbContext can reattach to the same DB across scopes.
_connection = new SqliteConnection("DataSource=:memory:");
@@ -59,6 +61,19 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
db.Database.EnsureCreated();
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
// 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.
@@ -427,6 +442,23 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
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>
/// 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

View File

@@ -7,6 +7,7 @@ using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services;
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)
{
var factory = new SVSimTestFactory();
@@ -73,11 +61,9 @@ public class ArenaTwoPickServiceDraftTests
new FakePool(),
scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
scope.ServiceProvider.GetRequiredService<RewardGrantService>(),
new FakeEntitlements(),
scope.ServiceProvider.GetRequiredService<IInventoryService>(),
new SystemRandom(seed: 1),
db,
scope.ServiceProvider.GetRequiredService<ICurrencySpendService>());
db);
return (svc, runs, 7);
}

View File

@@ -7,6 +7,7 @@ using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
@@ -23,24 +24,10 @@ public class ArenaTwoPickServiceEntryTests
=> 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(
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 db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await db.Database.EnsureCreatedAsync();
@@ -56,8 +43,8 @@ public class ArenaTwoPickServiceEntryTests
db.Viewers.Add(viewer);
await db.SaveChangesAsync();
var grants = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var config = scope.ServiceProvider.GetRequiredService<IGameConfigService>();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
// Seed reward catalog so GetMaxWinCountAsync returns 7.
await new ArenaTwoPickRewardImporter()
@@ -69,11 +56,9 @@ public class ArenaTwoPickServiceEntryTests
new NullCardPoolService(),
config,
scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
grants,
new FakeEntitlements { IsFreeplay = freeplay },
inv,
new SystemRandom(seed: 1234),
db,
scope.ServiceProvider.GetRequiredService<ICurrencySpendService>());
db);
return (db, svc, viewer.Id);
}

View File

@@ -8,6 +8,7 @@ using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
@@ -17,19 +18,6 @@ public class ArenaTwoPickServiceFinishTests
{
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
{
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new();
@@ -90,11 +78,9 @@ public class ArenaTwoPickServiceFinishTests
new FakePool(),
scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
scope.ServiceProvider.GetRequiredService<RewardGrantService>(),
new FakeEntitlements(),
scope.ServiceProvider.GetRequiredService<IInventoryService>(),
new SystemRandom(seed: 1),
db,
scope.ServiceProvider.GetRequiredService<ICurrencySpendService>());
db);
return (db, svc, 7L);
}

View File

@@ -81,7 +81,7 @@ public class ArenaTwoPickServiceTopTests
private static IArenaTwoPickService BuildService(SVSimDbContext db, IArenaTwoPickRunRepository runRepo)
{
// 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.
return new ArenaTwoPickService(runRepo, null!, null!, null!, null!, null!, null!, null!, db, null!);
// never touches them.
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.Viewer;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
@@ -17,19 +18,6 @@ public class ArenaTwoPickServiceWeightedRewardsTests
{
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
{
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new();
@@ -100,11 +88,9 @@ public class ArenaTwoPickServiceWeightedRewardsTests
new FakePool(),
scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
scope.ServiceProvider.GetRequiredService<RewardGrantService>(),
new FakeEntitlements(),
scope.ServiceProvider.GetRequiredService<IInventoryService>(),
rng,
db,
scope.ServiceProvider.GetRequiredService<ICurrencySpendService>());
db);
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.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
@@ -380,14 +381,17 @@ public class GachaPointServiceTests
SeedPackWithOneLegendary(db, packId: 10008, threshold: 400);
var viewer = await db.Viewers
.Include(v => v.GachaPointBalances)
.FirstAsync(v => v.Id == viewerId);
var viewer = await db.Viewers.Include(v => v.GachaPointBalances).FirstAsync(v => v.Id == viewerId);
viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 399 });
await db.SaveChangesAsync();
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.Error, Is.EqualTo("insufficient_gacha_points"));
@@ -403,14 +407,17 @@ public class GachaPointServiceTests
SeedPackWithOneLegendary(db, packId: 10008, threshold: 400);
var viewer = await db.Viewers
.Include(v => v.GachaPointBalances)
.FirstAsync(v => v.Id == viewerId);
var viewer = await db.Viewers.Include(v => v.GachaPointBalances).FirstAsync(v => v.Id == viewerId);
viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 400 });
await db.SaveChangesAsync();
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.Error, Is.EqualTo("card_not_exchangeable"));
@@ -438,7 +445,12 @@ public class GachaPointServiceTests
await db.SaveChangesAsync();
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.Error, Is.EqualTo("already_received"));
@@ -454,29 +466,31 @@ public class GachaPointServiceTests
SeedPackWithOneLegendary(db, packId: 10008, threshold: 400);
var viewer = await db.Viewers
var preViewer = await db.Viewers
.Include(v => v.GachaPointBalances)
.Include(v => v.GachaPointReceived)
.Include(v => v.Cards)
.Include(v => v.Emblems)
.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();
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010);
await db.SaveChangesAsync();
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.True);
// Balance debited.
Assert.That(viewer.GachaPointBalances.Single().Points, Is.EqualTo(100));
await tx.CommitAsync();
// Balance debited (check via tx.Viewer which is tracked).
Assert.That(tx.Viewer.GachaPointBalances.Single().Points, Is.EqualTo(100));
// Marker written.
Assert.That(viewer.GachaPointReceived
Assert.That(tx.Viewer.GachaPointReceived
.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.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == 108041010),
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.Repositories.Story;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
@@ -26,12 +27,12 @@ public class StoryServiceTests
{
_master = new Mock<IStoryMasterRepository>();
_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 rewards = new RewardGrantService(db, NullLogger<RewardGrantService>.Instance);
var inv = new Mock<IInventoryService>().Object;
_service = new StoryService(
_master.Object, _viewer.Object,
rewards: rewards,
inv: inv,
db: db,
configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
@@ -64,12 +65,12 @@ public class StoryServiceTests
scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var rewards = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
return new StoryService(
_master.Object,
_viewer.Object,
rewards: rewards,
inv: inv,
db: db,
configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
@@ -402,7 +403,7 @@ public class StoryServiceTests
db.SaveChanges();
return new StoryService(
_master.Object, _viewer.Object,
rewards: new RewardGrantService(db, NullLogger<RewardGrantService>.Instance),
inv: new Mock<IInventoryService>().Object,
db: db,
configService: StoryServiceTestHelpers.NewConfigService(),
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");
}
}