Schema: SleeveShopSeries -> SleeveShopProducts -> Rewards (owned).
Migration AddSleeveShop creates 3 tables with FK cascade.
Importer mirrors BuildDeck pattern: find-or-create per series/product,
rewards replaced wholesale on rerun (owned collection). 10 series,
270 products imported from seeds/sleeve-shop.json.
Controller:
- /sleeve/info returns wire-faithful dict-keyed shape
({sleeve_list: {<series_id>: {product_info: {<product_id>: ...}}}}).
is_purchased_product derived from viewer.Sleeves.Contains(sleeve_id).
- /sleeve/buy: sales_type 0=free / 1=crystal / 2=rupy / 3=ticket(501).
Validates series_product mismatch, currency, already-purchased.
Currency debited with post-state-total reward_list entry; cosmetic
grants dispatched through RewardGrantService.ApplyAsync (covers
sleeve + emblem bundled grants per product).
476 tests pass (was 466; +10 sleeve tests).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
190 lines
7.9 KiB
C#
190 lines
7.9 KiB
C#
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.Sleeve;
|
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Sleeve;
|
|
|
|
namespace SVSim.EmulatedEntrypoint.Controllers;
|
|
|
|
/// <summary>
|
|
/// /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).
|
|
/// </summary>
|
|
[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<ActionResult<SleeveInfoResponse>> Info()
|
|
{
|
|
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<string, SleeveSeriesDto>();
|
|
foreach (var s in series)
|
|
{
|
|
var products = new Dictionary<string, SleeveProductDto>();
|
|
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<ActionResult<SleeveBuyResponse>> 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<RewardListEntry>();
|
|
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 };
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static bool IsProductPurchased(SleeveShopProductEntry product, HashSet<long> ownedSleeveIds)
|
|
{
|
|
foreach (var r in product.Rewards)
|
|
{
|
|
if (r.RewardType == (int)UserGoodsType.Sleeve && ownedSleeveIds.Contains(r.RewardDetailId))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private Task<Viewer> 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);
|
|
}
|