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:
gamer147
2026-05-28 12:46:43 -04:00
parent ca678b56d1
commit 6819e65160
4 changed files with 206 additions and 47 deletions

View File

@@ -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,

View File

@@ -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,
};
}
}

View File

@@ -26,6 +26,14 @@ public class PackOpenResponse
[JsonPropertyName("mission_result")]
[Key("mission_result")]
public List<object> MissionResult { get; set; } = new();
/// <summary>
/// Set only on the /tutorial/pack_open path to signal the END (100) transition inline with
/// the pack reward. Global WhenWritingNull keeps it off the wire on regular /pack/open.
/// </summary>
[JsonPropertyName("tutorial_step")]
[Key("tutorial_step")]
public int? TutorialStep { get; set; }
}
[MessagePackObject]

View File

@@ -1,5 +1,11 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
@@ -26,4 +32,78 @@ public class PackControllerTests
Assert.That(tutorialBody, Is.EqualTo(directBody),
"tutorial/pack_info wire shape must match /pack/info exactly (no filtering in v1).");
}
[Test]
public async Task TutorialPackOpen_grants_pack_and_sets_tutorial_step_100()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
// Pack 99047 (starter legendary) has base_pack_id=90001. The minimal card seed only
// creates set 10001, so we seed set 90001 explicitly for the pool resolver.
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.CardSets.Add(new ShadowverseCardSetEntry
{
Id = 90001,
Name = "TutorialStarterSet",
IsInRotation = true,
IsBasic = false,
Cards =
[
new ShadowverseCardEntry { Id = 90001001L, Name = "StarterCard1", Rarity = Rarity.Bronze },
new ShadowverseCardEntry { Id = 90001002L, Name = "StarterCard2", Rarity = Rarity.Gold },
new ShadowverseCardEntry { Id = 90001003L, Name = "StarterCard3", Rarity = Rarity.Legendary },
],
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/tutorial/pack_open",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.That(root.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(100),
"tutorial/pack_open must include tutorial_step=100 in data — this is the END transition.");
Assert.That(root.GetProperty("pack_list").GetArrayLength(), Is.EqualTo(8),
"Starter pack 99047/990047 delivers 8 cards (child_gacha.card_count=8).");
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100));
}
[Test]
public async Task NonTutorial_pack_open_does_not_emit_tutorial_step()
{
// Verify that regular /pack/open still works AND does not include tutorial_step in the response.
// Use the tutorial pack (99047/990047) which has type_detail=5 — the non-tutorial path
// still hits the currency_path_not_implemented guard and returns 501.
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 100);
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/pack/open",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
// Non-tutorial pack/open + type_detail=5 STILL returns 501 — that's the established behavior.
Assert.That((int)response.StatusCode, Is.EqualTo(501),
"Non-tutorial /pack/open with type_detail=5 should still hit the currency_path_not_implemented guard.");
// Even on a 501, no tutorial_step field should appear in the response body.
var body = await response.Content.ReadAsStringAsync();
Assert.That(body.Contains("\"tutorial_step\""), Is.False,
"Regular /pack/open must never emit tutorial_step.");
}
}