From ac077dfc137f880af81d29e52eefa7f393a5597b Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 28 May 2026 20:15:31 -0400 Subject: [PATCH] fix(pack): tutorial pack_open ThenIncludes OwnedItemEntry.Item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without .ThenInclude(i => i.Item), the OwnedItemEntry.Item nav defaults to a new ItemEntry() with Id=0 (project_ef_nav_include_pitfall), so the FirstOrDefault(i => i.Item.Id == ticketItemId) lookup never matched. The ticket was never decremented and reward_list omitted the post-state entry — on the next /tutorial/pack_info the pack stayed visible and the client re-clicked into plain /pack/open, which 501s on type_detail=5. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/PackController.cs | 2 +- .../Controllers/PackControllerTests.cs | 22 +++++++++ .../Controllers/TutorialFlowEndToEndTests.cs | 5 ++ .../Infrastructure/SVSimTestFactory.cs | 46 +++++++++++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index b7f8653..8942416 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -183,7 +183,7 @@ public class PackController : SVSimController var viewer = await _db.Viewers .Include(v => v.PackOpenCounts) .Include(v => v.MissionData) - .Include(v => v.Items) + .Include(v => v.Items).ThenInclude(i => i.Item) .AsSplitQuery() .FirstAsync(v => v.Id == viewerId); int packNumber = Math.Max(1, request.PackNumber); diff --git a/SVSim.UnitTests/Controllers/PackControllerTests.cs b/SVSim.UnitTests/Controllers/PackControllerTests.cs index 0df617f..d744949 100644 --- a/SVSim.UnitTests/Controllers/PackControllerTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerTests.cs @@ -40,6 +40,11 @@ public class PackControllerTests await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 41); + // Seed the starter ticket the gift_receive step would have granted. /tutorial/pack_open + // is supposed to decrement this count by `pack_number` (1) and emit a post-state entry + // into reward_list (per project_wire_reward_list_post_state). + await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket"); + // Pack 99047 (starter legendary) has base_pack_id=90001. The minimal card seed only // creates set 10001, so we seed set 90001 explicitly for the pool resolver. using (var scope = factory.Services.CreateScope()) @@ -80,6 +85,23 @@ public class PackControllerTests "Starter pack 99047/990047 delivers 8 cards (child_gacha.card_count=8)."); Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100)); + + // Ticket decrement: the legendary starter ticket (90001) should be consumed. + Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(0), + "Tutorial pack_open must decrement the gating ticket; otherwise /tutorial/pack_info " + + "keeps showing the pack and the client re-clicks into /pack/open (501 on type_detail=5)."); + + // reward_list must carry a post-state item entry for the ticket. RewardType=4 (Item), + // RewardId=90001, RewardNum=0 (post-state total, NOT delta). + var rewardList = root.GetProperty("reward_list"); + var ticketEntry = rewardList.EnumerateArray() + .FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 4 + && e.GetProperty("reward_id").GetInt64() == 90001); + Assert.That(ticketEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined), + "reward_list must include a type=4 entry for the consumed ticket (90001) so the " + + "client's _userItemDict updates immediately — project_wire_reward_list_post_state."); + Assert.That(ticketEntry.GetProperty("reward_num").GetInt32(), Is.EqualTo(0), + "RewardNum is the post-state TOTAL, not the delta consumed."); } [Test] diff --git a/SVSim.UnitTests/Controllers/TutorialFlowEndToEndTests.cs b/SVSim.UnitTests/Controllers/TutorialFlowEndToEndTests.cs index b2e02fe..6254f39 100644 --- a/SVSim.UnitTests/Controllers/TutorialFlowEndToEndTests.cs +++ b/SVSim.UnitTests/Controllers/TutorialFlowEndToEndTests.cs @@ -103,6 +103,11 @@ public class TutorialFlowEndToEndTests Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100), "Viewer reaches TUTORIAL_END after the full flow."); + + // The gift granted item 90001 count=1 (via /tutorial/gift_receive entry 71478630). + // /tutorial/pack_open consumes it; assert the ticket is gone post-flow. + Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(0), + "Starter legendary ticket must be consumed by /tutorial/pack_open."); } private static Task Post(HttpClient client, string url, string body) diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index 13cf1b5..fceabc9 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -397,6 +397,52 @@ internal sealed class SVSimTestFactory : WebApplicationFactory return (viewer.Currency.Crystals, viewer.Currency.Rupees, viewer.Currency.RedEther); } + /// + /// Seeds an OwnedItemEntry for the viewer. Inserts the ItemEntry master row if missing + /// (Type defaults to 2 = card-pack ticket since both tutorial gift items 80001 and 90001 + /// are tickets). Tests use this to set up the ticket inventory that /tutorial/pack_open + /// is supposed to consume. + /// + public async Task SeedOwnedItemAsync(long viewerId, int itemId, int count, string itemName = "TestItem", int itemType = 2) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var item = await db.Items.FindAsync(itemId); + if (item is null) + { + item = new ItemEntry { Id = itemId, Name = itemName, Type = itemType }; + db.Items.Add(item); + await db.SaveChangesAsync(); + } + var viewer = await db.Viewers + .Include(v => v.Items).ThenInclude(i => i.Item) + .FirstAsync(v => v.Id == viewerId); + var existing = viewer.Items.FirstOrDefault(i => i.Item.Id == itemId); + if (existing is null) + { + viewer.Items.Add(new OwnedItemEntry { Item = item, Count = count, Viewer = viewer }); + } + else + { + existing.Count = count; + } + await db.SaveChangesAsync(); + } + + /// + /// Reads the viewer's current owned count for . Returns 0 if no + /// row exists. Tests use this to assert ticket consumption after /tutorial/pack_open. + /// + public async Task GetOwnedItemCountAsync(long viewerId, int itemId) + { + using var scope = 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); + return viewer.Items.FirstOrDefault(i => i.Item.Id == itemId)?.Count ?? 0; + } + protected override void Dispose(bool disposing) { base.Dispose(disposing);