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; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase; using SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Controllers; /// /// /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 . /// [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> Info(BaseRequest _) { 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(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> 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(); // 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 }; } /// /// Debit of (, ) /// from the viewer, returning a post-state-aware the client /// uses to refresh its cached count. Returns an error string on insufficient balance. /// 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 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 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); }