From bc9ffe1d31fbf04cbf6ba49106ca499a774522ab Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 28 May 2026 11:47:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(tutorial):=20add=20/tutorial/update=20?= =?UTF-8?q?=E2=80=94=20echo=20step=20+=20persist=20to=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /tutorial/update echoes tutorial_step back and saves it to Viewer.MissionData.TutorialState. is_skip=1 is handled server-side by honoring whatever tutorial_step value the client sends (client already sends 100 when skipping). Adds TutorialUpdateRequest DTO, TutorialUpdateResponse DTO, injects SVSimDbContext into TutorialController, and adds GetViewerTutorialStateAsync helper to SVSimTestFactory. Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/TutorialController.cs | 25 +++++++++++ .../Tutorial/TutorialUpdateRequest.cs | 23 ++++++++++ .../Tutorial/TutorialUpdateResponse.cs | 17 +++++++ .../Controllers/TutorialControllerTests.cs | 44 +++++++++++++++++++ .../Infrastructure/SVSimTestFactory.cs | 12 +++++ 5 files changed, 121 insertions(+) create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Tutorial/TutorialUpdateRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Tutorial/TutorialUpdateResponse.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs b/SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs index 7106d15..a6370a7 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs @@ -1,5 +1,8 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SVSim.Database; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Tutorial; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Tutorial; namespace SVSim.EmulatedEntrypoint.Controllers; @@ -9,6 +12,13 @@ namespace SVSim.EmulatedEntrypoint.Controllers; /// public class TutorialController : SVSimController { + private readonly SVSimDbContext _db; + + public TutorialController(SVSimDbContext db) + { + _db = db; + } + [HttpPost("update_action")] public IActionResult UpdateAction([FromBody] TutorialUpdateActionRequest request) { @@ -16,4 +26,19 @@ public class TutorialController : SVSimController // We still emit an empty object so the translation middleware has a `data` payload to wrap. return new JsonResult(new { }); } + + [HttpPost("update")] + public async Task> Update([FromBody] TutorialUpdateRequest request) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + + var viewer = await _db.Viewers + .Include(v => v.MissionData) + .FirstAsync(v => v.Id == viewerId); + + viewer.MissionData.TutorialState = request.TutorialStep; + await _db.SaveChangesAsync(); + + return new TutorialUpdateResponse { TutorialStep = request.TutorialStep }; + } } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Tutorial/TutorialUpdateRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Tutorial/TutorialUpdateRequest.cs new file mode 100644 index 0000000..f2f5806 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Tutorial/TutorialUpdateRequest.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Tutorial; + +/// +/// POST /tutorial/update — client reports the step it is moving TO. +/// Client task: Wizard/TutorialUpdateTask.cs. +/// +[MessagePackObject] +public class TutorialUpdateRequest : BaseRequest +{ + /// The tutorial step the client is moving TO (0, 1, 11, 21, 31, 41, 100). + [JsonPropertyName("tutorial_step")] + [Key("tutorial_step")] + public int TutorialStep { get; set; } + + /// 0 = normal, 1 = user chose Skip Tutorial. + [JsonPropertyName("is_skip")] + [Key("is_skip")] + public int IsSkip { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Tutorial/TutorialUpdateResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Tutorial/TutorialUpdateResponse.cs new file mode 100644 index 0000000..1d2d929 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/Tutorial/TutorialUpdateResponse.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Tutorial; + +/// +/// Server echoes the new step. Capture confirms exact value mirror — no validation, +/// no munging. tutorial_replay_step is in the spec as optional but the live capture +/// never includes it; omit unless we observe a need. +/// +[MessagePackObject] +public class TutorialUpdateResponse +{ + [JsonPropertyName("tutorial_step")] + [Key("tutorial_step")] + public int TutorialStep { get; set; } +} diff --git a/SVSim.UnitTests/Controllers/TutorialControllerTests.cs b/SVSim.UnitTests/Controllers/TutorialControllerTests.cs index ad11db4..8b1f66a 100644 --- a/SVSim.UnitTests/Controllers/TutorialControllerTests.cs +++ b/SVSim.UnitTests/Controllers/TutorialControllerTests.cs @@ -33,4 +33,48 @@ public class TutorialControllerTests Assert.That(doc.RootElement.EnumerateObject().Count(), Is.EqualTo(0), "update_action returns empty data — client uses SkipAllNetworkChecks and reads nothing."); } + + [TestCase(11)] + [TestCase(21)] + [TestCase(31)] + public async Task Update_echoes_requested_step_and_persists(int step) + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 0); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var requestJson = $$""" + {"tutorial_step":{{step}},"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(step)); + + // Side effect: viewer state advanced. + Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(step)); + } + + [Test] + public async Task Update_with_is_skip_1_jumps_to_100() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 0); + using var client = factory.CreateAuthenticatedClient(viewerId); + + // The client sends the step it's MOVING TO. is_skip=1 means "skip the rest" — typically + // sent with tutorial_step=100 already (matches what `TutorialUpdateTask` does with the + // is_skip flag), so the server's job is just to honor whatever value is provided. + var requestJson = """{"tutorial_step":100,"is_skip":1,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + + var response = await client.PostAsync("/tutorial/update", + new StringContent(requestJson, Encoding.UTF8, "application/json")); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.That(doc.RootElement.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(100)); + Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100)); + } } diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index fae522c..481a130 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -372,6 +372,18 @@ internal sealed class SVSimTestFactory : WebApplicationFactory await db.SaveChangesAsync(); } + /// + /// Reads the viewer's current TutorialState from the DB. + /// Tests use this to verify that /tutorial/update persisted the step. + /// + public async Task GetViewerTutorialStateAsync(long viewerId) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.MissionData).FirstAsync(v => v.Id == viewerId); + return viewer.MissionData.TutorialState; + } + protected override void Dispose(bool disposing) { base.Dispose(disposing);