diff --git a/SVSim.EmulatedEntrypoint/Controllers/CardController.cs b/SVSim.EmulatedEntrypoint/Controllers/CardController.cs index 9cc985e9..9950c181 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/CardController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/CardController.cs @@ -1,7 +1,11 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SVSim.Database; using SVSim.Database.Enums; +using SVSim.Database.Models; using SVSim.Database.Repositories.Card; using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Card; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Card; @@ -10,18 +14,28 @@ 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. +/// /card/* — viewer card-inventory mutations. Ships /card/destruct, /card/create, /card/protect, +/// /card/create_foil_card. /// [Route("card")] public class CardController : SVSimController { + /// Item catalog id for Orb (Seer's Globe currency). Source: PremiumCardConversionTask + private const int OrbItemId = 1000; + + /// Per-card ownership cap mirrored from the client gate (CardDetailUI.cs:2261). + private const int MaxCardCopies = 3; + private readonly ICardInventoryRepository _inventory; + private readonly IInventoryService _inv; + private readonly SVSimDbContext _db; private readonly ILogger _log; - public CardController(ICardInventoryRepository inventory, ILogger log) + public CardController(ICardInventoryRepository inventory, IInventoryService inv, SVSimDbContext db, ILogger log) { _inventory = inventory; + _inv = inv; + _db = db; _log = log; } @@ -132,6 +146,82 @@ public class CardController : SVSimController _ => "malformed_request", }; + /// + /// Seer's Globe conversion (decomp: Wizard/PremiumCardConversionTask.cs). Spends N Orbs + /// (Item 1000) + N copies of the base card, grants N copies of the foil twin (base.Id + 1). + /// Foil convention is the documented one on — + /// the foil twin shares the base's CardSet at card_id = base + 1. The 3-copy cap is mirrored + /// from the client gate (CardDetailUI.cs:2261); reject server-side too so direct API hits + /// can't bypass. + /// + [HttpPost("create_foil_card")] + public async Task> CreateFoilCard( + CardCreateFoilCardRequest request, CancellationToken ct) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + if (request.CreateNumber < 1) return BadRequest(new { error = "invalid_create_number" }); + + var baseCard = await _db.Set() + .FirstOrDefaultAsync(c => c.Id == request.BaseCardId, ct); + if (baseCard is null || baseCard.IsFoil) + return BadRequest(new { error = "invalid_base_card" }); + + var foilCard = await _db.Set() + .FirstOrDefaultAsync(c => c.Id == baseCard.Id + 1 && c.IsFoil, ct); + if (foilCard is null) + return BadRequest(new { error = "no_foil_variant" }); + + await using var tx = await _inv.BeginAsync(viewerId, ct); + var viewer = tx.Viewer; + + var baseOwned = viewer.Cards.FirstOrDefault(c => c.Card.Id == baseCard.Id); + if (baseOwned is null || baseOwned.Count < request.CreateNumber) + return BadRequest(new { error = "insufficient_base_card" }); + + var foilOwned = viewer.Cards.FirstOrDefault(c => c.Card.Id == foilCard.Id); + int existingFoilCount = foilOwned?.Count ?? 0; + if (existingFoilCount + request.CreateNumber > MaxCardCopies) + return BadRequest(new { error = "would_exceed_max_copies" }); + + // Snapshot mismatch is warn-log only — matches the /card/create + /card/destruct pattern. + if (request.BaseCardNumber != baseOwned.Count) + { + _log.LogWarning( + "CreateFoilCard possession-snapshot mismatch: card={BaseCardId} client_snapshot={Snapshot} server={Server}", + request.BaseCardId, request.BaseCardNumber, baseOwned.Count); + } + + // Spend Orbs first so an Orb-poor viewer keeps their base copy on failure. The inventory + // facade throws InventoryCatalogException when the item has never been owned at all (no + // OwnedItemEntry row), so pre-check rather than relying on TryDebitAsync alone. + var orbOwned = viewer.Items.FirstOrDefault(i => i.Item.Id == OrbItemId); + if (orbOwned is null || orbOwned.Count < request.CreateNumber) + return BadRequest(new { error = "insufficient_orb" }); + var orbSpend = await tx.TryDebitAsync(UserGoodsType.Item, OrbItemId, request.CreateNumber, ct); + if (!orbSpend.Success) + return BadRequest(new { error = "insufficient_orb" }); + + // Debit base copies directly (InventoryTransaction.TryDebitAsync doesn't route Card). + baseOwned.Count -= request.CreateNumber; + + // Grant foil copies via the canonical primitive so CardCosmeticReward cascades fire. + await tx.GrantAsync(UserGoodsType.Card, foilCard.Id, request.CreateNumber, ct); + + var result = await tx.CommitAsync(ct); + + // Append the base card's post-state total so the client can update its on-screen count + // (UpdateHaveUserGoodsNumByJsonData does direct assignment, not delta). + var rewardList = result.RewardList.ToRewardList(); + rewardList.Add(new RewardListEntry + { + RewardType = (int)UserGoodsType.Card, + RewardId = baseCard.Id, + RewardNum = baseOwned.Count, + }); + + return new CardCreateFoilCardResponse { RewardList = rewardList }; + } + [HttpPost("protect")] public async Task> Protect(CardProtectRequest request) { diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Card/CardCreateFoilCardRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Card/CardCreateFoilCardRequest.cs new file mode 100644 index 00000000..8afa21b4 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Card/CardCreateFoilCardRequest.cs @@ -0,0 +1,30 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Card; + +/// +/// POST /card/create_foil_card — Seer's Globe conversion (Wizard/PremiumCardConversionTask.cs). +/// Consumes Orbs (Item id 1000) + copies of +/// to grant copies of the foil twin (base+1). +/// +[MessagePackObject] +public class CardCreateFoilCardRequest : BaseRequest +{ + [JsonPropertyName("base_card_id")] + [Key("base_card_id")] + public int BaseCardId { get; set; } + + /// + /// Client's snapshot of how many normal copies it has at request time. Sanity-check only — + /// the actual spend is create_number copies, not this value. + /// + [JsonPropertyName("base_card_number")] + [Key("base_card_number")] + public int BaseCardNumber { get; set; } + + /// Hardcoded to 1 by PremiumCardConversionTask.SetParameter. + [JsonPropertyName("create_number")] + [Key("create_number")] + public int CreateNumber { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Card/CardCreateFoilCardResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Card/CardCreateFoilCardResponse.cs new file mode 100644 index 00000000..51ef0423 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Card/CardCreateFoilCardResponse.cs @@ -0,0 +1,19 @@ +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Card; + +/// +/// /card/create_foil_card response. reward_list entries are POST-STATE TOTALS — client's +/// PlayerStaticData.UpdateHaveUserGoodsNumByJsonData does direct assignment. Contains +/// the Item (Orb) post-balance, base-card post-count, and foil-card post-count. +/// +[MessagePackObject] +public class CardCreateFoilCardResponse +{ + [JsonPropertyName("reward_list")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + [Key("reward_list")] + public List RewardList { get; set; } = new(); +} diff --git a/SVSim.UnitTests/Controllers/CardControllerCreateFoilCardTests.cs b/SVSim.UnitTests/Controllers/CardControllerCreateFoilCardTests.cs new file mode 100644 index 00000000..fcf598e3 --- /dev/null +++ b/SVSim.UnitTests/Controllers/CardControllerCreateFoilCardTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using NUnit.Framework; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class CardControllerCreateFoilCardTests +{ + private const int OrbItemId = 1000; + + private static async Task<(long baseCardId, long foilCardId)> SeedFoilPairAsync(SVSimTestFactory factory) + { + long baseId = await factory.SeedCardAsync(isFoil: false); + // SeedCardAsync increments by 2 to keep base/foil at consecutive ids; the foil twin + // for our baseId is at baseId + 1. + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + long foilId = baseId + 1; + if (!await db.Cards.AnyAsync(c => c.Id == foilId)) + { + db.Cards.Add(new ShadowverseCardEntry { Id = foilId, IsFoil = true, Name = $"SeedCard{foilId}" }); + await db.SaveChangesAsync(); + } + return (baseId, foilId); + } + + private static async Task GiveBaseCopyAsync(SVSimTestFactory factory, long viewerId, long baseCardId, int count) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var card = await db.Cards.FirstAsync(c => c.Id == baseCardId); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = count, IsProtected = false }); + await db.SaveChangesAsync(); + } + + private static async Task GiveOrbsAsync(SVSimTestFactory factory, long viewerId, int count) + => await factory.SeedOwnedItemAsync(viewerId, OrbItemId, count, itemName: "Orb", itemType: 1); + + [Test] + public async Task CreateFoilCard_consumes_one_orb_and_one_normal_copy_and_grants_one_foil() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 0); + var (baseCardId, foilCardId) = await SeedFoilPairAsync(factory); + await GiveBaseCopyAsync(factory, viewerId, baseCardId, count: 1); + await GiveOrbsAsync(factory, viewerId, count: 1); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var requestJson = $$"""{"base_card_id":{{baseCardId}},"base_card_number":1,"create_number":1,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + var response = await client.PostAsync("/card/create_foil_card", + new StringContent(requestJson, Encoding.UTF8, "application/json")); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var scope2 = factory.Services.CreateScope(); + var db2 = scope2.ServiceProvider.GetRequiredService(); + var viewer2 = await db2.Viewers + .Include(v => v.Cards).ThenInclude(c => c.Card) + .Include(v => v.Items).ThenInclude(i => i.Item) + .FirstAsync(v => v.Id == viewerId); + + var foilOwn = viewer2.Cards.SingleOrDefault(c => c.Card.Id == foilCardId); + var baseOwn = viewer2.Cards.SingleOrDefault(c => c.Card.Id == baseCardId); + Assert.That(foilOwn?.Count ?? 0, Is.EqualTo(1), "foil count"); + Assert.That(baseOwn?.Count ?? 0, Is.EqualTo(0), "base count after consume"); + + var orbBalance = viewer2.Items.SingleOrDefault(i => i.Item.Id == OrbItemId)?.Count ?? 0; + Assert.That(orbBalance, Is.EqualTo(0), "orb balance after spend"); + + using var doc = JsonDocument.Parse(body); + var rewards = doc.RootElement.GetProperty("reward_list").EnumerateArray().ToList(); + Assert.That(rewards, Is.Not.Empty); + } + + [Test] + public async Task CreateFoilCard_rejects_without_orb() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 0); + var (baseCardId, foilCardId) = await SeedFoilPairAsync(factory); + await GiveBaseCopyAsync(factory, viewerId, baseCardId, count: 1); + // No orbs granted. + + using var client = factory.CreateAuthenticatedClient(viewerId); + var requestJson = $$"""{"base_card_id":{{baseCardId}},"base_card_number":1,"create_number":1,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + var response = await client.PostAsync("/card/create_foil_card", + new StringContent(requestJson, Encoding.UTF8, "application/json")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Cards.Single(c => c.Card.Id == baseCardId).Count, Is.EqualTo(1), "base count untouched"); + Assert.That(viewer.Cards.Any(c => c.Card.Id == foilCardId && c.Count > 0), Is.False, "no foil granted"); + } + + [Test] + public async Task CreateFoilCard_rejects_without_base_copy() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 0); + var (baseCardId, _) = await SeedFoilPairAsync(factory); + await GiveOrbsAsync(factory, viewerId, count: 1); + // No base copy granted. + + using var client = factory.CreateAuthenticatedClient(viewerId); + var requestJson = $$"""{"base_card_id":{{baseCardId}},"base_card_number":1,"create_number":1,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + var response = await client.PostAsync("/card/create_foil_card", + new StringContent(requestJson, Encoding.UTF8, "application/json")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.Items).ThenInclude(i => i.Item).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Items.Single(i => i.Item.Id == OrbItemId).Count, Is.EqualTo(1), "orb balance untouched"); + } +}