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(); 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 public interface ICollectionRepository
{ {
Task<List<LeaderSkinEntry>> GetLeaderSkins(); 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 IBuildDeckRepository _repo;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards; private readonly RewardGrantService _rewards;
private readonly ICurrencySpendService _spend;
public BuildDeckController( public BuildDeckController(
IBuildDeckRepository repo, IBuildDeckRepository repo,
SVSimDbContext db, SVSimDbContext db,
RewardGrantService rewards) RewardGrantService rewards,
ICurrencySpendService spend)
{ {
_repo = repo; _repo = repo;
_db = db; _db = db;
_rewards = rewards; _rewards = rewards;
_spend = spend;
} }
/// <summary> /// <summary>
@@ -200,19 +203,15 @@ public class BuildDeckController : SVSimController
// Debit + post-state currency entry // Debit + post-state currency entry
if (request.SalesType == 1) if (request.SalesType == 1)
{ {
ulong cost = (ulong)priceCrystal!.Value; var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, priceCrystal!.Value);
if (viewer.Currency.Crystals < cost) if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
return BadRequest(new { error = "insufficient_crystals" }); rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal });
viewer.Currency.Crystals -= cost;
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals });
} }
else if (request.SalesType == 2) else if (request.SalesType == 2)
{ {
ulong cost = (ulong)priceRupy!.Value; var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, priceRupy!.Value);
if (viewer.Currency.Rupees < cost) if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
return BadRequest(new { error = "insufficient_rupees" }); rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal });
viewer.Currency.Rupees -= cost;
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees });
} }
// sales_type == 0 (free): no debit, no currency entry // 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 SVSimDbContext _db;
private readonly RewardGrantService _rewards; private readonly RewardGrantService _rewards;
private readonly TimeProvider _time; 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; _db = db;
_rewards = rewards; _rewards = rewards;
_time = time; _time = time;
_spend = spend;
} }
[HttpPost("info")] [HttpPost("info")]
@@ -117,7 +119,7 @@ public class ItemPurchaseController : SVSimController
var rewardList = new List<RewardListEntry>(); var rewardList = new List<RewardListEntry>();
// Debit the require side. RewardGrantService is grant-only, so handle this inline. // 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.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState); 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 /// 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. /// uses to refresh its cached count. Returns an error string on insufficient balance.
/// </summary> /// </summary>
private static (RewardListEntry? PostState, string? Error) TryDebit( private async Task<(RewardListEntry? PostState, string? Error)> TryDebit(
Viewer viewer, UserGoodsType type, long detailId, int num) Viewer viewer, UserGoodsType type, long detailId, int num)
{ {
switch (type) switch (type)
{ {
case UserGoodsType.RedEther: case UserGoodsType.RedEther:
if (viewer.Currency.RedEther < (ulong)num) {
return (null, "insufficient_red_ether"); var r = await _spend.TrySpendAsync(viewer, SpendCurrency.RedEther, num);
viewer.Currency.RedEther -= (ulong)num; if (!r.Success) return (null, "insufficient_red_ether");
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)viewer.Currency.RedEther }, null); return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
case UserGoodsType.Crystal: case UserGoodsType.Crystal:
if (viewer.Currency.Crystals < (ulong)num) {
return (null, "insufficient_crystals"); var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, num);
viewer.Currency.Crystals -= (ulong)num; if (!r.Success) return (null, "insufficient_crystals");
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null); return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
case UserGoodsType.Rupy: case UserGoodsType.Rupy:
if (viewer.Currency.Rupees < (ulong)num) {
return (null, "insufficient_rupees"); var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, num);
viewer.Currency.Rupees -= (ulong)num; if (!r.Success) return (null, "insufficient_rupees");
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null); return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
case UserGoodsType.Item: case UserGoodsType.Item:
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId); var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
if (owned is null || owned.Count < num) if (owned is null || owned.Count < num)

View File

