Merge freeplay-mode: global freeplay toggle + centralized spend/entitlement primitives

This commit is contained in:
gamer147
2026-05-29 16:40:46 -04:00
27 changed files with 1064 additions and 141 deletions

View File

@@ -0,0 +1,17 @@
namespace SVSim.Database.Models.Config;
/// <summary>
/// Global "freeplay" toggle. When <see cref="Enabled"/>, every viewer is treated (in logic,
/// never in the DB) as owning all cards (<see cref="CardCopies"/> each), all cosmetics, and
/// <see cref="CurrencyAmount"/> of Crystal/Rupee/Red-Ether. See
/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md.
/// </summary>
[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();
}

View File

@@ -16,4 +16,16 @@ public class CollectionRepository : ICollectionRepository
{
return await _dbContext.Set<LeaderSkinEntry>().AsNoTracking().Include(skin => skin.Class).ToListAsync();
}
public Task<List<int>> GetAllSleeveIds() =>
_dbContext.Set<SleeveEntry>().AsNoTracking().Select(s => s.Id).ToListAsync();
public Task<List<int>> GetAllEmblemIds() =>
_dbContext.Set<EmblemEntry>().AsNoTracking().Select(e => e.Id).ToListAsync();
public Task<List<int>> GetAllDegreeIds() =>
_dbContext.Set<DegreeEntry>().AsNoTracking().Select(d => d.Id).ToListAsync();
public Task<List<int>> GetAllMyPageBackgroundIds() =>
_dbContext.Set<MyPageBackgroundEntry>().AsNoTracking().Select(m => m.Id).ToListAsync();
}

View File

@@ -5,4 +5,8 @@ namespace SVSim.Database.Repositories.Collectibles;
public interface ICollectionRepository
{
Task<List<LeaderSkinEntry>> GetLeaderSkins();
Task<List<int>> GetAllSleeveIds();
Task<List<int>> GetAllEmblemIds();
Task<List<int>> GetAllDegreeIds();
Task<List<int>> GetAllMyPageBackgroundIds();
}

View File

@@ -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<SpendResult> TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default)
{
if (cost < 0) cost = 0;
// Freeplay bypass applies only to the three main currencies; SpotPoint always real.
if (_entitlements.IsFreeplay && currency != SpendCurrency.SpotPoint)
{
return Task.FromResult(new SpendResult(
SpendOutcome.Success, _entitlements.EffectiveBalance(viewer, currency)));
}
ulong current = GetBalance(viewer, currency);
if (current < (ulong)cost)
return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current));
ulong post = current - (ulong)cost;
SetBalance(viewer, currency, post);
return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post));
}
private static ulong GetBalance(Viewer v, SpendCurrency c) => c switch
{
SpendCurrency.Crystal => v.Currency.Crystals,
SpendCurrency.Rupee => v.Currency.Rupees,
SpendCurrency.RedEther => v.Currency.RedEther,
SpendCurrency.SpotPoint => v.Currency.SpotPoints,
_ => throw new ArgumentOutOfRangeException(nameof(c)),
};
private static void SetBalance(Viewer v, SpendCurrency c, ulong value)
{
switch (c)
{
case SpendCurrency.Crystal: v.Currency.Crystals = value; break;
case SpendCurrency.Rupee: v.Currency.Rupees = value; break;
case SpendCurrency.RedEther: v.Currency.RedEther = value; break;
case SpendCurrency.SpotPoint: v.Currency.SpotPoints = value; break;
default: throw new ArgumentOutOfRangeException(nameof(c));
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
namespace SVSim.Database.Services;
/// <summary>The scalar wallet currencies the central debit primitive understands.</summary>
public enum SpendCurrency { Crystal, Rupee, RedEther, SpotPoint }
public enum SpendOutcome { Success, Insufficient }
/// <summary>
/// Result of a <see cref="ICurrencySpendService.TrySpendAsync"/> call. <see cref="PostStateTotal"/>
/// 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.
/// </summary>
public sealed record SpendResult(SpendOutcome Outcome, long PostStateTotal)
{
public bool Success => Outcome == SpendOutcome.Success;
}

View File

@@ -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<FreeplayConfig>();
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<IReadOnlyList<OwnedCardEntry>> 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<EffectiveCosmetics> 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());
}
}

View File

@@ -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;
}
/// <summary>
@@ -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

View File

@@ -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<RewardListEntry>();
// 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 <see cref="RewardListEntry"/> the client
/// uses to refresh its cached count. Returns an error string on insufficient balance.
/// </summary>
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)

View File

