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)