diff --git a/SVSim.Bootstrap/Data/test-fixtures/seeds/packs.json b/SVSim.Bootstrap/Data/test-fixtures/seeds/packs.json
index b267c3e..c4fafb5 100644
--- a/SVSim.Bootstrap/Data/test-fixtures/seeds/packs.json
+++ b/SVSim.Bootstrap/Data/test-fixtures/seeds/packs.json
@@ -61,6 +61,46 @@
],
"banners": []
},
+ {
+ "parent_gacha_id": 99047,
+ "base_pack_id": 90001,
+ "gacha_type": 1,
+ "pack_category": 1,
+ "poster_type": 0,
+ "commence_date": "2026-05-01 02:00:00",
+ "complete_date": "2030-12-31 23:59:59",
+ "sleeve_id": 5090001,
+ "special_sleeve_id": 0,
+ "override_draw_effect_pack_id": 90001,
+ "override_ui_effect_pack_id": 90001,
+ "gacha_detail": "A pack contains 8 cards, including at least one legendary card from Throwback Rotation (Altersphere - Colosseum)!",
+ "is_hide": true,
+ "is_new": false,
+ "is_pre_release": false,
+ "open_count_limit": 0,
+ "sales_period_time": null,
+ "gacha_point": null,
+ "child_gachas": [
+ {
+ "gacha_id": 990047,
+ "type_detail": 5,
+ "cost": 1,
+ "card_count": 8,
+ "item_id": 90001,
+ "is_daily_single": false,
+ "override_increase_gacha_point": 0,
+ "purchase_limit_count": 0,
+ "free_gacha_campaign_id": null,
+ "campaign_name": null
+ }
+ ],
+ "banners": [
+ {
+ "banner_name": "card_pack_99047_dialog",
+ "dialog_title": "Dia_BuyCard_006_Title"
+ }
+ ]
+ },
{
"parent_gacha_id": 92001,
"base_pack_id": 90001,
diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs
index ed27392..0a2ddda 100644
--- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs
+++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs
@@ -14,8 +14,8 @@ using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
///
-/// /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.
///
[Route("pack")]
public class PackController : SVSimController
@@ -111,10 +111,13 @@ public class PackController : SVSimController
}
[HttpPost("open")]
+ [HttpPost("/tutorial/pack_open")]
public async Task> 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();
- 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,
};
}
}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Pack/PackOpenResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Pack/PackOpenResponse.cs
index 7cc8464..2367c48 100644
--- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Pack/PackOpenResponse.cs
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Pack/PackOpenResponse.cs
@@ -26,6 +26,14 @@ public class PackOpenResponse
[JsonPropertyName("mission_result")]
[Key("mission_result")]
public List