From 45fa3d75bf703c79cdd5ef2462867e9c147533d8 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 16:23:50 -0400 Subject: [PATCH] refactor(leader-skin): route shop through InventoryService Co-Authored-By: Claude Opus 4.7 --- .../Controllers/LeaderSkinController.cs | 239 +++++++----------- 1 file changed, 93 insertions(+), 146 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs index 33239d3..0024540 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs @@ -5,6 +5,7 @@ using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin; @@ -29,19 +30,15 @@ namespace SVSim.EmulatedEntrypoint.Controllers; public class LeaderSkinController : SVSimController { private readonly SVSimDbContext _db; - private readonly RewardGrantService _rewards; + private readonly IInventoryService _inv; private readonly TimeProvider _time; - private readonly ICurrencySpendService _spend; - private readonly IViewerEntitlements _entitlements; private readonly ICollectionRepository _collection; - public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection) + public LeaderSkinController(SVSimDbContext db, IInventoryService inv, TimeProvider time, ICollectionRepository collection) { _db = db; - _rewards = rewards; + _inv = inv; _time = time; - _spend = spend; - _entitlements = entitlements; _collection = collection; } @@ -69,7 +66,8 @@ public class LeaderSkinController : SVSimController var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId); if (skin is null) return BadRequest(new { error = "unknown_skin" }); if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" }); - if (!_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, skin.Id)) + var cosmeticsForSet = await _inv.EffectiveCosmeticsAsync(viewer); + if (!cosmeticsForSet.OwnedLeaderSkinIds.Contains(skin.Id)) return BadRequest(new { error = "skin_not_owned" }); classData.LeaderSkin = skin; @@ -88,18 +86,13 @@ public class LeaderSkinController : SVSimController { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - if (_entitlements.IsFreeplay) - { - var all = (await _collection.GetLeaderSkins()).Select(s => s.Id).OrderBy(id => id).ToList(); - return new LeaderSkinIdsResponse { UserLeaderSkinIds = all }; - } - - var ids = await _db.Viewers - .Where(v => v.Id == viewerId) - .SelectMany(v => v.LeaderSkins.Select(s => s.Id)) - .OrderBy(id => id) - .ToListAsync(); + var viewer = await _db.Viewers + .Include(v => v.LeaderSkins) + .FirstOrDefaultAsync(v => v.Id == viewerId); + if (viewer is null) return Unauthorized(); + var cosmetics = await _inv.EffectiveCosmeticsAsync(viewer); + var ids = cosmetics.OwnedLeaderSkinIds.OrderBy(id => id).ToList(); return new LeaderSkinIdsResponse { UserLeaderSkinIds = ids }; } @@ -108,12 +101,13 @@ public class LeaderSkinController : SVSimController { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); - var ownedSkinIds = _entitlements.IsFreeplay - ? (await _collection.GetLeaderSkins()).Select(s => s.Id).ToHashSet() - : (await _db.Viewers - .Where(v => v.Id == viewerId) - .SelectMany(v => v.LeaderSkins.Select(s => s.Id)) - .ToListAsync()).ToHashSet(); + var viewerForProducts = await _db.Viewers + .Include(v => v.LeaderSkins) + .FirstOrDefaultAsync(v => v.Id == viewerId); + if (viewerForProducts is null) return Unauthorized(); + + var cosmeticsForProducts = await _inv.EffectiveCosmeticsAsync(viewerForProducts); + var ownedSkinIds = cosmeticsForProducts.OwnedLeaderSkinIds; var claimedSeries = (await _db.ViewerLeaderSkinSetClaims .Where(c => c.ViewerId == viewerId) @@ -183,21 +177,41 @@ public class LeaderSkinController : SVSimController if (!product.IsEnabled || product.Series is not { IsEnabled: true }) return BadRequest(new { error = "product_not_available" }); - var viewer = await LoadViewerGraphAsync(viewerId); + await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted); // Already-purchased = viewer owns the leader_skin this product grants. - if (_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, product.LeaderSkinId)) + if (tx.OwnsCosmetic(CosmeticType.Skin, product.LeaderSkinId)) return BadRequest(new { error = "already_purchased" }); - var rewardList = new List(); - var debit = await DebitProductPrice(viewer, product, request.SalesType); - if (debit.Error is not null) return BadRequest(new { error = debit.Error }); - if (debit.PostState is not null) rewardList.Add(debit.PostState); + // Debit currency + switch (request.SalesType) + { + case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0: + break; // free + case 0: + return BadRequest(new { error = "price_not_available_for_currency" }); + case 1: + if (product.SinglePriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" }); + { var r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.SinglePriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); } + break; + case 2: + if (product.SinglePriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" }); + { var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.SinglePriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); } + break; + default: + return BadRequest(new { error = "invalid_sales_type" }); + } - await ApplyRewardsAsync(viewer, product.Rewards, rewardList); + foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex)) + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); - await _db.SaveChangesAsync(); - return new LeaderSkinBuyResponse { RewardList = rewardList }; + var result = await tx.CommitAsync(HttpContext.RequestAborted); + return new LeaderSkinBuyResponse + { + RewardList = result.RewardList + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(), + }; } [HttpPost("buy_set")] @@ -218,25 +232,44 @@ public class LeaderSkinController : SVSimController if (!series.IsEnabled || series.SetSalesStatus == 0) return BadRequest(new { error = "set_sale_not_active" }); - var viewer = await LoadViewerGraphAsync(viewerId); + await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted); - if (_entitlements.IsFreeplay) + if (tx.IsFreeplay) return BadRequest(new { error = "already_purchased" }); - var rewardList = new List(); - var debit = await DebitSetPrice(viewer, series, request.SalesType); - if (debit.Error is not null) return BadRequest(new { error = debit.Error }); - if (debit.PostState is not null) rewardList.Add(debit.PostState); - - // Grant every product's rewards; RewardGrantService is idempotent on already-owned - // cosmetics, so partial-set buyers don't double-add. - foreach (var p in series.Products.OrderBy(p => p.Id)) + // Debit set price + switch (request.SalesType) { - await ApplyRewardsAsync(viewer, p.Rewards, rewardList); + case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0: + break; // free + case 0: + return BadRequest(new { error = "price_not_available_for_currency" }); + case 1: + if (series.SetPriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" }); + { var r = await tx.TrySpendAsync(SpendCurrency.Crystal, series.SetPriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); } + break; + case 2: + if (series.SetPriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" }); + { var r = await tx.TrySpendAsync(SpendCurrency.Rupee, series.SetPriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); } + break; + default: + return BadRequest(new { error = "invalid_sales_type" }); } - await _db.SaveChangesAsync(); - return new LeaderSkinBuyResponse { RewardList = rewardList }; + // Grant every product's rewards; tx.GrantAsync is idempotent on already-owned cosmetics. + foreach (var p in series.Products.OrderBy(p => p.Id)) + { + foreach (var r in p.Rewards.OrderBy(r => r.OrderIndex)) + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); + } + + var result = await tx.CommitAsync(HttpContext.RequestAborted); + return new LeaderSkinBuyResponse + { + RewardList = result.RewardList + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(), + }; } [HttpPost("buy_set_item")] @@ -257,16 +290,15 @@ public class LeaderSkinController : SVSimController if (existingClaim is not null) return new LeaderSkinBuyResponse { RewardList = new() }; - var viewer = await LoadViewerGraphAsync(viewerId); + await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted); // Must own every skin in the series to claim the bonus. - var ownedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet(); - bool ownsAll = series.Products.Count > 0 && series.Products.All(p => ownedSkinIds.Contains(p.LeaderSkinId)); + bool ownsAll = series.Products.Count > 0 && series.Products.All(p => tx.OwnsCosmetic(CosmeticType.Skin, p.LeaderSkinId)); if (!ownsAll) return BadRequest(new { error = "series_not_completed" }); - var rewardList = new List(); - await ApplyRewardsAsync(viewer, series.SetCompletionRewards, rewardList); + foreach (var r in series.SetCompletionRewards.OrderBy(r => r.OrderIndex)) + await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); _db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim { @@ -275,8 +307,13 @@ public class LeaderSkinController : SVSimController ClaimedAt = _time.GetUtcNow().UtcDateTime, }); - await _db.SaveChangesAsync(); - return new LeaderSkinBuyResponse { RewardList = rewardList }; + var result = await tx.CommitAsync(HttpContext.RequestAborted); + return new LeaderSkinBuyResponse + { + RewardList = result.RewardList + .Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum }) + .ToList(), + }; } /// @@ -304,7 +341,7 @@ public class LeaderSkinController : SVSimController return 1; } - private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, HashSet ownedSkinIds) + private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, IReadOnlySet ownedSkinIds) { bool isPurchased = ownedSkinIds.Contains(p.LeaderSkinId); return new SkinProductDto @@ -339,7 +376,7 @@ public class LeaderSkinController : SVSimController /// emblem/sleeve typically come with the skin, so the heuristic is "skin owned → all three /// bundle items are de-facto owned." Refine later if a capture shows independent state. /// - private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, HashSet ownedSkinIds) + private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, IReadOnlySet ownedSkinIds) { // Skin reward: direct check. if (r.RewardType == (int)UserGoodsType.Skin) @@ -350,94 +387,4 @@ public class LeaderSkinController : SVSimController return false; } - private async Task<(RewardListEntry? PostState, string? Error)> DebitProductPrice( - Viewer viewer, LeaderSkinShopProductEntry product, int salesType) - { - switch (salesType) - { - case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0: - return (null, null); - case 0: - return (null, "price_not_available_for_currency"); - case 1: - if (product.SinglePriceCrystal is null) return (null, "price_not_available_for_currency"); - return await DebitCrystal(viewer, product.SinglePriceCrystal.Value); - case 2: - if (product.SinglePriceRupy is null) return (null, "price_not_available_for_currency"); - return await DebitRupy(viewer, product.SinglePriceRupy.Value); - default: - return (null, "invalid_sales_type"); - } - } - - private async Task<(RewardListEntry? PostState, string? Error)> DebitSetPrice( - Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType) - { - switch (salesType) - { - case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0: - return (null, null); - case 0: - return (null, "price_not_available_for_currency"); - case 1: - if (series.SetPriceCrystal is null) return (null, "price_not_available_for_currency"); - return await DebitCrystal(viewer, series.SetPriceCrystal.Value); - case 2: - if (series.SetPriceRupy is null) return (null, "price_not_available_for_currency"); - return await DebitRupy(viewer, series.SetPriceRupy.Value); - default: - return (null, "invalid_sales_type"); - } - } - - private async Task<(RewardListEntry?, string?)> DebitCrystal(Viewer viewer, int amount) - { - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, amount); - if (!r.Success) return (null, "insufficient_crystals"); - return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null); - } - - private async Task<(RewardListEntry?, string?)> DebitRupy(Viewer viewer, int amount) - { - var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, amount); - if (!r.Success) return (null, "insufficient_rupees"); - return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null); - } - - private async Task ApplyRewardsAsync( - Viewer viewer, IEnumerable rewards, List rewardList) where T : notnull - { - foreach (var r in rewards) - { - var (type, detailId, number) = ExtractTuple(r); - var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)type, detailId, number); - foreach (var g in granted) - { - rewardList.Add(new RewardListEntry - { - RewardType = g.RewardType, - RewardId = g.RewardId, - RewardNum = g.RewardNum, - }); - } - } - } - - private static (int Type, long Id, int Num) ExtractTuple(object reward) => reward switch - { - LeaderSkinShopProductRewardEntry p => (p.RewardType, p.RewardDetailId, p.RewardNumber), - LeaderSkinShopSeriesRewardEntry s => (s.RewardType, s.RewardDetailId, s.RewardNumber), - _ => throw new InvalidOperationException($"unexpected reward type {reward.GetType().Name}"), - }; - - private Task LoadViewerGraphAsync(long viewerId) => _db.Viewers - .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.Cards).ThenInclude(c => c.Card) - .AsSplitQuery() - .FirstAsync(v => v.Id == viewerId); }