diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 98f7984..1f97f0c 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -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); diff --git a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs index 5c2f2b2..f0ca05d 100644 --- a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs @@ -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(); + 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() {