fix(story): tutorial section is_finished derives from viewer tutorial_step

Section 0 (prologue) has no chapter rows server-side — the prologue is
hardcoded client-side in Wizard/Prologue.cs — so the chapter-completion
rollup always emitted is_finished=false. The client uses that flag to
derive IsTutorialReplay; with it false, AreaSelectUI.OnTouchChapterListTutorial
blocks every chapter switch and the default focus (last visible chapter)
becomes the only confirmable one, matching the reported "all 3 greyed out,
only the 3rd playable" symptom on replay.

Override sectionFinished for id=0 with viewer.MissionData.TutorialState >= 100,
matching prod traffic_prod_626_story.ndjson btn_story_tutorial.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-28 22:01:50 -04:00
parent 22c01ed11a
commit 261ce67cee
2 changed files with 85 additions and 0 deletions

View File

@@ -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
{

View File

@@ -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<StorySection> {
new() { Id = 0, WorldId = 1, StoryApiType = StoryApiType.Main, IsLeaderSelect = false } });
_master.Setup(m => m.GetWorldsForSectionsAsync(It.IsAny<IEnumerable<int>>()))
.ReturnsAsync(new List<StoryWorld> { new() { Id = 1, TitleTextKey = "world_1" } });
_master.Setup(m => m.GetChaptersBySectionsAsync(It.IsAny<IEnumerable<int>>()))
.ReturnsAsync(new List<StoryChapter>());
_viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny<IEnumerable<int>>()))
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
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<StorySection> {
new() { Id = 0, WorldId = 1, StoryApiType = StoryApiType.Main, IsLeaderSelect = false } });
_master.Setup(m => m.GetWorldsForSectionsAsync(It.IsAny<IEnumerable<int>>()))
.ReturnsAsync(new List<StoryWorld> { new() { Id = 1, TitleTextKey = "world_1" } });
_master.Setup(m => m.GetChaptersBySectionsAsync(It.IsAny<IEnumerable<int>>()))
.ReturnsAsync(new List<StoryChapter>());
_viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny<IEnumerable<int>>()))
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
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<RewardGrantService>.Instance),
db: db,
configService: StoryServiceTestHelpers.NewConfigService(),
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
logger: NullLogger<StoryService>.Instance);
}
[Test]
public async Task GetLeaderSelectAsync_section_with_custom_leaders_returns_only_those_charas_in_min_story_id_order()
{