using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SVSim.Database; 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; 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 IInventoryService _inv; private readonly TimeProvider _time; private readonly ICollectionRepository _collection; public LeaderSkinController(SVSimDbContext db, IInventoryService inv, TimeProvider time, ICollectionRepository collection) { _db = db; _inv = inv; _time = time; _collection = collection; } [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" }); var cosmeticsForSet = await _inv.EffectiveCosmeticsAsync(viewer); if (!cosmeticsForSet.OwnedLeaderSkinIds.Contains(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(BaseRequest _) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); 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 }; } [HttpPost("products")] public async Task>> Products(BaseRequest _) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); 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) .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 = (int)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" }); await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.LeaderSkinBuy); // Already-purchased = viewer owns the leader_skin this product grants. if (tx.OwnsCosmetic(CosmeticType.Skin, product.LeaderSkinId)) return BadRequest(new { error = "already_purchased" }); // 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" }); } foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex)) await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber); var result = await tx.CommitAsync(HttpContext.RequestAborted); return new LeaderSkinBuyResponse { RewardList = result.RewardList.ToRewardList(), }; } [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" }); await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.LeaderSkinBuy); if (tx.IsFreeplay) return BadRequest(new { error = "already_purchased" }); // Debit set price switch (request.SalesType) { 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" }); } // 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(r.RewardType, r.RewardDetailId, r.RewardNumber); } var result = await tx.CommitAsync(HttpContext.RequestAborted); return new LeaderSkinBuyResponse { RewardList = result.RewardList.ToRewardList(), }; } [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() }; await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.LeaderSkinBuy); // Must own every skin in the series to claim the bonus. 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" }); foreach (var r in series.SetCompletionRewards.OrderBy(r => r.OrderIndex)) await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber); _db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim { ViewerId = viewerId, SeriesId = series.Id, ClaimedAt = _time.GetUtcNow().UtcDateTime, }); var result = await tx.CommitAsync(HttpContext.RequestAborted); return new LeaderSkinBuyResponse { RewardList = result.RewardList.ToRewardList(), }; } /// /// Computes the per-viewer rewards.status for a series: /// 0=none — set_sales_status==0 OR no bonus items configured (matches prod, which ships /// status=0 for series where items[] is empty even when set_sales_status==1) /// 1=not_got — bonus exists, series completed by viewer, bonus unclaimed /// 2=got — viewer claimed the bonus /// 1 (effectively "available later") when set sale active with bonus and viewer hasn't /// completed the series. /// The 1/2 distinction matches the client enum (RewardStatus.not_got vs .got). /// /// Important: emitting status=1 when items[] is empty triggers the client's /// is_completed && not_got branch in SkinPurchaseInfoTask.CreateSetSaleInfo, /// which marks the set sale as FREE and renders a useless "claim" button for a /// nonexistent bonus. Always return 0 when there's nothing to claim. /// /// private static int ComputeRewardStatus(LeaderSkinShopSeriesEntry series, bool seriesCompleted, bool claimed) { if (series.SetSalesStatus == 0) return 0; if (series.SetCompletionRewards.Count == 0) return 0; if (claimed) return 2; if (seriesCompleted) return 1; return 1; } private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, IReadOnlySet 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 = (int)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, IReadOnlySet ownedSkinIds) { // Skin reward: direct check. if (r.RewardType == 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; } }