@@ -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<RewardListEntry>();
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<RewardListEntry>();
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<T>(

View File

@@ -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<LeaderSkinEntry> allLeaderSkins = await _collectionRepository.GetLeaderSkins();
var cosmetics = await _entitlements.EffectiveCosmeticsAsync(viewer, ct);
var classExpCurve = await _globalsRepository.GetClassExpCurve();
List<ClassExp> 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,

View File

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

View File

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

View File

@@ -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).

View File

@@ -84,6 +84,8 @@ public class Program
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.Repositories.BattlePass.IBattlePassRepository,
SVSim.Database.Repositories.BattlePass.BattlePassRepository>();
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IViewerBattlePassRepository,

View File

@@ -23,19 +23,22 @@ public sealed class BattlePassService : IBattlePassService
private readonly TimeProvider _time;
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly ICurrencySpendService _spend;
public BattlePassService(
IBattlePassRepository bp,
IViewerBattlePassRepository viewerBp,
TimeProvider time,
SVSimDbContext db,
RewardGrantService rewards)
RewardGrantService rewards,
ICurrencySpendService spend)
{
_bp = bp;
_viewerBp = viewerBp;
_time = time;
_db = db;
_rewards = rewards;
_spend = spend;
}
public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
@@ -166,13 +169,13 @@ public sealed class BattlePassService : IBattlePassService
if (progress.IsPremium)
return new BattlePassBuyOutcome(23, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
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<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);
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);
}

View File

@@ -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<SVSimDbContext>();
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<SVSimDbContext>();
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");
}
/// <summary>
/// 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.
/// </summary>
private static async Task SeedCrystalPack(SVSimTestFactory f, long viewerId)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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();
}
}

View File

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

View File

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

View File

@@ -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<SVSimDbContext>();
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()
{

View File

@@ -244,6 +244,28 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
await new PackImporter().ImportAsync(ctx, seedDir);
}
/// <summary>
/// Enables Freeplay mode by writing the GameConfigs DB row (tier-1 of GameConfigService).
/// Call before issuing the request under test. Idempotent.
/// </summary>
public async Task EnableFreeplayAsync(ulong currencyAmount = 99999, int cardCopies = 3)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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();
}
/// <summary>Convenience: bake the X-Test-Viewer-Id header into a fresh client.</summary>
public HttpClient CreateAuthenticatedClient(long viewerId)
{

View File

@@ -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<ResourceConfig>(byName["ResourceConfig"].ValueJson)!;

View File

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

View File

@@ -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<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<OwnedCardEntry>>(new List<OwnedCardEntry>());
public Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default)
=> throw new NotSupportedException();
}
private static Viewer NewViewer() => new() { Currency = new ViewerCurrency() };
[Test]
public async Task Normal_deducts_and_returns_post_state()
{
var v = NewViewer();
v.Currency.Crystals = 250;
var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = false });
var r = await svc.TrySpendAsync(v, SpendCurrency.Crystal, 100);
Assert.That(r.Success, Is.True);
Assert.That(r.PostStateTotal, Is.EqualTo(150));
Assert.That(v.Currency.Crystals, Is.EqualTo(150UL));
}
[Test]
public async Task Normal_insufficient_does_not_deduct()
{
var v = NewViewer();
v.Currency.Rupees = 50;
var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = false });
var r = await svc.TrySpendAsync(v, SpendCurrency.Rupee, 100);
Assert.That(r.Success, Is.False);
Assert.That(r.Outcome, Is.EqualTo(SpendOutcome.Insufficient));
Assert.That(v.Currency.Rupees, Is.EqualTo(50UL), "no deduction on insufficient funds");
}
[Test]
public async Task Freeplay_main_currency_succeeds_without_deducting()
{
var v = NewViewer();
v.Currency.Crystals = 10;
var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = true });
var r = await svc.TrySpendAsync(v, SpendCurrency.Crystal, 100000);
Assert.That(r.Success, Is.True, "freeplay never blocks on affordability");
Assert.That(r.PostStateTotal, Is.EqualTo(99999), "post-state shows the freeplay balance");
Assert.That(v.Currency.Crystals, Is.EqualTo(10UL), "DB balance untouched in freeplay");
}
[Test]
public async Task Freeplay_spot_points_still_deduct()
{
var v = NewViewer();
v.Currency.SpotPoints = 300;
var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = true });
var r = await svc.TrySpendAsync(v, SpendCurrency.SpotPoint, 100);
Assert.That(r.Success, Is.True);
Assert.That(r.PostStateTotal, Is.EqualTo(200));
Assert.That(v.Currency.SpotPoints, Is.EqualTo(200UL), "spot points are real even in freeplay");
}
}

View File

@@ -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<SVSimDbContext>();
var svc = new GameConfigService(db, new ConfigurationBuilder().Build());
var cfg = svc.Get<FreeplayConfig>();
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));
}
}

View File

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