@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using SVSim.Database; using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
@@ -30,12 +31,18 @@ public class LeaderSkinController : SVSimController
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards; private readonly RewardGrantService _rewards;
private readonly TimeProvider _time; 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; _db = db;
_rewards = rewards; _rewards = rewards;
_time = time; _time = time;
_spend = spend;
_entitlements = entitlements;
_collection = collection;
} }
[HttpPost("set")] [HttpPost("set")]
@@ -62,7 +69,7 @@ public class LeaderSkinController : SVSimController
var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId); var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId);
if (skin is null) return BadRequest(new { error = "unknown_skin" }); if (skin is null) return BadRequest(new { error = "unknown_skin" });
if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" }); if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" });
if (viewer.LeaderSkins.All(s => s.Id != skin.Id)) if (!_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, skin.Id))
return BadRequest(new { error = "skin_not_owned" }); return BadRequest(new { error = "skin_not_owned" });
classData.LeaderSkin = skin; classData.LeaderSkin = skin;
@@ -81,6 +88,12 @@ public class LeaderSkinController : SVSimController
{ {
if (!TryGetViewerId(out long viewerId)) return Unauthorized(); 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 var ids = await _db.Viewers
.Where(v => v.Id == viewerId) .Where(v => v.Id == viewerId)
.SelectMany(v => v.LeaderSkins.Select(s => s.Id)) .SelectMany(v => v.LeaderSkins.Select(s => s.Id))
@@ -95,10 +108,12 @@ public class LeaderSkinController : SVSimController
{ {
if (!TryGetViewerId(out long viewerId)) return Unauthorized(); if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var ownedSkinIds = (await _db.Viewers var ownedSkinIds = _entitlements.IsFreeplay
.Where(v => v.Id == viewerId) ? (await _collection.GetLeaderSkins()).Select(s => s.Id).ToHashSet()
.SelectMany(v => v.LeaderSkins.Select(s => s.Id)) : (await _db.Viewers
.ToListAsync()).ToHashSet(); .Where(v => v.Id == viewerId)
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
.ToListAsync()).ToHashSet();
var claimedSeries = (await _db.ViewerLeaderSkinSetClaims var claimedSeries = (await _db.ViewerLeaderSkinSetClaims
.Where(c => c.ViewerId == viewerId) .Where(c => c.ViewerId == viewerId)
@@ -171,11 +186,11 @@ public class LeaderSkinController : SVSimController
var viewer = await LoadViewerGraphAsync(viewerId); var viewer = await LoadViewerGraphAsync(viewerId);
// Already-purchased = viewer owns the leader_skin this product grants. // 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" }); return BadRequest(new { error = "already_purchased" });
var rewardList = new List<RewardListEntry>(); 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.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState); if (debit.PostState is not null) rewardList.Add(debit.PostState);
@@ -205,8 +220,11 @@ public class LeaderSkinController : SVSimController
var viewer = await LoadViewerGraphAsync(viewerId); var viewer = await LoadViewerGraphAsync(viewerId);
if (_entitlements.IsFreeplay)
return BadRequest(new { error = "already_purchased" });
var rewardList = new List<RewardListEntry>(); 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.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState); if (debit.PostState is not null) rewardList.Add(debit.PostState);
@@ -332,52 +350,58 @@ public class LeaderSkinController : SVSimController
return false; return false;
} }
private (RewardListEntry? PostState, string? Error) DebitProductPrice( private async Task<(RewardListEntry? PostState, string? Error)> DebitProductPrice(
Viewer viewer, LeaderSkinShopProductEntry product, int salesType) Viewer viewer, LeaderSkinShopProductEntry product, int salesType)
{ {
return salesType switch switch (salesType)
{ {
0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0 => (null, null), case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0:
0 => (null, "price_not_available_for_currency"), return (null, null);
1 => product.SinglePriceCrystal is null case 0:
? (null, "price_not_available_for_currency") return (null, "price_not_available_for_currency");
: DebitCrystal(viewer, product.SinglePriceCrystal.Value), case 1:
2 => product.SinglePriceRupy is null if (product.SinglePriceCrystal is null) return (null, "price_not_available_for_currency");
? (null, "price_not_available_for_currency") return await DebitCrystal(viewer, product.SinglePriceCrystal.Value);
: DebitRupy(viewer, product.SinglePriceRupy.Value), case 2:
_ => (null, "invalid_sales_type"), 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) Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType)
{ {
return salesType switch switch (salesType)
{ {
0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0 => (null, null), case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0:
0 => (null, "price_not_available_for_currency"), return (null, null);
1 => series.SetPriceCrystal is null case 0:
? (null, "price_not_available_for_currency") return (null, "price_not_available_for_currency");
: DebitCrystal(viewer, series.SetPriceCrystal.Value), case 1:
2 => series.SetPriceRupy is null if (series.SetPriceCrystal is null) return (null, "price_not_available_for_currency");
? (null, "price_not_available_for_currency") return await DebitCrystal(viewer, series.SetPriceCrystal.Value);
: DebitRupy(viewer, series.SetPriceRupy.Value), case 2:
_ => (null, "invalid_sales_type"), 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"); var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, amount);
viewer.Currency.Crystals -= (ulong)amount; if (!r.Success) return (null, "insufficient_crystals");
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null); 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"); var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, amount);
viewer.Currency.Rupees -= (ulong)amount; if (!r.Success) return (null, "insufficient_rupees");
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null); return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
} }
private async Task ApplyRewardsAsync<T>( private async Task ApplyRewardsAsync<T>(

View File

@@ -6,8 +6,6 @@ using SVSim.Database.Models;
using SVSim.Database.Models.Config; using SVSim.Database.Models.Config;
using PreReleaseInfoEntity = SVSim.Database.Models.PreReleaseInfo; using PreReleaseInfoEntity = SVSim.Database.Models.PreReleaseInfo;
using PreReleaseInfoDto = SVSim.EmulatedEntrypoint.Models.Dtos.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.Globals;
using SVSim.Database.Repositories.Viewer; using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services; using SVSim.Database.Services;
@@ -42,30 +40,27 @@ public class LoadController : SVSimController
}; };
private readonly IViewerRepository _viewerRepository; private readonly IViewerRepository _viewerRepository;
private readonly ICardRepository _cardRepository;
private readonly ICollectionRepository _collectionRepository;
private readonly IGlobalsRepository _globalsRepository; private readonly IGlobalsRepository _globalsRepository;
private readonly ICardAcquisitionService _acquisition; private readonly ICardAcquisitionService _acquisition;
private readonly IGameConfigService _config; private readonly IGameConfigService _config;
private readonly IBattlePassService _battlePass; private readonly IBattlePassService _battlePass;
private readonly IViewerMissionStateService _missionState; private readonly IViewerMissionStateService _missionState;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly IViewerEntitlements _entitlements;
public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository, public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository,
ICardAcquisitionService acquisition, IGameConfigService config, ICardAcquisitionService acquisition, IGameConfigService config,
IBattlePassService battlePass, IViewerMissionStateService missionState, IBattlePassService battlePass, IViewerMissionStateService missionState,
SVSimDbContext db) SVSimDbContext db, IViewerEntitlements entitlements)
{ {
_viewerRepository = viewerRepository; _viewerRepository = viewerRepository;
_cardRepository = cardRepository;
_collectionRepository = collectionRepository;
_globalsRepository = globalsRepository; _globalsRepository = globalsRepository;
_acquisition = acquisition; _acquisition = acquisition;
_config = config; _config = config;
_battlePass = battlePass; _battlePass = battlePass;
_missionState = missionState; _missionState = missionState;
_db = db; _db = db;
_entitlements = entitlements;
} }
[HttpPost("index")] [HttpPost("index")]
@@ -127,20 +122,11 @@ public class LoadController : SVSimController
// * card_set_id=90000 (engine tokens, char_type=4): never collectible // * 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; // 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. // re-confirm the filter if we later move to Option B and start iterating card-sets.
var defaultCards = await _cardRepository.GetDefaultCards(); // Owned-card projection (incl. the freeplay "all cards" path) lives in the entitlements
var defaultCardIds = defaultCards.Select(c => c.Id).ToHashSet(); // service so both modes share one definition.
var ownedCollectibles = viewer.Cards var allCardsAsOwned = await _entitlements.EffectiveOwnedCardsAsync(viewer, ct);
.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();
List<LeaderSkinEntry> allLeaderSkins = await _collectionRepository.GetLeaderSkins(); var cosmetics = await _entitlements.EffectiveCosmeticsAsync(viewer, ct);
var classExpCurve = await _globalsRepository.GetClassExpCurve(); var classExpCurve = await _globalsRepository.GetClassExpCurve();
List<ClassExp> classExps = new(); List<ClassExp> classExps = new();
@@ -179,7 +165,13 @@ public class LoadController : SVSimController
{ {
UserTutorial = new UserTutorial { TutorialStep = viewer.MissionData.TutorialState }, UserTutorial = new UserTutorial { TutorialStep = viewer.MissionData.TutorialState },
UserInfo = new UserInfo(deviceType, viewer), UserInfo = new UserInfo(deviceType, viewer),
UserCurrency = new UserCurrency(viewer), UserCurrency = new UserCurrency(viewer)
{
Crystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal),
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(), UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
SpotPoint = checked((int)viewer.Currency.SpotPoints), SpotPoint = checked((int)viewer.Currency.SpotPoints),
UserRotationDecks = new UserFormatDeckInfo UserRotationDecks = new UserFormatDeckInfo
@@ -199,13 +191,13 @@ public class LoadController : SVSimController
}, },
UserCards = allCardsAsOwned.Select(card => new UserCard(card)).ToList(), UserCards = allCardsAsOwned.Select(card => new UserCard(card)).ToList(),
UserClasses = viewer.Classes.Select(vc => new UserClass(vc)).ToList(), UserClasses = viewer.Classes.Select(vc => new UserClass(vc)).ToList(),
Sleeves = viewer.Sleeves.Select(s => new SleeveIdentifier { SleeveId = s.Id }).ToList(), Sleeves = cosmetics.SleeveIds.Select(id => new SleeveIdentifier { SleeveId = id }).ToList(),
UserEmblems = viewer.Emblems.Select(e => new EmblemIdentifier { EmblemId = e.Id }).ToList(), UserEmblems = cosmetics.EmblemIds.Select(id => new EmblemIdentifier { EmblemId = id }).ToList(),
UserDegrees = viewer.Degrees.Select(d => new DegreeIdentifier { DegreeId = d.Id }).ToList(), UserDegrees = cosmetics.DegreeIds.Select(id => new DegreeIdentifier { DegreeId = id }).ToList(),
LeaderSkins = allLeaderSkins LeaderSkins = cosmetics.AllLeaderSkins
.Select(skin => new UserLeaderSkin(skin, viewer.LeaderSkins.Any(vs => vs.Id == skin.Id))) .Select(skin => new UserLeaderSkin(skin, cosmetics.OwnedLeaderSkinIds.Contains(skin.Id)))
.ToList(), .ToList(),
MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id.ToString()).ToList(), MyPageBackgrounds = cosmetics.MyPageBackgroundIds.Select(id => id.ToString()).ToList(),
LootBoxRegulations = new LootBoxRegulations(), LootBoxRegulations = new LootBoxRegulations(),
GatheringInfo = new GatheringInfo(), GatheringInfo = new GatheringInfo(),
IsBattlePassPeriod = rotation.IsBattlePassPeriod, 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;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Services; using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers; namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -29,6 +30,8 @@ public class PackController : SVSimController
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly ICardAcquisitionService _acquisition; private readonly ICardAcquisitionService _acquisition;
private readonly IGachaPointService _gachaPoint; private readonly IGachaPointService _gachaPoint;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
public PackController( public PackController(
IPackRepository packs, IPackRepository packs,
@@ -37,7 +40,9 @@ public class PackController : SVSimController
IRandom rng, IRandom rng,
SVSimDbContext db, SVSimDbContext db,
ICardAcquisitionService acquisition, ICardAcquisitionService acquisition,
IGachaPointService gachaPoint) IGachaPointService gachaPoint,
ICurrencySpendService spend,
IViewerEntitlements entitlements)
{ {
_packs = packs; _packs = packs;
_opener = opener; _opener = opener;
@@ -46,6 +51,8 @@ public class PackController : SVSimController
_db = db; _db = db;
_acquisition = acquisition; _acquisition = acquisition;
_gachaPoint = gachaPoint; _gachaPoint = gachaPoint;
_spend = spend;
_entitlements = entitlements;
} }
[HttpPost("info")] [HttpPost("info")]
@@ -292,18 +299,16 @@ public class PackController : SVSimController
{ {
case 2: // CRYSTAL_MULTI case 2: // CRYSTAL_MULTI
{ {
ulong cost = (ulong)child.Cost * (ulong)packNumber; long cost = (long)child.Cost * packNumber;
if (viewer.Currency.Crystals < cost) var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, cost);
return BadRequest(new { error = "insufficient_crystals" }); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
viewer.Currency.Crystals -= cost;
break; break;
} }
case 7: // RUPY_MULTI case 7: // RUPY_MULTI
{ {
ulong cost = (ulong)child.Cost * (ulong)packNumber; long cost = (long)child.Cost * packNumber;
if (viewer.Currency.Rupees < cost) var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
return BadRequest(new { error = "insufficient_rupees" }); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
viewer.Currency.Rupees -= cost;
break; break;
} }
case 3: // DAILY single — once per UTC day 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) if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date)
return BadRequest(new { error = "daily_free_already_claimed" }); return BadRequest(new { error = "daily_free_already_claimed" });
ulong cost = (ulong)child.Cost * (ulong)packNumber; long cost = (long)child.Cost * packNumber;
if (cost > 0 && viewer.Currency.Rupees < cost) var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
return BadRequest(new { error = "insufficient_rupees" }); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
if (cost > 0) viewer.Currency.Rupees -= cost;
break; break;
} }
} }
@@ -359,14 +363,13 @@ public class PackController : SVSimController
// Currency reward entries only apply to purchasable packs; tutorial path omits them. // Currency reward entries only apply to purchasable packs; tutorial path omits them.
if (!isTutorialPath) if (!isTutorialPath)
{ {
var postViewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId);
if (child.TypeDetail == 2) 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) 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); rewardList.AddRange(grant.RewardList);

