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.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck; namespace SVSim.EmulatedEntrypoint.Controllers; /// /// /build_deck/* — the in-game "Structure Deck" prebuilt-deck shop. Catalog + /// purchase + per-product purchase counter refresh. See /// docs/superpowers/specs/2026-05-26-prebuilt-decks-design.md. /// [Route("build_deck")] public class BuildDeckController : SVSimController { private readonly IBuildDeckRepository _repo; private readonly SVSimDbContext _db; private readonly RewardGrantService _rewards; public BuildDeckController( IBuildDeckRepository repo, SVSimDbContext db, RewardGrantService rewards) { _repo = repo; _db = db; _rewards = rewards; } /// /// 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: // for (int i = 0; i < data.Count; i++) data[i]["series_id"].ToInt(); // So `data` must be either an array OR an object whose values are series. Wrapping in // `{series_list: [...]}` breaks the iteration: `data.Count` is 1 and `data[0]` is the // inner array, so `data[0]["series_id"]` throws "Instance of JsonData is not a dictionary". // We return a bare array — simpler than the dict-keyed-by-order_id shape prod emits, and // LitJson's numeric indexer iterates both shapes identically. [HttpPost("info")] public async Task>> Info(BuildDeckInfoRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var series = await _repo.GetEnabledCatalog(request.AddSeriesId); var purchases = await _repo.GetPurchasesForViewer(viewerId); return series.Select(s => ToSeriesDto(s, purchases)).ToList(); } private static BuildDeckSeriesDto ToSeriesDto( BuildDeckSeriesEntry s, IReadOnlyDictionary purchases) { int totalSeriesPurchases = s.Products .Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0); return new BuildDeckSeriesDto { SeriesId = s.Id, OrderId = s.OrderIndex, IsNew = s.IsNew, Products = s.Products .OrderBy(p => p.Id) .Select(p => ToProductDto(p, purchases)) .ToList(), SeriesRewards = GroupSeriesRewards(s.SeriesRewards, totalSeriesPurchases), }; } private static BuildDeckProductDto ToProductDto( BuildDeckProductEntry p, IReadOnlyDictionary purchases) { int current = purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0; bool isFirstPrice = current == 0; int? priceCrystal = SelectPrice(isFirstPrice, p.IntroPriceCrystal, p.RegularPriceCrystal); int? priceRupy = SelectPrice(isFirstPrice, p.IntroPriceRupy, p.RegularPriceRupy); return new BuildDeckProductDto { ProductId = p.Id, ProductName = p.ProductNameKey, LeaderId = p.LeaderId, DeckCode = p.DeckCode, FeaturedCardId = p.FeaturedCardId, PurchaseNumMax = p.PurchaseNumMax, PurchaseNumCurrent = current, IsFirstPrice = isFirstPrice, PriceCrystal = priceCrystal, PriceRupy = priceRupy, Rewards = p.Rewards .OrderBy(r => r.RewardIndex) .Select(r => new BuildDeckProductRewardDto { RewardType = r.RewardType, RewardDetailId = r.RewardDetailId, RewardNumber = r.RewardNumber, MessageId = r.MessageId, }).ToList(), }; } private static int? SelectPrice(bool isFirstPrice, int? intro, int? regular) { if (isFirstPrice) return intro ?? regular; // fall back when only one tier known return regular ?? intro; } private static List GroupSeriesRewards( IReadOnlyList rows, int totalSeriesPurchases) { return rows .GroupBy(r => r.TierIndex) .OrderBy(g => g.Key) .Select(g => new BuildDeckSeriesRewardTierDto { IsGet = totalSeriesPurchases >= g.Key, RewardList = g.OrderBy(r => r.ItemIndex).Select(r => new BuildDeckProductRewardDto { RewardType = r.RewardType, RewardDetailId = r.RewardDetailId, RewardNumber = r.RewardNumber, MessageId = r.MessageId, }).ToList(), }).ToList(); } [HttpPost("buy")] public async Task> Buy(BuildDeckBuyRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var product = await _repo.GetProduct(request.ProductId); if (product is null) return NotFound(new { error = "unknown_product" }); if (!product.IsEnabled || product.Series is not { IsEnabled: true }) return BadRequest(new { error = "product_not_available" }); if (request.SalesType is 3) return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_currency_path_not_implemented" }); if (request.SalesType is < 0 or > 3) return BadRequest(new { error = "invalid_sales_type" }); var purchases = await _repo.GetPurchasesForViewer(viewerId); int currentCount = purchases.TryGetValue(product.Id, out var pp) ? pp.PurchaseCount : 0; if (currentCount >= product.PurchaseNumMax) return BadRequest(new { error = "purchase_limit_reached" }); bool isFirstPrice = currentCount == 0; int? priceCrystal = SelectPrice(isFirstPrice, product.IntroPriceCrystal, product.RegularPriceCrystal); int? priceRupy = SelectPrice(isFirstPrice, product.IntroPriceRupy, product.RegularPriceRupy); // Currency validation switch (request.SalesType) { case 0: // free if (!(product.IntroPriceCrystal == 0 && product.IntroPriceRupy == 0)) return BadRequest(new { error = "price_not_available_for_currency" }); break; case 1: // crystal if (priceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" }); break; case 2: // rupy if (priceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" }); 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(); // Debit + post-state currency entry if (request.SalesType == 1) { ulong cost = (ulong)priceCrystal!.Value; if (viewer.Currency.Crystals < cost) return BadRequest(new { error = "insufficient_crystals" }); viewer.Currency.Crystals -= cost; rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }); } else if (request.SalesType == 2) { ulong cost = (ulong)priceRupy!.Value; if (viewer.Currency.Rupees < cost) return BadRequest(new { error = "insufficient_rupees" }); viewer.Currency.Rupees -= cost; rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }); } // sales_type == 0 (free): no debit, no currency entry // 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. 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); // 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); // 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. var crossedTiers = product.Series.SeriesRewards .Where(r => r.TierIndex > prevSeriesCount && r.TierIndex <= newSeriesCount) .GroupBy(r => r.TierIndex) .OrderBy(g => g.Key) .ToList(); 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)) { seriesRewards.Add(new BuildDeckProductRewardDto { RewardType = item.RewardType, RewardDetailId = item.RewardDetailId, RewardNumber = item.RewardNumber, MessageId = item.MessageId, }); } } await _db.SaveChangesAsync(); return new BuildDeckBuyResponse { RewardList = rewardList, 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) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var product = await _repo.GetProduct(request.ProductId); if (product is null) return NotFound(new { error = "unknown_product" }); var purchases = await _repo.GetPurchasesForViewer(viewerId); int current = purchases.TryGetValue(request.ProductId, out var p) ? p.PurchaseCount : 0; return new BuildDeckGetPurchaseCountResponse { PurchaseNumCurrent = current, PurchaseNumMax = product.PurchaseNumMax, }; } }