Files
SVSimServer/SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs
gamer147 6fd8705990 fix(tutorial): /tutorial/update preserves max TutorialState
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 <noreply@anthropic.com>
2026-05-28 20:22:59 -04:00

52 lines
2.2 KiB
C#

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;
/// <summary>
/// Tutorial step bookkeeping. The tutorial itself runs entirely client-side
/// (StoryTutorial*BattleMgr per class); the server only persists step transitions.
/// </summary>
public class TutorialController : SVSimController
{
private readonly SVSimDbContext _db;
public TutorialController(SVSimDbContext db)
{
_db = db;
}
[HttpPost("update_action")]
public IActionResult UpdateAction([FromBody] TutorialUpdateActionRequest request)
{
// Fire-and-forget. Client uses SkipAllNetworkChecks; response body is ignored.
// 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<ActionResult<TutorialUpdateResponse>> 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);
// 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 };
}
}