using Microsoft.AspNetCore.Mvc; using SVSim.Database.Enums; using SVSim.Database.Repositories.Card; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Card; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Card; using System.Text.Json; namespace SVSim.EmulatedEntrypoint.Controllers; /// /// /card/* — viewer card-inventory mutations. Ships /card/destruct, /card/create, /card/protect. /// /card/create_foil_card is reserved for a follow-up slice. /// [Route("card")] public class CardController : SVSimController { private readonly ICardInventoryRepository _inventory; private readonly ILogger _log; public CardController(ICardInventoryRepository inventory, ILogger log) { _inventory = inventory; _log = log; } [HttpPost("destruct")] public async Task> Destruct(CardDestructRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); if (!TryParseCardCountDict(request.CardIdNumberArray, out var destructCounts, out var snapshots, out var parseError)) return BadRequest(new { error = parseError }); if (destructCounts.Count == 0) return BadRequest(new { error = "malformed_request" }); var outcome = await _inventory.DestructCards(viewerId, destructCounts); if (!outcome.IsSuccess) return BadRequest(new { error = ErrorKey(outcome.Error!.Value) }); // Client snapshot mismatch is warn-log only; never blocks the request. foreach (var (cardId, snapshot) in snapshots) { // We don't carry pre-state counts back, but post + destructed = pre. int destructedNum = destructCounts[cardId]; int reconstructedPre = outcome.Result!.NewOwnedCounts[cardId] + destructedNum; if (reconstructedPre != snapshot) { _log.LogWarning( "Destruct possession-snapshot mismatch: card={CardId} client_snapshot={Snapshot} server_pre={ServerPre}", cardId, snapshot, reconstructedPre); } } // Wire spec is int; clamp the ulong total so a hypothetical 2B+ balance can't underflow // to a negative wire value. Realistic balances are well under int.MaxValue. int redEtherWire = outcome.Result!.NewRedEtherTotal > int.MaxValue ? int.MaxValue : (int)outcome.Result!.NewRedEtherTotal; var rewardList = new List { new() { RewardType = 1, RewardId = 0, RewardNum = redEtherWire }, }; foreach (var (cardId, postCount) in outcome.Result!.NewOwnedCounts) { rewardList.Add(new RewardListEntry { RewardType = 5, RewardId = cardId, RewardNum = postCount }); } return new CardDestructResponse { RewardList = rewardList }; } [HttpPost("create")] public async Task> Create(CardCreateRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); if (!TryParseCardCountDict(request.CardIdNumberArray, out var createCounts, out var snapshots, out var parseError)) return BadRequest(new { error = parseError }); if (createCounts.Count == 0) return BadRequest(new { error = "malformed_request" }); var outcome = await _inventory.CreateCards(viewerId, createCounts); if (!outcome.IsSuccess) return BadRequest(new { error = CreateErrorKey(outcome.Error!.Value) }); // Snapshot mismatch is warn-log only. pre-state = post-state - num. var grants = outcome.Result!.Grants; foreach (var (cardId, snapshot) in snapshots) { int requestedNum = createCounts[cardId]; int postCount = grants.FirstOrDefault(g => g.RewardType == (int)UserGoodsType.Card && g.RewardId == cardId)?.RewardNum ?? 0; int reconstructedPre = postCount - requestedNum; if (reconstructedPre != snapshot) { _log.LogWarning( "Create possession-snapshot mismatch: card={CardId} client_snapshot={Snapshot} server_pre={ServerPre}", cardId, snapshot, reconstructedPre); } } // Wire spec is int; clamp the ulong total so a hypothetical 2B+ balance can't underflow // to a negative wire value. Mirrors destruct's clamp. int redEtherWire = outcome.Result!.NewRedEtherTotal > int.MaxValue ? int.MaxValue : (int)outcome.Result!.NewRedEtherTotal; var rewardList = new List { new() { RewardType = (int)UserGoodsType.RedEther, RewardId = 0, RewardNum = redEtherWire }, }; foreach (var grant in grants) { rewardList.Add(new RewardListEntry { RewardType = grant.RewardType, RewardId = grant.RewardId, RewardNum = grant.RewardNum, }); } return new CardCreateResponse { RewardList = rewardList }; } private static string CreateErrorKey(CreateError error) => error switch { CreateError.UnknownCard => "unknown_card", CreateError.NotCraftable => "not_craftable", CreateError.WouldExceedMaxCopies => "would_exceed_max_copies", CreateError.InsufficientVials => "insufficient_vials", _ => "malformed_request", }; [HttpPost("protect")] public async Task> Protect(CardProtectRequest request) { if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var outcome = await _inventory.SetProtected(viewerId, request.CardId, request.IsProtected); if (!outcome.IsSuccess) { return outcome.Error switch { ProtectError.UnknownCard => BadRequest(new { error = "unknown_card" }), _ => BadRequest(new { error = "malformed_request" }), }; } return new CardProtectResponse(); } private static string ErrorKey(DestructError error) => error switch { DestructError.UnknownCard => "unknown_card", DestructError.NotDestructible => "not_destructible", DestructError.CardProtected => "card_protected", DestructError.InsufficientCards => "insufficient_cards", _ => "malformed_request", }; /// /// Decodes the inner JSON of card_id_number_array. Values are /// "<num_to_destruct>,<client_possession_snapshot>" — both strings. /// Returns false (and sets ) on any structural problem. /// private static bool TryParseCardCountDict( string raw, out Dictionary counts, out Dictionary clientSnapshots, out string errorKey) { counts = new(); clientSnapshots = new(); errorKey = "malformed_request"; if (string.IsNullOrWhiteSpace(raw)) return false; JsonDocument? doc; try { doc = JsonDocument.Parse(raw); } catch (JsonException) { return false; } using (doc) { if (doc.RootElement.ValueKind != JsonValueKind.Object) return false; foreach (var prop in doc.RootElement.EnumerateObject()) { if (!long.TryParse(prop.Name, out long cardId)) return false; if (prop.Value.ValueKind != JsonValueKind.String) return false; var pair = prop.Value.GetString()!.Split(','); if (pair.Length != 2) return false; if (!int.TryParse(pair[0], out int num) || num <= 0) return false; if (!int.TryParse(pair[1], out int snapshot) || snapshot < 0) return false; counts[cardId] = num; clientSnapshots[cardId] = snapshot; } } return true; } }