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:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user