fix(viewer): RegisterViewer defaults to post-tutorial TutorialState=100

BuildDefaultViewer hardcoded TutorialState=1 — correct for fresh anonymous
signups (RegisterAnonymousViewer) but wrong for AdminController.ImportViewer
and Steam-social signups, which both go through RegisterViewer and expect a
prod-replica viewer that boots to the home screen. Add an initialTutorialState
parameter (default 1 preserves RegisterAnonymousViewer behavior); RegisterViewer
passes 100.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-28 20:07:05 -04:00
parent c2c6a95170
commit b50a884af9
2 changed files with 29 additions and 11 deletions

View File

@@ -72,7 +72,11 @@ public class ViewerRepository : IViewerRepository
public async Task<Models.Viewer> RegisterViewer(string displayName, SocialAccountType socialType,
ulong socialAccountIdentifier, ulong? shortUdid = null)
{
var viewer = await BuildDefaultViewer(displayName);
// RegisterViewer is the import / Steam-social path. Default to the post-tutorial baseline
// (state 100) so AdminController.ImportViewer materializes prod-replicas at the home screen
// unless the import request explicitly overrides via request.TutorialState. The anonymous
// signup path (RegisterAnonymousViewer) uses the parameter default of 1.
var viewer = await BuildDefaultViewer(displayName, initialTutorialState: 100);
viewer.SocialAccountConnections.Add(new SocialAccountConnection
{
AccountId = socialAccountIdentifier,
@@ -195,7 +199,7 @@ public class ViewerRepository : IViewerRepository
await _dbContext.SaveChangesAsync();
}
private async Task<Models.Viewer> BuildDefaultViewer(string displayName)
private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1)
{
Models.Viewer viewer = new Models.Viewer
{
@@ -211,15 +215,11 @@ public class ViewerRepository : IViewerRepository
viewer.Currency.Crystals = grants.Crystals;
viewer.Currency.Rupees = grants.Rupees;
viewer.Currency.RedEther = grants.Ether;
// TUTORIAL_STEP0 (= 1) — "fresh signup, ready to play the prologue", confirmed by the
// prod capture in data_dumps/traffic_prod_tutorial.ndjson (game_start returned
// now_tutorial_step="1"). Step 0 is a pre-existence state we never want to expose
// on the wire: Wizard.Title.NextSceneSwitcher routes step==1 → Prologue scene and
// every other non-{31,41,100} step → AreaSelect at section 0, which has no chapter
// data (the prologue's chapters are baked into Wizard/Prologue.cs, not served) and
// crashes AreaSelectUI.SelectChapter with a "Sequence contains no matching element"
// LINQ Single() failure.
viewer.MissionData.TutorialState = 1;
// TUTORIAL_STEP0 (= 1) is the fresh-signup default — see RegisterAnonymousViewer for
// why step==0 is unsafe. RegisterViewer (admin-import + Steam-social) passes 100 so
// those callers land at the post-tutorial baseline; import requests can still override
// via the explicit ImportViewerRequest.TutorialState field.
viewer.MissionData.TutorialState = initialTutorialState;
// Load classes WITH their LeaderSkins — DefaultLeaderSkin iterates the nav collection
// and would otherwise be null (audit §6 #3 latent NRE — this is the one).

View File

@@ -141,6 +141,24 @@ public class ViewerRepositoryTests
await repo.RegisterAnonymousViewer(Guid.Empty));
}
[Test]
public async Task RegisterViewer_starts_at_post_tutorial_state()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
var viewer = await repo.RegisterViewer(
"Imported Viewer",
SVSim.Database.Enums.SocialAccountType.Steam,
socialAccountIdentifier: 76_561_198_000_000_999UL);
Assert.That(viewer.MissionData.TutorialState, Is.EqualTo(100),
"RegisterViewer (admin-import + Steam-social signup) must produce a post-tutorial " +
"viewer by default. Import requests can override via request.TutorialState; absence " +
"means 'a prod-replica viewer ready for the home screen', NOT 'replay tutorial'.");
}
[Test]
public async Task GetViewerByUdid_returns_viewer_or_null()
{