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);
}