From e1f5b9b6c3d74072a8eccc90ca56775ae441c2a7 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 28 May 2026 23:37:00 -0400 Subject: [PATCH] feat(pack): gacha-point exchange (debit + grant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements GachaPointService.TryExchangeAsync: validates pack exchangeability, balance >= threshold, card in catalog, not already received; debits balance, marks received, grants the card through RewardGrantService (cascade handles cosmetics). Re-adds the RewardGrantService injection that was removed in the Task 3 fix-up (matches the "inject when you call" convention). Card grant produces the wire-shape reward_list directly via the cosmetic cascade — the catalog's reward_list remains the display-only shape for /pack/get_gacha_point_rewards. Co-Authored-By: Claude Opus 4.7 --- .../Services/GachaPointService.cs | 58 +++++++- .../Services/GachaPointServiceTests.cs | 136 ++++++++++++++++++ 2 files changed, 191 insertions(+), 3 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs b/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs index a4717fd..9ab0eb4 100644 --- a/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs +++ b/SVSim.EmulatedEntrypoint/Services/GachaPointService.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; +using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos; namespace SVSim.EmulatedEntrypoint.Services; @@ -10,11 +11,13 @@ public sealed class GachaPointService : IGachaPointService { private readonly SVSimDbContext _db; private readonly ICardPoolProvider _pools; + private readonly RewardGrantService _grants; - public GachaPointService(SVSimDbContext db, ICardPoolProvider pools) + public GachaPointService(SVSimDbContext db, ICardPoolProvider pools, RewardGrantService grants) { _db = db; _pools = pools; + _grants = grants; } public async Task> GetRewardsAsync(int packId, long viewerId) @@ -149,6 +152,55 @@ public sealed class GachaPointService : IGachaPointService } } - public Task TryExchangeAsync(Viewer viewer, int packId, long cardId) - => throw new NotImplementedException(); + public async Task TryExchangeAsync(Viewer viewer, int packId, long cardId) + { + var pack = await _db.Packs.FirstOrDefaultAsync(p => p.Id == packId); + if (pack?.GachaPointConfig is null) + return ExchangeOutcome.Fail("pack_not_exchangeable"); + + int threshold = pack.GachaPointConfig.ExchangeablePoint; + var balance = viewer.GachaPointBalances.FirstOrDefault(b => b.PackId == packId); + int currentPoints = balance?.Points ?? 0; + if (currentPoints < threshold) + return ExchangeOutcome.Fail("insufficient_gacha_points"); + + // Validate the card is in the catalog by re-running GetRewardsAsync. This re-uses the + // same eligibility rules (in-pool + Legendary + has Emblem cosmetic) without + // duplicating them — and naturally excludes ticket-only packs whose pool we already + // hide from /pack/info. + var catalog = await GetRewardsAsync(packId, viewer.Id); + var entry = catalog.FirstOrDefault(e => e.CardId == cardId); + if (entry is null) + return ExchangeOutcome.Fail("card_not_exchangeable"); + + if (viewer.GachaPointReceived.Any(r => r.PackId == packId && r.CardId == cardId)) + return ExchangeOutcome.Fail("already_received"); + + // Debit balance + mark received. + balance!.Points -= threshold; + viewer.GachaPointReceived.Add(new ViewerGachaPointReceived + { + PackId = packId, CardId = cardId, ReceivedAt = DateTime.UtcNow, + }); + + // Grant the card itself through RewardGrantService — its CardCosmeticReward cascade + // covers the Emblem (standard legendary) or Skin+Emblem (leader) the catalog + // advertised. The catalog's reward_list is a wire-shape *display* (what the player + // sees on /pack/get_gacha_point_rewards) — the actual grant uses the canonical + // primitive per feedback_reward_grant_service. For leader-card exchanges the catalog + // also advertises a synthetic Sleeve(=card_id) entry, but that's not in + // CardCosmeticRewards; if a capture ever shows leader exchanges granting a sleeve + // row, add that here. Today no leader exchange has been captured. + var granted = await _grants.ApplyAsync(viewer, UserGoodsType.Card, cardId, 1); + var rewardList = new List(); + foreach (var g in granted) + { + rewardList.Add(new RewardListEntry + { + RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum, + }); + } + + return ExchangeOutcome.Ok(rewardList); + } } diff --git a/SVSim.UnitTests/Services/GachaPointServiceTests.cs b/SVSim.UnitTests/Services/GachaPointServiceTests.cs index 9d23850..c545a55 100644 --- a/SVSim.UnitTests/Services/GachaPointServiceTests.cs +++ b/SVSim.UnitTests/Services/GachaPointServiceTests.cs @@ -322,4 +322,140 @@ public class GachaPointServiceTests "second Accrue must update the existing row, not create a duplicate"); Assert.That(viewer.GachaPointBalances.Single().Points, Is.EqualTo(8)); } + + [Test] + public async Task TryExchange_fails_when_balance_below_threshold() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); + + var viewer = await db.Viewers + .Include(v => v.GachaPointBalances) + .FirstAsync(v => v.Id == viewerId); + viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 399 }); + await db.SaveChangesAsync(); + + var svc = scope.ServiceProvider.GetRequiredService(); + var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010); + + Assert.That(outcome.Success, Is.False); + Assert.That(outcome.Error, Is.EqualTo("insufficient_gacha_points")); + } + + [Test] + public async Task TryExchange_fails_when_card_not_in_pack_catalog() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); + + var viewer = await db.Viewers + .Include(v => v.GachaPointBalances) + .FirstAsync(v => v.Id == viewerId); + viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 400 }); + await db.SaveChangesAsync(); + + var svc = scope.ServiceProvider.GetRequiredService(); + var outcome = await svc.TryExchangeAsync(viewer, 10008, cardId: 999999999); // not in pool + + Assert.That(outcome.Success, Is.False); + Assert.That(outcome.Error, Is.EqualTo("card_not_exchangeable")); + } + + [Test] + public async Task TryExchange_fails_when_card_already_received() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); + + var viewer = await db.Viewers + .Include(v => v.GachaPointBalances) + .Include(v => v.GachaPointReceived) + .FirstAsync(v => v.Id == viewerId); + viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 400 }); + viewer.GachaPointReceived.Add(new ViewerGachaPointReceived + { + PackId = 10008, CardId = 108041010, ReceivedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + + var svc = scope.ServiceProvider.GetRequiredService(); + var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010); + + Assert.That(outcome.Success, Is.False); + Assert.That(outcome.Error, Is.EqualTo("already_received")); + } + + [Test] + public async Task TryExchange_succeeds_debits_marks_received_and_returns_grants() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + SeedPackWithOneLegendary(db, packId: 10008, threshold: 400); + + var viewer = await db.Viewers + .Include(v => v.GachaPointBalances) + .Include(v => v.GachaPointReceived) + .Include(v => v.Cards) + .Include(v => v.Emblems) + .FirstAsync(v => v.Id == viewerId); + viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 500 }); + await db.SaveChangesAsync(); + + var svc = scope.ServiceProvider.GetRequiredService(); + var outcome = await svc.TryExchangeAsync(viewer, 10008, 108041010); + await db.SaveChangesAsync(); + + Assert.That(outcome.Success, Is.True); + + // Balance debited. + Assert.That(viewer.GachaPointBalances.Single().Points, Is.EqualTo(100)); + + // Marker written. + Assert.That(viewer.GachaPointReceived + .Any(r => r.PackId == 10008 && r.CardId == 108041010), Is.True); + + // Reward list non-empty: at minimum the card grant and the gacha-point post-state entry. + Assert.That(outcome.RewardList, Is.Not.Empty); + Assert.That(outcome.RewardList.Any(r => r.RewardType == (int)UserGoodsType.Card && r.RewardId == 108041010), + Is.True, "card grant missing"); + } + + private static void SeedPackWithOneLegendary(SVSimDbContext db, int packId, int threshold) + { + var cls = db.Classes.Find(0) ?? new ClassEntry { Id = 0, Name = "Neutral" }; + if (db.Classes.Find(0) is null) db.Classes.Add(cls); + var set = new ShadowverseCardSetEntry { Id = packId, IsInRotation = true }; + db.CardSets.Add(set); + set.Cards.Add(new ShadowverseCardEntry + { + Id = 108041010, Name = "leg", Rarity = Rarity.Legendary, + Class = cls, IsFoil = false, + }); + db.CardCosmeticRewards.Add(new CardCosmeticReward + { + CardId = 108041010, Type = CosmeticType.Emblem, CosmeticId = 1080410100, + }); + db.Packs.Add(new PackConfigEntry + { + Id = packId, BasePackId = packId, PackCategory = PackCategory.LegendCardPack, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = threshold, IncreaseGachaPoint = 1 }, + }); + db.SaveChanges(); + } }