using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.Globals; using SVSim.EmulatedEntrypoint.Configuration; using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck; namespace SVSim.EmulatedEntrypoint.Controllers; public class DeckController : SVSimController { private readonly IDeckRepository _deckRepository; private readonly IGlobalsRepository _globalsRepository; private readonly SVSimDbContext _dbContext; private readonly DeckOptions _deckOptions; private static readonly System.Text.Json.JsonSerializerOptions JsonbReadOptions = new() { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower, NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, }; public DeckController(IDeckRepository deckRepository, IGlobalsRepository globalsRepository, SVSimDbContext dbContext, IOptions deckOptions) { _deckRepository = deckRepository; _globalsRepository = globalsRepository; _dbContext = dbContext; _deckOptions = deckOptions.Value; } /// /// Pads a viewer's real deck list with empty-slot placeholders up to . /// Required because the client's DeckUI.DeckViewData.CreateDeckViewList only renders /// a "New Deck" tile when the response contains an entry whose card_id_array is empty — /// without padding, the player cannot create additional decks once any exist. /// private List PadEmptySlots(List realDecks) { var taken = realDecks.Select(d => d.DeckNumber).ToHashSet(); var result = new List(realDecks); for (int slot = 1; slot <= _deckOptions.MaxDeckSlots; slot++) { if (!taken.Contains(slot)) { result.Add(UserDeck.CreateEmptySlot(slot)); } } return result; } // Request deck_format fields arrive as wire ints (MessagePack-CSharp doesn't honor STJ // converters on request DTOs, so request DTO properties stay typed as int). Route through // FromApi here so controllers always work in internal Format space when comparing / // persisting. private static Format AsFormat(int apiValue) => FormatExtensions.FromApi(apiValue); [HttpPost("info")] public async Task> Info(DeckInfoRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat)); } [HttpPost("my_list")] public async Task> MyList(DeckFormatRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat)); } /// /// Shared hydration for /deck/info and /deck/my_list — both endpoints return the /// same DTO and the client's DeckInfoTask.Parse / DeckMyListTask.Parse /// are identical (both call DeckGroupListData(jsonData, format)). /// /// Wire shape swaps based on the request format. When the client asks for All-format /// (deck_format=0), prod emits per-format keys (user_deck_rotation, etc.); /// for a specific format request, prod emits a single user_deck_list. The client's /// DeckListUtility.ParseDeckInfoResponceData branches on these two shapes, so the /// controller mirrors it exactly. /// private async Task BuildDeckListResponseAsync(long viewerId, Format requestFormat) { var defaultDecks = await _globalsRepository.GetDefaultDecks(); // user_leader_skin_setting_list is PER-VIEWER (the wire `user_` prefix is honest, despite // the misleading docstring on DefaultLeaderSkinSetting). Source it from the viewer's // ViewerClassData rows, matching how /load/index's user_class_list reads them. The global // DefaultLeaderSkinSettings table is now used only as initial seed values for fresh // viewers (ViewerRepository.RegisterViewer); the per-class current skin is on // viewer.Classes[i].LeaderSkin and gets mutated by /leader_skin/update. var viewerClasses = await _dbContext.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.Classes) .Select(c => new { c.Class.Id, LeaderSkinId = c.LeaderSkin.Id }) .ToListAsync(); var response = new DeckListResponse { DefaultDeckList = defaultDecks.ToDictionary( d => d.Id.ToString(), d => new DefaultDeck { DeckNo = d.DeckNo, ClassId = d.ClassId, SleeveId = d.SleeveId, LeaderSkinId = d.LeaderSkinId, DeckName = d.DeckName, CardIdArray = System.Text.Json.JsonSerializer.Deserialize>(d.CardIdArray, JsonbReadOptions) ?? new(), // TODO(deck-stub): wire from real per-deck state once user maintenance / availability tracking lands. // Prod emits is_complete_deck=1, is_available_deck=1, maintenance_card_ids=[] for the 8 starter decks. IsCompleteDeck = 1, IsAvailableDeck = 1, MaintenanceCardIds = new(), }), UserLeaderSkinSettingList = viewerClasses.ToDictionary( vc => vc.Id.ToString(), vc => new UserLeaderSkinSetting { ClassId = vc.Id, IsRandomLeaderSkin = 0, // random-skin mode (per-class shuffle pool) not yet persisted LeaderSkinId = vc.LeaderSkinId, }), MaintenanceCardList = new(), // sourced from same place as /load/index when wired }; if (requestFormat == Format.All) { // Prod's All-format response emits these three per-format lists (each [] for fresh viewers). // The PreRotation / Crossover / Avatar siblings exist in client code but prod omits them // for our profile; we mirror that omission and leave the nullable DTO fields unset. var formats = new[] { Format.Rotation, Format.Unlimited, Format.MyRotation }; var byFormat = await _deckRepository.GetDecksByFormats(viewerId, formats); response.UserDeckRotation = PadEmptySlots(byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList()); response.UserDeckUnlimited = PadEmptySlots(byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList()); response.UserDeckMyRotation = PadEmptySlots(byFormat[Format.MyRotation].Select(d => new UserDeck(d)).ToList()); // trial_deck_list is prod-emitted on /deck/info (All format) but omitted on /deck/my_list // (specific format). Empty array in the 2026-05-23 prod capture. response.TrialDeckList = new(); } else { var decks = await _deckRepository.GetDecks(viewerId, requestFormat); response.UserDeckList = PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList()); } return response; } [HttpPost("get_empty_deck_number")] public async Task> GetEmptyDeckNumber(DeckFormatRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); return new EmptyDeckNumberResponse { EmptyDeckNum = await _deckRepository.GetEmptyDeckNumber(viewerId, AsFormat(request.DeckFormat)) }; } [HttpPost("update")] public async Task> Update(DeckUpdateRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var format = AsFormat(request.DeckFormat); if (request.IsDelete == 1) { await _deckRepository.DeleteDecks(viewerId, format, new[] { request.DeckNo }); } else { var cls = await _dbContext.Classes.FindAsync(request.ClassId); var sleeve = await _dbContext.Sleeves.FindAsync((int)request.SleeveId); var skin = await _dbContext.LeaderSkins.FindAsync(request.LeaderSkinId); var cards = await ResolveDeckCards(request.CardIdArray); await _deckRepository.UpsertDeck(viewerId, format, request.DeckNo, deck => { deck.Name = request.DeckName ?? string.Empty; if (cls is not null) deck.Class = cls; if (sleeve is not null) deck.Sleeve = sleeve; if (skin is not null) deck.LeaderSkin = skin; deck.RandomLeaderSkin = request.IsRandomLeaderSkin; deck.Cards = cards; // Clear stale rotation_id if the deck moved to a non-MyRotation format; // otherwise persist the chosen period so it survives the next /load/index. deck.MyRotationId = format == Format.MyRotation ? request.RotationId : null; }); } var decks = await _deckRepository.GetDecks(viewerId, format); return new DeckUpdateResponse { UserDeckList = PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList()) }; } [HttpPost("update_name")] public async Task> UpdateName(DeckUpdateNameRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var deck = await _deckRepository.UpsertDeck(viewerId, AsFormat(request.DeckFormat), request.DeckNo, d => d.Name = request.DeckName ?? string.Empty); return new SingleDeckResponse { UserDeck = new UserDeck(deck) }; } [HttpPost("update_sleeve")] public async Task> UpdateSleeve(DeckUpdateSleeveRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var sleeve = await _dbContext.Sleeves.FindAsync((int)request.SleeveId); if (sleeve is null) return BadRequest($"Unknown sleeve {request.SleeveId}"); var deck = await _deckRepository.UpsertDeck(viewerId, AsFormat(request.DeckFormat), request.DeckNo, d => d.Sleeve = sleeve); return new SingleDeckResponse { UserDeck = new UserDeck(deck) }; } [HttpPost("update_leader_skin")] public async Task> UpdateLeaderSkin(DeckUpdateLeaderSkinRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var skin = await _dbContext.LeaderSkins.FindAsync(request.LeaderSkinId); if (skin is null) return BadRequest($"Unknown leader skin {request.LeaderSkinId}"); var deck = await _deckRepository.UpsertDeck(viewerId, AsFormat(request.DeckFormat), request.DeckNo, d => { d.LeaderSkin = skin; d.RandomLeaderSkin = false; }); return new SingleDeckResponse { UserDeck = new UserDeck(deck) }; } // TODO: schema doesn't yet model the random-leader-skin pool — we just pick one and persist // that. Add a join table (DeckLeaderSkinPool) when ranked play / random skins become a real // feature. For now the UI flow still works (server returns a single chosen skin per spec). [HttpPost("update_random_leader_skin")] public async Task> UpdateRandomLeaderSkin(DeckUpdateRandomLeaderSkinRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var pool = request.LeaderSkinIdList ?? new List(); if (pool.Count == 0) return BadRequest("leader_skin_id_list must contain at least one id"); int chosenId = pool[Random.Shared.Next(pool.Count)]; var skin = await _dbContext.LeaderSkins.FindAsync(chosenId); if (skin is null) return BadRequest($"Unknown leader skin {chosenId}"); var deck = await _deckRepository.UpsertDeck(viewerId, AsFormat(request.DeckFormat), request.DeckNo, d => { d.LeaderSkin = skin; d.RandomLeaderSkin = true; }); return new SingleDeckResponse { UserDeck = new UserDeck(deck) }; } [HttpPost("update_order")] public async Task> UpdateOrder(DeckOrderRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); // Deck `Number` IS the slot order — the client sends the same slot numbers in a new // sequence. Today we don't model "display order" separately from "slot number", so // reordering is a no-op server-side. When a separate Order column lands, persist here. return new EmptyResponse(); } [HttpPost("delete_deck_list")] public async Task> DeleteDeckList(DeckDeleteListRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var nos = request.DeckNoList ?? new List(); if (nos.Count > 0) await _deckRepository.DeleteDecks(viewerId, AsFormat(request.DeckFormat), nos); return new EmptyResponse(); } // /deck/set_deck_redis — server side is a Redis-cached "active deck per class" hint for // matchmaking. We don't model matchmaking yet; acknowledge the call and move on (real // server may not persist this either; the `_redis` suffix suggests cache-only). [HttpPost("set_deck_redis")] public Task> SetDeckRedis(SetDeckRedisRequest request) { if (!TryGetViewerId(out long _)) return Task.FromResult>(Unauthorized()); return Task.FromResult>(new EmptyResponse()); } /// /// Convert a flat `card_id_array` (cards repeated for count) into a grouped DeckCard list. /// Cards not in the DB are silently dropped — until CardImport lands the result is always /// empty, which is acceptable for the deck-editing flow (UI saves what it can). /// private async Task> ResolveDeckCards(List? cardIdArray) { if (cardIdArray is null || cardIdArray.Count == 0) return new List(); var grouped = cardIdArray.GroupBy(id => id).Select(g => new { Id = g.Key, Count = g.Count() }).ToList(); var ids = grouped.Select(g => g.Id).ToList(); var cards = await _dbContext.Cards.Where(c => ids.Contains(c.Id)).ToDictionaryAsync(c => c.Id); return grouped .Where(g => cards.ContainsKey(g.Id)) .Select(g => new DeckCard { Card = cards[g.Id], Count = g.Count }) .ToList(); } }