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:
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 => v.Cards).ThenInclude(c => 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);
|
||||
28
SVSim.Database/Services/Inventory/IInventoryService.cs
Normal file
28
SVSim.Database/Services/Inventory/IInventoryService.cs
Normal 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") { }
|
||||
}
|
||||
49
SVSim.Database/Services/Inventory/IInventoryTransaction.cs
Normal file
49
SVSim.Database/Services/Inventory/IInventoryTransaction.cs
Normal 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);
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
20
SVSim.Database/Services/Inventory/InventoryCommitResult.cs
Normal file
20
SVSim.Database/Services/Inventory/InventoryCommitResult.cs
Normal 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);
|
||||
27
SVSim.Database/Services/Inventory/InventoryGrantTypes.cs
Normal file
27
SVSim.Database/Services/Inventory/InventoryGrantTypes.cs
Normal 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);
|
||||
31
SVSim.Database/Services/Inventory/InventoryLoadConfig.cs
Normal file
31
SVSim.Database/Services/Inventory/InventoryLoadConfig.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
455
SVSim.Database/Services/Inventory/InventoryTransaction.cs
Normal file
455
SVSim.Database/Services/Inventory/InventoryTransaction.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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<int></c>
|
||||
/// (cosmetics) and <c>BaseEntity<long></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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
66
SVSim.UnitTests/Services/Inventory/InventoryBackfillTests.cs
Normal file
66
SVSim.UnitTests/Services/Inventory/InventoryBackfillTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
104
SVSim.UnitTests/Services/Inventory/InventoryCommitTests.cs
Normal file
104
SVSim.UnitTests/Services/Inventory/InventoryCommitTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
76
SVSim.UnitTests/Services/Inventory/InventoryDebitTests.cs
Normal file
76
SVSim.UnitTests/Services/Inventory/InventoryDebitTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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); });
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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); });
|
||||
}
|
||||
}
|
||||
128
SVSim.UnitTests/Services/Inventory/InventoryReadSideTests.cs
Normal file
128
SVSim.UnitTests/Services/Inventory/InventoryReadSideTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
70
SVSim.UnitTests/Services/Inventory/InventorySpendTests.cs
Normal file
70
SVSim.UnitTests/Services/Inventory/InventorySpendTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
71
SVSim.UnitTests/Wire/InventoryRewardListWireShape.cs
Normal file
71
SVSim.UnitTests/Wire/InventoryRewardListWireShape.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user