using System.Net; using System.Text; using System.Text.Json; 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 PackControllerOpenTests { private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); /// /// Seeds an active pack (parent 10001, base = first seeded card set) with one rupee child /// gacha (gacha_id=400002, cost=100), then gives the viewer enough rupees to buy once. /// private static async Task SeedOpenablePack(SVSimTestFactory f, long viewerId, ulong rupees = 200) { int baseId; using (var scope = f.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); 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 = "test", SleeveId = 3000011, ChildGachas = { new PackChildGachaEntry { GachaId = 400002, TypeDetail = CardPackType.RupyMulti, Cost = 100, CardCount = 8 }, }, }); var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); v.Currency.Rupees = rupees; await db.SaveChangesAsync(); } return baseId; } [Test] public async Task Open_with_rupees_returns_8_cards_and_deducts_currency() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"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); using var doc = JsonDocument.Parse(body); var pack = doc.RootElement.GetProperty("pack_list"); Assert.That(pack.GetArrayLength(), Is.EqualTo(8)); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); Assert.That(v.Currency.Rupees, Is.EqualTo(100UL), "200 starting - 100 cost"); } [Test] public async Task Open_persists_drawn_cards_to_viewer_collection() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; await client.PostAsync("/pack/open", JsonBody(json)); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.Include(x => x.Cards).ThenInclude(c => c.Card).FirstAsync(x => x.Id == viewerId); var totalGranted = v.Cards.Sum(c => c.Count); Assert.That(totalGranted, Is.EqualTo(8), "8 cards drawn, all persisted (Count sums to 8 even when duplicates collapse)."); } [Test] public async Task Open_increments_viewer_open_count() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; await client.PostAsync("/pack/open", JsonBody(json)); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.Include(x => x.PackOpenCounts).FirstAsync(x => x.Id == viewerId); Assert.That(v.PackOpenCounts.Single(p => p.PackId == 10001).OpenCount, Is.EqualTo(1)); } [Test] public async Task Open_rejects_when_class_id_present() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"class_id":3}"""; var response = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented)); } [Test] public async Task Open_rejects_when_target_card_id_present() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"target_card_id":12345}"""; var response = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented)); } [Test] public async Task Open_rejects_skin_pack_category() { 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 = 70001, BasePackId = 70001, PackCategory = PackCategory.LeaderSkinPack, CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), GachaType = 1, GachaDetail = "skin pack", ChildGachas = { new PackChildGachaEntry { GachaId = 700017, TypeDetail = CardPackType.RupyMulti, Cost = 100, CardCount = 8 } }, }); await db.SaveChangesAsync(); } using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":70001,"gacha_id":700017,"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() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId, rupees: 50); // need 100 using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"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)); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); Assert.That(v.Currency.Rupees, Is.EqualTo(50UL), "no deduction on insufficient-funds reject"); } [Test] public async Task Open_with_crystals_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 = "test", ChildGachas = { new PackChildGachaEntry { GachaId = 100002, TypeDetail = CardPackType.CrystalMulti, Cost = 100, CardCount = 8 } }, }); var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); v.Currency.Crystals = 250; await db.SaveChangesAsync(); } using var client = factory.CreateAuthenticatedClient(viewerId); // gacha_type:1 (parent pack's gacha_type) not :2 (child's type_detail) — see project_wire_pack_gacha_type memory. var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100002,"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)); using (var scope2 = factory.Services.CreateScope()) { var db2 = scope2.ServiceProvider.GetRequiredService(); var v2 = await db2.Viewers.FirstAsync(x => x.Id == viewerId); Assert.That(v2.Currency.Crystals, Is.EqualTo(150UL)); } } [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 = CardPackType.Crystal, 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 = CardPackType.Rupy, 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 = CardPackType.Ticket, 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 = CardPackType.TicketMulti, 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 = CardPackType.Ticket, 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() { 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 = "daily test", ChildGachas = { new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 0, CardCount = 1, IsDailySingle = true } }, }); await db.SaveChangesAsync(); } using var client = factory.CreateAuthenticatedClient(viewerId); // gacha_type:1 (parent pack's gacha_type) not :3 (child's type_detail=DAILY) — prod-correct. var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":200001,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; var first = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK), await first.Content.ReadAsStringAsync()); var second = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), "Second daily-free open the same UTC day should be rejected."); } // ---------------- Regression tests for wire-shape quirks ---------------- [Test] public async Task Open_succeeds_when_request_gacha_type_differs_from_child_type_detail() { // Prod client sends gacha_type=1 (parent pack's gacha_type) for every buy on a // gacha_type=1 pack, regardless of which child gacha is being bought (RUPY_MULTI=7, // DAILY=3, CRYSTAL_MULTI=2, TICKET_MULTI=5, ...). Server must NOT reject on // `child.TypeDetail != request.GachaType` — gacha_id alone identifies the child. // See project_wire_pack_gacha_type memory. using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); // gacha_id 400002 is RUPY_MULTI (type_detail=7); request sends gacha_type=1 — should succeed. var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"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); } [Test] public async Task Open_reward_list_includes_post_state_rupee_balance() { // Client's PlayerStaticData.UpdateHaveUserGoodsNum does `UserRupyCount = reward_num` // (direct assignment). Without an entry, the on-screen rupee count stays stale until // restart / /mypage/refresh. Verify the entry shape: reward_type=9 (Rupy), reward_id=0, // reward_num=new post-state balance (starting 200 - cost 100 = 100). using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId, rupees: 200); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"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); using var doc = JsonDocument.Parse(body); var rewardList = doc.RootElement.GetProperty("reward_list"); Assert.That(rewardList.GetArrayLength(), Is.GreaterThan(0), "reward_list must be populated — empty list leaves client cache stale."); var rupyEntry = Enumerable.Range(0, rewardList.GetArrayLength()) .Select(i => rewardList[i]) .FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 9); Assert.That(rupyEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined), "missing Rupy (type=9) entry — client will keep showing the old rupee balance."); Assert.That(rupyEntry.GetProperty("reward_id").GetInt64(), Is.EqualTo(0L)); Assert.That(rupyEntry.GetProperty("reward_num").GetInt32(), Is.EqualTo(100), "reward_num is the new TOTAL balance (200 starting - 100 cost), not a delta."); } [Test] public async Task Open_reward_list_includes_post_state_card_counts() { // For each unique drawn card the client expects `{reward_type:5, reward_id:, // reward_num:}`. Without these the in-session collection cache // is stale (cards appear in the open animation but the collection view doesn't update). using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; var response = await client.PostAsync("/pack/open", JsonBody(json)); var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); var packList = doc.RootElement.GetProperty("pack_list"); var rewardList = doc.RootElement.GetProperty("reward_list"); // Every distinct card_id in pack_list must have a matching type=5 reward_list entry. var drawnCardIds = Enumerable.Range(0, packList.GetArrayLength()) .Select(i => packList[i].GetProperty("card_id").GetInt64()) .Distinct() .ToHashSet(); var cardEntryIds = Enumerable.Range(0, rewardList.GetArrayLength()) .Select(i => rewardList[i]) .Where(e => e.GetProperty("reward_type").GetInt32() == 5) .Select(e => e.GetProperty("reward_id").GetInt64()) .ToHashSet(); Assert.That(cardEntryIds, Is.SupersetOf(drawnCardIds), "Every unique drawn card_id must appear in reward_list with reward_type=5."); } [Test] public async Task OpenPack_ResponseIncludesSkinEntry_WhenLeaderCardDrawn() { // Verifies the C.1 refactor surfaces cosmetic grants from ICardAcquisitionService into // the /pack/open response. Seeds a pack whose pool contains ONLY a known leader card // (so every slot is forced to that card via PickCardOfRarity's fallback walk), plus the // CardCosmeticReward mapping + LeaderSkinEntry master row. Expectation: response's // reward_list contains a type=10 (Skin) entry with the mapped skin_id. const long LeaderCardId = 704741010L; const int SkinId = 407; using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); int parentGachaId = await SeedSingleLeaderCardPack(factory, LeaderCardId); await factory.SeedPackDrawTableAsync(parentGachaId, LeaderCardId); await SeedCosmeticMapping(factory, LeaderCardId, SkinId); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); v.Currency.Rupees = 200; await db.SaveChangesAsync(); } using var client = factory.CreateAuthenticatedClient(viewerId); var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":{{parentGachaId}},"gacha_id":{{parentGachaId * 100 + 2}},"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); using var doc = JsonDocument.Parse(body); var rewardList = doc.RootElement.GetProperty("reward_list"); var skinEntry = Enumerable.Range(0, rewardList.GetArrayLength()) .Select(i => rewardList[i]) .FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 10); Assert.That(skinEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined), "expected a Skin (type=10) entry in /pack/open's reward_list when a leader card is drawn"); Assert.That(skinEntry.GetProperty("reward_id").GetInt64(), Is.EqualTo((long)SkinId)); } /// /// Creates a new CardSet whose ONLY non-foil card is , plus /// a PackConfigEntry pointing at that set. PackOpenService's PickCardOfRarity fallback walks /// every rarity and lands on the sole available card, so every draw from this pack is /// guaranteed to produce . Returns the parent gacha id. /// private static async Task SeedSingleLeaderCardPack(SVSimTestFactory f, long leaderCardId) { const int CardSetId = 99001; const int ParentGachaId = 99001; using var scope = f.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); // Reuse an existing card row if one exists at this id (e.g. seeded by globals); else // insert a Legendary stub. Rarity=Legendary so SV Classic slot-8 (which forbids Bronze) // resolves cleanly without falling through. var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == leaderCardId); if (card is null) { card = new ShadowverseCardEntry { Id = leaderCardId, Name = $"SeededLeader{leaderCardId}", Rarity = Rarity.Legendary }; db.Cards.Add(card); await db.SaveChangesAsync(); } // Attach to a dedicated CardSet so the pool resolver (PackCategory.None branch) sees // exactly this one card. var set = new ShadowverseCardSetEntry { Id = CardSetId, Name = "SingleLeaderTestSet", IsInRotation = false, IsBasic = false, Cards = new List { card }, }; db.CardSets.Add(set); db.Packs.Add(new PackConfigEntry { Id = ParentGachaId, BasePackId = CardSetId, PackCategory = PackCategory.None, CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), GachaType = 1, GachaDetail = "single-leader test", SleeveId = 3000099, ChildGachas = { new PackChildGachaEntry { GachaId = ParentGachaId * 100 + 2, TypeDetail = CardPackType.RupyMulti, Cost = 100, CardCount = 8 }, }, }); await db.SaveChangesAsync(); return ParentGachaId; } /// /// Inserts a CardCosmeticReward(Skin) row mapping , /// and ensures the LeaderSkinEntry master row exists. Production seed for CardCosmeticReward /// is stripped in tests by SqliteFriendlyModelCustomizer, so the test must insert its own. /// private static async Task SeedCosmeticMapping(SVSimTestFactory f, long cardId, int skinId) { using var scope = f.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); if (await db.LeaderSkins.FindAsync(skinId) is null) db.LeaderSkins.Add(new LeaderSkinEntry { Id = skinId, Name = $"TestSkin{skinId}" }); db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = cardId, Type = CosmeticType.Skin, CosmeticId = skinId, Quantity = 1, }); await db.SaveChangesAsync(); } [Test] public async Task PackOpen_accrues_gacha_points_per_pack_drawn() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Classes.Add(new ClassEntry { Id = 0, Name = "Neutral" }); var set = new ShadowverseCardSetEntry { Id = 10008, IsInRotation = true }; db.CardSets.Add(set); // Need a pool that can draw 8 cards across the 4 default rarities. for (int i = 0; i < 30; i++) { set.Cards.Add(new ShadowverseCardEntry { Id = 10804_1010 + i, Name = $"c{i}", Rarity = (Rarity)((i % 4) + 1), // Bronze..Legendary Class = db.Classes.Local.First(), IsFoil = false, }); } db.Packs.Add(new PackConfigEntry { Id = 10008, BasePackId = 10008, PackCategory = PackCategory.LegendCardPack, CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), GachaType = 1, GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 }, ChildGachas = { new PackChildGachaEntry { GachaId = 100087, TypeDetail = CardPackType.RupyMulti, Cost = 100, CardCount = 8, OverrideIncreaseGachaPoint = 0, }, }, }); var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId); viewer.Currency.Rupees = 10000; await db.SaveChangesAsync(); } // 30 card stubs were seeded above (Ids 108041010..108041039); install a draw table // pointing the pack at those so the sampler picks from real test cards. var seededCardIds = Enumerable.Range(0, 30).Select(i => (long)(10804_1010 + i)).ToArray(); await factory.SeedPackDrawTableAsync(10008, seededCardIds); using var client = factory.CreateAuthenticatedClient(viewerId); var body = new StringContent( """{"parent_gacha_id":10008,"gacha_id":100087,"gacha_type":1,"pack_number":3,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""", System.Text.Encoding.UTF8, "application/json"); var response = await client.PostAsync("/pack/open", body); var text = await response.Content.ReadAsStringAsync(); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), text); using var scope2 = factory.Services.CreateScope(); var db2 = scope2.ServiceProvider.GetRequiredService(); var v = await db2.Viewers .Include(x => x.GachaPointBalances) .FirstAsync(x => x.Id == viewerId); Assert.That(v.GachaPointBalances.Single().Points, Is.EqualTo(3)); } [Test] public async Task Open_freeplay_succeeds_with_zero_balance_and_no_deduction() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId, rupees: 0); // broke, but freeplay await factory.EnableFreeplayAsync(); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"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 scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); Assert.That(v.Currency.Rupees, Is.EqualTo(0UL), "freeplay must not deduct real DB balance"); } [Test] public async Task TutorialPackOpen_does_not_accrue_gacha_points() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); // Seed the starter pack 99047 with a GachaPointConfig set — the tutorial-path skip // must hold even when the pack is technically point-eligible. using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Classes.Add(new ClassEntry { Id = 0, Name = "Neutral" }); var set = new ShadowverseCardSetEntry { Id = 99047, IsInRotation = true }; db.CardSets.Add(set); for (int i = 0; i < 30; i++) { set.Cards.Add(new ShadowverseCardEntry { Id = 99047_1010 + i, Name = $"c{i}", Rarity = (Rarity)((i % 4) + 1), Class = db.Classes.Local.First(), IsFoil = false, }); } db.Items.Add(new ItemEntry { Id = 90001, Name = "starter-ticket" }); db.Packs.Add(new PackConfigEntry { Id = 99047, BasePackId = 99047, PackCategory = PackCategory.LegendCardPack, CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), GachaType = 1, GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 }, ChildGachas = { new PackChildGachaEntry { GachaId = 990475, TypeDetail = CardPackType.TicketMulti, Cost = 0, CardCount = 8, ItemId = 90001, }, }, }); var viewer = await db.Viewers .Include(v => v.Items).ThenInclude(i => i.Item) .FirstAsync(v => v.Id == viewerId); viewer.Items.Add(new OwnedItemEntry { Item = db.Items.Local.First(), Count = 1, Viewer = viewer }); viewer.MissionData.TutorialState = 41; // pre-END so the tutorial path is allowed await db.SaveChangesAsync(); } // Install a draw table for 99047 pointing at the 30 seeded card stubs. var seededCardIds = Enumerable.Range(0, 30).Select(i => (long)(99047_1010 + i)).ToArray(); await factory.SeedPackDrawTableAsync(99047, seededCardIds); using var client = factory.CreateAuthenticatedClient(viewerId); var body = new StringContent( """{"parent_gacha_id":99047,"gacha_id":990475,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""", System.Text.Encoding.UTF8, "application/json"); var response = await client.PostAsync("/tutorial/pack_open", body); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); using var scope2 = factory.Services.CreateScope(); var db2 = scope2.ServiceProvider.GetRequiredService(); var v = await db2.Viewers .Include(x => x.GachaPointBalances) .FirstAsync(x => x.Id == viewerId); Assert.That(v.GachaPointBalances, Is.Empty, "tutorial path must not accrue gacha points"); } // ---------------- Free pack (type_detail=10) ---------------- /// /// Seeds parent pack 80032 with a single type_detail=10 free child (gacha_id=780032) /// for free-pack scenarios. Mirrors the shape from traffic_event_crate_free_pack.ndjson. /// private static async Task SeedFreePack(SVSimTestFactory f) { long[] cardIds; using (var scope = f.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync(); cardIds = await db.CardSets.Where(s => s.Id == baseId) .SelectMany(s => s.Cards).Select(c => c.Id).ToArrayAsync(); db.Packs.Add(new PackConfigEntry { Id = 80032, BasePackId = baseId, PackCategory = PackCategory.LegendCardPack, CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), GachaType = 1, GachaDetail = "free pack test", SleeveId = 5090001, ChildGachas = { new PackChildGachaEntry { GachaId = 780032, TypeDetail = CardPackType.FreePacks, Cost = 1, CardCount = 8, DailyFreeGachaCount = 1, PurchaseLimitCount = 1, FreeGachaCampaignId = 49, CampaignName = "Season Bonus", }, }, }); await db.SaveChangesAsync(); } await f.SeedPackDrawTableAsync(80032, cardIds); } private const string FreePackOpenBody = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":80032,"gacha_id":780032,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; [Test] public async Task Open_free_pack_returns_8_cards_and_spends_no_currency() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedFreePack(factory); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); v.Currency.Crystals = 0; v.Currency.Rupees = 0; await db.SaveChangesAsync(); } using var client = factory.CreateAuthenticatedClient(viewerId); var response = await client.PostAsync("/pack/open", JsonBody(FreePackOpenBody)); var body = await response.Content.ReadAsStringAsync(); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); using var doc = JsonDocument.Parse(body); Assert.That(doc.RootElement.GetProperty("pack_list").GetArrayLength(), Is.EqualTo(8)); using var scope2 = factory.Services.CreateScope(); var db2 = scope2.ServiceProvider.GetRequiredService(); var v2 = await db2.Viewers.FirstAsync(x => x.Id == viewerId); Assert.That(v2.Currency.Crystals, Is.EqualTo(0UL), "free pack must not debit crystals"); Assert.That(v2.Currency.Rupees, Is.EqualTo(0UL), "free pack must not debit rupees"); } [Test] public async Task Open_free_pack_rejects_second_claim_same_day() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedFreePack(factory); using var client = factory.CreateAuthenticatedClient(viewerId); var first = await client.PostAsync("/pack/open", JsonBody(FreePackOpenBody)); Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK), await first.Content.ReadAsStringAsync()); var second = await client.PostAsync("/pack/open", JsonBody(FreePackOpenBody)); Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), "Second free-pack open same UTC day must be rejected."); } [Test] public async Task Open_free_pack_records_free_pack_claim() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedFreePack(factory); using var client = factory.CreateAuthenticatedClient(viewerId); var response = await client.PostAsync("/pack/open", JsonBody(FreePackOpenBody)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.Include(x => x.FreePackClaims).FirstAsync(x => x.Id == viewerId); var claim = v.FreePackClaims.Single(c => c.FreeGachaCampaignId == 49); Assert.That(claim.ClaimCount, Is.EqualTo(1)); Assert.That(claim.LastClaimedAt.Date, Is.EqualTo(DateTime.UtcNow.Date)); } }