using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Sleeve; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Sleeve; namespace SVSim.EmulatedEntrypoint.Controllers; /// /// /sleeve/* — the sleeve shop. Catalog + single-product purchase. No series-completion bonus /// (sleeves are sold individually; the leader-skin shop is the family with set-buys). /// [Route("sleeve")] public class SleeveController : SVSimController { private readonly SVSimDbContext _db; private readonly RewardGrantService _rewards; public SleeveController(SVSimDbContext db, RewardGrantService rewards) { _db = db; _rewards = rewards; } [HttpPost("info")] public async Task> Info(BaseRequest _) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); // is_purchased_product is "viewer owns at least one sleeve granted by this product". // Loading the viewer's sleeve-id set once and checking each product against it avoids // an N+1 over products. var ownedSleeveIds = (await _db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.Sleeves.Select(s => (long)s.Id)) .ToListAsync()).ToHashSet(); var series = await _db.SleeveShopSeries .Where(s => s.IsEnabled) .Include(s => s.Products.Where(p => p.IsEnabled)).ThenInclude(p => p.Rewards) .OrderBy(s => s.Id) .ToListAsync(); var sleeveList = new Dictionary(); foreach (var s in series) { var products = new Dictionary(); foreach (var p in s.Products.OrderBy(p => p.Id)) { products[p.Id.ToString()] = new SleeveProductDto { ProductId = p.Id, Name = p.NameKey, PriceCrystal = p.PriceCrystal, PriceRupy = p.PriceRupy, IsPurchasedProduct = IsProductPurchased(p, ownedSleeveIds), Rewards = p.Rewards.OrderBy(r => r.OrderIndex).Select(r => new SleeveProductRewardDto { RewardType = r.RewardType, RewardDetailId = r.RewardDetailId, RewardNumber = r.RewardNumber, }).ToList(), }; } sleeveList[s.Id.ToString()] = new SleeveSeriesDto { SeriesId = s.Id, IsNew = s.IsNew, ProductInfo = products, }; } return new SleeveInfoResponse { SleeveList = sleeveList }; } [HttpPost("buy")] public async Task> Buy(SleeveBuyRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); 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 product = await _db.SleeveShopProducts .Include(p => p.Rewards) .Include(p => p.Series) .FirstOrDefaultAsync(p => p.Id == 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" }); // Defence-in-depth: client also sends series_id; reject mismatches so a misencoded // request can't accidentally bypass per-series state we'll later add (e.g. series-new flag). if (product.SeriesId != request.SeriesId) return BadRequest(new { error = "series_product_mismatch" }); var viewer = await LoadViewerGraphAsync(viewerId); if (IsProductPurchased(product, viewer.Sleeves.Select(s => (long)s.Id).ToHashSet())) return BadRequest(new { error = "already_purchased" }); // Pricing: capture-confirmed shape is single-price-per-currency (no intro/regular tiers // like BuildDeck). At least one of crystal/rupy must match the chosen sales_type; // sales_type==0 means "free", which requires both prices == 0. var rewardList = new List(); switch (request.SalesType) { case 0: // free if (!(product.PriceCrystal == 0 && product.PriceRupy == 0)) return BadRequest(new { error = "price_not_available_for_currency" }); break; case 1: // crystal if (product.PriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" }); var crystalCost = (ulong)product.PriceCrystal.Value; if (viewer.Currency.Crystals < crystalCost) return BadRequest(new { error = "insufficient_crystals" }); viewer.Currency.Crystals -= crystalCost; rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }); break; case 2: // rupy if (product.PriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" }); var rupyCost = (ulong)product.PriceRupy.Value; if (viewer.Currency.Rupees < rupyCost) return BadRequest(new { error = "insufficient_rupees" }); viewer.Currency.Rupees -= rupyCost; rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }); break; } // Grant each catalog reward through the central dispatcher — covers sleeve (6), emblem // (7), and any future bundled grants. ApplyAsync returns post-state-aware reward entries // suitable for emission as-is. foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex)) { var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber); foreach (var g in granted) { rewardList.Add(new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum, }); } } await _db.SaveChangesAsync(); return new SleeveBuyResponse { RewardList = rewardList }; } /// /// A product is "purchased" once the viewer owns at least one of its sleeve-typed reward /// grants. Emblem/other grants aren't load-bearing for this check — a viewer who somehow /// ended up with the emblem but not the sleeve (e.g. partial gift) should still be allowed /// to buy the product to pick up the sleeve. /// private static bool IsProductPurchased(SleeveShopProductEntry product, HashSet ownedSleeveIds) { foreach (var r in product.Rewards) { if (r.RewardType == (int)UserGoodsType.Sleeve && ownedSleeveIds.Contains(r.RewardDetailId)) return true; } return false; } private Task LoadViewerGraphAsync(long viewerId) => _db.Viewers .Include(v => v.Sleeves) .Include(v => v.Emblems) .Include(v => v.LeaderSkins) .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); }