More story fixes
This commit is contained in:
@@ -155,8 +155,8 @@ public class StoryServiceTests
|
||||
|
||||
var resp = await _service.GetInfoAsync(StoryApiType.Main, 17, 500901, viewerId: 7L);
|
||||
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3a").IsReleased, Is.False);
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3b").IsReleased, Is.False);
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3a").IsLock, Is.True);
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3b").IsLock, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -176,8 +176,96 @@ public class StoryServiceTests
|
||||
|
||||
var resp = await _service.GetInfoAsync(StoryApiType.Main, 17, 500901, viewerId: 7L);
|
||||
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3a").IsReleased, Is.True);
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3b").IsReleased, Is.False);
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3a").IsLock, Is.False, "selected branch is playable");
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3b").IsLock, Is.True, "unselected branch is locked");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetInfoAsync_branch_siblings_stay_visible_after_parent_finished_even_when_locked()
|
||||
{
|
||||
// Section 17 chara 500901 (Havencraft): ch2's selection_chapter_id picks one of 3a/3b/3c.
|
||||
// The two NOT chosen must stay visible (is_released=true) with is_lock=true so the UI
|
||||
// can render them as "locked alternative branches" — they vanish entirely if we tie
|
||||
// is_released to is_lock. Verified against traffic_prod_haven_choices.ndjson lines 22,28,34
|
||||
// (post-clear state showing chosen branch unlocked, others released-but-locked).
|
||||
var chapters = new List<StoryChapter> {
|
||||
Ch(200, 17, 500901, "2", "3a 3b 3c"),
|
||||
Ch(201, 17, 500901, "3a", "4a"),
|
||||
Ch(202, 17, 500901, "3b", "4b"),
|
||||
Ch(203, 17, 500901, "3c", "4c"),
|
||||
};
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(17, 500901)).ReturnsAsync(chapters);
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 200, new ViewerStoryProgress { StoryId = 200, IsFinish = true } } });
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int> { 201 }); // user picked 3a
|
||||
|
||||
var resp = await _service.GetInfoAsync(StoryApiType.Main, 17, 500901, viewerId: 7L);
|
||||
|
||||
var c3a = resp.StoryMasterList.Single(c => c.ChapterId == "3a");
|
||||
var c3b = resp.StoryMasterList.Single(c => c.ChapterId == "3b");
|
||||
var c3c = resp.StoryMasterList.Single(c => c.ChapterId == "3c");
|
||||
|
||||
Assert.That(c3a.IsReleased, Is.True); Assert.That(c3a.IsLock, Is.False); // selected
|
||||
Assert.That(c3b.IsReleased, Is.True); Assert.That(c3b.IsLock, Is.True); // visible-but-locked
|
||||
Assert.That(c3c.IsReleased, Is.True); Assert.That(c3c.IsLock, Is.True); // visible-but-locked
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetInfoAsync_emits_unlock_text_from_chapter_master()
|
||||
{
|
||||
// Client renders "Complete the following requirements to unlock this story: {0}" and
|
||||
// substitutes {0} with unlock_text. An empty unlock_text leaves the literal "{0}" visible.
|
||||
// Verified against traffic_prod_haven_choices.ndjson where every branch sibling carries
|
||||
// a populated unlock_text (e.g. "Select 'Head to the West Tower' in Chapter 2").
|
||||
var parent = Ch(200, 17, 500901, "2", "3a 3b");
|
||||
var branch3b = Ch(202, 17, 500901, "3b", "4b");
|
||||
branch3b.UnlockText = "Select \"Look for Leads on Amaryllis\" in Chapter 2";
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(17, 500901))
|
||||
.ReturnsAsync(new List<StoryChapter> { parent, branch3b });
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 200, new ViewerStoryProgress { StoryId = 200, IsFinish = true } } });
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int>());
|
||||
|
||||
var resp = await _service.GetInfoAsync(StoryApiType.Main, 17, 500901, viewerId: 7L);
|
||||
|
||||
var c3b = resp.StoryMasterList.Single(c => c.ChapterId == "3b");
|
||||
Assert.That(c3b.IsLock, Is.True, "precondition: chapter is locked");
|
||||
Assert.That(c3b.UnlockText, Is.EqualTo("Select \"Look for Leads on Amaryllis\" in Chapter 2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetInfoAsync_non_branch_downstream_of_unfinished_branch_is_unreleased_but_unlocked()
|
||||
{
|
||||
// Prod traffic_prod_haven_choices line 40: after ch3a is finished, ch4a is released+playable
|
||||
// but ch4b/ch4c are NOT released yet (their parent ch3b/3c not finished) AND is_lock=false
|
||||
// — is_lock is reserved for the branch-sibling gate, not the inverse of is_released.
|
||||
var chapters = new List<StoryChapter> {
|
||||
Ch(200, 17, 500901, "2", "3a 3b 3c"),
|
||||
Ch(201, 17, 500901, "3a", "4a"),
|
||||
Ch(202, 17, 500901, "3b", "4b"),
|
||||
Ch(203, 17, 500901, "3c", "4c"),
|
||||
Ch(300, 17, 500901, "4a", "5a"),
|
||||
Ch(301, 17, 500901, "4b", "5b"),
|
||||
Ch(302, 17, 500901, "4c", "7"),
|
||||
};
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(17, 500901)).ReturnsAsync(chapters);
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 200, new ViewerStoryProgress { StoryId = 200, IsFinish = true } },
|
||||
{ 201, new ViewerStoryProgress { StoryId = 201, IsFinish = true } } });
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int> { 201 }); // user picked + finished 3a
|
||||
|
||||
var resp = await _service.GetInfoAsync(StoryApiType.Main, 17, 500901, viewerId: 7L);
|
||||
|
||||
var c4a = resp.StoryMasterList.Single(c => c.ChapterId == "4a");
|
||||
var c4b = resp.StoryMasterList.Single(c => c.ChapterId == "4b");
|
||||
Assert.That(c4a.IsReleased, Is.True); Assert.That(c4a.IsLock, Is.False); // playable
|
||||
Assert.That(c4b.IsReleased, Is.False); Assert.That(c4b.IsLock, Is.False); // not reached, but not "locked"
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -222,6 +310,31 @@ public class StoryServiceTests
|
||||
Assert.That(chara2.IsFinished, Is.False); // chapter 6 not done yet
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetSectionsAsync_passes_through_spoiler_fields_from_section_master()
|
||||
{
|
||||
// Limited-story sections (section_id >= 9000) sit inside main-story worlds and prod uses
|
||||
// is_spoiler=1 + spoiler_message="story_section_N" to hide the section name until you've
|
||||
// cleared main section N. Verified against prod /story/section responses where section
|
||||
// 9003 carries is_spoiler=1, spoiler_message="story_section_14".
|
||||
_master.Setup(m => m.GetSectionsByFamilyAsync(StoryApiType.Main))
|
||||
.ReturnsAsync(new List<StorySection> {
|
||||
new() { Id = 9003, WorldId = 1, StoryApiType = StoryApiType.Limited,
|
||||
IsLeaderSelect = false, IsSpoiler = 1, SpoilerMessage = "story_section_14" } });
|
||||
_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(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
|
||||
|
||||
var resp = await _service.GetSectionsAsync(StoryApiType.Main, viewerId: 7L);
|
||||
|
||||
var section = resp.WorldList["1"].SectionList.Single(s => s.SectionId == "9003");
|
||||
Assert.That(section.IsSpoiler, Is.EqualTo(1));
|
||||
Assert.That(section.SpoilerMessage, Is.EqualTo("story_section_14"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLeaderSelectAsync_section_with_custom_leaders_returns_only_those_charas_in_min_story_id_order()
|
||||
{
|
||||
@@ -279,6 +392,85 @@ public class StoryServiceTests
|
||||
Assert.That(((Array)resp["0"]).Length, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetInfoAsync_emits_sub_chapters_with_per_sub_is_finish()
|
||||
{
|
||||
// Section 9 ch.13 (story_id 374) carries 5 sub-chapters (374/1, 375/2, 376/3, 377/4, 378/5).
|
||||
// The client's SubChapterData parser reads is_finish per sub-chapter to derive the parent's
|
||||
// ChapterClearStatus (AllCleared / AlreadyRead / NotCleared). Verified against
|
||||
// traffic_prod_more_stories.ndjson section_id=9 /info response.
|
||||
var parent = Ch(374, 9, 0, "13", "14", battle: false);
|
||||
parent.SubChapters.Add(new StorySubChapter { SubChapterId = 1, SubChapterStoryId = 374 });
|
||||
parent.SubChapters.Add(new StorySubChapter { SubChapterId = 2, SubChapterStoryId = 375 });
|
||||
parent.SubChapters.Add(new StorySubChapter { SubChapterId = 3, SubChapterStoryId = 376 });
|
||||
var ch14 = Ch(379, 9, 0, "14", "0", battle: false);
|
||||
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(9, 0))
|
||||
.ReturnsAsync(new List<StoryChapter> { parent, ch14 });
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 374, new ViewerStoryProgress { StoryId = 374, IsFinish = true } },
|
||||
{ 375, new ViewerStoryProgress { StoryId = 375, IsFinish = true } } });
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int>());
|
||||
|
||||
var resp = await _service.GetInfoAsync(StoryApiType.Main, 9, 0, viewerId: 7L);
|
||||
|
||||
var ch13 = resp.StoryMasterList.Single(c => c.ChapterId == "13");
|
||||
Assert.That(ch13.SubChapters, Has.Count.EqualTo(3));
|
||||
var subs = ch13.SubChapters.OrderBy(s => s.SubChapterId).ToList();
|
||||
Assert.That(subs[0].StoryId, Is.EqualTo(374)); Assert.That(subs[0].IsFinish, Is.True);
|
||||
Assert.That(subs[1].StoryId, Is.EqualTo(375)); Assert.That(subs[1].IsFinish, Is.True);
|
||||
Assert.That(subs[2].StoryId, Is.EqualTo(376)); Assert.That(subs[2].IsFinish, Is.False);
|
||||
|
||||
// Regular chapter (no subs) should not carry the sub_chapters key on the wire at all —
|
||||
// prod omits it entirely. We leave the DTO property null so the global WhenWritingNull
|
||||
// policy drops the key during serialization.
|
||||
var c14 = resp.StoryMasterList.Single(c => c.ChapterId == "14");
|
||||
Assert.That(c14.SubChapters, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishAsync_sub_chapter_id_marks_progress_via_resolution()
|
||||
{
|
||||
// Client sends /finish with the sub-chapter's story_id (e.g. 375), not the parent's.
|
||||
// Our chapter table has no row for 375 — GetChapterByIdAsync returns null. The service
|
||||
// must fall through to StorySubChapter resolution and upsert progress at the sub's id
|
||||
// with isFinish=true, isSkipped=true (sub-chapters are always narrative-only).
|
||||
// Confirmed against StoryFinishTask.cs line 391 in decompiled client.
|
||||
_master.Setup(m => m.GetChapterByIdAsync(375)).ReturnsAsync((StoryChapter?)null);
|
||||
_master.Setup(m => m.FindSubChapterByStoryIdAsync(375))
|
||||
.ReturnsAsync(new StorySubChapter { SubChapterId = 2, SubChapterStoryId = 375 });
|
||||
|
||||
var req = new FinishRequest { StoryId = 375, IsFinish = 1, ClassId = null };
|
||||
await _service.FinishAsync(StoryApiType.Main, req, viewerId: 7L);
|
||||
|
||||
_viewer.Verify(v => v.UpsertProgressAsync(7L, 375, true, true), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishAsync_no_battle_chapter_marks_both_isFinish_and_isSkipped_true()
|
||||
{
|
||||
// Limited-story narrative chapters have battle_exists=false. Prod's /info returns
|
||||
// is_finish=true AND is_skipped=true for these once /finish is called — the client uses
|
||||
// is_finish for the green "Cleared" badge, so leaving is_finish=false (only is_skipped)
|
||||
// renders the blue "AlreadyRead" badge instead. Verified against
|
||||
// traffic_prod_limited_stories.ndjson story_id=1 after first /finish.
|
||||
var chapter = Ch(100, 9001, 0, "1", "2", battle: false);
|
||||
_master.Setup(m => m.GetChapterByIdAsync(100)).ReturnsAsync(chapter);
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
|
||||
|
||||
var req = new FinishRequest {
|
||||
StoryId = 100, IsFinish = 1, ClassId = null, // play-shape absent (no battle to play)
|
||||
SelectionChapterId = null,
|
||||
};
|
||||
|
||||
await _service.FinishAsync(StoryApiType.Limited, req, viewerId: 7L);
|
||||
|
||||
_viewer.Verify(v => v.UpsertProgressAsync(7L, 100, true, true), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishAsync_skip_shape_sets_isSkipped_and_grants_nothing()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user