fix(pack): /tutorial/pack_open restricted to starter pack + pre-END viewer

The tutorial alias bypassed the currency / type_detail / open-count guards
unconditionally. Combined with the unconditional TutorialState=100 write, any
authenticated viewer could send /tutorial/pack_open with any parent_gacha_id
to draw a pack for free and clobber their state down to 100.

Two gates: parent_gacha_id MUST be 99047 (the legendary starter), and the
viewer's TutorialState MUST be below 100. The state write is also max-preserved
as a belt-and-braces backstop. Mirrors the 31→41 guard in GiftController.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-28 20:31:23 -04:00
parent 6fd8705990
commit 82d9668c9b
2 changed files with 90 additions and 2 deletions

View File

@@ -146,6 +146,14 @@ public class PackController : SVSimController
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" });
// Reject paths up front — class_id/target_card_id overloads aren't implemented.
if (request.ClassId.HasValue)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "starter_overload_not_implemented" });
@@ -186,6 +194,15 @@ public class PackController : SVSimController
.Include(v => v.Items).ThenInclude(i => i.Item)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// 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)
@@ -293,9 +310,14 @@ public class PackController : SVSimController
}
}
viewer.MissionData.TutorialState = 100;
// 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;
await _db.SaveChangesAsync();
responseTutorialStep = 100;
responseTutorialStep = TutorialEndStep;
}
return new PackOpenResponse