using Microsoft.AspNetCore.Mvc; using SVSim.Database.Repositories.Card; 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. v1 implements /card/destruct only; reserved /// for /card/create, /card/protect, /card/create-foil-card. /// [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 (!TryParseDestructDict(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 }; } 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 TryParseDestructDict( string raw, out Dictionary destructCounts, out Dictionary clientSnapshots, out string errorKey) { destructCounts = 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; destructCounts[cardId] = num; clientSnapshots[cardId] = snapshot; } } return true; } }