174 lines
7.3 KiB
C#
174 lines
7.3 KiB
C#
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.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 IInventoryService _inv;
|
|
private readonly ICollectionRepository _collection;
|
|
|
|
public SleeveController(SVSimDbContext db, IInventoryService inv, ICollectionRepository collection)
|
|
{
|
|
_db = db;
|
|
_inv = inv;
|
|
_collection = collection;
|
|
}
|
|
|
|
[HttpPost("info")]
|
|
public async Task<ActionResult<SleeveInfoResponse>> 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 viewerForInfo = await _db.Viewers
|
|
.Include(v => v.Sleeves)
|
|
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
|
if (viewerForInfo is null) return Unauthorized();
|
|
|
|
var cosmeticsForInfo = await _inv.EffectiveCosmeticsAsync(viewerForInfo);
|
|
var ownedSleeveIds = cosmeticsForInfo.SleeveIds.Select(id => (long)id).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" });
|
|
|
|
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
|
|
|
if (tx.IsFreeplay)
|
|
return BadRequest(new { error = "already_purchased" });
|
|
|
|
if (IsProductPurchased(product, tx.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.
|
|
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 r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.PriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
|
|
break;
|
|
case 2: // rupy
|
|
if (product.PriceRupy is null)
|
|
return BadRequest(new { error = "price_not_available_for_currency" });
|
|
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.PriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
|
|
break;
|
|
}
|
|
|
|
// Grant each catalog reward through the central dispatcher.
|
|
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
|
|
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
|
|
|
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
|
|
|
return new SleeveBuyResponse
|
|
{
|
|
RewardList = result.RewardList
|
|
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
|
.ToList(),
|
|
};
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
}
|