More story fixes
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
@@ -27,7 +29,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
@@ -42,7 +46,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
@@ -57,7 +63,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
@@ -72,7 +80,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
@@ -87,7 +97,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
@@ -102,7 +114,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
@@ -117,7 +131,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
@@ -132,7 +148,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
@@ -147,7 +165,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
@@ -162,7 +182,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
@@ -177,7 +199,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
@@ -192,7 +216,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
@@ -207,7 +233,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
@@ -222,7 +250,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
@@ -237,7 +267,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
@@ -252,7 +284,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
@@ -267,7 +301,9 @@
|
||||
"chapter_select_type": 2,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
@@ -282,7 +318,9 @@
|
||||
"chapter_select_type": 2,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
@@ -297,7 +335,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
@@ -312,7 +352,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": ""
|
||||
},
|
||||
{
|
||||
"id": 9001,
|
||||
@@ -327,7 +369,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 2,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 1,
|
||||
"spoiler_message": "story_section_04"
|
||||
},
|
||||
{
|
||||
"id": 9002,
|
||||
@@ -342,7 +386,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 2,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 1,
|
||||
"spoiler_message": "story_section_09"
|
||||
},
|
||||
{
|
||||
"id": 9003,
|
||||
@@ -357,7 +403,9 @@
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 2,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 1,
|
||||
"spoiler_message": "story_section_14"
|
||||
},
|
||||
{
|
||||
"id": 9005,
|
||||
@@ -372,6 +420,8 @@
|
||||
"chapter_select_type": 2,
|
||||
"story_type_overwrite": 2,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
"is_play_another_end_appearance_animation": false,
|
||||
"is_spoiler": 0,
|
||||
"spoiler_message": null
|
||||
}
|
||||
]
|
||||
@@ -140,6 +140,8 @@ public class StoryImporter
|
||||
row.ChapterSelectType = s.ChapterSelectType; row.StoryTypeOverwrite = s.StoryTypeOverwrite;
|
||||
row.IsUnderMaintenance = s.IsUnderMaintenance;
|
||||
row.IsPlayAnotherEndAppearanceAnimation = s.IsPlayAnotherEndAppearanceAnimation;
|
||||
row.IsSpoiler = s.IsSpoiler;
|
||||
row.SpoilerMessage = s.SpoilerMessage ?? string.Empty;
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -160,7 +162,7 @@ public class StoryImporter
|
||||
row.ChapterEffectPath = c.ChapterEffectPath; row.ChapterClearTextId = c.ChapterClearTextId;
|
||||
row.Battle3dFieldId = c.Battle3dFieldId; row.BgmId = c.BgmId ?? "";
|
||||
row.SpecialBattleSettingId = c.SpecialBattleSettingId;
|
||||
row.ReleasePoint = c.ReleasePoint; row.IsMaintenanceChapter = c.IsMaintenanceChapter;
|
||||
row.ReleasePoint = c.ReleasePoint; row.UnlockText = c.UnlockText; row.IsMaintenanceChapter = c.IsMaintenanceChapter;
|
||||
row.IsPlayAnotherEndAppearanceAnimation = c.IsPlayAnotherEndAppearanceAnimation;
|
||||
row.IsReleasedAnotherEnd = c.IsReleasedAnotherEnd;
|
||||
row.IsSkipEnabled = c.IsSkipEnabled;
|
||||
@@ -233,6 +235,8 @@ public class StoryImporter
|
||||
public int ChapterSelectType { get; set; } public int StoryTypeOverwrite { get; set; }
|
||||
public bool IsUnderMaintenance { get; set; }
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
public int IsSpoiler { get; set; }
|
||||
public string? SpoilerMessage { get; set; }
|
||||
}
|
||||
private class ChapterInput
|
||||
{
|
||||
@@ -251,7 +255,8 @@ public class StoryImporter
|
||||
public int Battle3dFieldId { get; set; }
|
||||
public string? BgmId { get; set; }
|
||||
public int? SpecialBattleSettingId { get; set; }
|
||||
public int ReleasePoint { get; set; } public bool IsMaintenanceChapter { get; set; }
|
||||
public int ReleasePoint { get; set; } public string? UnlockText { get; set; }
|
||||
public bool IsMaintenanceChapter { get; set; }
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
public bool IsReleasedAnotherEnd { get; set; } public bool IsSkipEnabled { get; set; }
|
||||
public List<BattleSettingInput>? BattleSettings { get; set; }
|
||||
|
||||
@@ -39,6 +39,7 @@ public class StoryChapter
|
||||
public SpecialBattleSetting? SpecialBattleSetting { get; set; }
|
||||
|
||||
public int ReleasePoint { get; set; }
|
||||
public string? UnlockText { get; set; }
|
||||
public bool IsMaintenanceChapter { get; set; }
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
public bool IsReleasedAnotherEnd { get; set; }
|
||||
|
||||
@@ -23,4 +23,7 @@ public class StorySection
|
||||
public int StoryTypeOverwrite { get; set; }
|
||||
public bool IsUnderMaintenance { get; set; }
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
|
||||
public int IsSpoiler { get; set; }
|
||||
public string SpoilerMessage { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
2579
SVSim.Database/Migrations/20260525212450_AddStoryChapterUnlockText.Designer.cs
generated
Normal file
2579
SVSim.Database/Migrations/20260525212450_AddStoryChapterUnlockText.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStoryChapterUnlockText : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "UnlockText",
|
||||
table: "StoryChapters",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UnlockText",
|
||||
table: "StoryChapters");
|
||||
}
|
||||
}
|
||||
}
|
||||
2586
SVSim.Database/Migrations/20260525213842_AddStorySectionSpoilerFields.Designer.cs
generated
Normal file
2586
SVSim.Database/Migrations/20260525213842_AddStorySectionSpoilerFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStorySectionSpoilerFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "IsSpoiler",
|
||||
table: "StorySections",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SpoilerMessage",
|
||||
table: "StorySections",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsSpoiler",
|
||||
table: "StorySections");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SpoilerMessage",
|
||||
table: "StorySections");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,9 @@ namespace SVSim.Database.Migrations
|
||||
b.Property<int?>("SpecialBattleSettingId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UnlockText")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal>("XCoordinate")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
@@ -271,6 +274,9 @@ namespace SVSim.Database.Migrations
|
||||
b.Property<bool>("IsPlayAnotherEndAppearanceAnimation")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("IsSpoiler")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsUnderMaintenance")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -281,6 +287,10 @@ namespace SVSim.Database.Migrations
|
||||
b.Property<int>("OrderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SpoilerMessage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("StoryApiType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
||||
@@ -16,4 +16,12 @@ public interface IStoryMasterRepository
|
||||
|
||||
Task<StoryChapter?> GetChapterByIdAsync(int storyId);
|
||||
Task<SpecialBattleSetting?> GetSbsByIdAsync(int sbsId);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a wire story_id to a sub-chapter row when no top-level <see cref="StoryChapter"/>
|
||||
/// exists for it. Sub-chapter story_ids have no chapter master data of their own — they're
|
||||
/// progress markers hanging off the parent. Used by /finish to record progress at the sub's
|
||||
/// story_id when the client sends sub-chapter ids directly.
|
||||
/// </summary>
|
||||
Task<StorySubChapter?> FindSubChapterByStoryIdAsync(int storyId);
|
||||
}
|
||||
|
||||
@@ -51,4 +51,15 @@ public class StoryMasterRepository : IStoryMasterRepository
|
||||
|
||||
public Task<SpecialBattleSetting?> GetSbsByIdAsync(int sbsId)
|
||||
=> _db.SpecialBattleSettings.FirstOrDefaultAsync(s => s.Id == sbsId);
|
||||
|
||||
public async Task<StorySubChapter?> FindSubChapterByStoryIdAsync(int storyId)
|
||||
{
|
||||
// StorySubChapter is an owned entity (no DbSet of its own); query through the owning
|
||||
// chapter. SelectMany over the owned collection translates to a JOIN in the relational
|
||||
// provider — no need to materialize the full chapter row.
|
||||
return await _db.StoryChapters
|
||||
.AsNoTracking()
|
||||
.SelectMany(c => c.SubChapters)
|
||||
.FirstOrDefaultAsync(sc => sc.SubChapterStoryId == storyId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,38 @@ public class StoryMasterEntry
|
||||
[JsonPropertyName("is_skip_enabled")]
|
||||
[Key("is_skip_enabled")]
|
||||
public bool IsSkipEnabled { get; set; }
|
||||
|
||||
// Optional — prod omits the key entirely on chapters without sub-chapters. Only emitted for
|
||||
// chapters that split into N narrative vignettes (e.g. section 9 ch.13 has 5 sub-chapters).
|
||||
// The client uses each sub's is_finish flag to derive the parent's ChapterClearStatus
|
||||
// (AllCleared / AlreadyRead / NotCleared per StoryChapterData.GetClearStatusUsingSubChapter).
|
||||
// Explicit WhenWritingNull (rather than relying on global policy) so the key is dropped
|
||||
// under any serializer config — including the wire-shape snapshot test which sets
|
||||
// DefaultIgnoreCondition=Never to exercise every populated field.
|
||||
[JsonPropertyName("sub_chapters")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[Key("sub_chapters")]
|
||||
public List<SubChapterDto>? SubChapters { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SubChapterDto
|
||||
{
|
||||
[JsonPropertyName("story_id")]
|
||||
[Key("story_id")]
|
||||
public int StoryId { get; set; }
|
||||
|
||||
[JsonPropertyName("sub_chapter_id")]
|
||||
[Key("sub_chapter_id")]
|
||||
public int SubChapterId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_finish")]
|
||||
[Key("is_finish")]
|
||||
public bool IsFinish { get; set; }
|
||||
|
||||
[JsonPropertyName("is_maintenance_chapter")]
|
||||
[Key("is_maintenance_chapter")]
|
||||
public bool IsMaintenanceChapter { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
|
||||
@@ -106,4 +106,15 @@ public class SectionEntry
|
||||
[JsonPropertyName("is_play_another_end_appearance_animation")]
|
||||
[Key("is_play_another_end_appearance_animation")]
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
|
||||
// Prod sends is_spoiler as 0/1 int (not bool) and spoiler_message as a SystemText key
|
||||
// (e.g. "story_section_14"). Used by limited-story sections that sit inside main-story
|
||||
// worlds — the client hides their title until you've cleared the gating main section.
|
||||
[JsonPropertyName("is_spoiler")]
|
||||
[Key("is_spoiler")]
|
||||
public int IsSpoiler { get; set; }
|
||||
|
||||
[JsonPropertyName("spoiler_message")]
|
||||
[Key("spoiler_message")]
|
||||
public string SpoilerMessage { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database;
|
||||
@@ -14,8 +13,6 @@ namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public class StoryService : IStoryService
|
||||
{
|
||||
private static readonly Regex BranchSuffixRx = new(@"^\d+[a-zA-Z]+", RegexOptions.Compiled);
|
||||
|
||||
private readonly IStoryMasterRepository _master;
|
||||
private readonly IViewerStoryProgressRepository _viewer;
|
||||
private readonly RewardGrantService _rewards;
|
||||
@@ -49,7 +46,13 @@ public class StoryService : IStoryService
|
||||
if (chapters.Count == 0)
|
||||
return new InfoResponse();
|
||||
|
||||
var storyIds = chapters.Select(c => c.StoryId).ToList();
|
||||
// Include sub-chapter story_ids in the progress lookup — they're independent progress
|
||||
// markers (each sub vignette gets its own ViewerStoryProgress row) and feed the per-sub
|
||||
// is_finish flag in the response.
|
||||
var storyIds = chapters.Select(c => c.StoryId)
|
||||
.Concat(chapters.SelectMany(c => c.SubChapters).Select(sc => sc.SubChapterStoryId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
// Sequential awaits — both repos share the scoped DbContext, and EF Core forbids
|
||||
// concurrent operations on a single context. Parallel Task.WhenAll throws
|
||||
// InvalidOperationException ("A second operation was started on this context...").
|
||||
@@ -62,18 +65,28 @@ public class StoryService : IStoryService
|
||||
foreach (var c in chapters.OrderBy(x => ChapterRowNum(x.ChapterId))
|
||||
.ThenBy(x => x.ChapterId, StringComparer.Ordinal))
|
||||
{
|
||||
bool isBranchChild = BranchSuffixRx.IsMatch(c.ChapterId);
|
||||
var parent = chapters.FirstOrDefault(p =>
|
||||
!ReferenceEquals(p, c) &&
|
||||
p.NextChapterId.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains(c.ChapterId));
|
||||
|
||||
bool released;
|
||||
if (parent is null) released = true;
|
||||
else if (isBranchChild) released = unlocked.Contains(c.StoryId);
|
||||
else released = (progress.TryGetValue(parent.StoryId, out var pp))
|
||||
&& (pp.IsFinish || pp.IsSkipped);
|
||||
// A chapter is a "branch child" only at the SPLIT point — where the parent declares
|
||||
// multiple successors (e.g. ch2.next="3a 3b 3c"). The alphabetic suffix is inherited
|
||||
// across the rest of the branched path (3a→4a→5a→...) but only ch3a/3b/3c carry the
|
||||
// explicit unlock gate; downstream "4a"/"4b" are normal single successors. Suffix-based
|
||||
// detection (^\d+[a-z]+) wrongly tagged every "4a"-style chapter as a branch child.
|
||||
bool isBranchChild = parent is not null
|
||||
&& parent.NextChapterId.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length > 1;
|
||||
|
||||
// Optional required_chapter_id gate
|
||||
// is_released = "chapter visible in the section UI" — gated on parent progress.
|
||||
// For branch children this is the PARENT's finish state, NOT whether THIS branch
|
||||
// was selected — siblings of the chosen branch must still appear so the player
|
||||
// sees the alternative paths exist (rendered locked). Verified against prod
|
||||
// traffic_prod_haven_choices.ndjson lines 22/28/34 where 3a/3b/3c all carry
|
||||
// is_released=true regardless of which branch was previously chosen.
|
||||
bool released = parent is null
|
||||
|| (progress.TryGetValue(parent.StoryId, out var pp) && (pp.IsFinish || pp.IsSkipped));
|
||||
|
||||
// Optional required_chapter_id gate (additional release condition only).
|
||||
if (!string.IsNullOrEmpty(c.RequiredChapterId) &&
|
||||
byChapterId.TryGetValue(c.RequiredChapterId, out var req))
|
||||
{
|
||||
@@ -82,6 +95,13 @@ public class StoryService : IStoryService
|
||||
released = released && reqDone;
|
||||
}
|
||||
|
||||
// is_lock = "chapter has an explicit gate not yet satisfied" — INDEPENDENT of
|
||||
// is_released. The only gate in the current catalog is the branch-sibling
|
||||
// selection: unselected branch children carry is_lock=true even though they
|
||||
// remain visible. Non-branch chapters never carry an implicit lock; their
|
||||
// availability is communicated entirely through is_released.
|
||||
bool locked = isBranchChild && !unlocked.Contains(c.StoryId);
|
||||
|
||||
var pState = progress.GetValueOrDefault(c.StoryId);
|
||||
|
||||
resp.StoryMasterList.Add(new StoryMasterEntry
|
||||
@@ -90,7 +110,6 @@ public class StoryService : IStoryService
|
||||
SectionId = c.SectionId.ToString(),
|
||||
CharaId = c.CharaId.ToString(),
|
||||
ChapterId = c.ChapterId,
|
||||
IsLock = !released,
|
||||
NextChapterId = c.NextChapterId,
|
||||
RequiredChapterId = c.RequiredChapterId ?? "",
|
||||
SelectionDisplayPosition = c.SelectionDisplayPosition ?? "",
|
||||
@@ -127,8 +146,19 @@ public class StoryService : IStoryService
|
||||
RewardDetailId = r.RewardDetailId.ToString(),
|
||||
RewardNumber = r.RewardNumber.ToString(),
|
||||
}).ToList(),
|
||||
SubChapters = c.SubChapters.Count == 0
|
||||
? null
|
||||
: c.SubChapters.Select(sc => new SubChapterDto
|
||||
{
|
||||
StoryId = sc.SubChapterStoryId,
|
||||
SubChapterId = sc.SubChapterId,
|
||||
IsFinish = progress.TryGetValue(sc.SubChapterStoryId, out var sp) && sp.IsFinish,
|
||||
IsMaintenanceChapter = sc.IsMaintenanceChapter,
|
||||
}).ToList(),
|
||||
IsMaintenanceChapter = c.IsMaintenanceChapter,
|
||||
IsReleased = released,
|
||||
IsLock = locked,
|
||||
UnlockText = c.UnlockText ?? "",
|
||||
IsSkipped = pState?.IsSkipped ?? false,
|
||||
IsFinish = pState?.IsFinish ?? false,
|
||||
IsPlayAnotherEndAppearanceAnimation = c.IsPlayAnotherEndAppearanceAnimation,
|
||||
@@ -241,6 +271,8 @@ public class StoryService : IStoryService
|
||||
StoryTypeOverwrite = s.StoryTypeOverwrite.ToString(),
|
||||
IsNew = false,
|
||||
IsPlayAnotherEndAppearanceAnimation = s.IsPlayAnotherEndAppearanceAnimation,
|
||||
IsSpoiler = s.IsSpoiler,
|
||||
SpoilerMessage = s.SpoilerMessage,
|
||||
});
|
||||
}
|
||||
worldDto.IsComplete = worldComplete;
|
||||
@@ -358,22 +390,49 @@ public class StoryService : IStoryService
|
||||
public async Task<FinishResponse> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId)
|
||||
{
|
||||
var chapter = await _master.GetChapterByIdAsync(req.StoryId);
|
||||
if (chapter is null) return new FinishResponse();
|
||||
if (chapter is null)
|
||||
{
|
||||
// Sub-chapter story_ids (e.g. section 9 ch.13's vignettes at 375-378) have no chapter
|
||||
// master row of their own — they're just progress markers on the parent. The client
|
||||
// sends them directly to /finish per StoryFinishTask.GetFinishStoryId. Resolve via the
|
||||
// StorySubChapter lookup and record progress at the sub's id with isFinish+isSkipped
|
||||
// both true (sub-chapters are always narrative-only — no battle settings on the wire).
|
||||
var sub = await _master.FindSubChapterByStoryIdAsync(req.StoryId);
|
||||
if (sub is null) return new FinishResponse();
|
||||
await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: true, isSkipped: true);
|
||||
return new FinishResponse();
|
||||
}
|
||||
|
||||
var progress = (await _viewer.GetProgressForChaptersAsync(viewerId, new[] { req.StoryId }))
|
||||
.GetValueOrDefault(req.StoryId);
|
||||
|
||||
var resp = new FinishResponse();
|
||||
|
||||
if (req.IsPlayShape)
|
||||
// Three finish shapes:
|
||||
// 1. Play-shape (class_id present): user fought the battle → is_finish=true.
|
||||
// 2. No-battle chapter + finish=1: narrative-only chapter that the client auto-finishes
|
||||
// with no class_id. Prod marks BOTH is_finish=true AND is_skipped=true — the client
|
||||
// uses is_finish for the green "Cleared" badge, so leaving it false here renders the
|
||||
// blue "AlreadyRead" badge instead (verified against traffic_prod_limited_stories
|
||||
// story_id=1 /info after /finish).
|
||||
// 3. Skip-shape on battle chapter: user chose to skip → is_skipped=true only.
|
||||
bool isPlayShape = req.IsPlayShape;
|
||||
bool isNoBattleAutoFinish = !isPlayShape && !chapter.BattleExists;
|
||||
|
||||
if (isPlayShape || isNoBattleAutoFinish)
|
||||
{
|
||||
bool firstClear = progress is null || !progress.IsFinish;
|
||||
await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: true, isSkipped: null);
|
||||
await _viewer.UpsertProgressAsync(
|
||||
viewerId, req.StoryId,
|
||||
isFinish: true,
|
||||
isSkipped: isNoBattleAutoFinish ? true : (bool?)null);
|
||||
|
||||
if (firstClear)
|
||||
if (firstClear && chapter.Rewards.Count > 0)
|
||||
{
|
||||
// Load viewer with all collections RewardGrantService might mutate. Split-query
|
||||
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query").
|
||||
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query"). Skip the
|
||||
// load entirely when the chapter has no rewards — common for narrative-only
|
||||
// chapters (limited/event story) where the only side effect is the progress upsert.
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.Sleeves)
|
||||
@@ -429,7 +488,12 @@ public class StoryService : IStoryService
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (firstClear && isPlayShape)
|
||||
{
|
||||
// XP grant requires a class_id (only sent on play-shape). No-battle chapters
|
||||
// have no class context — prod returns get_class_experience=0 for them.
|
||||
var xp = _configService.Get<StoryConfig>().ClassXpPerClear;
|
||||
resp.GetClassExperience = xp.ToString();
|
||||
// class_experience / class_level updates would consult the viewer's per-class XP
|
||||
|
||||
@@ -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