View File

@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using SVSim.Database; using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
@@ -20,11 +21,17 @@ public class SleeveController : SVSimController
{ {
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards; 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; _db = db;
_rewards = rewards; _rewards = rewards;
_spend = spend;
_entitlements = entitlements;
_collection = collection;
} }
[HttpPost("info")] [HttpPost("info")]
@@ -35,10 +42,12 @@ public class SleeveController : SVSimController
// is_purchased_product is "viewer owns at least one sleeve granted by this product". // is_purchased_product is "viewer owns at least one sleeve granted by this product".
// Loading the viewer's sleeve-id set once and checking each product against it avoids // Loading the viewer's sleeve-id set once and checking each product against it avoids
// an N+1 over products. // an N+1 over products.
var ownedSleeveIds = (await _db.Viewers var ownedSleeveIds = _entitlements.IsFreeplay
.Where(v => v.Id == viewerId) ? (await _collection.GetAllSleeveIds()).Select(id => (long)id).ToHashSet()
.SelectMany(v => v.Sleeves.Select(s => (long)s.Id)) : (await _db.Viewers
.ToListAsync()).ToHashSet(); .Where(v => v.Id == viewerId)
.SelectMany(v => v.Sleeves.Select(s => (long)s.Id))
.ToListAsync()).ToHashSet();
var series = await _db.SleeveShopSeries var series = await _db.SleeveShopSeries
.Where(s => s.IsEnabled) .Where(s => s.IsEnabled)
@@ -106,6 +115,9 @@ public class SleeveController : SVSimController
var viewer = await LoadViewerGraphAsync(viewerId); 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())) if (IsProductPurchased(product, viewer.Sleeves.Select(s => (long)s.Id).ToHashSet()))
return BadRequest(new { error = "already_purchased" }); return BadRequest(new { error = "already_purchased" });
@@ -122,20 +134,16 @@ public class SleeveController : SVSimController
case 1: // crystal case 1: // crystal
if (product.PriceCrystal is null) if (product.PriceCrystal is null)
return BadRequest(new { error = "price_not_available_for_currency" }); return BadRequest(new { error = "price_not_available_for_currency" });
var crystalCost = (ulong)product.PriceCrystal.Value; var crystalRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, product.PriceCrystal.Value);
if (viewer.Currency.Crystals < crystalCost) if (!crystalRes.Success) return BadRequest(new { error = "insufficient_crystals" });
return BadRequest(new { error = "insufficient_crystals" }); rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)crystalRes.PostStateTotal });
viewer.Currency.Crystals -= crystalCost;
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals });
break; break;
case 2: // rupy case 2: // rupy
if (product.PriceRupy is null) if (product.PriceRupy is null)
return BadRequest(new { error = "price_not_available_for_currency" }); return BadRequest(new { error = "price_not_available_for_currency" });
var rupyCost = (ulong)product.PriceRupy.Value; var rupyRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, product.PriceRupy.Value);
if (viewer.Currency.Rupees < rupyCost) if (!rupyRes.Success) return BadRequest(new { error = "insufficient_rupees" });
return BadRequest(new { error = "insufficient_rupees" }); rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)rupyRes.PostStateTotal });
viewer.Currency.Rupees -= rupyCost;
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees });
break; break;
} }

