refactor(build-deck): route Buy through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using SVSim.Database;
|
|
||||||
using SVSim.Database.Enums;
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
using SVSim.Database.Repositories.BuildDeck;
|
using SVSim.Database.Repositories.BuildDeck;
|
||||||
using SVSim.Database.Services;
|
using SVSim.Database.Services;
|
||||||
|
using SVSim.Database.Services.Inventory;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck;
|
||||||
@@ -20,39 +19,16 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
|||||||
public class BuildDeckController : SVSimController
|
public class BuildDeckController : SVSimController
|
||||||
{
|
{
|
||||||
private readonly IBuildDeckRepository _repo;
|
private readonly IBuildDeckRepository _repo;
|
||||||
private readonly SVSimDbContext _db;
|
private readonly IInventoryService _inv;
|
||||||
private readonly RewardGrantService _rewards;
|
|
||||||
private readonly ICurrencySpendService _spend;
|
|
||||||
|
|
||||||
public BuildDeckController(
|
public BuildDeckController(
|
||||||
IBuildDeckRepository repo,
|
IBuildDeckRepository repo,
|
||||||
SVSimDbContext db,
|
IInventoryService inv)
|
||||||
RewardGrantService rewards,
|
|
||||||
ICurrencySpendService spend)
|
|
||||||
{
|
{
|
||||||
_repo = repo;
|
_repo = repo;
|
||||||
_db = db;
|
_inv = inv;
|
||||||
_rewards = rewards;
|
|
||||||
_spend = spend;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private Task<Viewer> 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
|
// 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
|
// DTO with a `series_list` field. The client (BuildDeckPurchaseInfoTask.Parse) iterates
|
||||||
// `data` directly via numeric indexer:
|
// `data` directly via numeric indexer:
|
||||||
@@ -194,60 +170,45 @@ public class BuildDeckController : SVSimController
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single viewer load with the full graph — every subsequent mutation (currency debit,
|
// Open the inventory transaction — loads canonical graph + BuildDeckPurchases.
|
||||||
// purchase counter, card grants, cosmetic grants) operates on this one in-memory instance
|
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted,
|
||||||
// so we can save once at the end.
|
cfg => cfg.WithInclude(v => v.BuildDeckPurchases));
|
||||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
var viewer = tx.Viewer;
|
||||||
var rewardList = new List<RewardListEntry>();
|
|
||||||
|
|
||||||
// Debit + post-state currency entry
|
// Debit currency
|
||||||
if (request.SalesType == 1)
|
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" });
|
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)
|
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" });
|
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
|
// Compute series purchase total BEFORE this buy
|
||||||
int prevSeriesCount = product.Series!.Products
|
int prevSeriesCount = product.Series!.Products
|
||||||
.Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0);
|
.Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0);
|
||||||
int newSeriesCount = prevSeriesCount + 1;
|
int newSeriesCount = prevSeriesCount + 1;
|
||||||
|
|
||||||
// Increment purchase counter directly on the tracked viewer (we already loaded
|
// Increment purchase counter on tx.Viewer (tx loaded BuildDeckPurchases via WithInclude).
|
||||||
// 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.
|
|
||||||
var purchaseRow = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == product.Id);
|
var purchaseRow = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == product.Id);
|
||||||
if (purchaseRow is null)
|
if (purchaseRow is null)
|
||||||
viewer.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = product.Id, PurchaseCount = 1 });
|
viewer.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = product.Id, PurchaseCount = 1 });
|
||||||
else
|
else
|
||||||
purchaseRow.PurchaseCount += 1;
|
purchaseRow.PurchaseCount += 1;
|
||||||
|
|
||||||
// Grant the 40 deck cards. Bucket by CardId so duplicate (CardId, IsSpot) rows don't
|
// Grant deck cards (grouped by CardId)
|
||||||
// emit redundant reward_list entries — ApplyAsync(Card, ...) runs the cosmetic cascade
|
foreach (var grp in product.Cards.GroupBy(c => c.CardId))
|
||||||
// and returns a post-state-total entry per call.
|
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Sum(c => c.Number));
|
||||||
var deckGrants = product.Cards
|
|
||||||
.GroupBy(c => c.CardId)
|
|
||||||
.Select(g => (UserGoodsType.Card, g.Key, g.Sum(c => c.Number)));
|
|
||||||
await ApplyRewardsAsync(viewer, deckGrants, rewardList);
|
|
||||||
|
|
||||||
// Per-buy rewards from the product catalog: sleeve, emblem, skin, sometimes extra cards
|
// Per-buy rewards
|
||||||
// (Set 4 grants 3 copies of the featured card as a type=5 reward).
|
foreach (var r in product.Rewards.OrderBy(r => r.RewardIndex))
|
||||||
await ApplyRewardsAsync(viewer, product.Rewards
|
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||||
.OrderBy(r => r.RewardIndex)
|
|
||||||
.Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)),
|
|
||||||
rewardList);
|
|
||||||
|
|
||||||
// Series-reward tier crossings: tiers where prevSeriesCount < TierIndex <= newSeriesCount.
|
// Series-reward tier crossings
|
||||||
// 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.
|
|
||||||
var crossedTiers = product.Series.SeriesRewards
|
var crossedTiers = product.Series.SeriesRewards
|
||||||
.Where(r => r.TierIndex > prevSeriesCount && r.TierIndex <= newSeriesCount)
|
.Where(r => r.TierIndex > prevSeriesCount && r.TierIndex <= newSeriesCount)
|
||||||
.GroupBy(r => r.TierIndex)
|
.GroupBy(r => r.TierIndex)
|
||||||
@@ -257,13 +218,9 @@ public class BuildDeckController : SVSimController
|
|||||||
var seriesRewards = new List<BuildDeckProductRewardDto>();
|
var seriesRewards = new List<BuildDeckProductRewardDto>();
|
||||||
foreach (var tier in crossedTiers)
|
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))
|
foreach (var item in tier.OrderBy(r => r.ItemIndex))
|
||||||
{
|
{
|
||||||
|
await tx.GrantAsync((UserGoodsType)item.RewardType, item.RewardDetailId, item.RewardNumber);
|
||||||
seriesRewards.Add(new BuildDeckProductRewardDto
|
seriesRewards.Add(new BuildDeckProductRewardDto
|
||||||
{
|
{
|
||||||
RewardType = item.RewardType,
|
RewardType = item.RewardType,
|
||||||
@@ -274,39 +231,17 @@ public class BuildDeckController : SVSimController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||||
|
|
||||||
return new BuildDeckBuyResponse
|
return new BuildDeckBuyResponse
|
||||||
{
|
{
|
||||||
RewardList = rewardList,
|
RewardList = result.RewardList
|
||||||
|
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||||
|
.ToList(),
|
||||||
SeriesRewards = seriesRewards,
|
SeriesRewards = seriesRewards,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dispatches each (type, id, num) tuple through <see cref="RewardGrantService.ApplyAsync"/>
|
|
||||||
/// and appends the resulting wire entries to <paramref name="rewardList"/>. Caller saves.
|
|
||||||
/// </summary>
|
|
||||||
private async Task ApplyRewardsAsync(
|
|
||||||
Viewer viewer,
|
|
||||||
IEnumerable<(UserGoodsType Type, long DetailId, int Number)> rewards,
|
|
||||||
List<RewardListEntry> 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")]
|
[HttpPost("get_purchase_count")]
|
||||||
public async Task<ActionResult<BuildDeckGetPurchaseCountResponse>> GetPurchaseCount(
|
public async Task<ActionResult<BuildDeckGetPurchaseCountResponse>> GetPurchaseCount(
|
||||||
BuildDeckGetPurchaseCountRequest request)
|
BuildDeckGetPurchaseCountRequest request)
|
||||||
|
|||||||
Reference in New Issue
Block a user