feat: 支持长篇小说分层架构(卷/弧/章三级结构)
This commit is contained in:
@@ -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")
|
||||
|
||||
152
state/outline.go
152
state/outline.go
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user