From 9c9d0fc41f30cd6f1da17ce6ff4f9558d73ed3a3 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 30 May 2026 23:17:11 -0400 Subject: [PATCH] feat(packs): accept all currently-supported currencies on /pack/open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends /pack/open beyond the v1 CRYSTAL_MULTI=2 / DAILY=3 / RUPY_MULTI=7 trio to cover every type_detail whose payment primitive already exists: 1 CRYSTAL -> ICurrencySpendService crystal debit 6 RUPY -> ICurrencySpendService rupee debit 4 TICKET / 5 TICKET_MULTI -> debit child.ItemId from OwnedItemEntry (ticketsNeeded = cost * packNumber), 400 on missing/short balance; reward_list gets a RewardType=4 post-state Item entry to mirror project_wire_reward_list_post_state Skin-overload type_details (8/9/13) and free-pack overlays (10/11/12) stay 501 — they need selection / banner plumbing the current code doesn't have. Tutorial alias unchanged: it still consumes the gating ticket post-draw and stamps tutorial_step=100. The two ticket flows diverge by intent (tutorial = free server-grant; normal = paid by ticket inventory). Removed Open_rejects_ticket_type_detail (asserted the old 501 path); covered by Open_rejects_insufficient_tickets. Updated NonTutorial_pack_open_does_not_emit_tutorial_step to assert the new 200-on-ticket-success behavior — same invariant under test. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/PackController.cs | 49 +++- .../Controllers/PackControllerOpenTests.cs | 213 ++++++++++++++++-- .../Controllers/PackControllerTests.cs | 36 ++- 3 files changed, 256 insertions(+), 42 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 702dc3b..fa1ddfb 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -271,11 +271,14 @@ public class PackController : SVSimController // when buying a RUPY_MULTI (type_detail=7) child. The gacha_id alone disambiguates the // child; gacha_type validation against child.TypeDetail would falsely reject every buy. - // Supported currency types in v1: CRYSTAL_MULTI=2, DAILY=3, RUPY_MULTI=7. Ticket flows - // (TICKET=4, TICKET_MULTI=5) and the rest are explicitly out of scope for the normal path. - // The tutorial path (type_detail=5, TICKET_MULTI) bypasses this guard — the starter pack - // is a free server-granted bonus, not a purchasable pack. - if (!isTutorialPath && child.TypeDetail is not (2 or 3 or 7)) + // Supported type_details on the normal path: + // 1 CRYSTAL / 2 CRYSTAL_MULTI -> spend crystals + // 6 RUPY / 7 RUPY_MULTI -> spend rupees + // 3 DAILY -> spend rupees, once per UTC day + // 4 TICKET / 5 TICKET_MULTI -> consume child.ItemId from OwnedItemEntry + // Skin-overload types (8/9/13) and free-pack overlays (10/11/12) need extra + // selection / banner plumbing — kept 501 until the relevant flows land. + if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7)) return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" }); var viewer = await _db.Viewers @@ -301,14 +304,16 @@ public class PackController : SVSimController { switch (child.TypeDetail) { - case 2: // CRYSTAL_MULTI + case 1: // CRYSTAL (single) + case 2: // CRYSTAL_MULTI (10-pack) { long cost = (long)child.Cost * packNumber; var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, cost); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); break; } - case 7: // RUPY_MULTI + case 6: // RUPY (single) + case 7: // RUPY_MULTI (10-pack) { long cost = (long)child.Cost * packNumber; var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost); @@ -329,6 +334,20 @@ public class PackController : SVSimController if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); break; } + case 4: // TICKET (single) + case 5: // TICKET_MULTI (10-pack) + { + if (child.ItemId is not long ticketItemId) + return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" }); + + int ticketsNeeded = child.Cost * packNumber; + var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); + if (owned is null || owned.Count < ticketsNeeded) + return BadRequest(new { error = "insufficient_tickets" }); + + owned.Count -= ticketsNeeded; + break; + } } await _db.SaveChangesAsync(); } @@ -388,14 +407,26 @@ public class PackController : SVSimController // Currency reward entries only apply to purchasable packs; tutorial path omits them. if (!isTutorialPath) { - if (child.TypeDetail == 2) + if (child.TypeDetail is 1 or 2) { rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) }); } - else if (child.TypeDetail == 7 || child.TypeDetail == 3) + else if (child.TypeDetail is 3 or 6 or 7) { rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) }); } + else if (child.TypeDetail is 4 or 5 && child.ItemId is long ticketItemId) + { + // Item post-state count for the ticket we just consumed — client direct-assigns + // _userItemDict, so this must be the new total (project_wire_reward_list_post_state). + var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); + rewardList.Add(new RewardListEntry + { + RewardType = 4, // Item + RewardId = ticketItemId, + RewardNum = owned?.Count ?? 0, // post-state total + }); + } } rewardList.AddRange(grant.RewardList); diff --git a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs index fd0f9cc..06124ce 100644 --- a/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerOpenTests.cs @@ -153,30 +153,6 @@ public class PackControllerOpenTests Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented)); } - [Test] - public async Task Open_rejects_ticket_type_detail() - { - using var factory = new SVSimTestFactory(); - long viewerId = await factory.SeedViewerAsync(); - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.Packs.Add(new PackConfigEntry - { - Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack, - CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), - GachaType = 1, GachaDetail = "ticket-only pack", SleeveId = 5090001, - ChildGachas = { new PackChildGachaEntry { GachaId = 920002, TypeDetail = 5, Cost = 1, CardCount = 8, ItemId = 92001 } }, - }); - await db.SaveChangesAsync(); - } - using var client = factory.CreateAuthenticatedClient(viewerId); - var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":92001,"gacha_id":920002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; - var response = await client.PostAsync("/pack/open", JsonBody(json)); - - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented)); - } - [Test] public async Task Open_rejects_insufficient_rupees() { @@ -232,6 +208,195 @@ public class PackControllerOpenTests } } + [Test] + public async Task Open_with_crystal_single_deducts_crystals() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync(); + db.Packs.Add(new PackConfigEntry + { + Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "crystal single", + // type_detail=1 (CRYSTAL single-pack) — new path added 2026-05-31. + ChildGachas = { new PackChildGachaEntry { GachaId = 100012, TypeDetail = 1, Cost = 120, CardCount = 8 } }, + }); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 200; + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100012,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; + var response = await client.PostAsync("/pack/open", JsonBody(json)); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); + using var scope2 = factory.Services.CreateScope(); + var db2 = scope2.ServiceProvider.GetRequiredService(); + Assert.That((await db2.Viewers.FirstAsync(x => x.Id == viewerId)).Currency.Crystals, Is.EqualTo(80UL)); + } + + [Test] + public async Task Open_with_rupy_single_deducts_rupees() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync(); + db.Packs.Add(new PackConfigEntry + { + Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "rupy single", + ChildGachas = { new PackChildGachaEntry { GachaId = 600006, TypeDetail = 6, Cost = 100, CardCount = 8 } }, + }); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Rupees = 250; + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":600006,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; + var response = await client.PostAsync("/pack/open", JsonBody(json)); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); + using var scope2 = factory.Services.CreateScope(); + var db2 = scope2.ServiceProvider.GetRequiredService(); + Assert.That((await db2.Viewers.FirstAsync(x => x.Id == viewerId)).Currency.Rupees, Is.EqualTo(150UL)); + } + + [Test] + public async Task Open_with_ticket_consumes_ticket_and_emits_post_state_item_count() + { + const int TicketItemId = 90001; + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedItemAsync(viewerId, itemId: TicketItemId, count: 3, itemName: "Legendary Card Pack Ticket"); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync(); + db.Packs.Add(new PackConfigEntry + { + Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "ticket pack", + // type_detail=4 (TICKET single) — 1 ticket per pack. + ChildGachas = { new PackChildGachaEntry { GachaId = 100014, TypeDetail = 4, Cost = 1, CardCount = 8, ItemId = TicketItemId } }, + }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100014,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; + var response = await client.PostAsync("/pack/open", JsonBody(json)); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + // Ticket count should be 2 (started at 3, consumed 1). + using var scope2 = factory.Services.CreateScope(); + var db2 = scope2.ServiceProvider.GetRequiredService(); + var owned = await db2.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.Items) + .Where(i => i.Item.Id == TicketItemId) + .Select(i => i.Count) + .FirstAsync(); + Assert.That(owned, Is.EqualTo(2)); + + // reward_list must include the post-state Item entry (RewardType=4) so the client + // direct-assigns _userItemDict — same post-state convention as the tutorial path. + using var doc = JsonDocument.Parse(body); + var rewardList = doc.RootElement.GetProperty("reward_list"); + var ticketEntry = rewardList.EnumerateArray() + .FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 4 + && e.GetProperty("reward_id").GetInt64() == TicketItemId); + Assert.That(ticketEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined), + "ticket reward entry missing from reward_list"); + Assert.That(ticketEntry.GetProperty("reward_num").GetInt32(), Is.EqualTo(2), + "RewardNum must be post-state total, not delta"); + } + + [Test] + public async Task Open_with_ticket_multi_consumes_one_ticket_per_pack() + { + const int TicketItemId = 90001; + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedItemAsync(viewerId, itemId: TicketItemId, count: 10, itemName: "Bulk Ticket"); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync(); + db.Packs.Add(new PackConfigEntry + { + Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "10-pack ticket", + // type_detail=5 (TICKET_MULTI 10-pack) — cost=1 ticket per pack, packNumber=10 + // => 10 tickets consumed total. + ChildGachas = { new PackChildGachaEntry { GachaId = 100015, TypeDetail = 5, Cost = 1, CardCount = 8, ItemId = TicketItemId } }, + }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100015,"gacha_type":1,"pack_number":10,"exclude_card_ids":[]}"""; + var response = await client.PostAsync("/pack/open", JsonBody(json)); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); + + using var scope2 = factory.Services.CreateScope(); + var db2 = scope2.ServiceProvider.GetRequiredService(); + var owned = await db2.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.Items) + .Where(i => i.Item.Id == TicketItemId) + .Select(i => i.Count) + .FirstAsync(); + Assert.That(owned, Is.EqualTo(0), "10 tickets started, 10-pack buy consumes 10"); + } + + [Test] + public async Task Open_rejects_insufficient_tickets() + { + const int TicketItemId = 90001; + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedItemAsync(viewerId, itemId: TicketItemId, count: 0, itemName: "Ticket"); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync(); + db.Packs.Add(new PackConfigEntry + { + Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None, + CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), + GachaType = 1, GachaDetail = "ticket pack", + ChildGachas = { new PackChildGachaEntry { GachaId = 100014, TypeDetail = 4, Cost = 1, CardCount = 8, ItemId = TicketItemId } }, + }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100014,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; + var response = await client.PostAsync("/pack/open", JsonBody(json)); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + var body = await response.Content.ReadAsStringAsync(); + Assert.That(body, Does.Contain("insufficient_tickets")); + } + [Test] public async Task Open_daily_marks_last_daily_free_at_and_rejects_second_attempt() { diff --git a/SVSim.UnitTests/Controllers/PackControllerTests.cs b/SVSim.UnitTests/Controllers/PackControllerTests.cs index af64f73..11e8f40 100644 --- a/SVSim.UnitTests/Controllers/PackControllerTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerTests.cs @@ -141,26 +141,44 @@ public class PackControllerTests [Test] public async Task NonTutorial_pack_open_does_not_emit_tutorial_step() { - // Verify that regular /pack/open still works AND does not include tutorial_step in the response. - // Use the tutorial pack (99047/990047) which has type_detail=5 — the non-tutorial path - // still hits the currency_path_not_implemented guard and returns 501. + // Verify that regular /pack/open works on a ticket-funded pack AND does not include + // tutorial_step in the response. Pack 99047 uses type_detail=5 (TICKET_MULTI), which + // the non-tutorial path now accepts: a normal viewer with a ticket buys a normal pack + // — only the /tutorial/pack_open alias attaches tutorial_step. using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 100); + + // Pack 99047 has BasePackId=90001 (Throwback). The minimal card seed only creates set + // 10001, so seed set 90001 explicitly + install a draw table pointing at its cards. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.CardSets.Add(new ShadowverseCardSetEntry + { + Id = 90001, Name = "TutorialStarterSet", IsInRotation = true, IsBasic = false, + Cards = + [ + new ShadowverseCardEntry { Id = 90001001L, Name = "StarterCard1", Rarity = Rarity.Bronze }, + new ShadowverseCardEntry { Id = 90001002L, Name = "StarterCard2", Rarity = Rarity.Gold }, + new ShadowverseCardEntry { Id = 90001003L, Name = "StarterCard3", Rarity = Rarity.Legendary }, + ], + }); + await db.SaveChangesAsync(); + } + await factory.SeedPackDrawTableAsync(99047, 90001001L, 90001002L, 90001003L); + await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket"); + using var client = factory.CreateAuthenticatedClient(viewerId); var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; var response = await client.PostAsync("/pack/open", new StringContent(requestJson, Encoding.UTF8, "application/json")); - // Non-tutorial pack/open + type_detail=5 STILL returns 501 — that's the established behavior. - Assert.That((int)response.StatusCode, Is.EqualTo(501), - "Non-tutorial /pack/open with type_detail=5 should still hit the currency_path_not_implemented guard."); - - // Even on a 501, no tutorial_step field should appear in the response body. var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); Assert.That(body.Contains("\"tutorial_step\""), Is.False, - "Regular /pack/open must never emit tutorial_step."); + "Regular /pack/open must never emit tutorial_step — only /tutorial/pack_open does."); } [Test]