feat: 支持长篇小说分层架构(卷/弧/章三级结构)

This commit is contained in:
voocel
2026-03-12 16:27:15 +08:00
parent 3d65afa276
commit bce0adeff1
19 changed files with 1045 additions and 16 deletions

View File

@@ -28,6 +28,45 @@ func (s *Store) LoadCharacters() ([]domain.Character, error) {
return chars, nil
}
// SaveCharacterSnapshots 保存角色状态快照到 meta/snapshots/v{vol}a{arc}.json。
func (s *Store) SaveCharacterSnapshots(volume, arc int, snapshots []domain.CharacterSnapshot) error {
return s.writeJSON(fmt.Sprintf("meta/snapshots/v%02da%02d.json", volume, arc), snapshots)
}
// LoadSnapshots 读取指定卷弧的角色快照。
func (s *Store) LoadSnapshots(volume, arc int) ([]domain.CharacterSnapshot, error) {
var snapshots []domain.CharacterSnapshot
if err := s.readJSON(fmt.Sprintf("meta/snapshots/v%02da%02d.json", volume, arc), &snapshots); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return snapshots, nil
}
// LoadLatestSnapshots 加载最近一次角色快照(按卷弧倒序查找)。
// 从分层大纲获取实际卷弧数量,避免盲扫。
func (s *Store) LoadLatestSnapshots() ([]domain.CharacterSnapshot, error) {
volumes, _ := s.LoadLayeredOutline()
if len(volumes) == 0 {
return nil, nil
}
for vi := len(volumes) - 1; vi >= 0; vi-- {
v := volumes[vi]
for ai := len(v.Arcs) - 1; ai >= 0; ai-- {
snaps, err := s.LoadSnapshots(v.Index, v.Arcs[ai].Index)
if err != nil {
return nil, err
}
if len(snaps) > 0 {
return snaps, nil
}
}
}
return nil, nil
}
func renderCharacters(chars []domain.Character) string {
var b strings.Builder
b.WriteString("# 角色档案\n\n")

View File

@@ -56,6 +56,158 @@ func (s *Store) GetChapterOutline(chapter int) (*domain.OutlineEntry, error) {
return nil, fmt.Errorf("chapter %d not found in outline", chapter)
}
// SaveLayeredOutline 保存分层大纲(长篇模式)。
// 同时保存 layered_outline.json机器读和 layered_outline.md人读
func (s *Store) SaveLayeredOutline(volumes []domain.VolumeOutline) error {
if err := s.writeJSON("layered_outline.json", volumes); err != nil {
return err
}
return s.writeMarkdown("layered_outline.md", renderLayeredOutline(volumes))
}
// LoadLayeredOutline 读取分层大纲。
func (s *Store) LoadLayeredOutline() ([]domain.VolumeOutline, error) {
var volumes []domain.VolumeOutline
if err := s.readJSON("layered_outline.json", &volumes); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return volumes, nil
}
// GetChapterFromLayered 从分层大纲中按全局章节号查找。
func (s *Store) GetChapterFromLayered(chapter int) (*domain.OutlineEntry, error) {
volumes, err := s.LoadLayeredOutline()
if err != nil {
return nil, err
}
ch := 1
for _, v := range volumes {
for _, a := range v.Arcs {
for i := range a.Chapters {
if ch == chapter {
e := a.Chapters[i]
e.Chapter = ch
return &e, nil
}
ch++
}
}
}
return nil, fmt.Errorf("chapter %d not found in layered outline", chapter)
}
// LocateChapter 根据全局章节号定位所在的卷和弧。
func (s *Store) LocateChapter(chapter int) (volume, arc int, err error) {
volumes, err := s.LoadLayeredOutline()
if err != nil {
return 0, 0, err
}
ch := 1
for _, v := range volumes {
for _, a := range v.Arcs {
for range a.Chapters {
if ch == chapter {
return v.Index, a.Index, nil
}
ch++
}
}
}
return 0, 0, fmt.Errorf("chapter %d not found in layered outline", chapter)
}
// ArcBoundary 弧边界信息。
type ArcBoundary struct {
IsArcEnd bool // 是否为弧内最后一章
IsVolumeEnd bool // 是否同时为卷内最后一章
Volume int // 当前章所在卷
Arc int // 当前章所在弧
NextVolume int // 下一章所在卷0 = 全书结束)
NextArc int // 下一章所在弧0 = 全书结束)
}
// CheckArcBoundary 检查某章是否为弧/卷的最后一章。
// 非分层大纲或未找到章节时返回 nil。
func (s *Store) CheckArcBoundary(chapter int) (*ArcBoundary, error) {
volumes, err := s.LoadLayeredOutline()
if err != nil || len(volumes) == 0 {
return nil, err
}
type chapterPos struct {
volume, arc, indexInArc, arcLen int
isLastArc bool
}
// 构建全局章节号 → 位置映射
ch := 1
var cur *chapterPos
var nextVol, nextArc int
for _, v := range volumes {
for ai, a := range v.Arcs {
for ci := range a.Chapters {
if ch == chapter {
cur = &chapterPos{
volume: v.Index,
arc: a.Index,
indexInArc: ci,
arcLen: len(a.Chapters),
isLastArc: ai == len(v.Arcs)-1,
}
} else if cur != nil && nextVol == 0 {
// 紧跟 cur 的下一章
nextVol = v.Index
nextArc = a.Index
}
ch++
}
}
}
if cur == nil {
return nil, nil
}
b := &ArcBoundary{
Volume: cur.volume,
Arc: cur.arc,
NextVolume: nextVol,
NextArc: nextArc,
}
if cur.indexInArc == cur.arcLen-1 {
b.IsArcEnd = true
if cur.isLastArc {
b.IsVolumeEnd = true
}
}
return b, nil
}
func renderLayeredOutline(volumes []domain.VolumeOutline) string {
var b strings.Builder
b.WriteString("# 分层大纲\n\n")
ch := 1
for _, v := range volumes {
fmt.Fprintf(&b, "## 第 %d 卷:%s\n\n", v.Index, v.Title)
fmt.Fprintf(&b, "**主题**%s\n\n", v.Theme)
for _, a := range v.Arcs {
fmt.Fprintf(&b, "### 第 %d 弧:%s\n\n", a.Index, a.Title)
fmt.Fprintf(&b, "**目标**%s\n\n", a.Goal)
for _, e := range a.Chapters {
fmt.Fprintf(&b, "#### 第 %d 章:%s\n\n", ch, e.Title)
fmt.Fprintf(&b, "**核心事件**%s\n\n", e.CoreEvent)
if e.Hook != "" {
fmt.Fprintf(&b, "**钩子**%s\n\n", e.Hook)
}
ch++
}
}
}
return b.String()
}
func renderOutline(entries []domain.OutlineEntry) string {
var b strings.Builder
b.WriteString("# 大纲\n\n")

View File

@@ -179,6 +179,33 @@ func (s *Store) ClearLastCommit() error {
return s.removeFile("meta/last_commit.json")
}
// UpdateVolumeArc 更新当前卷弧位置。
func (s *Store) UpdateVolumeArc(volume, arc int) error {
p, err := s.LoadProgress()
if err != nil {
return err
}
if p == nil {
return nil
}
p.CurrentVolume = volume
p.CurrentArc = arc
return s.SaveProgress(p)
}
// SetLayered 设置分层模式标志。
func (s *Store) SetLayered(layered bool) error {
p, err := s.LoadProgress()
if err != nil {
return err
}
if p == nil {
return nil
}
p.Layered = layered
return s.SaveProgress(p)
}
// SetFlow 更新当前流程状态。
func (s *Store) SetFlow(flow domain.FlowState) error {
p, err := s.LoadProgress()

View File

@@ -44,3 +44,93 @@ func (s *Store) LoadRecentSummaries(current, count int) ([]domain.ChapterSummary
func (s *Store) LoadAllSummaries(current int) ([]domain.ChapterSummary, error) {
return s.LoadRecentSummaries(current, current)
}
// SaveArcSummary 保存弧级摘要到 summaries/arc-v{vol}a{arc}.json。
func (s *Store) SaveArcSummary(sum domain.ArcSummary) error {
return s.writeJSON(fmt.Sprintf("summaries/arc-v%02da%02d.json", sum.Volume, sum.Arc), sum)
}
// LoadArcSummary 读取指定弧的摘要。
func (s *Store) LoadArcSummary(volume, arc int) (*domain.ArcSummary, error) {
var sum domain.ArcSummary
if err := s.readJSON(fmt.Sprintf("summaries/arc-v%02da%02d.json", volume, arc), &sum); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return &sum, nil
}
// LoadArcSummaries 加载一卷内所有已有弧摘要。
// 从分层大纲获取实际弧数,无分层大纲时扫描到首个缺失为止。
func (s *Store) LoadArcSummaries(volume int) ([]domain.ArcSummary, error) {
maxArc := s.arcCountForVolume(volume)
var result []domain.ArcSummary
for arc := 1; arc <= maxArc; arc++ {
sum, err := s.LoadArcSummary(volume, arc)
if err != nil {
return nil, err
}
if sum != nil {
result = append(result, *sum)
}
}
return result, nil
}
// SaveVolumeSummary 保存卷级摘要到 summaries/vol-v{vol}.json。
func (s *Store) SaveVolumeSummary(sum domain.VolumeSummary) error {
return s.writeJSON(fmt.Sprintf("summaries/vol-v%02d.json", sum.Volume), sum)
}
// LoadVolumeSummary 读取指定卷的摘要。
func (s *Store) LoadVolumeSummary(volume int) (*domain.VolumeSummary, error) {
var sum domain.VolumeSummary
if err := s.readJSON(fmt.Sprintf("summaries/vol-v%02d.json", volume), &sum); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return &sum, nil
}
// LoadAllVolumeSummaries 加载所有已有卷摘要。
// 从分层大纲获取实际卷数,无分层大纲时扫描到首个缺失为止。
func (s *Store) LoadAllVolumeSummaries() ([]domain.VolumeSummary, error) {
maxVol := s.volumeCount()
var result []domain.VolumeSummary
for vol := 1; vol <= maxVol; vol++ {
sum, err := s.LoadVolumeSummary(vol)
if err != nil {
return nil, err
}
if sum != nil {
result = append(result, *sum)
}
}
return result, nil
}
// volumeCount 从分层大纲获取卷数,无大纲时返回安全上限。
func (s *Store) volumeCount() int {
volumes, err := s.LoadLayeredOutline()
if err == nil && len(volumes) > 0 {
return len(volumes)
}
return 20
}
// arcCountForVolume 从分层大纲获取指定卷的弧数,无大纲时返回安全上限。
func (s *Store) arcCountForVolume(volume int) int {
volumes, err := s.LoadLayeredOutline()
if err == nil {
for _, v := range volumes {
if v.Index == volume {
return len(v.Arcs)
}
}
}
return 20
}