View File

@@ -30,12 +30,14 @@ public class SpotCardExchangeController : SVSimController
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards; private readonly RewardGrantService _rewards;
private readonly TimeProvider _time; 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; _db = db;
_rewards = rewards; _rewards = rewards;
_time = time; _time = time;
_spend = spend;
} }
[HttpPost("top")] [HttpPost("top")]
@@ -131,14 +133,14 @@ public class SpotCardExchangeController : SVSimController
// Debit spot points. Client-supplied exchange_point isn't authoritative — server uses // Debit spot points. Client-supplied exchange_point isn't authoritative — server uses
// catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry // catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry
// first, then grants. // first, then grants.
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" }); return BadRequest(new { error = "insufficient_spot_points" });
viewer.Currency.SpotPoints -= (ulong)entry.ExchangePoint;
rewardList.Add(new RewardListEntry rewardList.Add(new RewardListEntry
{ {
RewardType = (int)UserGoodsType.SpotCardPoint, RewardType = (int)UserGoodsType.SpotCardPoint,
RewardId = 0, 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). // 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<IGachaPointService, GachaPointService>();
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>(); builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();
builder.Services.AddScoped<RewardGrantService>(); 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, builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IBattlePassRepository,
SVSim.Database.Repositories.BattlePass.BattlePassRepository>(); SVSim.Database.Repositories.BattlePass.BattlePassRepository>();
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IViewerBattlePassRepository, builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IViewerBattlePassRepository,

View File

@@ -23,19 +23,22 @@ public sealed class BattlePassService : IBattlePassService
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards; private readonly RewardGrantService _rewards;
private readonly ICurrencySpendService _spend;
public BattlePassService( public BattlePassService(
IBattlePassRepository bp, IBattlePassRepository bp,
IViewerBattlePassRepository viewerBp, IViewerBattlePassRepository viewerBp,
TimeProvider time, TimeProvider time,
SVSimDbContext db, SVSimDbContext db,
RewardGrantService rewards) RewardGrantService rewards,
ICurrencySpendService spend)
{ {
_bp = bp; _bp = bp;
_viewerBp = viewerBp; _viewerBp = viewerBp;
_time = time; _time = time;
_db = db; _db = db;
_rewards = rewards; _rewards = rewards;
_spend = spend;
} }
public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct) public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
@@ -166,13 +169,13 @@ public sealed class BattlePassService : IBattlePassService
if (progress.IsPremium) if (progress.IsPremium)
return new BattlePassBuyOutcome(23, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>()); return new BattlePassBuyOutcome(23, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
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>()); 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. // 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); await using var tx = await _db.Database.BeginTransactionAsync(ct);
viewer.Currency.Crystals -= (ulong)season.PriceCrystal;
progress.IsPremium = true; progress.IsPremium = true;
// Retroactive grants: every premium reward at level <= current_level not already claimed. // Retroactive grants: every premium reward at level <= current_level not already claimed.
@@ -206,7 +209,7 @@ public sealed class BattlePassService : IBattlePassService
// append the post-deduction total so the client gets the correct final balance. // append the post-deduction total so the client gets the correct final balance.
postState.RemoveAll(r => r.RewardType == (int)UserGoodsType.Crystal); postState.RemoveAll(r => r.RewardType == (int)UserGoodsType.Crystal);
postState.Add(new GrantedReward( 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); 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)); var resp = await client.PostAsync("/leader_skin/set", JsonBody(json));
Assert.That((int)resp.StatusCode, Is.EqualTo(501)); 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)); 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] [Test]
public async Task TutorialPackOpen_does_not_accrue_gacha_points() 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); 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> /// <summary>Convenience: bake the X-Test-Viewer-Id header into a fresh client.</summary>
public HttpClient CreateAuthenticatedClient(long viewerId) public HttpClient CreateAuthenticatedClient(long viewerId)
{ {

View File

@@ -25,12 +25,13 @@ public class GameConfigurationJsonbTests
var rows = await db.GameConfigs.AsNoTracking().ToListAsync(); var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
var byName = rows.ToDictionary(r => r.SectionName); var byName = rows.ToDictionary(r => r.SectionName);
// One row per [ConfigSection]-marked POCO (9 sections today: Player, DefaultGrants, // One row per [ConfigSection]-marked POCO (10 sections today: Player, DefaultGrants,
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig). // DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig,
// Freeplay).
Assert.That(byName.Keys, Is.EquivalentTo(new[] Assert.That(byName.Keys, Is.EquivalentTo(new[]
{ {
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates", "Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
"MyRotationSchedule", "Story", "ResourceConfig", "MyRotationSchedule", "Story", "ResourceConfig", "Freeplay",
})); }));
var resources = JsonSerializer.Deserialize<ResourceConfig>(byName["ResourceConfig"].ValueJson)!; 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");
}
}
}