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(
|
var allProgress = await _viewer.GetProgressForChaptersAsync(
|
||||||
viewerId, allChapters.Select(c => c.StoryId));
|
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.
|
// Index chapters by (sectionId, charaId) for O(1) lookup in the rollup loop.
|
||||||
var chaptersBySectionChara = allChapters
|
var chaptersBySectionChara = allChapters
|
||||||
.GroupBy(c => (c.SectionId, c.CharaId))
|
.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;
|
if (!sectionFinished) worldComplete = false;
|
||||||
worldDto.SectionList.Add(new SectionEntry
|
worldDto.SectionList.Add(new SectionEntry
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -335,6 +335,78 @@ public class StoryServiceTests
|
|||||||
Assert.That(section.SpoilerMessage, Is.EqualTo("story_section_14"));
|
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]
|
[Test]
|
||||||
public async Task GetLeaderSelectAsync_section_with_custom_leaders_returns_only_those_charas_in_min_story_id_order()
|
public async Task GetLeaderSelectAsync_section_with_custom_leaders_returns_only_those_charas_in_min_story_id_order()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user