diff --git a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs index bfd802f..56c9ed9 100644 --- a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs +++ b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs @@ -72,7 +72,11 @@ public class ViewerRepository : IViewerRepository public async Task 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 BuildDefaultViewer(string displayName) + private async Task 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). diff --git a/SVSim.UnitTests/Repositories/ViewerRepositoryTests.cs b/SVSim.UnitTests/Repositories/ViewerRepositoryTests.cs index acf2013..35b8c04 100644 --- a/SVSim.UnitTests/Repositories/ViewerRepositoryTests.cs +++ b/SVSim.UnitTests/Repositories/ViewerRepositoryTests.cs @@ -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(); + + 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() {