From 6fd870599091f4d2dd6241b69916a8ce5d804816 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 28 May 2026 20:22:59 -0400 Subject: [PATCH] fix(tutorial): /tutorial/update preserves max TutorialState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint used to write the client-supplied step verbatim, so a stale or replayed request with tutorial_step=0 against any later-stage viewer would regress the persisted state to 0. NextSceneSwitcher routes step==0 to AreaSelect section 0, which has no chapter data — the client LINQ-Single() crashes on next /load/index, bricking the viewer. Math.Max-preserve matches the 31→41 pattern in GiftController.TutorialGiftReceive. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/TutorialController.cs | 9 +++++++- .../Controllers/TutorialControllerTests.cs | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs b/SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs index a6370a7..addb769 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs @@ -36,7 +36,14 @@ public class TutorialController : SVSimController .Include(v => v.MissionData) .FirstAsync(v => v.Id == viewerId); - viewer.MissionData.TutorialState = request.TutorialStep; + // Preserve max — never regress. Mirrors GiftController.TutorialGiftReceive's 31→41 guard. + // Without this, a stale or replayed request with tutorial_step=0 (or any value below the + // viewer's current state) crashes the client on next /load/index: NextSceneSwitcher routes + // step==0 to AreaSelect section 0, which has no chapter data → LINQ Single() failure. + // Response keeps echoing request.TutorialStep so the client's own transition confirmation + // still works; the client owns the step-it-thinks-it's-moving-to concept and we don't + // want to surface a divergent value mid-flow. + viewer.MissionData.TutorialState = Math.Max(viewer.MissionData.TutorialState, request.TutorialStep); await _db.SaveChangesAsync(); return new TutorialUpdateResponse { TutorialStep = request.TutorialStep }; diff --git a/SVSim.UnitTests/Controllers/TutorialControllerTests.cs b/SVSim.UnitTests/Controllers/TutorialControllerTests.cs index 8b1f66a..c902211 100644 --- a/SVSim.UnitTests/Controllers/TutorialControllerTests.cs +++ b/SVSim.UnitTests/Controllers/TutorialControllerTests.cs @@ -77,4 +77,26 @@ public class TutorialControllerTests Assert.That(doc.RootElement.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(100)); Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100)); } + + [Test] + public async Task Update_does_not_regress_step() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 100); + using var client = factory.CreateAuthenticatedClient(viewerId); + + // Stale/replayed request: client thinks state is still 11 and sends an update for it. + var requestJson = """{"tutorial_step":11,"is_skip":0,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + var response = await client.PostAsync("/tutorial/update", + new StringContent(requestJson, Encoding.UTF8, "application/json")); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.That(doc.RootElement.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(11), + "Response echoes the requested step (the client confirms its own transition)."); + + Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100), + "Persisted state must NOT regress. Math.Max(current, requested) — mirrors the " + + "31→41 max-preserve pattern in GiftController.TutorialGiftReceive."); + } }