diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 6135f95..34ea443 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -63,10 +63,18 @@ public class PackController : SVSimController // OwnedItemEntry is [Owned] by Viewer, and EF refuses to track owned entities without // their owner in the result. Project to primitive pairs in the database query before // materialising into the dictionary — no entity tracking, single round-trip. + // + // Use EF.Property(i, "ItemId") to read the shadow FK directly instead of going + // through the OwnedItemEntry.Item nav. The nav route works today (EF translates + // `i.Item.Id` to the FK column), but a future model change that renames the FK or + // breaks the nav→column mapping would silently fall back to client eval — where + // `i.Item.Id` returns 0 for every row (the default-initialised ItemEntry) and the + // dictionary collapses every ticket to item_number=0. Shadow-FK access bypasses + // that hazard entirely. var ownedItemsByItemId = await _db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.Items) - .Select(i => new { ItemId = (long)i.Item.Id, i.Count }) + .Select(i => new { ItemId = (long)EF.Property(i, "ItemId"), i.Count }) .ToDictionaryAsync(x => x.ItemId, x => x.Count); return new PackInfoResponse diff --git a/SVSim.UnitTests/Controllers/PackControllerTests.cs b/SVSim.UnitTests/Controllers/PackControllerTests.cs index 3668578..7e0ee2f 100644 --- a/SVSim.UnitTests/Controllers/PackControllerTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerTests.cs @@ -12,6 +12,38 @@ namespace SVSim.UnitTests.Controllers; public class PackControllerTests { + [Test] + public async Task PackInfo_item_number_reflects_owned_ticket_count() + { + // Verifies the ownedItemsByItemId projection in PackController.Info — the dict that + // drives child_gacha_info.item_number. Tutorial flow filters packs by item_number > 0, + // so a regression on the projection (e.g. nav-eval collapsing to 0) silently hides + // any pack that requires a ticket. + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 41); + + // Seed item 90001 with count 7 — the legendary starter ticket the tutorial gift grants. + await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 7, itemName: "Starter Legendary Ticket"); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + var response = await client.PostAsync("/pack/info", + new StringContent(json, Encoding.UTF8, "application/json")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + + // Find pack 99047 (the starter legendary) and verify its child gacha reports item_number=7. + var pack99047 = doc.RootElement.GetProperty("pack_config_list").EnumerateArray() + .First(p => p.GetProperty("parent_gacha_id").GetInt32() == 99047); + var childWithTicket = pack99047.GetProperty("child_gacha_info").EnumerateArray() + .First(c => c.TryGetProperty("item_id", out var iid) && iid.GetString() == "90001"); + Assert.That(childWithTicket.GetProperty("item_number").GetInt32(), Is.EqualTo(7), + "child_gacha_info.item_number must reflect the viewer's owned count of the gating " + + "item; client filters tutorial packs on item_number > 0."); + } + [Test] public async Task TutorialPackInfo_returns_same_list_as_pack_info() {