Merge freeplay-mode: global freeplay toggle + centralized spend/entitlement primitives
This commit is contained in:
17
SVSim.Database/Models/Config/FreeplayConfig.cs
Normal file
17
SVSim.Database/Models/Config/FreeplayConfig.cs
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
51
SVSim.Database/Services/CurrencySpendService.cs
Normal file
51
SVSim.Database/Services/CurrencySpendService.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
14
SVSim.Database/Services/ICurrencySpendService.cs
Normal file
14
SVSim.Database/Services/ICurrencySpendService.cs
Normal 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);
|
||||
}
|
||||
54
SVSim.Database/Services/IViewerEntitlements.cs
Normal file
54
SVSim.Database/Services/IViewerEntitlements.cs
Normal 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 => v.Cards).ThenInclude(c => c.Card)</c>
|
||||
/// and the cosmetic collections
|
||||
/// (<c>Sleeves</c>, <c>Emblems</c>, <c>Degrees</c>, <c>LeaderSkins</c>, <c>MyPageBackgrounds</c>)
|
||||
/// included. Without those includes the EF owned-collection nav refs are null or zero-filled
|
||||
/// (see the EF owned-collection nav-include pitfall in MEMORY.md).
|
||||
/// </remarks>
|
||||
public interface IViewerEntitlements
|
||||
{
|
||||
/// <summary>True when the global Freeplay config section is enabled.</summary>
|
||||
bool IsFreeplay { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The balance the viewer is treated as having: the configured freeplay amount for
|
||||
/// Crystal/Rupee/RedEther when freeplay is on, otherwise (and always for SpotPoint) the real
|
||||
/// <c>viewer.Currency</c> field.
|
||||
/// </summary>
|
||||
long EffectiveBalance(Viewer viewer, SpendCurrency currency);
|
||||
|
||||
bool OwnsCard(Viewer viewer, long cardId);
|
||||
|
||||
/// <summary><paramref name="type"/> uses <see cref="CosmeticType"/> (Skin == leader skin).</summary>
|
||||
bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id);
|
||||
|
||||
/// <summary>The full owned-card projection for /load/index's user_card_list.</summary>
|
||||
Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The cosmetic id-lists + leader-skin catalog/owned-set for /load/index.</summary>
|
||||
Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns"
|
||||
/// (all of them in freeplay). Leader skins are always the full catalog with a per-skin owned flag;
|
||||
/// <see cref="OwnedLeaderSkinIds"/> is every skin id in freeplay.
|
||||
/// </summary>
|
||||
public sealed record EffectiveCosmetics(
|
||||
IReadOnlyList<int> SleeveIds,
|
||||
IReadOnlyList<int> EmblemIds,
|
||||
IReadOnlyList<int> DegreeIds,
|
||||
IReadOnlyList<int> MyPageBackgroundIds,
|
||||
IReadOnlyList<LeaderSkinEntry> AllLeaderSkins,
|
||||
IReadOnlySet<int> OwnedLeaderSkinIds);
|
||||
16
SVSim.Database/Services/SpendCurrency.cs
Normal file
16
SVSim.Database/Services/SpendCurrency.cs
Normal 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;
|
||||
}
|
||||
107
SVSim.Database/Services/ViewerEntitlements.cs
Normal file
107
SVSim.Database/Services/ViewerEntitlements.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
72
SVSim.UnitTests/Controllers/FreeplayInvariantTests.cs
Normal file
72
SVSim.UnitTests/Controllers/FreeplayInvariantTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
63
SVSim.UnitTests/Controllers/LoadControllerFreeplayTests.cs
Normal file
63
SVSim.UnitTests/Controllers/LoadControllerFreeplayTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
28
SVSim.UnitTests/Repositories/CollectionRepositoryTests.cs
Normal file
28
SVSim.UnitTests/Repositories/CollectionRepositoryTests.cs
Normal 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()));
|
||||
}
|
||||
}
|
||||
91
SVSim.UnitTests/Services/CurrencySpendServiceTests.cs
Normal file
91
SVSim.UnitTests/Services/CurrencySpendServiceTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
26
SVSim.UnitTests/Services/FreeplayConfigTests.cs
Normal file
26
SVSim.UnitTests/Services/FreeplayConfigTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
268
SVSim.UnitTests/Services/ViewerEntitlementsTests.cs
Normal file
268
SVSim.UnitTests/Services/ViewerEntitlementsTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user