fix(pack): route /tutorial/pack_open through the normal path

The tutorial pack-open alias hardcoded parent_gacha_id=99047 (the May 2026
throwback pair), so once the June rotation replaced it with 99032 the
tutorial's final pack-open 400ed. Fix the identity gate by removing it:
/tutorial/pack_open is now a plain alias for /pack/open with a single
epilogue that advances TutorialState to 100 and emits tutorial_step=100.

The tutorial gift's TICKET_MULTI item naturally scopes what the alias
can open — the normal path's ticket-debit branch already consumes it,
so no separate tutorial-path bypass is needed. Also drop the July-1
expiry on packs 80032/99032 so the current throwback pair stays visible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-04 14:21:52 -04:00
parent 9813f82edc
commit 7309488748
4 changed files with 122 additions and 250 deletions

View File

@@ -340,16 +340,14 @@ public class PackController : SVSimController
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// /tutorial/pack_open is a plain alias for /pack/open — the client uses it during the
// tutorial's final "open the starter legendary pack" step. The only wire-level
// difference is that the response carries tutorial_step=100 so the client transitions
// out of the tutorial. Currency/ticket debits, open-count tracking, and pack identity
// all flow through the normal path — the tutorial gift's tickets naturally constrain
// which packs are openable via this alias to the current throwback starter pair.
bool isTutorialPath = HttpContext.Request.Path.StartsWithSegments("/tutorial/pack_open");
// The tutorial alias bypasses the currency / type_detail / open-count guards because
// the legendary starter pack (99047) is a free server-grant during the 41→100 tutorial
// transition. Constrain the alias to that one pack so the bypass isn't a free draw on
// ANY pack the client supplies a parent_gacha_id for.
const int StarterParentGachaId = 99047;
if (isTutorialPath && request.ParentGachaId != StarterParentGachaId)
return BadRequest(new { error = "tutorial_path_only_for_starter_pack" });
// Skin-card overload not implemented; rotation-starter (class_id) IS supported below.
if (request.TargetCardId.HasValue)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "skin_overload_not_implemented" });
@@ -402,14 +400,13 @@ public class PackController : SVSimController
// when buying a RUPY_MULTI (type_detail=7) child. The gacha_id alone disambiguates the
// child; gacha_type validation against child.TypeDetail would falsely reject every buy.
// Supported on the normal path: Crystal / CrystalMulti -> spend crystals; Rupy /
// RupyMulti -> spend rupees; Daily -> spend rupees, once per UTC day; Ticket /
// TicketMulti -> consume child.ItemId from OwnedItemEntry; FreePacks -> no debit,
// gated by per-campaign daily quota.
// Supported: Crystal / CrystalMulti -> spend crystals; Rupy / RupyMulti -> spend rupees;
// Daily -> spend rupees, once per UTC day; Ticket / TicketMulti -> consume child.ItemId
// from OwnedItemEntry; FreePacks -> no debit, gated by per-campaign daily quota.
// CrystalSpecial / CrystalSelectSkin / CrystalAcquireSkinCardPack and the
// FreePackWithSkin / RotationStarterPack overlays need extra selection / banner
// plumbing — kept 501 until the relevant flows land.
if (!isTutorialPath && child.TypeDetail is not (
if (child.TypeDetail is not (
CardPackType.Crystal or CardPackType.CrystalMulti or CardPackType.Daily or
CardPackType.Ticket or CardPackType.TicketMulti or CardPackType.Rupy or
CardPackType.RupyMulti or CardPackType.FreePacks))
@@ -426,110 +423,97 @@ public class PackController : SVSimController
});
var viewer = tx.Viewer;
// Tutorial alias is only valid pre-END. After state>=100 the viewer has already
// completed the tutorial — re-running the path would re-consume the ticket they
// chose to keep, and (without the max-preserve write below) could regress a higher
// state value. Mirrors the 31<41 guard in GiftController.TutorialGiftReceive.
const int TutorialEndStep = 100;
if (isTutorialPath && viewer.MissionData.TutorialState >= TutorialEndStep)
return BadRequest(new { error = "tutorial_already_complete" });
int packNumber = Math.Max(1, request.PackNumber);
// Currency check + deduction (skipped for tutorial path — starter pack is free)
if (!isTutorialPath)
// Currency check + deduction. TICKET_MULTI is the mechanism the tutorial alias rides
// on: the tutorial gift grants the starter ticket, and this branch consumes it, so no
// separate tutorial-path bypass is needed.
switch (child.TypeDetail)
{
switch (child.TypeDetail)
case CardPackType.Crystal:
case CardPackType.CrystalMulti:
{
case CardPackType.Crystal:
case CardPackType.CrystalMulti:
{
long cost = (long)child.Cost * packNumber;
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
break;
}
case CardPackType.Rupy:
case CardPackType.RupyMulti:
{
long cost = (long)child.Cost * packNumber;
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
case CardPackType.Daily:
{
// DAILY is the once-per-day half-off crystal single-pack (GachaUI.cs:1046 →
// CheckBuyPackWithCrystal, decompile-confirmed). Currency is Crystal, NOT Rupee.
var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id);
if (!_calendar.ResetReady(existing?.LastDailyFreeAt))
return BadRequest(new { error = "daily_free_already_claimed" });
long cost = (long)child.Cost * packNumber;
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
break;
}
case CardPackType.Rupy:
case CardPackType.RupyMulti:
{
long cost = (long)child.Cost * packNumber;
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
case CardPackType.Daily:
{
// DAILY is the once-per-day half-off crystal single-pack (GachaUI.cs:1046 →
// CheckBuyPackWithCrystal, decompile-confirmed). Currency is Crystal, NOT Rupee.
var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id);
if (!_calendar.ResetReady(existing?.LastDailyFreeAt))
return BadRequest(new { error = "daily_free_already_claimed" });
long cost = (long)child.Cost * packNumber;
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
break;
}
case CardPackType.Ticket:
case CardPackType.TicketMulti:
long cost = (long)child.Cost * packNumber;
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
break;
}
case CardPackType.Ticket:
case CardPackType.TicketMulti:
{
if (child.ItemId is not long ticketItemId)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
int ticketsNeeded = child.Cost * packNumber;
var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded);
if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" });
break;
}
case CardPackType.FreePacks:
{
if (child.FreeGachaCampaignId is not int campaignId)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "free_pack_missing_campaign_id" });
int dailyCap = child.DailyFreeGachaCount > 0 ? child.DailyFreeGachaCount : 1;
var existing = viewer.FreePackClaims.FirstOrDefault(c => c.FreeGachaCampaignId == campaignId);
bool resetSinceLastClaim = existing is null || _calendar.ResetReady(existing.LastClaimedAt);
if (existing is not null && !resetSinceLastClaim && existing.ClaimCount >= dailyCap)
return BadRequest(new { error = "free_pack_already_claimed_today" });
// pack_number is forced to 1 — free-pack metadata never authorizes multi-opens.
// The capture shows pack_number=1 even when daily_free_gacha_count=1 == daily quota.
packNumber = 1;
if (existing is null)
{
if (child.ItemId is not long ticketItemId)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
int ticketsNeeded = child.Cost * packNumber;
var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded);
if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" });
break;
}
case CardPackType.FreePacks:
{
if (child.FreeGachaCampaignId is not int campaignId)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "free_pack_missing_campaign_id" });
int dailyCap = child.DailyFreeGachaCount > 0 ? child.DailyFreeGachaCount : 1;
var existing = viewer.FreePackClaims.FirstOrDefault(c => c.FreeGachaCampaignId == campaignId);
bool resetSinceLastClaim = existing is null || _calendar.ResetReady(existing.LastClaimedAt);
if (existing is not null && !resetSinceLastClaim && existing.ClaimCount >= dailyCap)
return BadRequest(new { error = "free_pack_already_claimed_today" });
// pack_number is forced to 1 — free-pack metadata never authorizes multi-opens.
// The capture shows pack_number=1 even when daily_free_gacha_count=1 == daily quota.
packNumber = 1;
if (existing is null)
viewer.FreePackClaims.Add(new ViewerFreePackClaim
{
viewer.FreePackClaims.Add(new ViewerFreePackClaim
{
FreeGachaCampaignId = campaignId,
ClaimCount = 1,
LastClaimedAt = DateTime.UtcNow,
});
}
else if (resetSinceLastClaim)
{
existing.ClaimCount = 1;
existing.LastClaimedAt = DateTime.UtcNow;
}
else
{
existing.ClaimCount++;
existing.LastClaimedAt = DateTime.UtcNow;
}
break;
FreeGachaCampaignId = campaignId,
ClaimCount = 1,
LastClaimedAt = DateTime.UtcNow,
});
}
else if (resetSinceLastClaim)
{
existing.ClaimCount = 1;
existing.LastClaimedAt = DateTime.UtcNow;
}
else
{
existing.ClaimCount++;
existing.LastClaimedAt = DateTime.UtcNow;
}
break;
}
}
// Increment open count + mark daily-free timestamp where relevant.
// Tutorial path skips these — the starter pack is a one-time free grant, not a
// purchasable/trackable open.
if (!isTutorialPath)
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
if (child.TypeDetail == CardPackType.Daily)
{
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
if (child.TypeDetail == CardPackType.Daily)
{
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
}
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
}
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
@@ -562,42 +546,21 @@ public class PackController : SVSimController
foreach (var grp in draw.Cards.GroupBy(c => c.CardId))
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count());
// Accrue gacha points (skip tutorial path — the starter pack isn't a real open).
if (!isTutorialPath)
{
_gachaPoint.Accrue(viewer, pack, child, drawCount);
}
_gachaPoint.Accrue(viewer, pack, child, drawCount);
// 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).
// Tutorial alias epilogue: advance TutorialState to END (max-preserve so a higher
// sentinel is never regressed) and emit tutorial_step=100 on the wire. The client's
// PackOpenTask.Parse runs _userTutorial.Update on the response — this is the END
// signal that transitions the client out of the tutorial.
int? responseTutorialStep = null;
if (isTutorialPath)
{
if (child.ItemId is long tutorialTicketItemId)
{
int ticketsToConsume = packNumber;
var debit = await tx.TryDebitAsync(UserGoodsType.Item, tutorialTicketItemId, ticketsToConsume);
// Silently accept if the viewer doesn't have the ticket (already consumed or never granted)
_ = debit;
}
// Max-preserve: never regress the persisted state, even though Gate B already
// rejected state>=100 above. Belt-and-braces against a future caller that
// bypasses Gate B (refactor, new alias, etc.). Wire still emits 100 — that's
// the tutorial-END signal the client expects.
if (viewer.MissionData.TutorialState < TutorialEndStep)
viewer.MissionData.TutorialState = TutorialEndStep;
responseTutorialStep = TutorialEndStep;
}
// CommitAsync saves all mutations and produces reward_list with currency-collision resolved.
// Tutorial path never calls TrySpendAsync so no currency op is in the log — correct.
var result = await tx.CommitAsync(HttpContext.RequestAborted);
var rewardList = result.RewardList.ToRewardList();