init
This commit is contained in:
45
state/characters.go
Normal file
45
state/characters.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
// SaveCharacters 同时保存 characters.json 和 characters.md。
|
||||
func (s *Store) SaveCharacters(chars []domain.Character) error {
|
||||
if err := s.writeJSON("characters.json", chars); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeMarkdown("characters.md", renderCharacters(chars))
|
||||
}
|
||||
|
||||
// LoadCharacters 从 characters.json 读取角色档案。
|
||||
func (s *Store) LoadCharacters() ([]domain.Character, error) {
|
||||
var chars []domain.Character
|
||||
if err := s.readJSON("characters.json", &chars); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return chars, nil
|
||||
}
|
||||
|
||||
func renderCharacters(chars []domain.Character) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# 角色档案\n\n")
|
||||
for _, c := range chars {
|
||||
fmt.Fprintf(&b, "## %s(%s)\n\n", c.Name, c.Role)
|
||||
fmt.Fprintf(&b, "%s\n\n", c.Description)
|
||||
if c.Arc != "" {
|
||||
fmt.Fprintf(&b, "**角色弧线**:%s\n\n", c.Arc)
|
||||
}
|
||||
if len(c.Traits) > 0 {
|
||||
fmt.Fprintf(&b, "**特征**:%s\n\n", strings.Join(c.Traits, "、"))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
114
state/drafts.go
Normal file
114
state/drafts.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
// SaveChapterPlan 保存章节规划到 drafts/{ch}.plan.json。
|
||||
func (s *Store) SaveChapterPlan(plan domain.ChapterPlan) error {
|
||||
return s.writeJSON(fmt.Sprintf("drafts/%02d.plan.json", plan.Chapter), plan)
|
||||
}
|
||||
|
||||
// LoadChapterPlan 读取章节规划。
|
||||
func (s *Store) LoadChapterPlan(chapter int) (*domain.ChapterPlan, error) {
|
||||
var plan domain.ChapterPlan
|
||||
if err := s.readJSON(fmt.Sprintf("drafts/%02d.plan.json", chapter), &plan); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// SaveSceneDraft 保存场景草稿到 drafts/{ch}.scene-{n}.md。
|
||||
func (s *Store) SaveSceneDraft(draft domain.SceneDraft) error {
|
||||
rel := fmt.Sprintf("drafts/%02d.scene-%d.md", draft.Chapter, draft.Scene)
|
||||
return s.writeMarkdown(rel, draft.Content)
|
||||
}
|
||||
|
||||
// LoadSceneDrafts 加载指定章节的所有场景草稿,按场景编号排序。
|
||||
func (s *Store) LoadSceneDrafts(chapter int) ([]domain.SceneDraft, error) {
|
||||
pattern := filepath.Join(s.dir, "drafts", fmt.Sprintf("%02d.scene-*.md", chapter))
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(matches)
|
||||
|
||||
var drafts []domain.SceneDraft
|
||||
for _, m := range matches {
|
||||
base := filepath.Base(m)
|
||||
sceneNum := parseSceneNum(base)
|
||||
content, err := os.ReadFile(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read scene draft %s: %w", base, err)
|
||||
}
|
||||
drafts = append(drafts, domain.SceneDraft{
|
||||
Chapter: chapter,
|
||||
Scene: sceneNum,
|
||||
Content: string(content),
|
||||
WordCount: utf8.RuneCountInString(string(content)),
|
||||
})
|
||||
}
|
||||
return drafts, nil
|
||||
}
|
||||
|
||||
// SavePolished 保存打磨后的章节正文到 drafts/{ch}.polished.md。
|
||||
func (s *Store) SavePolished(chapter int, content string) error {
|
||||
return s.writeMarkdown(fmt.Sprintf("drafts/%02d.polished.md", chapter), content)
|
||||
}
|
||||
|
||||
// LoadPolished 读取打磨后的章节正文。不存在时返回空字符串。
|
||||
func (s *Store) LoadPolished(chapter int) (string, error) {
|
||||
data, err := s.readFile(fmt.Sprintf("drafts/%02d.polished.md", chapter))
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// LoadChapterContent 加载章节正文:优先 polished,否则 merge scenes。
|
||||
func (s *Store) LoadChapterContent(chapter int) (string, int, error) {
|
||||
polished, err := s.LoadPolished(chapter)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if polished != "" {
|
||||
return polished, utf8.RuneCountInString(polished), nil
|
||||
}
|
||||
drafts, err := s.LoadSceneDrafts(chapter)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
content, wc := domain.MergeScenes(drafts)
|
||||
return content, wc, nil
|
||||
}
|
||||
|
||||
// SaveFinalChapter 保存最终章节正文到 chapters/{ch}.md。
|
||||
func (s *Store) SaveFinalChapter(chapter int, content string) error {
|
||||
return s.writeMarkdown(fmt.Sprintf("chapters/%02d.md", chapter), content)
|
||||
}
|
||||
|
||||
// parseSceneNum 从文件名如 "01.scene-2.md" 提取场景编号。
|
||||
func parseSceneNum(filename string) int {
|
||||
// 格式:{ch}.scene-{n}.md
|
||||
parts := strings.Split(filename, "scene-")
|
||||
if len(parts) < 2 {
|
||||
return 0
|
||||
}
|
||||
numStr := strings.TrimSuffix(parts[1], ".md")
|
||||
n, _ := strconv.Atoi(numStr)
|
||||
return n
|
||||
}
|
||||
76
state/foreshadow.go
Normal file
76
state/foreshadow.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
// SaveForeshadowLedger 全量写入 foreshadow_ledger.json + foreshadow_ledger.md。
|
||||
func (s *Store) SaveForeshadowLedger(entries []domain.ForeshadowEntry) error {
|
||||
if err := s.writeJSON("foreshadow_ledger.json", entries); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeMarkdown("foreshadow_ledger.md", renderForeshadow(entries))
|
||||
}
|
||||
|
||||
// LoadForeshadowLedger 读取伏笔账本。
|
||||
func (s *Store) LoadForeshadowLedger() ([]domain.ForeshadowEntry, error) {
|
||||
var entries []domain.ForeshadowEntry
|
||||
if err := s.readJSON("foreshadow_ledger.json", &entries); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// UpdateForeshadow 批量应用伏笔增量操作。
|
||||
func (s *Store) UpdateForeshadow(chapter int, updates []domain.ForeshadowUpdate) error {
|
||||
entries, err := s.LoadForeshadowLedger()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx := make(map[string]int, len(entries))
|
||||
for i, e := range entries {
|
||||
idx[e.ID] = i
|
||||
}
|
||||
for _, u := range updates {
|
||||
switch u.Action {
|
||||
case "plant":
|
||||
entries = append(entries, domain.ForeshadowEntry{
|
||||
ID: u.ID,
|
||||
Description: u.Description,
|
||||
PlantedAt: chapter,
|
||||
Status: "planted",
|
||||
})
|
||||
case "advance":
|
||||
if i, ok := idx[u.ID]; ok {
|
||||
entries[i].Status = "advanced"
|
||||
}
|
||||
case "resolve":
|
||||
if i, ok := idx[u.ID]; ok {
|
||||
entries[i].Status = "resolved"
|
||||
entries[i].ResolvedAt = chapter
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.SaveForeshadowLedger(entries)
|
||||
}
|
||||
|
||||
func renderForeshadow(entries []domain.ForeshadowEntry) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# 伏笔账本\n\n")
|
||||
for _, e := range entries {
|
||||
status := e.Status
|
||||
if e.ResolvedAt > 0 {
|
||||
status = fmt.Sprintf("已回收(第 %d 章)", e.ResolvedAt)
|
||||
}
|
||||
fmt.Fprintf(&b, "- **[%s]** %s — 埋设于第 %d 章,状态:%s\n",
|
||||
e.ID, e.Description, e.PlantedAt, status)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
77
state/outline.go
Normal file
77
state/outline.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
// SavePremise 保存故事前提到 premise.md。
|
||||
func (s *Store) SavePremise(content string) error {
|
||||
return s.writeMarkdown("premise.md", content)
|
||||
}
|
||||
|
||||
// LoadPremise 读取 premise.md。不存在时返回空字符串。
|
||||
func (s *Store) LoadPremise() (string, error) {
|
||||
data, err := s.readFile("premise.md")
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
// SaveOutline 同时保存 outline.json(机器读)和 outline.md(人读)。
|
||||
func (s *Store) SaveOutline(entries []domain.OutlineEntry) error {
|
||||
if err := s.writeJSON("outline.json", entries); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeMarkdown("outline.md", renderOutline(entries))
|
||||
}
|
||||
|
||||
// LoadOutline 从 outline.json 读取结构化大纲。
|
||||
func (s *Store) LoadOutline() ([]domain.OutlineEntry, error) {
|
||||
var entries []domain.OutlineEntry
|
||||
if err := s.readJSON("outline.json", &entries); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// GetChapterOutline 获取指定章节的大纲条目。
|
||||
func (s *Store) GetChapterOutline(chapter int) (*domain.OutlineEntry, error) {
|
||||
entries, err := s.LoadOutline()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range entries {
|
||||
if entries[i].Chapter == chapter {
|
||||
return &entries[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("chapter %d not found in outline", chapter)
|
||||
}
|
||||
|
||||
func renderOutline(entries []domain.OutlineEntry) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# 大纲\n\n")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(&b, "## 第 %d 章:%s\n\n", e.Chapter, e.Title)
|
||||
fmt.Fprintf(&b, "**核心事件**:%s\n\n", e.CoreEvent)
|
||||
if e.Hook != "" {
|
||||
fmt.Fprintf(&b, "**钩子**:%s\n\n", e.Hook)
|
||||
}
|
||||
if len(e.Scenes) > 0 {
|
||||
b.WriteString("**场景**:\n")
|
||||
for i, sc := range e.Scenes {
|
||||
fmt.Fprintf(&b, "%d. %s\n", i+1, sc)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
233
state/progress.go
Normal file
233
state/progress.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
// LoadProgress 读取 meta/progress.json。不存在时返回 nil。
|
||||
func (s *Store) LoadProgress() (*domain.Progress, error) {
|
||||
var p domain.Progress
|
||||
if err := s.readJSON("meta/progress.json", &p); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// SaveProgress 保存进度到 meta/progress.json。
|
||||
func (s *Store) SaveProgress(p *domain.Progress) error {
|
||||
return s.writeJSON("meta/progress.json", p)
|
||||
}
|
||||
|
||||
// InitProgress 创建初始进度。
|
||||
func (s *Store) InitProgress(novelName string, totalChapters int) error {
|
||||
return s.SaveProgress(&domain.Progress{
|
||||
NovelName: novelName,
|
||||
Phase: domain.PhaseInit,
|
||||
TotalChapters: totalChapters,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePhase 更新创作阶段。
|
||||
func (s *Store) UpdatePhase(phase domain.Phase) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
p = &domain.Progress{}
|
||||
}
|
||||
p.Phase = phase
|
||||
return s.SaveProgress(p)
|
||||
}
|
||||
|
||||
// MarkChapterComplete 标记章节完成,原子性更新进度。
|
||||
// 支持重写场景:如果章节已完成,先减去旧字数再加新字数。
|
||||
func (s *Store) MarkChapterComplete(chapter, wordCount int) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return fmt.Errorf("progress not initialized, call InitProgress first")
|
||||
}
|
||||
if p.ChapterWordCounts == nil {
|
||||
p.ChapterWordCounts = make(map[int]int)
|
||||
}
|
||||
// 重写场景:减去旧字数
|
||||
if oldWC, ok := p.ChapterWordCounts[chapter]; ok {
|
||||
p.TotalWordCount -= oldWC
|
||||
}
|
||||
p.ChapterWordCounts[chapter] = wordCount
|
||||
p.TotalWordCount += wordCount
|
||||
if !slices.Contains(p.CompletedChapters, chapter) {
|
||||
p.CompletedChapters = append(p.CompletedChapters, chapter)
|
||||
}
|
||||
// 仅在正常推进时更新 CurrentChapter,重写旧章节不回退指针
|
||||
if chapter+1 > p.CurrentChapter {
|
||||
p.CurrentChapter = chapter + 1
|
||||
}
|
||||
p.InProgressChapter = 0
|
||||
p.CompletedScenes = nil
|
||||
p.Phase = domain.PhaseWriting
|
||||
return s.SaveProgress(p)
|
||||
}
|
||||
|
||||
// MarkComplete 标记全书创作完成。
|
||||
func (s *Store) MarkComplete() error {
|
||||
return s.UpdatePhase(domain.PhaseComplete)
|
||||
}
|
||||
|
||||
// SaveLastCommit 保存最近一次 commit 结果到 meta/last_commit.json。
|
||||
// 用于宿主程序读取结构化信号。
|
||||
func (s *Store) SaveLastCommit(result domain.CommitResult) error {
|
||||
return s.writeJSON("meta/last_commit.json", result)
|
||||
}
|
||||
|
||||
// LoadLastCommit 读取最近一次 commit 结果。
|
||||
func (s *Store) LoadLastCommit() (*domain.CommitResult, error) {
|
||||
var r domain.CommitResult
|
||||
if err := s.readJSON("meta/last_commit.json", &r); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// MarkSceneComplete 标记场景完成,用于场景级 checkpoint。
|
||||
// 切换到不同章节时自动清空旧的 CompletedScenes。
|
||||
func (s *Store) MarkSceneComplete(chapter, scene int) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return fmt.Errorf("progress not initialized, call InitProgress first")
|
||||
}
|
||||
// 章节切换:清空旧场景列表
|
||||
if p.InProgressChapter != chapter {
|
||||
p.CompletedScenes = nil
|
||||
}
|
||||
p.InProgressChapter = chapter
|
||||
if !slices.Contains(p.CompletedScenes, scene) {
|
||||
p.CompletedScenes = append(p.CompletedScenes, scene)
|
||||
}
|
||||
return s.SaveProgress(p)
|
||||
}
|
||||
|
||||
// ClearInProgress 清除场景级进度状态(章节提交后调用)。
|
||||
func (s *Store) ClearInProgress() error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.InProgressChapter = 0
|
||||
p.CompletedScenes = nil
|
||||
return s.SaveProgress(p)
|
||||
}
|
||||
|
||||
// ClearLastCommit 清除 commit 信号文件,防止重复消费。
|
||||
func (s *Store) ClearLastCommit() error {
|
||||
return s.removeFile("meta/last_commit.json")
|
||||
}
|
||||
|
||||
// SetFlow 更新当前流程状态。
|
||||
func (s *Store) SetFlow(flow domain.FlowState) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.Flow = flow
|
||||
return s.SaveProgress(p)
|
||||
}
|
||||
|
||||
// SetPendingRewrites 设置待重写章节队列和原因。
|
||||
func (s *Store) SetPendingRewrites(chapters []int, reason string) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.PendingRewrites = chapters
|
||||
p.RewriteReason = reason
|
||||
return s.SaveProgress(p)
|
||||
}
|
||||
|
||||
// CompleteRewrite 从待重写队列中移除已完成的章节。
|
||||
// 队列清空时自动将 Flow 重置为 writing 并清除 RewriteReason。
|
||||
func (s *Store) CompleteRewrite(chapter int) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
var remaining []int
|
||||
for _, ch := range p.PendingRewrites {
|
||||
if ch != chapter {
|
||||
remaining = append(remaining, ch)
|
||||
}
|
||||
}
|
||||
p.PendingRewrites = remaining
|
||||
if len(remaining) == 0 {
|
||||
p.Flow = domain.FlowWriting
|
||||
p.RewriteReason = ""
|
||||
}
|
||||
return s.SaveProgress(p)
|
||||
}
|
||||
|
||||
// ClearPendingRewrites 强制清空重写队列。
|
||||
func (s *Store) ClearPendingRewrites() error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.PendingRewrites = nil
|
||||
p.RewriteReason = ""
|
||||
p.Flow = domain.FlowWriting
|
||||
return s.SaveProgress(p)
|
||||
}
|
||||
|
||||
// ValidateChapterCommit 校验当前章节是否允许提交。
|
||||
// 在重写/打磨流程中,只允许提交待处理队列中的章节。
|
||||
func (s *Store) ValidateChapterCommit(chapter int) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if p.Flow != domain.FlowRewriting && p.Flow != domain.FlowPolishing {
|
||||
return nil
|
||||
}
|
||||
if slices.Contains(p.PendingRewrites, chapter) {
|
||||
return nil
|
||||
}
|
||||
|
||||
verb := "重写"
|
||||
if p.Flow == domain.FlowPolishing {
|
||||
verb = "打磨"
|
||||
}
|
||||
return fmt.Errorf("第 %d 章不在待%s队列中,当前队列:%v", chapter, verb, p.PendingRewrites)
|
||||
}
|
||||
116
state/progress_test.go
Normal file
116
state/progress_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
func TestSetFlow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
_ = store.InitProgress("test", 10)
|
||||
|
||||
if err := store.SetFlow(domain.FlowRewriting); err != nil {
|
||||
t.Fatalf("SetFlow: %v", err)
|
||||
}
|
||||
|
||||
p, _ := store.LoadProgress()
|
||||
if p.Flow != domain.FlowRewriting {
|
||||
t.Errorf("expected FlowRewriting, got %s", p.Flow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPendingRewrites(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
_ = store.InitProgress("test", 10)
|
||||
|
||||
chapters := []int{3, 5, 7}
|
||||
if err := store.SetPendingRewrites(chapters, "角色动机不连贯"); err != nil {
|
||||
t.Fatalf("SetPendingRewrites: %v", err)
|
||||
}
|
||||
|
||||
p, _ := store.LoadProgress()
|
||||
if len(p.PendingRewrites) != 3 {
|
||||
t.Fatalf("expected 3 pending, got %d", len(p.PendingRewrites))
|
||||
}
|
||||
if p.RewriteReason != "角色动机不连贯" {
|
||||
t.Errorf("reason mismatch: %s", p.RewriteReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRewrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
_ = store.InitProgress("test", 10)
|
||||
_ = store.SetPendingRewrites([]int{3, 5, 7}, "测试重写")
|
||||
_ = store.SetFlow(domain.FlowRewriting)
|
||||
|
||||
// 完成第 5 章
|
||||
if err := store.CompleteRewrite(5); err != nil {
|
||||
t.Fatalf("CompleteRewrite(5): %v", err)
|
||||
}
|
||||
p, _ := store.LoadProgress()
|
||||
if len(p.PendingRewrites) != 2 {
|
||||
t.Fatalf("expected 2 pending after removing 5, got %d", len(p.PendingRewrites))
|
||||
}
|
||||
if p.Flow != domain.FlowRewriting {
|
||||
t.Errorf("flow should still be rewriting, got %s", p.Flow)
|
||||
}
|
||||
|
||||
// 完成第 3 章
|
||||
_ = store.CompleteRewrite(3)
|
||||
p, _ = store.LoadProgress()
|
||||
if len(p.PendingRewrites) != 1 {
|
||||
t.Fatalf("expected 1 pending, got %d", len(p.PendingRewrites))
|
||||
}
|
||||
|
||||
// 完成最后一章 → 自动重置 Flow
|
||||
_ = store.CompleteRewrite(7)
|
||||
p, _ = store.LoadProgress()
|
||||
if len(p.PendingRewrites) != 0 {
|
||||
t.Fatalf("expected 0 pending, got %d", len(p.PendingRewrites))
|
||||
}
|
||||
if p.Flow != domain.FlowWriting {
|
||||
t.Errorf("flow should reset to writing, got %s", p.Flow)
|
||||
}
|
||||
if p.RewriteReason != "" {
|
||||
t.Errorf("reason should be cleared, got %s", p.RewriteReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRewrite_NotInQueue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
_ = store.InitProgress("test", 10)
|
||||
_ = store.SetPendingRewrites([]int{3, 5}, "测试")
|
||||
|
||||
// 完成不在队列中的章节不应报错
|
||||
if err := store.CompleteRewrite(99); err != nil {
|
||||
t.Fatalf("CompleteRewrite(99): %v", err)
|
||||
}
|
||||
p, _ := store.LoadProgress()
|
||||
if len(p.PendingRewrites) != 2 {
|
||||
t.Errorf("queue should be unchanged, got %d", len(p.PendingRewrites))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearPendingRewrites(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
_ = store.InitProgress("test", 10)
|
||||
_ = store.SetPendingRewrites([]int{1, 2, 3}, "测试")
|
||||
_ = store.SetFlow(domain.FlowRewriting)
|
||||
|
||||
if err := store.ClearPendingRewrites(); err != nil {
|
||||
t.Fatalf("ClearPendingRewrites: %v", err)
|
||||
}
|
||||
p, _ := store.LoadProgress()
|
||||
if len(p.PendingRewrites) != 0 {
|
||||
t.Errorf("expected empty, got %d", len(p.PendingRewrites))
|
||||
}
|
||||
if p.Flow != domain.FlowWriting {
|
||||
t.Errorf("flow should be writing, got %s", p.Flow)
|
||||
}
|
||||
}
|
||||
70
state/relationships.go
Normal file
70
state/relationships.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
// SaveRelationships 全量写入 relationship_state.json + relationship_state.md。
|
||||
func (s *Store) SaveRelationships(entries []domain.RelationshipEntry) error {
|
||||
if err := s.writeJSON("relationship_state.json", entries); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeMarkdown("relationship_state.md", renderRelationships(entries))
|
||||
}
|
||||
|
||||
// LoadRelationships 读取人物关系状态。
|
||||
func (s *Store) LoadRelationships() ([]domain.RelationshipEntry, error) {
|
||||
var entries []domain.RelationshipEntry
|
||||
if err := s.readJSON("relationship_state.json", &entries); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// UpdateRelationships 合并关系变化。相同人物对的关系会被更新为最新值。
|
||||
func (s *Store) UpdateRelationships(changes []domain.RelationshipEntry) error {
|
||||
existing, err := s.LoadRelationships()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 用 pair key 索引
|
||||
idx := make(map[string]int, len(existing))
|
||||
for i, e := range existing {
|
||||
idx[pairKey(e.CharacterA, e.CharacterB)] = i
|
||||
}
|
||||
for _, c := range changes {
|
||||
key := pairKey(c.CharacterA, c.CharacterB)
|
||||
if i, ok := idx[key]; ok {
|
||||
existing[i].Relation = c.Relation
|
||||
existing[i].Chapter = c.Chapter
|
||||
} else {
|
||||
idx[key] = len(existing)
|
||||
existing = append(existing, c)
|
||||
}
|
||||
}
|
||||
return s.SaveRelationships(existing)
|
||||
}
|
||||
|
||||
func pairKey(a, b string) string {
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return a + "|" + b
|
||||
}
|
||||
|
||||
func renderRelationships(entries []domain.RelationshipEntry) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# 人物关系\n\n")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(&b, "- **%s ↔ %s**:%s(第 %d 章)\n",
|
||||
e.CharacterA, e.CharacterB, e.Relation, e.Chapter)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
66
state/reviews.go
Normal file
66
state/reviews.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
// SaveReview 保存审阅结果。scope=chapter 写 reviews/{ch}.json,scope=global 写 reviews/{ch}-global.json。
|
||||
func (s *Store) SaveReview(r domain.ReviewEntry) error {
|
||||
rel := fmt.Sprintf("reviews/%02d.json", r.Chapter)
|
||||
if r.Scope == "global" {
|
||||
rel = fmt.Sprintf("reviews/%02d-global.json", r.Chapter)
|
||||
}
|
||||
return s.writeJSON(rel, r)
|
||||
}
|
||||
|
||||
// LoadReview 读取章节审阅结果。
|
||||
func (s *Store) LoadReview(chapter int) (*domain.ReviewEntry, error) {
|
||||
var r domain.ReviewEntry
|
||||
if err := s.readJSON(fmt.Sprintf("reviews/%02d.json", chapter), &r); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// SaveLastReview 保存最近一次审阅结果到 meta/last_review.json,供宿主读取。
|
||||
func (s *Store) SaveLastReview(r domain.ReviewEntry) error {
|
||||
return s.writeJSON("meta/last_review.json", r)
|
||||
}
|
||||
|
||||
// LoadLastReviewSignal 读取审阅信号文件。
|
||||
func (s *Store) LoadLastReviewSignal() (*domain.ReviewEntry, error) {
|
||||
var r domain.ReviewEntry
|
||||
if err := s.readJSON("meta/last_review.json", &r); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ClearLastReview 清除审阅信号文件,防止重复消费。
|
||||
func (s *Store) ClearLastReview() error {
|
||||
return s.removeFile("meta/last_review.json")
|
||||
}
|
||||
|
||||
// LoadLastReview 读取最近一次全局审阅。从 chapter 往前搜索。
|
||||
func (s *Store) LoadLastReview(fromChapter int) (*domain.ReviewEntry, error) {
|
||||
for ch := fromChapter; ch >= 1; ch-- {
|
||||
var r domain.ReviewEntry
|
||||
if err := s.readJSON(fmt.Sprintf("reviews/%02d-global.json", ch), &r); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
91
state/run_meta.go
Normal file
91
state/run_meta.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
// SaveRunMeta 保存运行元信息到 meta/run.json。
|
||||
func (s *Store) SaveRunMeta(meta domain.RunMeta) error {
|
||||
return s.writeJSON("meta/run.json", meta)
|
||||
}
|
||||
|
||||
// LoadRunMeta 读取运行元信息。
|
||||
func (s *Store) LoadRunMeta() (*domain.RunMeta, error) {
|
||||
var meta domain.RunMeta
|
||||
if err := s.readJSON("meta/run.json", &meta); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
// InitRunMeta 初始化或更新运行元信息,保留已有的 SteerHistory。
|
||||
func (s *Store) InitRunMeta(style, model string) error {
|
||||
existing, _ := s.LoadRunMeta()
|
||||
meta := domain.RunMeta{
|
||||
StartedAt: time.Now().Format(time.RFC3339),
|
||||
Style: style,
|
||||
Model: model,
|
||||
}
|
||||
if existing != nil {
|
||||
meta.SteerHistory = existing.SteerHistory
|
||||
meta.PendingSteer = existing.PendingSteer
|
||||
}
|
||||
return s.SaveRunMeta(meta)
|
||||
}
|
||||
|
||||
// AppendSteerEntry 追加用户干预记录到 meta/run.json。
|
||||
func (s *Store) AppendSteerEntry(entry domain.SteerEntry) error {
|
||||
meta, err := s.LoadRunMeta()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = &domain.RunMeta{}
|
||||
}
|
||||
meta.SteerHistory = append(meta.SteerHistory, entry)
|
||||
return s.SaveRunMeta(*meta)
|
||||
}
|
||||
|
||||
// SetPendingSteer 记录未完成的 Steer 指令,用于中断恢复。
|
||||
func (s *Store) SetPendingSteer(input string) error {
|
||||
meta, err := s.LoadRunMeta()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = &domain.RunMeta{}
|
||||
}
|
||||
meta.PendingSteer = input
|
||||
return s.SaveRunMeta(*meta)
|
||||
}
|
||||
|
||||
// ClearPendingSteer 清除已处理的 Steer 指令。
|
||||
func (s *Store) ClearPendingSteer() error {
|
||||
meta, err := s.LoadRunMeta()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil || meta.PendingSteer == "" {
|
||||
return nil
|
||||
}
|
||||
meta.PendingSteer = ""
|
||||
return s.SaveRunMeta(*meta)
|
||||
}
|
||||
|
||||
// SaveCheckpoint 保存当前进度快照到 meta/checkpoints/。
|
||||
func (s *Store) SaveCheckpoint(label string) error {
|
||||
p, err := s.LoadProgress()
|
||||
if err != nil || p == nil {
|
||||
return err
|
||||
}
|
||||
ts := time.Now().Format("20060102-150405")
|
||||
rel := fmt.Sprintf("meta/checkpoints/%s-%s.json", ts, label)
|
||||
return s.writeJSON(rel, p)
|
||||
}
|
||||
183
state/run_meta_test.go
Normal file
183
state/run_meta_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
func TestSaveAndLoadRunMeta(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
meta := domain.RunMeta{
|
||||
StartedAt: "2026-03-07T10:00:00+08:00",
|
||||
Style: "fantasy",
|
||||
Model: "gpt-4o",
|
||||
}
|
||||
if err := store.SaveRunMeta(meta); err != nil {
|
||||
t.Fatalf("SaveRunMeta: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.LoadRunMeta()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRunMeta: %v", err)
|
||||
}
|
||||
if loaded.Style != "fantasy" {
|
||||
t.Errorf("style mismatch: %s", loaded.Style)
|
||||
}
|
||||
if loaded.Model != "gpt-4o" {
|
||||
t.Errorf("model mismatch: %s", loaded.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRunMeta_Empty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
meta, err := store.LoadRunMeta()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRunMeta on empty: %v", err)
|
||||
}
|
||||
if meta != nil {
|
||||
t.Fatalf("expected nil, got %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSteerEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
// 首次追加(meta/run.json 不存在)
|
||||
e1 := domain.SteerEntry{Input: "主角改成女性", Timestamp: "2026-03-07T10:01:00+08:00"}
|
||||
if err := store.AppendSteerEntry(e1); err != nil {
|
||||
t.Fatalf("AppendSteerEntry 1: %v", err)
|
||||
}
|
||||
|
||||
meta, _ := store.LoadRunMeta()
|
||||
if len(meta.SteerHistory) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(meta.SteerHistory))
|
||||
}
|
||||
if meta.SteerHistory[0].Input != "主角改成女性" {
|
||||
t.Errorf("input mismatch: %s", meta.SteerHistory[0].Input)
|
||||
}
|
||||
|
||||
// 追加第二条
|
||||
e2 := domain.SteerEntry{Input: "加入反转", Timestamp: "2026-03-07T10:02:00+08:00"}
|
||||
_ = store.AppendSteerEntry(e2)
|
||||
|
||||
meta, _ = store.LoadRunMeta()
|
||||
if len(meta.SteerHistory) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(meta.SteerHistory))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
// 先保存 RunMeta
|
||||
_ = store.SaveRunMeta(domain.RunMeta{
|
||||
StartedAt: "2026-03-07T10:00:00+08:00",
|
||||
Style: "suspense",
|
||||
Model: "gpt-4o",
|
||||
})
|
||||
|
||||
// 追加 Steer 不应覆盖其他字段
|
||||
_ = store.AppendSteerEntry(domain.SteerEntry{Input: "test", Timestamp: "now"})
|
||||
|
||||
meta, _ := store.LoadRunMeta()
|
||||
if meta.Style != "suspense" {
|
||||
t.Errorf("style should be preserved, got %s", meta.Style)
|
||||
}
|
||||
if meta.Model != "gpt-4o" {
|
||||
t.Errorf("model should be preserved, got %s", meta.Model)
|
||||
}
|
||||
if len(meta.SteerHistory) != 1 {
|
||||
t.Errorf("expected 1 steer entry, got %d", len(meta.SteerHistory))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunMeta_PreservesHistory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
// 先建立带历史的 RunMeta
|
||||
_ = store.SaveRunMeta(domain.RunMeta{
|
||||
StartedAt: "old",
|
||||
Style: "fantasy",
|
||||
Model: "old-model",
|
||||
SteerHistory: []domain.SteerEntry{{Input: "历史干预", Timestamp: "ts"}},
|
||||
PendingSteer: "待处理",
|
||||
})
|
||||
|
||||
// InitRunMeta 应保留 SteerHistory 和 PendingSteer
|
||||
_ = store.InitRunMeta("suspense", "new-model")
|
||||
|
||||
meta, _ := store.LoadRunMeta()
|
||||
if meta.Style != "suspense" {
|
||||
t.Errorf("style should be updated, got %s", meta.Style)
|
||||
}
|
||||
if meta.Model != "new-model" {
|
||||
t.Errorf("model should be updated, got %s", meta.Model)
|
||||
}
|
||||
if len(meta.SteerHistory) != 1 || meta.SteerHistory[0].Input != "历史干预" {
|
||||
t.Errorf("steer history should be preserved, got %v", meta.SteerHistory)
|
||||
}
|
||||
if meta.PendingSteer != "待处理" {
|
||||
t.Errorf("pending steer should be preserved, got %s", meta.PendingSteer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAndClearPendingSteer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
// 设置 PendingSteer
|
||||
if err := store.SetPendingSteer("主角改成女性"); err != nil {
|
||||
t.Fatalf("SetPendingSteer: %v", err)
|
||||
}
|
||||
meta, _ := store.LoadRunMeta()
|
||||
if meta.PendingSteer != "主角改成女性" {
|
||||
t.Errorf("expected pending steer, got %s", meta.PendingSteer)
|
||||
}
|
||||
|
||||
// 清除
|
||||
if err := store.ClearPendingSteer(); err != nil {
|
||||
t.Fatalf("ClearPendingSteer: %v", err)
|
||||
}
|
||||
meta, _ = store.LoadRunMeta()
|
||||
if meta.PendingSteer != "" {
|
||||
t.Errorf("expected empty pending steer, got %s", meta.PendingSteer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearPendingSteer_Noop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
// 空 meta 上调用不报错
|
||||
if err := store.ClearPendingSteer(); err != nil {
|
||||
t.Fatalf("ClearPendingSteer on empty: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCheckpoint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
_ = store.InitProgress("test", 10)
|
||||
|
||||
if err := store.SaveCheckpoint("ch01-commit"); err != nil {
|
||||
t.Fatalf("SaveCheckpoint: %v", err)
|
||||
}
|
||||
|
||||
// 验证 checkpoint 目录下有文件
|
||||
entries, err := os.ReadDir(dir + "/meta/checkpoints")
|
||||
if err != nil {
|
||||
t.Fatalf("read checkpoints dir: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 checkpoint, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
76
state/store.go
Normal file
76
state/store.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Store 封装小说输出目录,提供所有状态读写操作。
|
||||
type Store struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewStore 创建状态管理器,dir 为小说输出根目录。
|
||||
func NewStore(dir string) *Store {
|
||||
return &Store{dir: dir}
|
||||
}
|
||||
|
||||
// Dir 返回输出根目录。
|
||||
func (s *Store) Dir() string { return s.dir }
|
||||
|
||||
// Init 创建所需的子目录结构。
|
||||
func (s *Store) Init() error {
|
||||
dirs := []string{"chapters", "summaries", "drafts", "reviews", "meta"}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, d), 0o755); err != nil {
|
||||
return fmt.Errorf("create dir %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) path(rel string) string {
|
||||
return filepath.Join(s.dir, rel)
|
||||
}
|
||||
|
||||
func (s *Store) readFile(rel string) ([]byte, error) {
|
||||
return os.ReadFile(s.path(rel))
|
||||
}
|
||||
|
||||
func (s *Store) writeFile(rel string, data []byte) error {
|
||||
p := s.path(rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, data, 0o644)
|
||||
}
|
||||
|
||||
func (s *Store) readJSON(rel string, v any) error {
|
||||
data, err := s.readFile(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func (s *Store) writeJSON(rel string, v any) error {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeFile(rel, data)
|
||||
}
|
||||
|
||||
func (s *Store) writeMarkdown(rel string, content string) error {
|
||||
return s.writeFile(rel, []byte(content))
|
||||
}
|
||||
|
||||
func (s *Store) removeFile(rel string) error {
|
||||
err := os.Remove(s.path(rel))
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
41
state/summaries.go
Normal file
41
state/summaries.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
// SaveSummary 保存章节摘要到 summaries/{ch}.json。
|
||||
func (s *Store) SaveSummary(sum domain.ChapterSummary) error {
|
||||
return s.writeJSON(fmt.Sprintf("summaries/%02d.json", sum.Chapter), sum)
|
||||
}
|
||||
|
||||
// LoadSummary 读取指定章节的摘要。
|
||||
func (s *Store) LoadSummary(chapter int) (*domain.ChapterSummary, error) {
|
||||
var sum domain.ChapterSummary
|
||||
if err := s.readJSON(fmt.Sprintf("summaries/%02d.json", chapter), &sum); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sum, nil
|
||||
}
|
||||
|
||||
// LoadRecentSummaries 加载 current 章之前最近 count 章的摘要。
|
||||
func (s *Store) LoadRecentSummaries(current, count int) ([]domain.ChapterSummary, error) {
|
||||
var result []domain.ChapterSummary
|
||||
start := max(current-count, 1)
|
||||
for ch := start; ch < current; ch++ {
|
||||
sum, err := s.LoadSummary(ch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sum != nil {
|
||||
result = append(result, *sum)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
51
state/timeline.go
Normal file
51
state/timeline.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
// SaveTimeline 全量写入 timeline.json + timeline.md。
|
||||
func (s *Store) SaveTimeline(events []domain.TimelineEvent) error {
|
||||
if err := s.writeJSON("timeline.json", events); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeMarkdown("timeline.md", renderTimeline(events))
|
||||
}
|
||||
|
||||
// LoadTimeline 读取时间线。
|
||||
func (s *Store) LoadTimeline() ([]domain.TimelineEvent, error) {
|
||||
var events []domain.TimelineEvent
|
||||
if err := s.readJSON("timeline.json", &events); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// AppendTimelineEvents 追加时间线事件。
|
||||
func (s *Store) AppendTimelineEvents(newEvents []domain.TimelineEvent) error {
|
||||
existing, err := s.LoadTimeline()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.SaveTimeline(append(existing, newEvents...))
|
||||
}
|
||||
|
||||
func renderTimeline(events []domain.TimelineEvent) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# 时间线\n\n")
|
||||
for _, e := range events {
|
||||
chars := ""
|
||||
if len(e.Characters) > 0 {
|
||||
chars = "(" + strings.Join(e.Characters, "、") + ")"
|
||||
}
|
||||
fmt.Fprintf(&b, "- **第 %d 章 [%s]**:%s%s\n", e.Chapter, e.Time, e.Event, chars)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
58
state/world_rules.go
Normal file
58
state/world_rules.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
// SaveWorldRules 全量写入 world_rules.json + world_rules.md。
|
||||
func (s *Store) SaveWorldRules(rules []domain.WorldRule) error {
|
||||
if err := s.writeJSON("world_rules.json", rules); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeMarkdown("world_rules.md", renderWorldRules(rules))
|
||||
}
|
||||
|
||||
// LoadWorldRules 读取世界规则。
|
||||
func (s *Store) LoadWorldRules() ([]domain.WorldRule, error) {
|
||||
var rules []domain.WorldRule
|
||||
if err := s.readJSON("world_rules.json", &rules); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func renderWorldRules(rules []domain.WorldRule) string {
|
||||
grouped := make(map[string][]domain.WorldRule)
|
||||
var order []string
|
||||
for _, r := range rules {
|
||||
cat := r.Category
|
||||
if cat == "" {
|
||||
cat = "other"
|
||||
}
|
||||
if _, exists := grouped[cat]; !exists {
|
||||
order = append(order, cat)
|
||||
}
|
||||
grouped[cat] = append(grouped[cat], r)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# 世界观规则\n\n")
|
||||
for _, cat := range order {
|
||||
fmt.Fprintf(&b, "## %s\n\n", cat)
|
||||
for _, r := range grouped[cat] {
|
||||
fmt.Fprintf(&b, "- **规则**:%s\n", r.Rule)
|
||||
if r.Boundary != "" {
|
||||
fmt.Fprintf(&b, " - 边界:%s\n", r.Boundary)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
140
state/world_rules_test.go
Normal file
140
state/world_rules_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
func TestSaveAndLoadWorldRules(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
rules := []domain.WorldRule{
|
||||
{Category: "magic", Rule: "法术消耗精神力", Boundary: "精神力耗尽会昏迷"},
|
||||
{Category: "magic", Rule: "禁咒需要三人合力", Boundary: "单人强行施放会死亡"},
|
||||
{Category: "society", Rule: "贵族拥有领地裁判权", Boundary: "不得越权审判其他领地居民"},
|
||||
}
|
||||
|
||||
if err := store.SaveWorldRules(rules); err != nil {
|
||||
t.Fatalf("SaveWorldRules: %v", err)
|
||||
}
|
||||
|
||||
// 验证 JSON 文件存在
|
||||
if _, err := os.Stat(filepath.Join(dir, "world_rules.json")); err != nil {
|
||||
t.Fatalf("world_rules.json not created: %v", err)
|
||||
}
|
||||
// 验证 Markdown 文件存在
|
||||
if _, err := os.Stat(filepath.Join(dir, "world_rules.md")); err != nil {
|
||||
t.Fatalf("world_rules.md not created: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.LoadWorldRules()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWorldRules: %v", err)
|
||||
}
|
||||
if len(loaded) != 3 {
|
||||
t.Fatalf("expected 3 rules, got %d", len(loaded))
|
||||
}
|
||||
if loaded[0].Category != "magic" || loaded[0].Rule != "法术消耗精神力" {
|
||||
t.Errorf("first rule mismatch: %+v", loaded[0])
|
||||
}
|
||||
if loaded[2].Category != "society" {
|
||||
t.Errorf("third rule category mismatch: %+v", loaded[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWorldRules_Empty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
rules, err := store.LoadWorldRules()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWorldRules on empty dir: %v", err)
|
||||
}
|
||||
if rules != nil {
|
||||
t.Fatalf("expected nil for missing file, got %v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveWorldRules_Overwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(dir)
|
||||
|
||||
v1 := []domain.WorldRule{{Category: "old", Rule: "旧规则", Boundary: "旧边界"}}
|
||||
if err := store.SaveWorldRules(v1); err != nil {
|
||||
t.Fatalf("SaveWorldRules v1: %v", err)
|
||||
}
|
||||
|
||||
v2 := []domain.WorldRule{
|
||||
{Category: "new", Rule: "新规则A", Boundary: "新边界A"},
|
||||
{Category: "new", Rule: "新规则B", Boundary: "新边界B"},
|
||||
}
|
||||
if err := store.SaveWorldRules(v2); err != nil {
|
||||
t.Fatalf("SaveWorldRules v2: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.LoadWorldRules()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWorldRules: %v", err)
|
||||
}
|
||||
if len(loaded) != 2 {
|
||||
t.Fatalf("expected 2 rules after overwrite, got %d", len(loaded))
|
||||
}
|
||||
if loaded[0].Category != "new" {
|
||||
t.Errorf("expected new category, got %s", loaded[0].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWorldRules(t *testing.T) {
|
||||
rules := []domain.WorldRule{
|
||||
{Category: "magic", Rule: "法术消耗精神力", Boundary: "精神力耗尽会昏迷"},
|
||||
{Category: "society", Rule: "贵族有裁判权", Boundary: ""},
|
||||
{Category: "magic", Rule: "禁咒需三人", Boundary: "单人施放会死"},
|
||||
}
|
||||
|
||||
md := renderWorldRules(rules)
|
||||
|
||||
// 验证标题
|
||||
if got := md[:len("# 世界观规则")]; got != "# 世界观规则" {
|
||||
t.Errorf("missing title, got: %s", got)
|
||||
}
|
||||
// 验证分组:magic 应该出现在 society 之前(按输入顺序)
|
||||
magicPos := indexOf(md, "## magic")
|
||||
societyPos := indexOf(md, "## society")
|
||||
if magicPos < 0 || societyPos < 0 {
|
||||
t.Fatalf("missing category headers in:\n%s", md)
|
||||
}
|
||||
if magicPos >= societyPos {
|
||||
t.Errorf("magic should appear before society")
|
||||
}
|
||||
// 验证边界渲染:有 boundary 的条目应包含"边界"字样
|
||||
if indexOf(md, "边界:精神力耗尽会昏迷") < 0 {
|
||||
t.Errorf("missing boundary in:\n%s", md)
|
||||
}
|
||||
// 验证无 boundary 的条目不输出边界行
|
||||
if indexOf(md, "边界:\n") >= 0 {
|
||||
t.Errorf("empty boundary should not be rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWorldRules_EmptyCategoryFallback(t *testing.T) {
|
||||
rules := []domain.WorldRule{
|
||||
{Category: "", Rule: "无分类规则", Boundary: "边界"},
|
||||
}
|
||||
md := renderWorldRules(rules)
|
||||
if indexOf(md, "## other") < 0 {
|
||||
t.Errorf("empty category should fall back to 'other', got:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
func indexOf(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
Reference in New Issue
Block a user