More story fixes

This commit is contained in:
gamer147
2026-05-25 19:07:49 -04:00
parent ce8d80559b
commit fa0901b776
16 changed files with 6361 additions and 48 deletions

View File

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

View File

@@ -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; } = "";
}

View File

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