diff --git a/SVSim.EmulatedEntrypoint/Services/StoryService.cs b/SVSim.EmulatedEntrypoint/Services/StoryService.cs index 82dbac6..e2c2059 100644 --- a/SVSim.EmulatedEntrypoint/Services/StoryService.cs +++ b/SVSim.EmulatedEntrypoint/Services/StoryService.cs @@ -192,6 +192,17 @@ public class StoryService : IStoryService var allProgress = await _viewer.GetProgressForChaptersAsync( viewerId, allChapters.Select(c => c.StoryId)); + // Tutorial section (id=0) has no chapter rows server-side — the prologue is hardcoded + // client-side in Wizard/Prologue.cs. Derive its is_finished from viewer.tutorial_step + // instead (matches prod traffic_prod_626_story.ndjson btn_story_tutorial). The client + // uses is_finished to flip IsTutorialReplay, which is what re-enables chapter switching + // in AreaSelectUI.OnTouchChapterListTutorial when the user re-visits the prologue. + const int TutorialEndStep = 100; + var tutorialState = await _db.Viewers + .Where(v => v.Id == viewerId) + .Select(v => v.MissionData.TutorialState) + .FirstOrDefaultAsync(); + // Index chapters by (sectionId, charaId) for O(1) lookup in the rollup loop. var chaptersBySectionChara = allChapters .GroupBy(c => (c.SectionId, c.CharaId)) @@ -253,6 +264,8 @@ public class StoryService : IStoryService } } + if (s.Id == 0) sectionFinished = tutorialState >= TutorialEndStep; + if (!sectionFinished) worldComplete = false; worldDto.SectionList.Add(new SectionEntry { diff --git a/SVSim.UnitTests/Story/StoryServiceTests.cs b/SVSim.UnitTests/Story/StoryServiceTests.cs index e759b40..4bdefc9 100644 --- a/SVSim.UnitTests/Story/StoryServiceTests.cs +++ b/SVSim.UnitTests/Story/StoryServiceTests.cs @@ -335,6 +335,78 @@ public class StoryServiceTests Assert.That(section.SpoilerMessage, Is.EqualTo("story_section_14")); } + [Test] + public async Task GetSectionsAsync_tutorial_section_is_finished_when_viewer_tutorial_state_at_end_step() + { + // The tutorial section (id=0) has zero chapter rows — the prologue is hardcoded + // client-side in Wizard/Prologue.cs. Prod nonetheless returns is_finished=true once + // viewer.tutorial_step reaches 100; the client uses that to flip IsTutorialReplay, + // which is what re-enables chapter switching in AreaSelectUI.OnTouchChapterListTutorial. + // Verified against traffic_prod_626_story.ndjson btn_story_tutorial entry. + const long viewerId = 7L; + var service = NewServiceWithSeededViewerTutorialState( + nameof(GetSectionsAsync_tutorial_section_is_finished_when_viewer_tutorial_state_at_end_step), + viewerId, tutorialState: 100); + + _master.Setup(m => m.GetSectionsByFamilyAsync(StoryApiType.Main)) + .ReturnsAsync(new List { + new() { Id = 0, WorldId = 1, StoryApiType = StoryApiType.Main, IsLeaderSelect = false } }); + _master.Setup(m => m.GetWorldsForSectionsAsync(It.IsAny>())) + .ReturnsAsync(new List { new() { Id = 1, TitleTextKey = "world_1" } }); + _master.Setup(m => m.GetChaptersBySectionsAsync(It.IsAny>())) + .ReturnsAsync(new List()); + _viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny>())) + .ReturnsAsync(new Dictionary()); + + var resp = await service.GetSectionsAsync(StoryApiType.Main, viewerId); + + var section = resp.WorldList["1"].SectionList.Single(s => s.SectionId == "0"); + Assert.That(section.IsFinished, Is.True); + } + + [Test] + public async Task GetSectionsAsync_tutorial_section_not_finished_when_viewer_mid_tutorial() + { + // Mid-tutorial (step < 100): client still needs IsTutorialReplay=false so the AreaSelectUI + // confirm-only-current-step flow runs. is_finished must stay false. + const long viewerId = 7L; + var service = NewServiceWithSeededViewerTutorialState( + nameof(GetSectionsAsync_tutorial_section_not_finished_when_viewer_mid_tutorial), + viewerId, tutorialState: 41); + + _master.Setup(m => m.GetSectionsByFamilyAsync(StoryApiType.Main)) + .ReturnsAsync(new List { + new() { Id = 0, WorldId = 1, StoryApiType = StoryApiType.Main, IsLeaderSelect = false } }); + _master.Setup(m => m.GetWorldsForSectionsAsync(It.IsAny>())) + .ReturnsAsync(new List { new() { Id = 1, TitleTextKey = "world_1" } }); + _master.Setup(m => m.GetChaptersBySectionsAsync(It.IsAny>())) + .ReturnsAsync(new List()); + _viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny>())) + .ReturnsAsync(new Dictionary()); + + var resp = await service.GetSectionsAsync(StoryApiType.Main, viewerId); + + var section = resp.WorldList["1"].SectionList.Single(s => s.SectionId == "0"); + Assert.That(section.IsFinished, Is.False); + } + + private StoryService NewServiceWithSeededViewerTutorialState(string dbName, long viewerId, int tutorialState) + { + var db = StoryServiceTestHelpers.NewInMemoryDb(dbName); + db.Viewers.Add(new SVSim.Database.Models.Viewer { + Id = viewerId, + MissionData = new SVSim.Database.Models.ViewerMissionData { TutorialState = tutorialState }, + }); + db.SaveChanges(); + return new StoryService( + _master.Object, _viewer.Object, + rewards: new RewardGrantService(db, NullLogger.Instance), + db: db, + configService: StoryServiceTestHelpers.NewConfigService(), + deckRepository: new Mock().Object, + logger: NullLogger.Instance); + } + [Test] public async Task GetLeaderSelectAsync_section_with_custom_leaders_returns_only_those_charas_in_min_story_id_order() {