diff --git a/SVSim.Database/Repositories/Pack/PackRepository.cs b/SVSim.Database/Repositories/Pack/PackRepository.cs index 51f41e1..111510f 100644 --- a/SVSim.Database/Repositories/Pack/PackRepository.cs +++ b/SVSim.Database/Repositories/Pack/PackRepository.cs @@ -13,6 +13,12 @@ public class PackRepository : IPackRepository .Include(p => p.ChildGachas) .Include(p => p.Banners) .Where(p => p.CommenceDate <= now && p.CompleteDate >= now) + // parent_gacha_id DESC matches the prod /pack/info wire order. The tutorial pack + // UI runs with controls locked and auto-selects the FIRST entry in + // pack_config_list, so the legendary starter pack (99047) MUST be index 0 for the + // tutorial to progress. Verified against data_dumps/traffic_prod_tutorial.ndjson — + // prod emits [99047, 92001, 80047, 16015..16011, 10032..10001]. + .OrderByDescending(p => p.Id) .ToListAsync(); public async Task GetPack(int parentGachaId) => diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 0a2ddda..b7f8653 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -54,13 +54,31 @@ public class PackController : SVSimController var packs = await _packs.GetActivePacks(DateTime.UtcNow); var openCounts = await _packs.GetOpenCountsForViewer(viewerId); + // Load owned-item counts so child_gacha_info.item_number reflects the viewer's actual + // ticket inventory (see ToDto). The client filters tutorial packs by item_number > 0 + // — without this the legendary starter pack (99047, requires 1× item 90001) and the + // throwback pack (80047, requires 1× item 80001) are hidden even when the tutorial + // gift just granted those tickets, blocking the END transition. + // + // 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. + var ownedItemsByItemId = await _db.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.Items) + .Select(i => new { ItemId = (long)i.Item.Id, i.Count }) + .ToDictionaryAsync(x => x.ItemId, x => x.Count); + return new PackInfoResponse { - PackConfigList = packs.Select(p => ToDto(p, openCounts)).ToList(), + PackConfigList = packs.Select(p => ToDto(p, openCounts, ownedItemsByItemId)).ToList(), }; } - private static PackConfigDto ToDto(PackConfigEntry p, IReadOnlyDictionary openCounts) + private static PackConfigDto ToDto( + PackConfigEntry p, + IReadOnlyDictionary openCounts, + IReadOnlyDictionary ownedItemsByItemId) { int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0; return new PackConfigDto @@ -87,6 +105,16 @@ public class PackController : SVSimController Cost = c.Cost, Count = c.CardCount, ItemId = c.ItemId?.ToString(CultureInfo.InvariantCulture), + // item_number is viewer-specific — the count of item_id this viewer currently + // owns, NOT a per-pack-catalog value. Verified against the prod tutorial + // capture: legendary pack 99047 reports item_number=1 right after the gift + // granted 1× ticket id=90001; throwback 80047 reports 40 right after the gift + // granted 40× ticket id=80001. Client filters the tutorial pack list to + // packs with non-zero item_number (free packs like 92001 are special-cased + // separately), so this lookup is what makes the tutorial-final pack show up. + ItemNumber = c.ItemId is long iid && ownedItemsByItemId.TryGetValue(iid, out var ownedCount) + ? ownedCount + : 0, IsDailySingle = c.IsDailySingle, OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint.ToString(CultureInfo.InvariantCulture), }).ToList(), @@ -155,6 +183,8 @@ public class PackController : SVSimController var viewer = await _db.Viewers .Include(v => v.PackOpenCounts) .Include(v => v.MissionData) + .Include(v => v.Items) + .AsSplitQuery() .FirstAsync(v => v.Id == viewerId); int packNumber = Math.Max(1, request.PackNumber); @@ -237,11 +267,32 @@ public class PackController : SVSimController } rewardList.AddRange(grant.RewardList); - // Advance tutorial state to 100 (END) on the tutorial path. This is the sole mechanism - // for the END transition — there is no /tutorial/update → 100 call in captured traffic. + // Tutorial path consumes the granted ticket (same item_id used to gate display) so the + // pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still + // shows item_number=1 after the tutorial pack-open, the client lets the user re-click + // it, and the second click hits /pack/open (not /tutorial/pack_open) — which 501s on + // type_detail=5 (TICKET_MULTI is out of scope for the normal path). Emitting the + // post-state count in reward_list direct-assigns the client's _userItemDict so the + // UI also goes stale-safe immediately (client does direct assignment per + // project_wire_reward_list_post_state memory). int? responseTutorialStep = null; if (isTutorialPath) { + if (child.ItemId is long ticketItemId) + { + var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId); + if (owned is not null) + { + owned.Count = Math.Max(0, owned.Count - packNumber); + rewardList.Add(new RewardListEntry + { + RewardType = 4, // Item + RewardId = ticketItemId, + RewardNum = owned.Count, // POST-STATE total + }); + } + } + viewer.MissionData.TutorialState = 100; await _db.SaveChangesAsync(); responseTutorialStep = 100; diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/PackConfigDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/PackConfigDto.cs index ebe7154..2855fa9 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/PackConfigDto.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/PackConfigDto.cs @@ -99,6 +99,17 @@ public class PackConfigDto /// when unset. v1 always emits an empty object when the field is null on the entity — /// matches the active-window case and the client tolerates both shapes via /// ShopExpirtyInfo's LitJson parser. Revisit if a capture proves otherwise. + /// + /// TODO(2026-05-28): the prod tutorial capture has each active pack with + /// "sales_period_info": {"sales_period_time": "<complete_date>"} — i.e., the + /// pack's complete_date echoed inside the object. Our controller emits {} + /// which the client tolerates (the tutorial flow doesn't filter on this field), but for + /// wire fidelity we should populate it from PackConfigEntry.CompleteDate. While + /// doing that, also retype this field from Dictionary<string, string?> to a + /// typed PackSalesPeriodInfoDto { string SalesPeriodTime } — the current dict + /// shape is the lazy-key anti-pattern documented in + /// feedback_no_lazy_response_dicts. Deferred from the tutorial-bringup pass + /// because it doesn't gate any observable flow. /// [JsonPropertyName("sales_period_info")] [Key("sales_period_info")] diff --git a/SVSim.EmulatedEntrypoint/Services/DbCardPoolProvider.cs b/SVSim.EmulatedEntrypoint/Services/DbCardPoolProvider.cs index 773036a..614b0e3 100644 --- a/SVSim.EmulatedEntrypoint/Services/DbCardPoolProvider.cs +++ b/SVSim.EmulatedEntrypoint/Services/DbCardPoolProvider.cs @@ -16,11 +16,31 @@ public class DbCardPoolProvider : ICardPoolProvider { case PackCategory.None: case PackCategory.LegendCardPack: - return _db.CardSets + { + var pool = _db.CardSets .Where(s => s.Id == pack.BasePackId) .SelectMany(s => s.Cards) .Where(c => !c.IsFoil) .ToList(); + if (pool.Count > 0) return pool; + + // BasePackId 90001 (and the 9xxxx range generally) is a synthetic "Throwback + // Rotation" category that doesn't have a corresponding real card_set in the + // prod card master — its real pool is a curated subset of rotation-eligible + // older sets (Altersphere–Colosseum for 99047; see the gacha_detail string). + // We don't have that membership map, so fall back to all in-rotation cards. + // Broader pool than prod but produces a valid 8-card draw, which is what the + // tutorial flow needs to advance to step 100. + // TODO: import the real Throwback Rotation card-set membership and key the + // pool off that. Source data is in the client's pack-pool master, not yet + // captured. + return _db.CardSets + .Where(s => s.IsInRotation) + .SelectMany(s => s.Cards) + .Where(c => !c.IsFoil) + .Distinct() + .ToList(); + } case PackCategory.SpecialCardPack: case PackCategory.LimitedSpecialCardPack: