fix(pack): tutorial flow display + open end-to-end
Four targeted fixes that together let /tutorial/pack_info display
the legendary starter at index 0, let /tutorial/pack_open succeed
on it, and let the pack drop out of the shop after.
1. /pack/info now loads viewer.Items into a Dictionary<long,int>
and threads it through ToDto so child_gacha_info.item_number
reflects the viewer's actual owned count of item_id. Previously
defaulted to 0 for every pack, so the legendary pack 99047
reported item_number=0 immediately after the gift granted 1×
ticket id=90001. Verified against the prod tutorial capture.
2. PackRepository.GetActivePacks now orders parent_gacha_id DESC
to match prod's /pack/info wire order (99047, 92001, 80047,
16015...10001). The tutorial pack UI runs with controls locked
and auto-selects index 0 via GachaUI.GetCurrentLegendPackId
(FirstOrDefault on IsLegendPackId), so the legendary starter
needs to be the first legend pack in the list.
3. DbCardPoolProvider.GetPool falls back to all in-rotation cards
when a LegendCardPack's base set has no rows. Pack 99047's
base_pack_id is 90001, a synthetic "Throwback Rotation" category
that doesn't correspond to a real card_set in the prod card
master — its real pool is curated across older rotation sets
(Altersphere through Colosseum). We don't have that membership
map captured yet; the rotation fallback is broader than prod
but produces a valid 8-card draw, which is what the tutorial
needs to advance to step 100. TODO in code points at the
real fix.
4. PackController.Open's tutorial path now consumes the granted
ticket (decrement viewer.Items by packNumber for child.ItemId)
and emits the post-state count in reward_list as
{reward_type:4, reward_id:item_id, reward_num:post_count}.
Without this, the pack stayed at item_number=1 forever, the
shop kept showing it post-tutorial, and the next click hit
/pack/open (not /tutorial/pack_open) which 501s on type_detail=5.
Also: docstring on PackConfigDto.SalesPeriodInfo flags the deferred
wire-fidelity fix (prod emits {"sales_period_time": "<complete_date>"}
for limited windows, [] for evergreens; we always emit {}) and the
retype from Dictionary<string,string?> to a typed
PackSalesPeriodInfoDto. Doesn't affect tutorial flow, deferred for
the pack-system rework.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<PackConfigEntry?> GetPack(int parentGachaId) =>
|
||||
|
||||
@@ -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<int, ViewerPackOpenCount> openCounts)
|
||||
private static PackConfigDto ToDto(
|
||||
PackConfigEntry p,
|
||||
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
|
||||
IReadOnlyDictionary<long, int> 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;
|
||||
|
||||
@@ -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
|
||||
/// <c>ShopExpirtyInfo</c>'s LitJson parser. Revisit if a capture proves otherwise.
|
||||
///
|
||||
/// TODO(2026-05-28): the prod tutorial capture has each active pack with
|
||||
/// <c>"sales_period_info": {"sales_period_time": "<complete_date>"}</c> — i.e., the
|
||||
/// pack's <c>complete_date</c> echoed inside the object. Our controller emits <c>{}</c>
|
||||
/// which the client tolerates (the tutorial flow doesn't filter on this field), but for
|
||||
/// wire fidelity we should populate it from <c>PackConfigEntry.CompleteDate</c>. While
|
||||
/// doing that, also retype this field from <c>Dictionary<string, string?></c> to a
|
||||
/// typed <c>PackSalesPeriodInfoDto { string SalesPeriodTime }</c> — the current dict
|
||||
/// shape is the lazy-key anti-pattern documented in
|
||||
/// <c>feedback_no_lazy_response_dicts</c>. Deferred from the tutorial-bringup pass
|
||||
/// because it doesn't gate any observable flow.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sales_period_info")]
|
||||
[Key("sales_period_info")]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user