From 57dd524d9fc4a8291c8667952f0b5643441e1211 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:17:31 -0400 Subject: [PATCH] refactor(build-deck): route Buy through InventoryService Co-Authored-By: Claude Opus 4.7 --- .../Controllers/BuildDeckController.cs | 115 ++++-------------- 1 file changed, 25 insertions(+), 90 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs b/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs index 01e12df..414befd 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs @@ -1,10 +1,9 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.BuildDeck; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck; @@ -20,39 +19,16 @@ namespace SVSim.EmulatedEntrypoint.Controllers; public class BuildDeckController : SVSimController { private readonly IBuildDeckRepository _repo; - private readonly SVSimDbContext _db; - private readonly RewardGrantService _rewards; - private readonly ICurrencySpendService _spend; + private readonly IInventoryService _inv; public BuildDeckController( IBuildDeckRepository repo, - SVSimDbContext db, - RewardGrantService rewards, - ICurrencySpendService spend) + IInventoryService inv) { _repo = repo; - _db = db; - _rewards = rewards; - _spend = spend; + _inv = inv; } - /// - /// Loads the viewer with the full cosmetic / inventory graph + BuildDeckPurchases. This is - /// the single load /build_deck/buy makes; every subsequent mutation operates on the returned - /// instance and the controller saves once at the end. - /// - private Task LoadViewerGraphAsync(long viewerId) => _db.Viewers - .Include(v => v.Cards).ThenInclude(c => c.Card) - .Include(v => v.LeaderSkins) - .Include(v => v.Sleeves) - .Include(v => v.Emblems) - .Include(v => v.Degrees) - .Include(v => v.MyPageBackgrounds) - .Include(v => v.Items).ThenInclude(i => i.Item) - .Include(v => v.BuildDeckPurchases) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); - // The wire shape for /build_deck/info has `data` as a bare collection of series, not a // DTO with a `series_list` field. The client (BuildDeckPurchaseInfoTask.Parse) iterates // `data` directly via numeric indexer: @@ -194,60 +170,45 @@ public class BuildDeckController : SVSimController break; } - // Single viewer load with the full graph — every subsequent mutation (currency debit, - // purchase counter, card grants, cosmetic grants) operates on this one in-memory instance - // so we can save once at the end. - var viewer = await LoadViewerGraphAsync(viewerId); - var rewardList = new List(); + // Open the inventory transaction — loads canonical graph + BuildDeckPurchases. + await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, + cfg => cfg.WithInclude(v => v.BuildDeckPurchases)); + var viewer = tx.Viewer; - // Debit + post-state currency entry + // Debit currency if (request.SalesType == 1) { - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, priceCrystal!.Value); + var r = await tx.TrySpendAsync(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) { - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, priceRupy!.Value); + var r = await tx.TrySpendAsync(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 + // sales_type == 0 (free): no debit // Compute series purchase total BEFORE this buy int prevSeriesCount = product.Series!.Products .Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0); int newSeriesCount = prevSeriesCount + 1; - // Increment purchase counter directly on the tracked viewer (we already loaded - // BuildDeckPurchases via LoadViewerGraphAsync). The repo's IncrementPurchaseCount would - // re-attach to the same instance and trigger an extra save — inlining keeps the - // controller's single-save model intact. + // Increment purchase counter on tx.Viewer (tx loaded BuildDeckPurchases via WithInclude). var purchaseRow = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == product.Id); if (purchaseRow is null) viewer.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = product.Id, PurchaseCount = 1 }); else purchaseRow.PurchaseCount += 1; - // Grant the 40 deck cards. Bucket by CardId so duplicate (CardId, IsSpot) rows don't - // emit redundant reward_list entries — ApplyAsync(Card, ...) runs the cosmetic cascade - // and returns a post-state-total entry per call. - var deckGrants = product.Cards - .GroupBy(c => c.CardId) - .Select(g => (UserGoodsType.Card, g.Key, g.Sum(c => c.Number))); - await ApplyRewardsAsync(viewer, deckGrants, rewardList); + // Grant deck cards (grouped by CardId) + foreach (var grp in product.Cards.GroupBy(c => c.CardId)) + await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Sum(c => c.Number)); - // Per-buy rewards from the product catalog: sleeve, emblem, skin, sometimes extra cards - // (Set 4 grants 3 copies of the featured card as a type=5 reward). - await ApplyRewardsAsync(viewer, product.Rewards - .OrderBy(r => r.RewardIndex) - .Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)), - rewardList); + // Per-buy rewards + foreach (var r in product.Rewards.OrderBy(r => r.RewardIndex)) + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); - // Series-reward tier crossings: tiers where prevSeriesCount < TierIndex <= newSeriesCount. - // Captured tiers include type 4 (Item), 5 (Card), 6 (Sleeve), 7 (Emblem) — granting them - // all uniformly avoids the earlier card-only path that dropped non-card tier rewards. + // Series-reward tier crossings var crossedTiers = product.Series.SeriesRewards .Where(r => r.TierIndex > prevSeriesCount && r.TierIndex <= newSeriesCount) .GroupBy(r => r.TierIndex) @@ -257,13 +218,9 @@ public class BuildDeckController : SVSimController var seriesRewards = new List(); foreach (var tier in crossedTiers) { - await ApplyRewardsAsync(viewer, tier - .OrderBy(r => r.ItemIndex) - .Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)), - rewardList); - foreach (var item in tier.OrderBy(r => r.ItemIndex)) { + await tx.GrantAsync((UserGoodsType)item.RewardType, item.RewardDetailId, item.RewardNumber); seriesRewards.Add(new BuildDeckProductRewardDto { RewardType = item.RewardType, @@ -274,39 +231,17 @@ public class BuildDeckController : SVSimController } } - await _db.SaveChangesAsync(); + var result = await tx.CommitAsync(HttpContext.RequestAborted); return new BuildDeckBuyResponse { - RewardList = rewardList, + RewardList = result.RewardList + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(), SeriesRewards = seriesRewards, }; } - /// - /// Dispatches each (type, id, num) tuple through - /// and appends the resulting wire entries to . Caller saves. - /// - private async Task ApplyRewardsAsync( - Viewer viewer, - IEnumerable<(UserGoodsType Type, long DetailId, int Number)> rewards, - List rewardList) - { - foreach (var (type, detailId, number) in rewards) - { - var granted = await _rewards.ApplyAsync(viewer, type, detailId, number); - foreach (var g in granted) - { - rewardList.Add(new RewardListEntry - { - RewardType = g.RewardType, - RewardId = g.RewardId, - RewardNum = g.RewardNum, - }); - } - } - } - [HttpPost("get_purchase_count")] public async Task> GetPurchaseCount( BuildDeckGetPurchaseCountRequest request)