feat(tutorial): /tutorial/pack_open emits tutorial_step=100, advances viewer state
Stack [HttpPost("/tutorial/pack_open")] alias on PackController.Open. Detect
isTutorialPath via HttpContext.Request.Path; gate the type_detail rejection,
currency switch, open-count tracking, and currency reward_list entries behind
!isTutorialPath so the starter legendary pack (99047/990047, type_detail=5)
bypasses the purchasable-pack code path. After grant, set MissionData.TutorialState=100
and emit tutorial_step=100 in PackOpenResponse — this is the sole END transition,
per live-traffic capture. Add pack 99047 to test-fixture packs.json.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -14,8 +14,8 @@ using SVSim.EmulatedEntrypoint.Services;
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /pack/* — card-pack shop catalog and pack opening. /tutorial/pack_info is aliased here.
|
||||
/// /tutorial/pack_open is out of scope for v1.
|
||||
/// /pack/* — card-pack shop catalog and pack opening. /tutorial/pack_info and
|
||||
/// /tutorial/pack_open are aliased here.
|
||||
/// </summary>
|
||||
[Route("pack")]
|
||||
public class PackController : SVSimController
|
||||
@@ -111,10 +111,13 @@ public class PackController : SVSimController
|
||||
}
|
||||
|
||||
[HttpPost("open")]
|
||||
[HttpPost("/tutorial/pack_open")]
|
||||
public async Task<ActionResult<PackOpenResponse>> Open(PackOpenRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
bool isTutorialPath = HttpContext.Request.Path.StartsWithSegments("/tutorial/pack_open");
|
||||
|
||||
// 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" });
|
||||
@@ -143,55 +146,68 @@ public class PackController : SVSimController
|
||||
// child; gacha_type validation against child.TypeDetail would falsely reject every buy.
|
||||
|
||||
// Supported currency types in v1: CRYSTAL_MULTI=2, DAILY=3, RUPY_MULTI=7. Ticket flows
|
||||
// (TICKET=4, TICKET_MULTI=5) and the rest are explicitly out of scope.
|
||||
if (child.TypeDetail is not (2 or 3 or 7))
|
||||
// (TICKET=4, TICKET_MULTI=5) and the rest are explicitly out of scope for the normal path.
|
||||
// The tutorial path (type_detail=5, TICKET_MULTI) bypasses this guard — the starter pack
|
||||
// is a free server-granted bonus, not a purchasable pack.
|
||||
if (!isTutorialPath && child.TypeDetail is not (2 or 3 or 7))
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
|
||||
|
||||
var viewer = await _db.Viewers.Include(v => v.PackOpenCounts).FirstAsync(v => v.Id == viewerId);
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.PackOpenCounts)
|
||||
.Include(v => v.MissionData)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
int packNumber = Math.Max(1, request.PackNumber);
|
||||
|
||||
// Currency check + deduction
|
||||
switch (child.TypeDetail)
|
||||
// Currency check + deduction (skipped for tutorial path — starter pack is free)
|
||||
if (!isTutorialPath)
|
||||
{
|
||||
case 2: // CRYSTAL_MULTI
|
||||
switch (child.TypeDetail)
|
||||
{
|
||||
ulong cost = (ulong)child.Cost * (ulong)packNumber;
|
||||
if (viewer.Currency.Crystals < cost)
|
||||
return BadRequest(new { error = "insufficient_crystals" });
|
||||
viewer.Currency.Crystals -= cost;
|
||||
break;
|
||||
}
|
||||
case 7: // RUPY_MULTI
|
||||
{
|
||||
ulong cost = (ulong)child.Cost * (ulong)packNumber;
|
||||
if (viewer.Currency.Rupees < cost)
|
||||
return BadRequest(new { error = "insufficient_rupees" });
|
||||
viewer.Currency.Rupees -= cost;
|
||||
break;
|
||||
}
|
||||
case 3: // DAILY single — once per UTC day
|
||||
{
|
||||
// TODO(daily-reset): no project-wide daily-reset convention exists yet. Using UTC
|
||||
// midnight; revisit when the global reset boundary is settled.
|
||||
var now = DateTime.UtcNow;
|
||||
var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id);
|
||||
if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date)
|
||||
return BadRequest(new { error = "daily_free_already_claimed" });
|
||||
case 2: // CRYSTAL_MULTI
|
||||
{
|
||||
ulong cost = (ulong)child.Cost * (ulong)packNumber;
|
||||
if (viewer.Currency.Crystals < cost)
|
||||
return BadRequest(new { error = "insufficient_crystals" });
|
||||
viewer.Currency.Crystals -= cost;
|
||||
break;
|
||||
}
|
||||
case 7: // RUPY_MULTI
|
||||
{
|
||||
ulong cost = (ulong)child.Cost * (ulong)packNumber;
|
||||
if (viewer.Currency.Rupees < cost)
|
||||
return BadRequest(new { error = "insufficient_rupees" });
|
||||
viewer.Currency.Rupees -= cost;
|
||||
break;
|
||||
}
|
||||
case 3: // DAILY single — once per UTC day
|
||||
{
|
||||
// TODO(daily-reset): no project-wide daily-reset convention exists yet. Using UTC
|
||||
// midnight; revisit when the global reset boundary is settled.
|
||||
var now = DateTime.UtcNow;
|
||||
var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id);
|
||||
if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date)
|
||||
return BadRequest(new { error = "daily_free_already_claimed" });
|
||||
|
||||
ulong cost = (ulong)child.Cost * (ulong)packNumber;
|
||||
if (cost > 0 && viewer.Currency.Rupees < cost)
|
||||
return BadRequest(new { error = "insufficient_rupees" });
|
||||
if (cost > 0) viewer.Currency.Rupees -= cost;
|
||||
break;
|
||||
ulong cost = (ulong)child.Cost * (ulong)packNumber;
|
||||
if (cost > 0 && viewer.Currency.Rupees < cost)
|
||||
return BadRequest(new { error = "insufficient_rupees" });
|
||||
if (cost > 0) viewer.Currency.Rupees -= cost;
|
||||
break;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Increment open count + mark daily-free timestamp where relevant
|
||||
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
|
||||
if (child.TypeDetail == 3)
|
||||
// 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.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
|
||||
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
|
||||
if (child.TypeDetail == 3)
|
||||
{
|
||||
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
|
||||
@@ -205,18 +221,32 @@ public class PackController : SVSimController
|
||||
// PlayerStaticData.UpdateHaveUserGoodsNum does direct assignment, so currency/card counts
|
||||
// must be the new TOTAL — emitting deltas would leave the on-screen balances stale.
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
var postViewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
if (child.TypeDetail == 2)
|
||||
// Currency reward entries only apply to purchasable packs; tutorial path omits them.
|
||||
if (!isTutorialPath)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)postViewer.Currency.Crystals });
|
||||
}
|
||||
else if (child.TypeDetail == 7 || child.TypeDetail == 3)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees });
|
||||
var postViewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
if (child.TypeDetail == 2)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)postViewer.Currency.Crystals });
|
||||
}
|
||||
else if (child.TypeDetail == 7 || child.TypeDetail == 3)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees });
|
||||
}
|
||||
}
|
||||
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.
|
||||
int? responseTutorialStep = null;
|
||||
if (isTutorialPath)
|
||||
{
|
||||
viewer.MissionData.TutorialState = 100;
|
||||
await _db.SaveChangesAsync();
|
||||
responseTutorialStep = 100;
|
||||
}
|
||||
|
||||
return new PackOpenResponse
|
||||
{
|
||||
PackList = draw.Cards.Select(c => new CardPackEntryDto
|
||||
@@ -226,6 +256,7 @@ public class PackController : SVSimController
|
||||
Number = 1,
|
||||
}).ToList(),
|
||||
RewardList = rewardList,
|
||||
TutorialStep = responseTutorialStep,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user