diff --git a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs index 8942416..6135f95 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PackController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PackController.cs @@ -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 diff --git a/SVSim.UnitTests/Controllers/PackControllerTests.cs b/SVSim.UnitTests/Controllers/PackControllerTests.cs index d744949..3668578 100644 --- a/SVSim.UnitTests/Controllers/PackControllerTests.cs +++ b/SVSim.UnitTests/Controllers/PackControllerTests.cs @@ -128,4 +128,70 @@ public class PackControllerTests Assert.That(body.Contains("\"tutorial_step\""), Is.False, "Regular /pack/open must never emit tutorial_step."); } + + [Test] + public async Task TutorialPackOpen_rejects_non_starter_parent_gacha_id() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 41); + using var client = factory.CreateAuthenticatedClient(viewerId); + + // Pick any non-99047 parent_gacha_id seeded by SeedGlobalsAsync (10032 is the most + // recent crystal-multi pack in the catalog). The alias must reject it BadRequest. + var requestJson = """{"parent_gacha_id":10032,"gacha_id":100320,"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")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), + "Tutorial alias must only accept the starter pack (99047); otherwise any authenticated " + + "viewer can draw any pack for free via the currency-bypass tutorial path."); + + // State must NOT have advanced. + Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(41), + "Rejected requests leave TutorialState untouched."); + } + + [Test] + public async Task TutorialPackOpen_rejects_completed_viewer() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 100); + await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket"); + 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")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), + "Tutorial alias must reject viewers past the tutorial-end gate (state>=100); the path " + + "would otherwise re-clobber state and consume a ticket the viewer kept post-tutorial."); + + Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(1), + "Rejected requests do not consume tickets."); + } + + [Test] + public async Task TutorialPackOpen_does_not_downgrade_state_past_100() + { + // This is the max-preserve check. A future state > 100 (e.g., a post-tutorial training + // sentinel) must not be clobbered down to 100. Today nothing in prod sets state above 100, + // so synthesize the case directly. + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 200); + 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")); + + // Either the request is rejected (because state>=100, see Gate B above), OR — if the + // implementation reads the gate differently — at minimum the persisted state must not + // regress. Encode the load-bearing invariant: state never goes backwards. + Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.GreaterThanOrEqualTo(200), + "TutorialState must not regress regardless of the alias's accept/reject decision."); + } }