Files
SVSimServer/SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs
gamer147 559a170957 feat(item-purchase): /item_purchase/{info,purchase} + catalog
Schema: ItemPurchaseCatalogEntry (single table). Per-viewer quota tracked
via existing ViewerEventCounter keyed by "item_purchase:<id>" with period
JstPeriod.MonthKey when IsMonthlyReset else AllTime.

Controller:
- /info returns catalog + per-period rest (server-computed
  max(0, PurchaseLimit - counter)) + user_card_pack_ticket_list (every
  Items.Type==2 row joined to viewer count, zeros included — client
  unconditionally UpdateItemNum's each entry).
- /purchase: sold_out check before currency check (no counter increment
  on currency failure), inline TryDebit covers RedEther/Crystal/Rupy/Item
  with post-state-total reward_list entry, grant via RewardGrantService.
  Request `rest` accepted but ignored (server counter is canonical).

Importer mirrors PaymentItemImporter shape — idempotent find-or-create,
seed-missing rows preserved. 3 entries from the prod capture.

486 tests pass (was 476; +10 item_purchase tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:41:02 -04:00

216 lines
8.6 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.ItemPurchase;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /item_purchase/* — the generic item shop where viewers spend item-or-currency to acquire
/// other items (e.g. RedEther → Seer's Globe, Orb Shards → Seer's Globe). Per-viewer monthly
/// or lifetime quota tracked via <see cref="ViewerEventCounter"/>.
/// </summary>
[Route("item_purchase")]
public class ItemPurchaseController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly TimeProvider _time;
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
{
_db = db;
_rewards = rewards;
_time = time;
}
[HttpPost("info")]
public async Task<ActionResult<ItemPurchaseInfoResponse>> Info()
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var catalog = await _db.ItemPurchaseCatalog
.Where(c => c.IsEnabled)
.OrderBy(c => c.Id)
.ToListAsync();
var now = _time.GetUtcNow();
var monthKey = JstPeriod.MonthKey(now);
var keys = catalog.Select(c => CounterKey(c.Id)).ToList();
var counters = await _db.ViewerEventCounters
.Where(c => c.ViewerId == viewerId && keys.Contains(c.EventKey))
.ToListAsync();
var info = new List<ItemPurchaseEntryDto>(catalog.Count);
foreach (var c in catalog)
{
int count = CounterCount(counters, c, monthKey);
info.Add(new ItemPurchaseEntryDto
{
PurchaseId = c.Id,
RequireItemType = c.RequireItemType,
RequireItemId = c.RequireItemId,
RequireItemNum = c.RequireItemNum,
PurchaseItemType = c.PurchaseItemType,
PurchaseItemId = c.PurchaseItemId,
PurchaseItemNum = c.PurchaseItemNum,
PurchaseName = c.PurchaseName,
IsMonthlyReset = c.IsMonthlyReset ? 1 : 0,
Rest = Math.Max(0, c.PurchaseLimit - count),
});
}
// user_card_pack_ticket_list: every item with Type == 2 paired with the viewer's count
// (zero counts included — the client unconditionally calls UpdateItemNum per entry).
var ticketItems = await _db.Items
.Where(i => i.Type == 2)
.OrderByDescending(i => i.Id)
.ToListAsync();
var ownedByItemId = (await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Items)
.Select(oi => new { oi.Item.Id, oi.Count })
.ToListAsync())
.ToDictionary(x => x.Id, x => x.Count);
var ticketList = ticketItems.Select(i => new UserCardPackTicketDto
{
ItemId = i.Id,
Number = ownedByItemId.TryGetValue(i.Id, out var cnt) ? cnt : 0,
}).ToList();
return new ItemPurchaseInfoResponse
{
ItemPurchaseInfo = info,
UserCardPackTicketList = ticketList,
};
}
[HttpPost("purchase")]
public async Task<ActionResult<ItemPurchasePurchaseResponse>> Purchase(ItemPurchasePurchaseRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var entry = await _db.ItemPurchaseCatalog.FindAsync(request.PurchaseId);
if (entry is null || !entry.IsEnabled)
return BadRequest(new { error = "unknown_purchase" });
var now = _time.GetUtcNow();
var period = entry.IsMonthlyReset ? JstPeriod.MonthKey(now) : JstPeriod.AllTime;
var key = CounterKey(entry.Id);
var counter = await _db.ViewerEventCounters
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.EventKey == key && c.Period == period);
int currentCount = counter?.Count ?? 0;
int rest = entry.PurchaseLimit - currentCount;
if (rest <= 0)
return BadRequest(new { error = "sold_out" });
var viewer = await LoadViewerGraphAsync(viewerId);
var rewardList = new List<RewardListEntry>();
// Debit the require side. RewardGrantService is grant-only, so handle this inline.
var debit = TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState);
// Grant the purchase side through the central dispatcher.
var granted = await _rewards.ApplyAsync(viewer,
(UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
// Increment the per-period counter.
if (counter is null)
{
_db.ViewerEventCounters.Add(new ViewerEventCounter
{
ViewerId = viewerId,
EventKey = key,
Period = period,
Count = 1,
});
}
else
{
counter.Count++;
}
await _db.SaveChangesAsync();
return new ItemPurchasePurchaseResponse { RewardList = rewardList };
}
/// <summary>
/// Debit <paramref name="num"/> of (<paramref name="type"/>, <paramref name="detailId"/>)
/// from the viewer, returning a post-state-aware <see cref="RewardListEntry"/> the client
/// uses to refresh its cached count. Returns an error string on insufficient balance.
/// </summary>
private static (RewardListEntry? PostState, string? Error) TryDebit(
Viewer viewer, UserGoodsType type, long detailId, int num)
{
switch (type)
{
case UserGoodsType.RedEther:
if (viewer.Currency.RedEther < (ulong)num)
return (null, "insufficient_red_ether");
viewer.Currency.RedEther -= (ulong)num;
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)viewer.Currency.RedEther }, null);
case UserGoodsType.Crystal:
if (viewer.Currency.Crystals < (ulong)num)
return (null, "insufficient_crystals");
viewer.Currency.Crystals -= (ulong)num;
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null);
case UserGoodsType.Rupy:
if (viewer.Currency.Rupees < (ulong)num)
return (null, "insufficient_rupees");
viewer.Currency.Rupees -= (ulong)num;
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null);
case UserGoodsType.Item:
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
if (owned is null || owned.Count < num)
return (null, "insufficient_item");
owned.Count -= num;
return (new RewardListEntry { RewardType = 4, RewardId = detailId, RewardNum = owned.Count }, null);
default:
return (null, $"debit_type_not_supported:{type}");
}
}
private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}";
private static int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey)
{
var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime;
return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0;
}
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}