feat(services): CurrencySpendService (central debit primitive, freeplay-aware)
This commit is contained in:
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user