diff --git a/SVSim.Database/Services/CurrencySpendService.cs b/SVSim.Database/Services/CurrencySpendService.cs new file mode 100644 index 0000000..1d3ef5e --- /dev/null +++ b/SVSim.Database/Services/CurrencySpendService.cs @@ -0,0 +1,51 @@ +using SVSim.Database.Models; + +namespace SVSim.Database.Services; + +public class CurrencySpendService : ICurrencySpendService +{ + private readonly IViewerEntitlements _entitlements; + + public CurrencySpendService(IViewerEntitlements entitlements) => _entitlements = entitlements; + + public Task TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default) + { + if (cost < 0) cost = 0; + + // Freeplay bypass applies only to the three main currencies; SpotPoint always real. + if (_entitlements.IsFreeplay && currency != SpendCurrency.SpotPoint) + { + return Task.FromResult(new SpendResult( + SpendOutcome.Success, _entitlements.EffectiveBalance(viewer, currency))); + } + + ulong current = GetBalance(viewer, currency); + if (current < (ulong)cost) + return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current)); + + ulong post = current - (ulong)cost; + SetBalance(viewer, currency, post); + return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post)); + } + + private static ulong GetBalance(Viewer v, SpendCurrency c) => c switch + { + SpendCurrency.Crystal => v.Currency.Crystals, + SpendCurrency.Rupee => v.Currency.Rupees, + SpendCurrency.RedEther => v.Currency.RedEther, + SpendCurrency.SpotPoint => v.Currency.SpotPoints, + _ => throw new ArgumentOutOfRangeException(nameof(c)), + }; + + private static void SetBalance(Viewer v, SpendCurrency c, ulong value) + { + switch (c) + { + case SpendCurrency.Crystal: v.Currency.Crystals = value; break; + case SpendCurrency.Rupee: v.Currency.Rupees = value; break; + case SpendCurrency.RedEther: v.Currency.RedEther = value; break; + case SpendCurrency.SpotPoint: v.Currency.SpotPoints = value; break; + default: throw new ArgumentOutOfRangeException(nameof(c)); + } + } +} diff --git a/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs b/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs new file mode 100644 index 0000000..f506b66 --- /dev/null +++ b/SVSim.UnitTests/Services/CurrencySpendServiceTests.cs @@ -0,0 +1,91 @@ +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services; + +namespace SVSim.UnitTests.Services; + +public class CurrencySpendServiceTests +{ + private sealed class FakeEntitlements : IViewerEntitlements + { + public bool IsFreeplay { get; init; } + public long FreeplayAmount { get; init; } = 99999; + + public long EffectiveBalance(Viewer viewer, SpendCurrency currency) + { + if (IsFreeplay && currency != SpendCurrency.SpotPoint) return FreeplayAmount; + return currency switch + { + SpendCurrency.Crystal => (long)viewer.Currency.Crystals, + SpendCurrency.Rupee => (long)viewer.Currency.Rupees, + SpendCurrency.RedEther => (long)viewer.Currency.RedEther, + SpendCurrency.SpotPoint => (long)viewer.Currency.SpotPoints, + _ => 0, + }; + } + public bool OwnsCard(Viewer viewer, long cardId) => IsFreeplay; + public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id) => IsFreeplay; + public Task> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default) + => Task.FromResult>(new List()); + public Task EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default) + => throw new NotSupportedException(); + } + + private static Viewer NewViewer() => new() { Currency = new ViewerCurrency() }; + + [Test] + public async Task Normal_deducts_and_returns_post_state() + { + var v = NewViewer(); + v.Currency.Crystals = 250; + var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = false }); + + var r = await svc.TrySpendAsync(v, SpendCurrency.Crystal, 100); + + Assert.That(r.Success, Is.True); + Assert.That(r.PostStateTotal, Is.EqualTo(150)); + Assert.That(v.Currency.Crystals, Is.EqualTo(150UL)); + } + + [Test] + public async Task Normal_insufficient_does_not_deduct() + { + var v = NewViewer(); + v.Currency.Rupees = 50; + var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = false }); + + var r = await svc.TrySpendAsync(v, SpendCurrency.Rupee, 100); + + Assert.That(r.Success, Is.False); + Assert.That(r.Outcome, Is.EqualTo(SpendOutcome.Insufficient)); + Assert.That(v.Currency.Rupees, Is.EqualTo(50UL), "no deduction on insufficient funds"); + } + + [Test] + public async Task Freeplay_main_currency_succeeds_without_deducting() + { + var v = NewViewer(); + v.Currency.Crystals = 10; + var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = true }); + + var r = await svc.TrySpendAsync(v, SpendCurrency.Crystal, 100000); + + Assert.That(r.Success, Is.True, "freeplay never blocks on affordability"); + Assert.That(r.PostStateTotal, Is.EqualTo(99999), "post-state shows the freeplay balance"); + Assert.That(v.Currency.Crystals, Is.EqualTo(10UL), "DB balance untouched in freeplay"); + } + + [Test] + public async Task Freeplay_spot_points_still_deduct() + { + var v = NewViewer(); + v.Currency.SpotPoints = 300; + var svc = new CurrencySpendService(new FakeEntitlements { IsFreeplay = true }); + + var r = await svc.TrySpendAsync(v, SpendCurrency.SpotPoint, 100); + + Assert.That(r.Success, Is.True); + Assert.That(r.PostStateTotal, Is.EqualTo(200)); + Assert.That(v.Currency.SpotPoints, Is.EqualTo(200UL), "spot points are real even in freeplay"); + } +}