using System.Text.Json; using Microsoft.AspNetCore.Mvc; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Models.Config; using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum; using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaColosseum; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaColosseum; namespace SVSim.EmulatedEntrypoint.Controllers; /// /// Arena Colosseum (Grand Prix) lobby. Phase 1 covers the three read endpoints (/top, /// /get_fee_info, /event_info) plus the entry/register-deck pair. Defaults to /// "no event scheduled" via — flipping /// the event on is an admin operation per docs/operations/grand-prix-event-setup.md. /// [Route("arena_colosseum")] public class ArenaColosseumController : SVSimController { private readonly IGameConfigService _config; private readonly IArenaColosseumRunRepository _runs; private readonly IInventoryService _inventory; private readonly IDeckRepository _decks; public ArenaColosseumController( IGameConfigService config, IArenaColosseumRunRepository runs, IInventoryService inventory, IDeckRepository decks) { _config = config; _runs = runs; _inventory = inventory; _decks = decks; } [HttpPost("top")] public async Task Top([FromBody] BaseRequest _) { if (!TryGetViewerId(out var vid)) return Unauthorized(); var season = _config.Get(); var run = await _runs.GetByViewerIdAsync(vid); var response = new TopResponse { ColosseumInfo = BuildColosseumInfo(season), ColosseumStatus = BuildOwnStatus(run), LeaderSkinId = run?.LeaderSkinId ?? 0, }; if (run is not null) { response.EntryInfo = new ColosseumEntryRef { Id = run.EntryId }; response.NowRoundId = run.RoundId; response.MaxBattleCount = run.MaxBattleCountThisRound; response.IsFinish = run.IsChampion; response.FinalRoundEliminateCount = season.FinalRoundEliminateCount; response.EndTime = FormatTime(season.EventEndTime); response.BattleResults = new ColosseumBattleResults { WinCount = run.WinCount, ResultList = ParseIntList(run.ResultListJson), }; response.BreakthroughNumber = run.BreakthroughNumberThisRound > 0 ? run.BreakthroughNumberThisRound : null; } return Ok(response); } [HttpPost("get_fee_info")] public async Task GetFeeInfo([FromBody] BaseRequest _) { if (!TryGetViewerId(out var vid)) return Unauthorized(); var season = _config.Get(); var run = await _runs.GetByViewerIdAsync(vid); var response = new GetFeeInfoResponseDto { ColosseumInfo = BuildColosseumInfo(season), ColosseumStatus = BuildOwnStatus(run), }; if (!season.IsColosseumPeriod) { return Ok(response); } response.IsUnfinishedEntryExists = run is not null; response.IsAllowedFreeEntry = season.IsAllowedFreeEntry; response.FeeList = new ColosseumFeeList { RupyCost = season.RupyCost, TicketCost = season.TicketCost, CrystalCost = season.CrystalCost, }; if (run is not null) { response.DeckFormat = (int)run.DeckFormat; } return Ok(response); } [HttpPost("event_info")] public async Task EventInfo([FromBody] BaseRequest _) { if (!TryGetViewerId(out var vid)) return Unauthorized(); var season = _config.Get(); var rounds = _config.Get(); var run = await _runs.GetByViewerIdAsync(vid); return Ok(new EventInfoResponse { ColosseumInfo = new ColosseumEventInfo { Format = (int)season.DeckFormat, StartTime = FormatTime(season.EventStartTime), EndTime = FormatTime(season.EventEndTime), AnnounceId = season.AnnounceId, FinalRoundEliminateCount = season.FinalRoundEliminateCount, }, Round1 = BuildRoundDetail(rounds, 1), Round2 = BuildRoundDetail(rounds, 2), Round3 = BuildRoundDetail(rounds, 3), ColosseumStatus = BuildOwnStatus(run), }); } [HttpPost("entry")] public async Task Entry([FromBody] ArenaColosseumEntryRequest req) { if (!TryGetViewerId(out var vid)) return Unauthorized(); var season = _config.Get(); if (!season.IsColosseumPeriod) { return BadRequest(new { error = "colosseum_period_closed" }); } var serverRoundId = ResolveServerRoundId(season); if (req.NowRoundId != serverRoundId) { return BadRequest(new { error = "now_round_id_mismatch", server_round_id = serverRoundId }); } if (await _runs.GetByViewerIdAsync(vid) is not null) { return BadRequest(new { error = "arena_colosseum_already_in_progress" }); } await using var tx = await _inventory.BeginAsync(vid); RewardEntryDto? feeEntry = req.ConsumeItemType switch { 1 => await DebitCrystalAsync(tx, season.CrystalCost), 3 => await DebitTicketAsync(tx, season.TicketCost), 4 => await DebitRupyAsync(tx, season.RupyCost), 5 when season.IsAllowedFreeEntry => null, _ => throw new InvalidOperationException($"invalid consume_item_type {req.ConsumeItemType}"), }; var rounds = _config.Get(); var roundConfig = rounds.Rounds.FirstOrDefault(r => r.RoundId == serverRoundId); var group = roundConfig?.Groups.FirstOrDefault(); var run = new ViewerArenaColosseumRun { ViewerId = vid, EntryId = 0, SeasonId = season.SeasonId, RoundId = serverRoundId, DeckFormat = season.DeckFormat, LeaderSkinId = 0, ConsumeItemType = req.ConsumeItemType, MaxBattleCountThisRound = group?.MaxBattleCount ?? 0, BreakthroughNumberThisRound = group?.BreakthroughNumber ?? 0, RestEntryNum = 0, }; await _runs.UpsertAsync(run); run.EntryId = run.Id; await _runs.UpsertAsync(run); await tx.CommitAsync(); return Ok(new EntryResponse { RewardList = feeEntry is null ? new() : new() { feeEntry }, EntryInfo = new ColosseumEntryRef { Id = run.EntryId, DeckFormat = (int)season.DeckFormat, }, }); } [HttpPost("register_deck")] public async Task RegisterDeck([FromBody] ArenaColosseumRegisterDeckRequest req) { if (!TryGetViewerId(out var vid)) return Unauthorized(); var run = await _runs.GetByViewerIdAsync(vid); if (run is null) { return BadRequest(new { error = "no_active_run" }); } List deckNos; try { deckNos = JsonSerializer.Deserialize>(req.DeckNoList) ?? new(); } catch (JsonException) { return BadRequest(new { error = "deck_no_list_malformed" }); } if (deckNos.Count == 0) { return BadRequest(new { error = "deck_no_list_empty" }); } // GetDeck filters by (viewerId, format, deckNo) — a slot that exists under a different // format returns null here, which is the format-mismatch case from the spec. foreach (var no in deckNos) { var deck = await _decks.GetDeck(vid, run.DeckFormat, no); if (deck is null) { return BadRequest(new { error = "deck_not_found", deck_no = no }); } } run.RegisteredDeckNoListJson = JsonSerializer.Serialize(deckNos); run.IsPublished = req.IsPublished; await _runs.UpsertAsync(run); return Ok(new { }); } private static int ResolveServerRoundId(ColosseumSeasonConfig season) => 1; private async Task DebitCrystalAsync(IInventoryTransaction tx, int cost) { var result = await tx.TrySpendAsync(SpendCurrency.Crystal, cost); if (!result.Success) throw new InvalidOperationException("insufficient_crystal"); return new RewardEntryDto { RewardType = (int)UserGoodsType.Crystal, RewardId = 0, RewardNum = (int)result.PostStateTotal, }; } private async Task DebitRupyAsync(IInventoryTransaction tx, int cost) { var result = await tx.TrySpendAsync(SpendCurrency.Rupee, cost); if (!result.Success) throw new InvalidOperationException("insufficient_rupy"); return new RewardEntryDto { RewardType = (int)UserGoodsType.Rupy, RewardId = 0, RewardNum = (int)result.PostStateTotal, }; } private async Task DebitTicketAsync(IInventoryTransaction tx, int cost) { // Colosseum's ticket id is server-internal — using ArenaTwoPick's TicketItemId convention // (item id 1) until a per-season override is captured. const int ticketItemId = 1; var result = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, cost); if (!result.Success) throw new InvalidOperationException("insufficient_ticket"); return new RewardEntryDto { RewardType = (int)UserGoodsType.Item, RewardId = ticketItemId, RewardNum = (int)result.PostStateTotal, }; } // --- helpers --- private static ColosseumLobbyInfo BuildColosseumInfo(ColosseumSeasonConfig season) { if (!season.IsColosseumPeriod) { return new ColosseumLobbyInfo { IsColosseumPeriod = false }; } return new ColosseumLobbyInfo { IsColosseumPeriod = true, DeckFormat = (int)season.DeckFormat, IsNormalTwoPick = season.IsNormalTwoPick ? "1" : "0", ColosseumName = season.ColosseumName, IsRoundPeriod = true, IsSpecialMode = season.IsSpecialMode, CardPoolName = string.IsNullOrEmpty(season.CardPoolName) ? null : season.CardPoolName, NowRound = 1, StartTime = FormatTime(season.EventStartTime), EndTime = FormatTime(season.EventEndTime), IsAllCardEnabled = season.IsAllCardEnabled ? 1 : 0, SalesPeriodInfo = new SVSim.EmulatedEntrypoint.Models.Dtos.ColosseumSalesPeriodInfo { SalesPeriodTime = FormatTime(season.SalesPeriodEnd), }, StrategyPickNum = season.StrategyPickNum > 0 ? season.StrategyPickNum : null, }; } /// Builds the colosseum_status block. When the viewer has no run, every /// property is null and global WhenWritingNull renders {} — the client /// (SetColosseumOwnStatus) short-circuits on status.Count == 0. private static ColosseumOwnStatus BuildOwnStatus(ViewerArenaColosseumRun? run) { if (run is null) return new ColosseumOwnStatus(); return new ColosseumOwnStatus { RestEntryNum = run.RestEntryNum, NowRoundId = run.RoundId, IsChampion = run.IsChampion ? true : null, }; } private static ColosseumRoundDetail BuildRoundDetail(ColosseumRoundsConfig rounds, int roundId) { var match = rounds.Rounds.FirstOrDefault(r => r.RoundId == roundId); if (match is null) return new ColosseumRoundDetail(); return new ColosseumRoundDetail { StartTime = FormatTime(match.StartTime), EndTime = FormatTime(match.EndTime), IsNowRound = IsNowRound(match), RoundDetail = match.Groups.Select(g => new ColosseumGroupRow { Group = g.Group, MaxBattleCount = g.MaxBattleCount, BreakthroughNumber = g.BreakthroughNumber, EntryNumber = g.EntryNumber, }).ToList(), }; } private static bool IsNowRound(ColosseumRoundsConfig.RoundEntry round) { var now = DateTime.UtcNow; return now >= round.StartTime && now <= round.EndTime; } private static string FormatTime(DateTime t) => t == default ? "" : t.ToString("yyyy-MM-dd HH:mm:ss"); private static List ParseIntList(string json) => string.IsNullOrEmpty(json) ? new() : System.Text.Json.JsonSerializer.Deserialize>(json) ?? new(); }