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.LeaderSkin; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin; namespace SVSim.EmulatedEntrypoint.Controllers; /// /// /leader_skin/* — the leader-skin shop family. /// /// /set: per-class equipped-skin preference (the fallback when a deck has /// leader_skin_id == 0). Per-deck overrides go through /deck/update_leader_skin. /// /products: shop catalog (dict-keyed by series_id). /// /buy: single-skin purchase. Currency dispatch crystal/rupy/ticket(501). /// /buy_set: whole-series purchase at set discount. /// /buy_set_item: claim series-completion bonus (idempotent via /// ). /// /ids: flat list of owned skin ids for badge refresh. /// /// [Route("leader_skin")] public class LeaderSkinController : SVSimController { private readonly SVSimDbContext _db; private readonly RewardGrantService _rewards; private readonly TimeProvider _time; public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time) { _db = db; _rewards = rewards; _time = time; } [HttpPost("set")] public async Task> Set(LeaderSkinSetRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); if (request.IsRandomLeaderSkin) { return StatusCode(StatusCodes.Status501NotImplemented, new { error = "random_leader_skin_not_implemented" }); } var viewer = await _db.Viewers .Include(v => v.Classes).ThenInclude(c => c.Class) .Include(v => v.Classes).ThenInclude(c => c.LeaderSkin) .Include(v => v.LeaderSkins) .FirstOrDefaultAsync(v => v.Id == viewerId); if (viewer is null) return Unauthorized(); var classData = viewer.Classes.FirstOrDefault(c => c.Class.Id == request.ClassId); if (classData is null) return BadRequest(new { error = "unknown_class" }); 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 (viewer.LeaderSkins.All(s => s.Id != skin.Id)) return BadRequest(new { error = "skin_not_owned" }); classData.LeaderSkin = skin; await _db.SaveChangesAsync(); return new LeaderSkinSetResponse { IsRandomLeaderSkin = false, LeaderSkinId = skin.Id, LeaderSkinIdList = new(), }; } [HttpPost("ids")] public async Task> Ids() { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var ids = await _db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.LeaderSkins.Select(s => s.Id)) .OrderBy(id => id) .ToListAsync(); return new LeaderSkinIdsResponse { UserLeaderSkinIds = ids }; } [HttpPost("products")] public async Task>> Products() { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var ownedSkinIds = (await _db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.LeaderSkins.Select(s => s.Id)) .ToListAsync()).ToHashSet(); var claimedSeries = (await _db.ViewerLeaderSkinSetClaims .Where(c => c.ViewerId == viewerId) .Select(c => c.SeriesId) .ToListAsync()).ToHashSet(); var series = await _db.LeaderSkinShopSeries .Where(s => s.IsEnabled) .Include(s => s.SetCompletionRewards) .Include(s => s.Products.Where(p => p.IsEnabled)).ThenInclude(p => p.Rewards) .OrderBy(s => s.Id) .ToListAsync(); var result = new Dictionary(); foreach (var s in series) { var products = s.Products.OrderBy(p => p.Id).Select(p => ToProductDto(p, ownedSkinIds)).ToList(); bool seriesCompleted = products.Count > 0 && products.All(p => p.IsPurchased); int rewardStatus = ComputeRewardStatus(s, seriesCompleted, claimedSeries.Contains(s.Id)); result[s.Id.ToString()] = new SkinSeriesDto { SeriesId = s.Id, IsCompleted = seriesCompleted, IsNew = s.IsNew, SetSalesStatus = s.SetSalesStatus, Rewards = new SkinSeriesRewardsDto { Status = rewardStatus, Items = s.SetCompletionRewards.OrderBy(r => r.OrderIndex).Select(r => new SkinSeriesRewardItemDto { RewardType = r.RewardType, RewardDetailId = r.RewardDetailId, RewardNumber = r.RewardNumber, }).ToList(), }, SetPrices = new SkinSeriesSetPricesDto { SetPriceCrystal = s.SetPriceCrystal, SetPriceRupy = s.SetPriceRupy, SetPriceTicket = s.SetPriceTicket, TicketId = s.SetPriceTicketId, }, Products = products, }; } return result; } [HttpPost("buy")] public async Task> Buy(LeaderSkinBuyRequest 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.LeaderSkinShopProducts .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" }); var viewer = await LoadViewerGraphAsync(viewerId); // Already-purchased = viewer owns the leader_skin this product grants. if (viewer.LeaderSkins.Any(s => s.Id == product.LeaderSkinId)) return BadRequest(new { error = "already_purchased" }); var rewardList = new List(); var debit = 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); await ApplyRewardsAsync(viewer, product.Rewards, rewardList); await _db.SaveChangesAsync(); return new LeaderSkinBuyResponse { RewardList = rewardList }; } [HttpPost("buy_set")] public async Task> BuySet(LeaderSkinBuySetRequest 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 series = await _db.LeaderSkinShopSeries .Include(s => s.Products.Where(p => p.IsEnabled)).ThenInclude(p => p.Rewards) .FirstOrDefaultAsync(s => s.Id == request.SeriesId); if (series is null) return NotFound(new { error = "unknown_series" }); if (!series.IsEnabled || series.SetSalesStatus == 0) return BadRequest(new { error = "set_sale_not_active" }); var viewer = await LoadViewerGraphAsync(viewerId); var rewardList = new List(); var debit = 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)) { await ApplyRewardsAsync(viewer, p.Rewards, rewardList); } await _db.SaveChangesAsync(); return new LeaderSkinBuyResponse { RewardList = rewardList }; } [HttpPost("buy_set_item")] public async Task> BuySetItem(LeaderSkinBuySetItemRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var series = await _db.LeaderSkinShopSeries .Include(s => s.SetCompletionRewards) .Include(s => s.Products.Where(p => p.IsEnabled)) .FirstOrDefaultAsync(s => s.Id == request.SeriesId); if (series is null) return NotFound(new { error = "unknown_series" }); // Check claim hasn't been made already (idempotent — returns empty reward_list rather // than 400 so the client doesn't error if it retries). var existingClaim = await _db.ViewerLeaderSkinSetClaims .FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.SeriesId == series.Id); if (existingClaim is not null) return new LeaderSkinBuyResponse { RewardList = new() }; var viewer = await LoadViewerGraphAsync(viewerId); // 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)); if (!ownsAll) return BadRequest(new { error = "series_not_completed" }); var rewardList = new List(); await ApplyRewardsAsync(viewer, series.SetCompletionRewards, rewardList); _db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim { ViewerId = viewerId, SeriesId = series.Id, ClaimedAt = _time.GetUtcNow().UtcDateTime, }); await _db.SaveChangesAsync(); return new LeaderSkinBuyResponse { RewardList = rewardList }; } /// /// Computes the per-viewer rewards.status for a series: /// 0=none — set_sales_status==0 (no set sale active) /// 1=not_got — series completed by viewer but bonus unclaimed /// 2=got — viewer claimed the bonus /// 1 (effectively "available later") when set sale active but viewer hasn't completed it. /// The 1/2 distinction matches the client enum (RewardStatus.not_got vs .got). /// private static int ComputeRewardStatus(LeaderSkinShopSeriesEntry series, bool seriesCompleted, bool claimed) { if (series.SetSalesStatus == 0) return 0; if (claimed) return 2; if (seriesCompleted) return 1; return 1; } private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, HashSet ownedSkinIds) { bool isPurchased = ownedSkinIds.Contains(p.LeaderSkinId); return new SkinProductDto { ProductId = p.Id, LeaderSkinId = p.LeaderSkinId, ProductName = p.ProductNameKey, Introduction = p.IntroductionKey, CvName = p.CvNameKey, IsPurchased = isPurchased, Sale = new SkinProductSaleDto { SinglePriceCrystal = p.SinglePriceCrystal, SinglePriceRupy = p.SinglePriceRupy, SinglePriceTicket = p.SinglePriceTicket, TicketNumber = p.TicketNumber, ItemId = p.TicketItemId, }, Rewards = p.Rewards.OrderBy(r => r.OrderIndex).Select(r => new SkinProductRewardDto { RewardType = r.RewardType, RewardDetailId = r.RewardDetailId, RewardNumber = r.RewardNumber, IsOwned = IsRewardOwned(r, ownedSkinIds), }).ToList(), }; } /// /// A bundled reward shows as "owned" when the viewer already has the cosmetic. For now we /// only flag the Skin reward (type==10) against the viewer's skin collection — the cascaded /// 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) { // Skin reward: direct check. if (r.RewardType == (int)UserGoodsType.Skin) return ownedSkinIds.Contains((int)r.RewardDetailId); // Other types: we don't have the full cosmetic-owned graph in scope here. The product's // sibling Skin reward tells us whether the bundle was purchased; piggy-back on that by // letting the caller pre-compute IsPurchased. Conservative default: not owned. return false; } private (RewardListEntry? PostState, string? Error) DebitProductPrice( Viewer viewer, LeaderSkinShopProductEntry product, int salesType) { return salesType switch { 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0 => (null, null), 0 => (null, "price_not_available_for_currency"), 1 => product.SinglePriceCrystal is null ? (null, "price_not_available_for_currency") : DebitCrystal(viewer, product.SinglePriceCrystal.Value), 2 => product.SinglePriceRupy is null ? (null, "price_not_available_for_currency") : DebitRupy(viewer, product.SinglePriceRupy.Value), _ => (null, "invalid_sales_type"), }; } private (RewardListEntry? PostState, string? Error) DebitSetPrice( Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType) { return salesType switch { 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0 => (null, null), 0 => (null, "price_not_available_for_currency"), 1 => series.SetPriceCrystal is null ? (null, "price_not_available_for_currency") : DebitCrystal(viewer, series.SetPriceCrystal.Value), 2 => series.SetPriceRupy is null ? (null, "price_not_available_for_currency") : DebitRupy(viewer, series.SetPriceRupy.Value), _ => (null, "invalid_sales_type"), }; } private static (RewardListEntry?, string?) DebitCrystal(Viewer viewer, int amount) { if (viewer.Currency.Crystals < (ulong)amount) return (null, "insufficient_crystals"); viewer.Currency.Crystals -= (ulong)amount; return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null); } private static (RewardListEntry?, string?) DebitRupy(Viewer viewer, int amount) { if (viewer.Currency.Rupees < (ulong)amount) return (null, "insufficient_rupees"); viewer.Currency.Rupees -= (ulong)amount; return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, 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); }