diff --git a/SVSim.Database/Models/Config/FreeplayConfig.cs b/SVSim.Database/Models/Config/FreeplayConfig.cs
new file mode 100644
index 0000000..9d26507
--- /dev/null
+++ b/SVSim.Database/Models/Config/FreeplayConfig.cs
@@ -0,0 +1,17 @@
+namespace SVSim.Database.Models.Config;
+
+///
+/// Global "freeplay" toggle. When , every viewer is treated (in logic,
+/// never in the DB) as owning all cards ( each), all cosmetics, and
+/// of Crystal/Rupee/Red-Ether. See
+/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md.
+///
+[ConfigSection("Freeplay")]
+public class FreeplayConfig
+{
+ public bool Enabled { get; set; } = false;
+ public ulong CurrencyAmount { get; set; } = 99999;
+ public int CardCopies { get; set; } = 3;
+
+ public static FreeplayConfig ShippedDefaults() => new();
+}
diff --git a/SVSim.Database/Repositories/Collectibles/CollectionRepository.cs b/SVSim.Database/Repositories/Collectibles/CollectionRepository.cs
index c398aac..859d705 100644
--- a/SVSim.Database/Repositories/Collectibles/CollectionRepository.cs
+++ b/SVSim.Database/Repositories/Collectibles/CollectionRepository.cs
@@ -16,4 +16,16 @@ public class CollectionRepository : ICollectionRepository
{
return await _dbContext.Set().AsNoTracking().Include(skin => skin.Class).ToListAsync();
}
+
+ public Task> GetAllSleeveIds() =>
+ _dbContext.Set().AsNoTracking().Select(s => s.Id).ToListAsync();
+
+ public Task> GetAllEmblemIds() =>
+ _dbContext.Set().AsNoTracking().Select(e => e.Id).ToListAsync();
+
+ public Task> GetAllDegreeIds() =>
+ _dbContext.Set().AsNoTracking().Select(d => d.Id).ToListAsync();
+
+ public Task> GetAllMyPageBackgroundIds() =>
+ _dbContext.Set().AsNoTracking().Select(m => m.Id).ToListAsync();
}
\ No newline at end of file
diff --git a/SVSim.Database/Repositories/Collectibles/ICollectionRepository.cs b/SVSim.Database/Repositories/Collectibles/ICollectionRepository.cs
index 17e7dab..870102e 100644
--- a/SVSim.Database/Repositories/Collectibles/ICollectionRepository.cs
+++ b/SVSim.Database/Repositories/Collectibles/ICollectionRepository.cs
@@ -5,4 +5,8 @@ namespace SVSim.Database.Repositories.Collectibles;
public interface ICollectionRepository
{
Task> GetLeaderSkins();
+ Task> GetAllSleeveIds();
+ Task> GetAllEmblemIds();
+ Task> GetAllDegreeIds();
+ Task> GetAllMyPageBackgroundIds();
}
\ No newline at end of file
diff --git a/SVSim.Database/Services/CurrencySpendService.cs b/SVSim.Database/Services/CurrencySpendService.cs
new file mode 100644
index 0000000..1d3ef5e
--- /dev/null
+++ b/SVSim.Database/Services/CurrencySpendService.cs
@@ -0,0 +1,51 @@
+using SVSim.Database.Models;
+
+namespace SVSim.Database.Services;
+
+public class CurrencySpendService : ICurrencySpendService
+{
+ private readonly IViewerEntitlements _entitlements;
+
+ public CurrencySpendService(IViewerEntitlements entitlements) => _entitlements = entitlements;
+
+ public Task 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));
+ }
+ }
+}
diff --git a/SVSim.Database/Services/ICurrencySpendService.cs b/SVSim.Database/Services/ICurrencySpendService.cs
new file mode 100644
index 0000000..6aa27c1
--- /dev/null
+++ b/SVSim.Database/Services/ICurrencySpendService.cs
@@ -0,0 +1,14 @@
+using SVSim.Database.Models;
+
+namespace SVSim.Database.Services;
+
+///
+/// Centralized debit primitive — the symmetric twin of RewardGrantService.ApplyAsync.
+/// Encapsulates the affordability-check + deduction + post-state-total pattern that was inlined
+/// across the shop/pack controllers. Does NOT call SaveChangesAsync; the caller saves.
+/// Freeplay (for Crystal/Rupee/RedEther) makes spends always succeed without deducting.
+///
+public interface ICurrencySpendService
+{
+ Task TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default);
+}
diff --git a/SVSim.Database/Services/IViewerEntitlements.cs b/SVSim.Database/Services/IViewerEntitlements.cs
new file mode 100644
index 0000000..fd5d23d
--- /dev/null
+++ b/SVSim.Database/Services/IViewerEntitlements.cs
@@ -0,0 +1,54 @@
+using SVSim.Database.Enums;
+using SVSim.Database.Models;
+
+namespace SVSim.Database.Services;
+
+///
+/// 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.
+///
+///
+/// Include precondition: methods that inspect the viewer's collections require the
+/// viewer to have been loaded with .Include(v => v.Cards).ThenInclude(c => c.Card)
+/// and the cosmetic collections
+/// (Sleeves, Emblems, Degrees, LeaderSkins, MyPageBackgrounds)
+/// 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).
+///
+public interface IViewerEntitlements
+{
+ /// True when the global Freeplay config section is enabled.
+ bool IsFreeplay { get; }
+
+ ///
+ /// 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
+ /// viewer.Currency field.
+ ///
+ long EffectiveBalance(Viewer viewer, SpendCurrency currency);
+
+ bool OwnsCard(Viewer viewer, long cardId);
+
+ /// uses (Skin == leader skin).
+ bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id);
+
+ /// The full owned-card projection for /load/index's user_card_list.
+ Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default);
+
+ /// The cosmetic id-lists + leader-skin catalog/owned-set for /load/index.
+ Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default);
+}
+
+///
+/// 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;
+/// is every skin id in freeplay.
+///
+public sealed record EffectiveCosmetics(
+ IReadOnlyList SleeveIds,
+ IReadOnlyList EmblemIds,
+ IReadOnlyList DegreeIds,
+ IReadOnlyList MyPageBackgroundIds,
+ IReadOnlyList AllLeaderSkins,
+ IReadOnlySet OwnedLeaderSkinIds);
diff --git a/SVSim.Database/Services/SpendCurrency.cs b/SVSim.Database/Services/SpendCurrency.cs
new file mode 100644
index 0000000..ee7f246
--- /dev/null
+++ b/SVSim.Database/Services/SpendCurrency.cs
@@ -0,0 +1,16 @@
+namespace SVSim.Database.Services;
+
+/// The scalar wallet currencies the central debit primitive understands.
+public enum SpendCurrency { Crystal, Rupee, RedEther, SpotPoint }
+
+public enum SpendOutcome { Success, Insufficient }
+
+///
+/// Result of a call.
+/// is the balance the client should show after the spend — the real post-deduction balance, or the
+/// freeplay effective balance when the spend was a freeplay no-op.
+///
+public sealed record SpendResult(SpendOutcome Outcome, long PostStateTotal)
+{
+ public bool Success => Outcome == SpendOutcome.Success;
+}
diff --git a/SVSim.Database/Services/ViewerEntitlements.cs b/SVSim.Database/Services/ViewerEntitlements.cs
new file mode 100644
index 0000000..4e0a137
--- /dev/null
+++ b/SVSim.Database/Services/ViewerEntitlements.cs
@@ -0,0 +1,107 @@
+using SVSim.Database.Enums;
+using SVSim.Database.Models;
+using SVSim.Database.Models.Config;
+using SVSim.Database.Repositories.Card;
+using SVSim.Database.Repositories.Collectibles;
+
+namespace SVSim.Database.Services;
+
+public class ViewerEntitlements : IViewerEntitlements
+{
+ private readonly IGameConfigService _config;
+ private readonly ICardRepository _cards;
+ private readonly ICollectionRepository _collection;
+
+ public ViewerEntitlements(IGameConfigService config, ICardRepository cards, ICollectionRepository collection)
+ {
+ _config = config;
+ _cards = cards;
+ _collection = collection;
+ }
+
+ private FreeplayConfig Cfg => _config.Get();
+
+ public bool IsFreeplay => Cfg.Enabled;
+
+ public long EffectiveBalance(Viewer viewer, SpendCurrency currency)
+ {
+ var cfg = Cfg;
+ if (cfg.Enabled && currency != SpendCurrency.SpotPoint)
+ return checked((long)cfg.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(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> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default)
+ {
+ var defaults = await _cards.GetDefaultCards();
+ var defaultIds = defaults.Select(c => c.Id).ToHashSet();
+ var cfg = Cfg;
+
+ if (cfg.Enabled)
+ {
+ var all = await _cards.GetAll(onlyCollectible: true);
+ return all
+ .Select(c => new OwnedCardEntry
+ {
+ Card = c,
+ Count = cfg.CardCopies,
+ IsProtected = defaultIds.Contains(c.Id),
+ })
+ .ToList();
+ }
+
+ var owned = viewer.Cards.Where(c => c.Count > 0 && !defaultIds.Contains(c.Card.Id));
+ return owned
+ .Concat(defaults.Select(bc => new OwnedCardEntry { Card = bc, Count = 3, IsProtected = true }))
+ .ToList();
+ }
+
+ public async Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default)
+ {
+ var allSkins = await _collection.GetLeaderSkins();
+
+ if (Cfg.Enabled)
+ {
+ return new EffectiveCosmetics(
+ await _collection.GetAllSleeveIds(),
+ await _collection.GetAllEmblemIds(),
+ await _collection.GetAllDegreeIds(),
+ await _collection.GetAllMyPageBackgroundIds(),
+ allSkins,
+ allSkins.Select(s => s.Id).ToHashSet());
+ }
+
+ return new EffectiveCosmetics(
+ viewer.Sleeves.Select(s => s.Id).ToList(),
+ viewer.Emblems.Select(e => e.Id).ToList(),
+ viewer.Degrees.Select(d => d.Id).ToList(),
+ viewer.MyPageBackgrounds.Select(m => m.Id).ToList(),
+ allSkins,
+ viewer.LeaderSkins.Select(s => s.Id).ToHashSet());
+ }
+}
diff --git a/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs b/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs
index 5fb3075..01e12df 100644
--- a/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs
+++ b/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs
@@ -22,15 +22,18 @@ public class BuildDeckController : SVSimController
private readonly IBuildDeckRepository _repo;
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
+ private readonly ICurrencySpendService _spend;
public BuildDeckController(
IBuildDeckRepository repo,
SVSimDbContext db,
- RewardGrantService rewards)
+ RewardGrantService rewards,
+ ICurrencySpendService spend)
{
_repo = repo;
_db = db;
_rewards = rewards;
+ _spend = spend;
}
///
@@ -200,19 +203,15 @@ public class BuildDeckController : SVSimController
// Debit + post-state currency entry
if (request.SalesType == 1)
{
- ulong cost = (ulong)priceCrystal!.Value;
- if (viewer.Currency.Crystals < cost)
- return BadRequest(new { error = "insufficient_crystals" });
- viewer.Currency.Crystals -= cost;
- rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals });
+ var r = await _spend.TrySpendAsync(viewer, 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)
{
- ulong cost = (ulong)priceRupy!.Value;
- if (viewer.Currency.Rupees < cost)
- return BadRequest(new { error = "insufficient_rupees" });
- viewer.Currency.Rupees -= cost;
- rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees });
+ var r = await _spend.TrySpendAsync(viewer, 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
diff --git a/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs b/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs
index 3510c5c..7b1d3b4 100644
--- a/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs
+++ b/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs
@@ -23,12 +23,14 @@ public class ItemPurchaseController : SVSimController
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly TimeProvider _time;
+ private readonly ICurrencySpendService _spend;
- public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
+ public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend)
{
_db = db;
_rewards = rewards;
_time = time;
+ _spend = spend;
}
[HttpPost("info")]
@@ -117,7 +119,7 @@ public class ItemPurchaseController : SVSimController
var rewardList = new List();
// Debit the require side. RewardGrantService is grant-only, so handle this inline.
- var debit = TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
+ 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);
@@ -160,29 +162,29 @@ public class ItemPurchaseController : SVSimController
/// from the viewer, returning a post-state-aware the client
/// uses to refresh its cached count. Returns an error string on insufficient balance.
///
- private static (RewardListEntry? PostState, string? Error) TryDebit(
+ private async Task<(RewardListEntry? PostState, string? Error)> TryDebit(
Viewer viewer, UserGoodsType type, long detailId, int num)
{
switch (type)
{
case UserGoodsType.RedEther:
- if (viewer.Currency.RedEther < (ulong)num)
- return (null, "insufficient_red_ether");
- viewer.Currency.RedEther -= (ulong)num;
- return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)viewer.Currency.RedEther }, null);
-
+ {
+ 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:
- if (viewer.Currency.Crystals < (ulong)num)
- return (null, "insufficient_crystals");
- viewer.Currency.Crystals -= (ulong)num;
- return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null);
-
+ {
+ 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:
- if (viewer.Currency.Rupees < (ulong)num)
- return (null, "insufficient_rupees");
- viewer.Currency.Rupees -= (ulong)num;
- return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null);
-
+ {
+ 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)
diff --git a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs
index a85850a..33239d3 100644
--- a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs
+++ b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
+using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
@@ -30,12 +31,18 @@ public class LeaderSkinController : SVSimController
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly TimeProvider _time;
+ private readonly ICurrencySpendService _spend;
+ private readonly IViewerEntitlements _entitlements;
+ private readonly ICollectionRepository _collection;
- public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
+ public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection)
{
_db = db;
_rewards = rewards;
_time = time;
+ _spend = spend;
+ _entitlements = entitlements;
+ _collection = collection;
}
[HttpPost("set")]
@@ -62,7 +69,7 @@ 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 (viewer.LeaderSkins.All(s => s.Id != skin.Id))
+ if (!_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, skin.Id))
return BadRequest(new { error = "skin_not_owned" });
classData.LeaderSkin = skin;
@@ -81,6 +88,12 @@ 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))
@@ -95,10 +108,12 @@ public class LeaderSkinController : SVSimController
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
- var ownedSkinIds = (await _db.Viewers
- .Where(v => v.Id == viewerId)
- .SelectMany(v => v.LeaderSkins.Select(s => s.Id))
- .ToListAsync()).ToHashSet();
+ 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 claimedSeries = (await _db.ViewerLeaderSkinSetClaims
.Where(c => c.ViewerId == viewerId)
@@ -171,11 +186,11 @@ public class LeaderSkinController : SVSimController
var viewer = await LoadViewerGraphAsync(viewerId);
// Already-purchased = viewer owns the leader_skin this product grants.
- if (viewer.LeaderSkins.Any(s => s.Id == product.LeaderSkinId))
+ if (_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, product.LeaderSkinId))
return BadRequest(new { error = "already_purchased" });
var rewardList = new List();
- var debit = DebitProductPrice(viewer, product, request.SalesType);
+ 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);
@@ -205,8 +220,11 @@ public class LeaderSkinController : SVSimController
var viewer = await LoadViewerGraphAsync(viewerId);
+ if (_entitlements.IsFreeplay)
+ return BadRequest(new { error = "already_purchased" });
+
var rewardList = new List();
- var debit = DebitSetPrice(viewer, series, request.SalesType);
+ 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);
@@ -332,52 +350,58 @@ public class LeaderSkinController : SVSimController
return false;
}
- private (RewardListEntry? PostState, string? Error) DebitProductPrice(
+ private async Task<(RewardListEntry? PostState, string? Error)> DebitProductPrice(
Viewer viewer, LeaderSkinShopProductEntry product, int salesType)
{
- return salesType switch
+ switch (salesType)
{
- 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0 => (null, null),
- 0 => (null, "price_not_available_for_currency"),
- 1 => product.SinglePriceCrystal is null
- ? (null, "price_not_available_for_currency")
- : DebitCrystal(viewer, product.SinglePriceCrystal.Value),
- 2 => product.SinglePriceRupy is null
- ? (null, "price_not_available_for_currency")
- : DebitRupy(viewer, product.SinglePriceRupy.Value),
- _ => (null, "invalid_sales_type"),
- };
+ 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 (RewardListEntry? PostState, string? Error) DebitSetPrice(
+ private async Task<(RewardListEntry? PostState, string? Error)> DebitSetPrice(
Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType)
{
- return salesType switch
+ switch (salesType)
{
- 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0 => (null, null),
- 0 => (null, "price_not_available_for_currency"),
- 1 => series.SetPriceCrystal is null
- ? (null, "price_not_available_for_currency")
- : DebitCrystal(viewer, series.SetPriceCrystal.Value),
- 2 => series.SetPriceRupy is null
- ? (null, "price_not_available_for_currency")
- : DebitRupy(viewer, series.SetPriceRupy.Value),
- _ => (null, "invalid_sales_type"),
- };
+ 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 static (RewardListEntry?, string?) DebitCrystal(Viewer viewer, int amount)
+ private async Task<(RewardListEntry?, string?)> DebitCrystal(Viewer viewer, int amount)
{
- if (viewer.Currency.Crystals < (ulong)amount) return (null, "insufficient_crystals");
- viewer.Currency.Crystals -= (ulong)amount;
- return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null);
+ 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 static (RewardListEntry?, string?) DebitRupy(Viewer viewer, int amount)
+ private async Task<(RewardListEntry?, string?)> DebitRupy(Viewer viewer, int amount)
{
- if (viewer.Currency.Rupees < (ulong)amount) return (null, "insufficient_rupees");
- viewer.Currency.Rupees -= (ulong)amount;
- return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null);
+ 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(
diff --git a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs
index 7b10a6f..d9feae1 100644
--- a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs
+++ b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs
@@ -6,8 +6,6 @@ using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using PreReleaseInfoEntity = SVSim.Database.Models.PreReleaseInfo;
using PreReleaseInfoDto = SVSim.EmulatedEntrypoint.Models.Dtos.PreReleaseInfo;
-using SVSim.Database.Repositories.Card;
-using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
@@ -42,30 +40,27 @@ public class LoadController : SVSimController
};
private readonly IViewerRepository _viewerRepository;
- private readonly ICardRepository _cardRepository;
- private readonly ICollectionRepository _collectionRepository;
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;
- public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository,
- ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository,
+ public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
ICardAcquisitionService acquisition, IGameConfigService config,
IBattlePassService battlePass, IViewerMissionStateService missionState,
- SVSimDbContext db)
+ SVSimDbContext db, IViewerEntitlements entitlements)
{
_viewerRepository = viewerRepository;
- _cardRepository = cardRepository;
- _collectionRepository = collectionRepository;
_globalsRepository = globalsRepository;
_acquisition = acquisition;
_config = config;
_battlePass = battlePass;
_missionState = missionState;
_db = db;
+ _entitlements = entitlements;
}
[HttpPost("index")]
@@ -127,20 +122,11 @@ public class LoadController : SVSimController
// * card_set_id=90000 (engine tokens, char_type=4): never collectible
// Both naturally fall out of "ownership-only" since the viewer can't own them;
// re-confirm the filter if we later move to Option B and start iterating card-sets.
- var defaultCards = await _cardRepository.GetDefaultCards();
- var defaultCardIds = defaultCards.Select(c => c.Id).ToHashSet();
- var ownedCollectibles = viewer.Cards
- .Where(c => c.Count > 0 && !defaultCardIds.Contains(c.Card.Id));
- var allCardsAsOwned = ownedCollectibles
- .Concat(defaultCards.Select(bc => new OwnedCardEntry
- {
- Card = bc,
- Count = 3,
- IsProtected = true
- }))
- .ToList();
+ // 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);
- List allLeaderSkins = await _collectionRepository.GetLeaderSkins();
+ var cosmetics = await _entitlements.EffectiveCosmeticsAsync(viewer, ct);
var classExpCurve = await _globalsRepository.GetClassExpCurve();
List classExps = new();
@@ -179,7 +165,13 @@ public class LoadController : SVSimController
{
UserTutorial = new UserTutorial { TutorialStep = viewer.MissionData.TutorialState },
UserInfo = new UserInfo(deviceType, viewer),
- UserCurrency = new UserCurrency(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),
+ },
UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
SpotPoint = checked((int)viewer.Currency.SpotPoints),
UserRotationDecks = new UserFormatDeckInfo
@@ -199,13 +191,13 @@ public class LoadController : SVSimController
},
UserCards = allCardsAsOwned.Select(card => new UserCard(card)).ToList(),
UserClasses = viewer.Classes.Select(vc => new UserClass(vc)).ToList(),
- Sleeves = viewer.Sleeves.Select(s => new SleeveIdentifier { SleeveId = s.Id }).ToList(),
- UserEmblems = viewer.Emblems.Select(e => new EmblemIdentifier { EmblemId = e.Id }).ToList(),
- UserDegrees = viewer.Degrees.Select(d => new DegreeIdentifier { DegreeId = d.Id }).ToList(),
- LeaderSkins = allLeaderSkins
- .Select(skin => new UserLeaderSkin(skin, viewer.LeaderSkins.Any(vs => vs.Id == skin.Id)))
+ Sleeves = cosmetics.SleeveIds.Select(id => new SleeveIdentifier { SleeveId = id }).ToList(),
+ UserEmblems = cosmetics.EmblemIds.Select(id => new EmblemIdentifier { EmblemId = id }).ToList(),
+ UserDegrees = cosmetics.DegreeIds.Select(id => new DegreeIdentifier { DegreeId = id }).ToList(),
+ LeaderSkins = cosmetics.AllLeaderSkins
+ .Select(skin => new UserLeaderSkin(skin, cosmetics.OwnedLeaderSkinIds.Contains(skin.Id)))
.ToList(),
- MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id.ToString()).ToList(),
+ MyPageBackgrounds = cosmetics.MyPageBackgroundIds.Select(id => id.ToString()).ToList(),
LootBoxRegulations = new LootBoxRegulations(),
GatheringInfo = new GatheringInfo(),
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs
index 98f7984..1f97f0c 100644
--- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs
+++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs
@@ -9,6 +9,7 @@ using SVSim.EmulatedEntrypoint.Models.Dtos;
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.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -29,6 +30,8 @@ public class PackController : SVSimController
private readonly SVSimDbContext _db;
private readonly ICardAcquisitionService _acquisition;
private readonly IGachaPointService _gachaPoint;
+ private readonly ICurrencySpendService _spend;
+ private readonly IViewerEntitlements _entitlements;
public PackController(
IPackRepository packs,
@@ -37,7 +40,9 @@ public class PackController : SVSimController
IRandom rng,
SVSimDbContext db,
ICardAcquisitionService acquisition,
- IGachaPointService gachaPoint)
+ IGachaPointService gachaPoint,
+ ICurrencySpendService spend,
+ IViewerEntitlements entitlements)
{
_packs = packs;
_opener = opener;
@@ -46,6 +51,8 @@ public class PackController : SVSimController
_db = db;
_acquisition = acquisition;
_gachaPoint = gachaPoint;
+ _spend = spend;
+ _entitlements = entitlements;
}
[HttpPost("info")]
@@ -292,18 +299,16 @@ public class PackController : SVSimController
{
case 2: // CRYSTAL_MULTI
{
- ulong cost = (ulong)child.Cost * (ulong)packNumber;
- if (viewer.Currency.Crystals < cost)
- return BadRequest(new { error = "insufficient_crystals" });
- viewer.Currency.Crystals -= cost;
+ long cost = (long)child.Cost * packNumber;
+ var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, cost);
+ if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
break;
}
case 7: // RUPY_MULTI
{
- ulong cost = (ulong)child.Cost * (ulong)packNumber;
- if (viewer.Currency.Rupees < cost)
- return BadRequest(new { error = "insufficient_rupees" });
- viewer.Currency.Rupees -= cost;
+ long cost = (long)child.Cost * packNumber;
+ var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
+ if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
case 3: // DAILY single — once per UTC day
@@ -315,10 +320,9 @@ public class PackController : SVSimController
if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date)
return BadRequest(new { error = "daily_free_already_claimed" });
- ulong cost = (ulong)child.Cost * (ulong)packNumber;
- if (cost > 0 && viewer.Currency.Rupees < cost)
- return BadRequest(new { error = "insufficient_rupees" });
- if (cost > 0) viewer.Currency.Rupees -= cost;
+ long cost = (long)child.Cost * packNumber;
+ var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
+ if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
}
@@ -359,14 +363,13 @@ public class PackController : SVSimController
// Currency reward entries only apply to purchasable packs; tutorial path omits them.
if (!isTutorialPath)
{
- var postViewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId);
if (child.TypeDetail == 2)
{
- rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)postViewer.Currency.Crystals });
+ rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) });
}
else if (child.TypeDetail == 7 || child.TypeDetail == 3)
{
- rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees });
+ rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) });
}
}
rewardList.AddRange(grant.RewardList);
diff --git a/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs b/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs
index c9d623e..e852639 100644
--- a/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs
+++ b/SVSim.EmulatedEntrypoint/Controllers/SleeveController.cs
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
+using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
@@ -20,11 +21,17 @@ public class SleeveController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
+ private readonly ICurrencySpendService _spend;
+ private readonly IViewerEntitlements _entitlements;
+ private readonly ICollectionRepository _collection;
- public SleeveController(SVSimDbContext db, RewardGrantService rewards)
+ public SleeveController(SVSimDbContext db, RewardGrantService rewards, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection)
{
_db = db;
_rewards = rewards;
+ _spend = spend;
+ _entitlements = entitlements;
+ _collection = collection;
}
[HttpPost("info")]
@@ -35,10 +42,12 @@ 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 = (await _db.Viewers
- .Where(v => v.Id == viewerId)
- .SelectMany(v => v.Sleeves.Select(s => (long)s.Id))
- .ToListAsync()).ToHashSet();
+ 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 series = await _db.SleeveShopSeries
.Where(s => s.IsEnabled)
@@ -106,6 +115,9 @@ public class SleeveController : SVSimController
var viewer = await LoadViewerGraphAsync(viewerId);
+ if (_entitlements.IsFreeplay)
+ return BadRequest(new { error = "already_purchased" });
+
if (IsProductPurchased(product, viewer.Sleeves.Select(s => (long)s.Id).ToHashSet()))
return BadRequest(new { error = "already_purchased" });
@@ -122,20 +134,16 @@ public class SleeveController : SVSimController
case 1: // crystal
if (product.PriceCrystal is null)
return BadRequest(new { error = "price_not_available_for_currency" });
- var crystalCost = (ulong)product.PriceCrystal.Value;
- if (viewer.Currency.Crystals < crystalCost)
- return BadRequest(new { error = "insufficient_crystals" });
- viewer.Currency.Crystals -= crystalCost;
- rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals });
+ 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 });
break;
case 2: // rupy
if (product.PriceRupy is null)
return BadRequest(new { error = "price_not_available_for_currency" });
- var rupyCost = (ulong)product.PriceRupy.Value;
- if (viewer.Currency.Rupees < rupyCost)
- return BadRequest(new { error = "insufficient_rupees" });
- viewer.Currency.Rupees -= rupyCost;
- rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees });
+ 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 });
break;
}
diff --git a/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs b/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs
index 584cbc7..d5d8ec9 100644
--- a/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs
+++ b/SVSim.EmulatedEntrypoint/Controllers/SpotCardExchangeController.cs
@@ -30,12 +30,14 @@ public class SpotCardExchangeController : SVSimController
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly TimeProvider _time;
+ private readonly ICurrencySpendService _spend;
- public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
+ public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend)
{
_db = db;
_rewards = rewards;
_time = time;
+ _spend = spend;
}
[HttpPost("top")]
@@ -131,14 +133,14 @@ public class SpotCardExchangeController : SVSimController
// 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.
- if (viewer.Currency.SpotPoints < (ulong)entry.ExchangePoint)
+ var spotRes = await _spend.TrySpendAsync(viewer, SpendCurrency.SpotPoint, entry.ExchangePoint);
+ if (!spotRes.Success)
return BadRequest(new { error = "insufficient_spot_points" });
- viewer.Currency.SpotPoints -= (ulong)entry.ExchangePoint;
rewardList.Add(new RewardListEntry
{
RewardType = (int)UserGoodsType.SpotCardPoint,
RewardId = 0,
- RewardNum = checked((int)viewer.Currency.SpotPoints),
+ RewardNum = checked((int)spotRes.PostStateTotal),
});
// Grant the card itself via the existing card dispatcher (handles cosmetic cascade).
diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs
index 43391b8..524916c 100644
--- a/SVSim.EmulatedEntrypoint/Program.cs
+++ b/SVSim.EmulatedEntrypoint/Program.cs
@@ -84,6 +84,8 @@ public class Program
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped?> GetLevelCurveAsync(CancellationToken ct)
@@ -166,13 +169,13 @@ public sealed class BattlePassService : IBattlePassService
if (progress.IsPremium)
return new BattlePassBuyOutcome(23, Array.Empty(), Array.Empty());
- if (viewer.Currency.Crystals < (ulong)season.PriceCrystal)
+ var spendResult = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, season.PriceCrystal, ct);
+ if (!spendResult.Success)
return new BattlePassBuyOutcome(22, Array.Empty(), Array.Empty());
// 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);
- viewer.Currency.Crystals -= (ulong)season.PriceCrystal;
progress.IsPremium = true;
// Retroactive grants: every premium reward at level <= current_level not already claimed.
@@ -206,7 +209,7 @@ public sealed class BattlePassService : IBattlePassService
// 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)viewer.Currency.Crystals));
+ (int)UserGoodsType.Crystal, 0, (int)spendResult.PostStateTotal));
return new BattlePassBuyOutcome(1, achieved, postState);
}
diff --git a/SVSim.UnitTests/Controllers/FreeplayInvariantTests.cs b/SVSim.UnitTests/Controllers/FreeplayInvariantTests.cs
new file mode 100644
index 0000000..0e97276
--- /dev/null
+++ b/SVSim.UnitTests/Controllers/FreeplayInvariantTests.cs
@@ -0,0 +1,72 @@
+using System.Net;
+using System.Text;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SVSim.Database;
+using SVSim.Database.Enums;
+using SVSim.Database.Models;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Controllers;
+
+public class FreeplayInvariantTests
+{
+ private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
+
+ [Test]
+ public async Task Freeplay_pack_open_leaves_viewer_currency_unchanged()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ await SeedCrystalPack(factory, viewerId);
+ await factory.EnableFreeplayAsync();
+
+ ulong before;
+ using (var scope = factory.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ before = (await db.Viewers.FirstAsync(v => v.Id == viewerId)).Currency.Crystals;
+ }
+
+ // Verify the precondition: viewer has 0 crystals, so without freeplay this would be rejected.
+ Assert.That(before, Is.EqualTo(0UL), "precondition: viewer must be broke before the open");
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ // gacha_type:1 is the parent pack's gacha_type — see project_wire_pack_gacha_type memory.
+ var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
+ var resp = await client.PostAsync("/pack/open", JsonBody(json));
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), await resp.Content.ReadAsStringAsync());
+
+ ulong after;
+ using (var scope2 = factory.Services.CreateScope())
+ {
+ var db2 = scope2.ServiceProvider.GetRequiredService();
+ after = (await db2.Viewers.FirstAsync(v => v.Id == viewerId)).Currency.Crystals;
+ }
+
+ Assert.That(after, Is.EqualTo(before), "freeplay must not write currency to the DB");
+ }
+
+ ///
+ /// Seeds a crystal pack (parent gacha 10001, child gacha_id 100002, TypeDetail=2, cost=100)
+ /// with the viewer broke (0 crystals). Mirrors the pack shape from
+ /// PackControllerOpenTests.Open_with_crystals_deducts_crystals — the only difference is
+ /// Crystals=0 instead of 250, so without freeplay this open would be refused.
+ ///
+ private static async Task SeedCrystalPack(SVSimTestFactory f, long viewerId)
+ {
+ using var scope = f.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync();
+ db.Packs.Add(new PackConfigEntry
+ {
+ Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
+ CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
+ GachaType = 1, GachaDetail = "test",
+ ChildGachas = { new PackChildGachaEntry { GachaId = 100002, TypeDetail = 2, Cost = 100, CardCount = 8 } },
+ });
+ var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
+ v.Currency.Crystals = 0;
+ await db.SaveChangesAsync();
+ }
+}
diff --git a/SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs b/SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs
index 4dd7aff..434fce9 100644
--- a/SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs
+++ b/SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs
@@ -123,4 +123,27 @@ public class LeaderSkinControllerTests
var resp = await client.PostAsync("/leader_skin/set", JsonBody(json));
Assert.That((int)resp.StatusCode, Is.EqualTo(501));
}
+
+ [Test]
+ public async Task Set_freeplay_allows_equipping_unowned_skin()
+ {
+ using var factory = new SVSimTestFactory();
+ await factory.SeedGlobalsAsync();
+ long viewerId = await factory.SeedViewerAsync();
+ await factory.EnableFreeplayAsync();
+
+ int classId, skinId;
+ using (var scope = factory.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var skin = await db.LeaderSkins.FirstAsync(s => s.ClassId != null);
+ skinId = skin.Id; classId = skin.ClassId!.Value;
+ }
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","class_id":{{classId}},"leader_skin_id":{{skinId}},"is_random_leader_skin":false,"leader_skin_id_list":[]}""";
+ var resp = await client.PostAsync("/leader_skin/set", new StringContent(json, System.Text.Encoding.UTF8, "application/json"));
+
+ Assert.That(resp.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.OK), await resp.Content.ReadAsStringAsync());
+ }
}
diff --git a/SVSim.UnitTests/Controllers/LoadControllerFreeplayTests.cs b/SVSim.UnitTests/Controllers/LoadControllerFreeplayTests.cs
new file mode 100644
index 0000000..2d7afe4
--- /dev/null
+++ b/SVSim.UnitTests/Controllers/LoadControllerFreeplayTests.cs
@@ -0,0 +1,63 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using SVSim.Database;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Controllers;
+
+public class LoadControllerFreeplayTests
+{
+ private static StringContent Body() => new(
+ """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","carrier":"steam","card_master_hash":""}""",
+ Encoding.UTF8, "application/json");
+
+ [Test]
+ public async Task LoadIndex_freeplay_on_inflates_currency_and_grants_all_cards()
+ {
+ using var factory = new SVSimTestFactory();
+ await factory.SeedGlobalsAsync();
+ long viewerId = await factory.SeedViewerAsync();
+ // Seed one collectible card so EffectiveOwnedCardsAsync has at least one entry
+ // (the minimal test set has no CollectionInfo rows — those cards are non-collectible).
+ await factory.SeedOwnedCardAsync(viewerId, 50001001L, count: 1);
+ await factory.EnableFreeplayAsync();
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var resp = await client.PostAsync("/load/index", Body());
+ var json = await resp.Content.ReadAsStringAsync();
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), json);
+
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ // Wire key for UserCurrency is "user_crystal_count" (IndexResponse.[JsonPropertyName("user_crystal_count")])
+ Assert.That(root.GetProperty("user_crystal_count").GetProperty("crystal").GetUInt64(), Is.EqualTo(99999UL));
+ Assert.That(root.GetProperty("user_crystal_count").GetProperty("rupy").GetUInt64(), Is.EqualTo(99999UL));
+ Assert.That(root.GetProperty("user_crystal_count").GetProperty("red_ether").GetUInt64(), Is.EqualTo(99999UL));
+
+ var cards = root.GetProperty("user_card_list");
+ Assert.That(cards.GetArrayLength(), Is.GreaterThan(0));
+ // Wire key for card count is "number" (UserCard.[JsonPropertyName("number")])
+ for (int i = 0; i < cards.GetArrayLength(); i++)
+ Assert.That(cards[i].GetProperty("number").GetInt32(), Is.EqualTo(3));
+ }
+
+ [Test]
+ public async Task LoadIndex_freeplay_off_unchanged_baseline()
+ {
+ using var factory = new SVSimTestFactory();
+ await factory.SeedGlobalsAsync();
+ long viewerId = await factory.SeedViewerAsync();
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var resp = await client.PostAsync("/load/index", Body());
+ var json = await resp.Content.ReadAsStringAsync();
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), json);
+
+ using var doc = JsonDocument.Parse(json);
+ Assert.That(doc.RootElement.GetProperty("user_crystal_count").GetProperty("crystal").GetUInt64(),
+ Is.Not.EqualTo(99999UL), "freeplay off must not inflate currency");
+ }
+}
diff --git a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs
index 5c2f2b2..f0ca05d 100644
--- a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs
+++ b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs
@@ -524,6 +524,25 @@ public class PackControllerOpenTests
Assert.That(v.GachaPointBalances.Single().Points, Is.EqualTo(3));
}
+ [Test]
+ public async Task Open_freeplay_succeeds_with_zero_balance_and_no_deduction()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ await SeedOpenablePack(factory, viewerId, rupees: 0); // broke, but freeplay
+ await factory.EnableFreeplayAsync();
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
+ var response = await client.PostAsync("/pack/open", JsonBody(json));
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync());
+
+ using var scope = factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
+ Assert.That(v.Currency.Rupees, Is.EqualTo(0UL), "freeplay must not deduct real DB balance");
+ }
+
[Test]
public async Task TutorialPackOpen_does_not_accrue_gacha_points()
{
diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs
index fceabc9..dfc75a1 100644
--- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs
+++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs
@@ -244,6 +244,28 @@ internal sealed class SVSimTestFactory : WebApplicationFactory
await new PackImporter().ImportAsync(ctx, seedDir);
}
+ ///
+ /// Enables Freeplay mode by writing the GameConfigs DB row (tier-1 of GameConfigService).
+ /// Call before issuing the request under test. Idempotent.
+ ///
+ public async Task EnableFreeplayAsync(ulong currencyAmount = 99999, int cardCopies = 3)
+ {
+ using var scope = Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var json = System.Text.Json.JsonSerializer.Serialize(new
+ {
+ Enabled = true,
+ CurrencyAmount = currencyAmount,
+ CardCopies = cardCopies,
+ });
+ var existing = await db.GameConfigs.FirstOrDefaultAsync(s => s.SectionName == "Freeplay");
+ if (existing is null)
+ db.GameConfigs.Add(new GameConfigSection { SectionName = "Freeplay", ValueJson = json });
+ else
+ existing.ValueJson = json;
+ await db.SaveChangesAsync();
+ }
+
/// Convenience: bake the X-Test-Viewer-Id header into a fresh client.
public HttpClient CreateAuthenticatedClient(long viewerId)
{
diff --git a/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs b/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs
index ea39563..7024e4d 100644
--- a/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs
+++ b/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs
@@ -25,12 +25,13 @@ public class GameConfigurationJsonbTests
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
var byName = rows.ToDictionary(r => r.SectionName);
- // One row per [ConfigSection]-marked POCO (9 sections today: Player, DefaultGrants,
- // DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig).
+ // One row per [ConfigSection]-marked POCO (10 sections today: Player, DefaultGrants,
+ // DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig,
+ // Freeplay).
Assert.That(byName.Keys, Is.EquivalentTo(new[]
{
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
- "MyRotationSchedule", "Story", "ResourceConfig",
+ "MyRotationSchedule", "Story", "ResourceConfig", "Freeplay",
}));
var resources = JsonSerializer.Deserialize(byName["ResourceConfig"].ValueJson)!;
diff --git a/SVSim.UnitTests/Repositories/CollectionRepositoryTests.cs b/SVSim.UnitTests/Repositories/CollectionRepositoryTests.cs
new file mode 100644
index 0000000..2f853ca
--- /dev/null
+++ b/SVSim.UnitTests/Repositories/CollectionRepositoryTests.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SVSim.Database;
+using SVSim.Database.Models;
+using SVSim.Database.Repositories.Collectibles;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Repositories;
+
+public class CollectionRepositoryTests
+{
+ [Test]
+ public async Task GetAllSleeveIds_returns_every_master_sleeve()
+ {
+ using var factory = new SVSimTestFactory();
+ await factory.SeedGlobalsAsync();
+ using var scope = factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Sleeves.Add(new SleeveEntry { Id = 123456 });
+ await db.SaveChangesAsync();
+
+ var repo = new CollectionRepository(db);
+ var ids = await repo.GetAllSleeveIds();
+
+ Assert.That(ids, Does.Contain(123456));
+ Assert.That(ids.Count, Is.EqualTo(await db.Sleeves.CountAsync()));
+ }
+}
diff --git a/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs b/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs
new file mode 100644
index 0000000..f506b66
--- /dev/null
+++ b/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs
@@ -0,0 +1,91 @@
+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> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default)
+ => Task.FromResult>(new List());
+ public Task 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");
+ }
+}
diff --git a/SVSim.UnitTests/Services/FreeplayConfigTests.cs b/SVSim.UnitTests/Services/FreeplayConfigTests.cs
new file mode 100644
index 0000000..60683a7
--- /dev/null
+++ b/SVSim.UnitTests/Services/FreeplayConfigTests.cs
@@ -0,0 +1,26 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using SVSim.Database;
+using SVSim.Database.Models.Config;
+using SVSim.EmulatedEntrypoint.Services;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Services;
+
+public class FreeplayConfigTests
+{
+ [Test]
+ public void Freeplay_defaults_to_disabled_with_canonical_amounts()
+ {
+ using var factory = new SVSimTestFactory();
+ using var scope = factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var svc = new GameConfigService(db, new ConfigurationBuilder().Build());
+
+ var cfg = svc.Get();
+
+ Assert.That(cfg.Enabled, Is.False, "freeplay must be off unless explicitly enabled");
+ Assert.That(cfg.CurrencyAmount, Is.EqualTo(99999UL));
+ Assert.That(cfg.CardCopies, Is.EqualTo(3));
+ }
+}
diff --git a/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs b/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs
new file mode 100644
index 0000000..91665a0
--- /dev/null
+++ b/SVSim.UnitTests/Services/ViewerEntitlementsTests.cs
@@ -0,0 +1,268 @@
+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
+{
+ ///
+ /// 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.
+ ///
+ 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(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();
+ 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();
+ // 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ // 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();
+ // 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();
+ 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");
+ }
+ }
+}