From 6819e651608d23f956c945ab2c82d5bae27b7c04 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 28 May 2026 12:46:43 -0400 Subject: [PATCH] feat(tutorial): /tutorial/pack_open emits tutorial_step=100, advances viewer state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Data/test-fixtures/seeds/packs.json | 40 ++++++ .../Controllers/PackController.cs | 125 +++++++++++------- .../Dtos/Responses/Pack/PackOpenResponse.cs | 8 ++ .../Controllers/PackControllerTests.cs | 80 +++++++++++ 4 files changed, 206 insertions(+), 47 deletions(-) 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 MissionResult { get; set; } = new(); + + /// + /// 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. + /// + [JsonPropertyName("tutorial_step")] + [Key("tutorial_step")] + public int? TutorialStep { get; set; } } [MessagePackObject] diff --git a/SVSim.UnitTests/Controllers/PackControllerTests.cs b/SVSim.UnitTests/Controllers/PackControllerTests.cs index 8dc6c2b..0df617f 100644 --- a/SVSim.UnitTests/Controllers/PackControllerTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerTests.cs @@ -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(); + 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."); + } }