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);