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.SpotCardExchange; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.SpotCardExchange; namespace SVSim.EmulatedEntrypoint.Controllers; /// /// /spot_card_exchange/* — trade spot points for individual cards from the rotating exchange /// pool. Spot points are earned from battles/missions (not implemented here — earners live in /// battle/mission finish reward emitters via + /// ). /// [Route("spot_card_exchange")] public class SpotCardExchangeController : SVSimController { /// /// Pre-release exchange cap. Captures show "2" — global limit, not per-card. When /// IsPreRelease is active on the catalog level we honour this; otherwise the cap is /// effectively unbounded (UI never shows the warning). /// private const int PreReleaseLimit = 2; private readonly SVSimDbContext _db; private readonly RewardGrantService _rewards; private readonly TimeProvider _time; public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time) { _db = db; _rewards = rewards; _time = time; } [HttpPost("top")] public async Task> Top() { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var viewer = await _db.Viewers .Where(v => v.Id == viewerId) .Select(v => new { v.Currency.SpotPoints }) .FirstOrDefaultAsync(); if (viewer is null) return Unauthorized(); var catalog = await _db.SpotCardExchangeCatalog .Where(c => c.IsEnabled) .OrderBy(c => c.Id) .ToListAsync(); var exchanges = await _db.ViewerSpotCardExchanges .Where(e => e.ViewerId == viewerId) .ToListAsync(); var exchangedIds = exchanges.Select(e => e.CardId).ToHashSet(); int preReleaseExchangedCount = exchanges.Count(e => e.IsPreRelease); bool preReleaseActive = catalog.Any(c => c.IsPreRelease); bool preReleaseLimitHit = preReleaseExchangedCount >= PreReleaseLimit; // Build the 9-clan-bucket dict-of-arrays. Every clan slot is present even when empty; // the inner dict always uses key "1" matching the captured prod shape. var byClan = new List>>(9); for (int clan = 0; clan < 9; clan++) { byClan.Add(new Dictionary> { ["1"] = new List(), }); } foreach (var c in catalog) { int clanIdx = Math.Clamp(c.ClassId, 0, 8); byClan[clanIdx]["1"].Add(new SpotCardExchangeCardDto { CardId = c.Id, ExchangeStatus = ComputeExchangeStatus(c, exchangedIds, preReleaseLimitHit), ExchangePoint = c.ExchangePoint.ToString(), Class = c.ClassId.ToString(), IsPreRelease = c.IsPreRelease, TsRotationId = c.TsRotationId.ToString(), }); } return new SpotCardExchangeTopResponse { SpotPoint = checked((int)viewer.SpotPoints), ExchangeableCardList = byClan, SoonCycleOutCardSetId = string.Empty, // No captured value to derive; spec allows "" PreReleaseInfo = new PreReleaseInfoDto { IsPreRelease = preReleaseActive, PreReleaseSpotCardExchangeCount = preReleaseExchangedCount, PreReleaseSpotCardExchangeLimit = PreReleaseLimit, }, }; } [HttpPost("exchange")] public async Task> Exchange(SpotCardExchangeRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var entry = await _db.SpotCardExchangeCatalog.FindAsync((long)request.CardId); if (entry is null || !entry.IsEnabled) return BadRequest(new { error = "unknown_card" }); // Already-exchanged guard — each catalog row is one card per viewer. var existingExchange = await _db.ViewerSpotCardExchanges .FirstOrDefaultAsync(e => e.ViewerId == viewerId && e.CardId == entry.Id); if (existingExchange is not null) return BadRequest(new { error = "already_exchanged" }); if (entry.IsPreRelease) { int prCount = await _db.ViewerSpotCardExchanges .CountAsync(e => e.ViewerId == viewerId && e.IsPreRelease); if (prCount >= PreReleaseLimit) return BadRequest(new { error = "pre_release_limit_reached" }); } var viewer = await LoadViewerGraphAsync(viewerId); var rewardList = new List(); // Debit spot points. Client-supplied exchange_point isn't authoritative — server uses // catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry // first, then grants. if (viewer.Currency.SpotPoints < (ulong)entry.ExchangePoint) return BadRequest(new { error = "insufficient_spot_points" }); viewer.Currency.SpotPoints -= (ulong)entry.ExchangePoint; rewardList.Add(new RewardListEntry { RewardType = (int)UserGoodsType.SpotCardPoint, RewardId = 0, RewardNum = checked((int)viewer.Currency.SpotPoints), }); // Grant the card itself via the existing card dispatcher (handles cosmetic cascade). var granted = await _rewards.ApplyAsync(viewer, UserGoodsType.Card, entry.Id, 1); foreach (var g in granted) { rewardList.Add(new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum, }); } _db.ViewerSpotCardExchanges.Add(new ViewerSpotCardExchange { ViewerId = viewerId, CardId = entry.Id, IsPreRelease = entry.IsPreRelease, ExchangedAt = _time.GetUtcNow().UtcDateTime, }); await _db.SaveChangesAsync(); return new SpotCardExchangeResponse { RewardList = rewardList }; } /// /// Maps to : /// 0 = EnableExchange /// 1 = AlreadyExchange (viewer has already exchanged this card) /// 2 = LimitOver (pre-release card and viewer hit the global pre-release cap) /// Insufficient-balance is NOT surfaced here — the client greys those out by comparing /// spot_point to exchange_point. /// private static int ComputeExchangeStatus(SpotCardExchangeEntry c, HashSet exchangedIds, bool preReleaseLimitHit) { if (exchangedIds.Contains(c.Id)) return 1; if (c.IsPreRelease && preReleaseLimitHit) return 2; return 0; } private Task LoadViewerGraphAsync(long viewerId) => _db.Viewers .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) .Include(v => v.Items).ThenInclude(i => i.Item) .AsSplitQuery() .FirstAsync(v => v.Id == viewerId); }