@cloud411716/fancy-webnovel 2.2.0 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +65 -0
  2. package/client.js +201 -15
  3. package/cordis.patch.yml +5 -1
  4. package/docs/douyin-plugin.md +118 -0
  5. package/docs/material-library/README.md +404 -0
  6. package/docs/material-library/import.js +437 -0
  7. package/docs/material-library/prompt.md +370 -0
  8. package/docs/material-library/queries.surql +94 -0
  9. package/docs/material-library/sample.jsonl +14 -0
  10. package/docs/material-library/schema.surql +46 -0
  11. package/docs/mcp-profile.example.yml +24 -0
  12. package/docs/story-graph-design.md +602 -0
  13. package/docs/story-graph-validation.md +81 -0
  14. package/docs/workspace-mcp.md +284 -0
  15. package/index.js +12 -7
  16. package/lib/atomic-write.js +109 -0
  17. package/lib/chapter-order.js +100 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +11 -11
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +31 -31
  23. package/lib/infra.js +29 -4
  24. package/lib/material-db.js +149 -0
  25. package/lib/path-guard.js +150 -0
  26. package/lib/projects.js +400 -0
  27. package/lib/scraper-utils.js +140 -0
  28. package/lib/surreal-server.js +338 -0
  29. package/lib/topic-decision.js +860 -0
  30. package/lib/topic-fields.js +251 -0
  31. package/lib/topic-schema.js +225 -0
  32. package/lib/workspace.js +501 -0
  33. package/package.json +31 -7
  34. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  35. package/plugins/fancy-analyze/index.js +1128 -77
  36. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  37. package/plugins/fancy-analyze/schema-analyze.md +355 -107
  38. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  39. package/plugins/fancy-analyze/summary-progress.js +71 -7
  40. package/plugins/fancy-analyze/templates.js +437 -139
  41. package/plugins/fancy-bootstrap/index.js +276 -131
  42. package/plugins/fancy-bootstrap/scripts/init.js +59 -25
  43. package/plugins/fancy-bootstrap/templates.js +35 -18
  44. package/plugins/fancy-db/index.js +172 -0
  45. package/plugins/fancy-douyin/deploy.js +134 -0
  46. package/plugins/fancy-douyin/index.js +551 -0
  47. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  48. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  49. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  50. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  51. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  52. package/plugins/fancy-douyin/state.js +197 -0
  53. package/plugins/fancy-douyin/templates.js +127 -0
  54. package/plugins/fancy-export/exporter.js +805 -0
  55. package/plugins/fancy-export/index.js +63 -0
  56. package/plugins/fancy-import/import-driver.js +29 -6
  57. package/plugins/fancy-import/index.js +13 -5
  58. package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
  59. package/plugins/fancy-import/templates.js +17 -8
  60. package/plugins/fancy-material/exporter.js +153 -0
  61. package/plugins/fancy-material/genres.js +256 -0
  62. package/plugins/fancy-material/index.js +835 -0
  63. package/plugins/fancy-material/inventory.js +145 -0
  64. package/plugins/fancy-material/kinds.js +313 -0
  65. package/plugins/fancy-material/queue.js +55 -0
  66. package/plugins/fancy-material/scripts/query.js +254 -0
  67. package/plugins/fancy-material/templates.js +258 -0
  68. package/plugins/fancy-material/tool-deploy.js +138 -0
  69. package/plugins/fancy-material/validate.js +149 -0
  70. package/plugins/fancy-project/index.js +133 -0
  71. package/plugins/fancy-scan/existing-block.js +100 -0
  72. package/plugins/fancy-scan/index.js +410 -21
  73. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  74. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  75. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  76. package/plugins/fancy-scan/scripts/run-scan.js +169 -22
  77. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
  78. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
  79. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
  80. package/plugins/fancy-scan/templates/blocks.js +155 -0
  81. package/plugins/fancy-scan/templates/platforms.js +372 -0
  82. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  83. package/plugins/fancy-scan/templates.js +545 -146
  84. package/plugins/fancy-scan/tool-deploy.js +335 -0
  85. package/plugins/fancy-scan/wait-turn.js +46 -0
  86. package/plugins/fancy-story-builder/db/README.md +106 -0
  87. package/plugins/fancy-story-builder/db/client.js +228 -0
  88. package/plugins/fancy-story-builder/db/commands.js +145 -0
  89. package/plugins/fancy-story-builder/db/ids.js +39 -0
  90. package/plugins/fancy-story-builder/db/import.js +798 -0
  91. package/plugins/fancy-story-builder/db/project.js +299 -0
  92. package/plugins/fancy-story-builder/db/query.js +361 -0
  93. package/plugins/fancy-story-builder/db/schema.js +417 -0
  94. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  95. package/plugins/fancy-story-builder/flesh-output.js +19 -6
  96. package/plugins/fancy-story-builder/index.js +4105 -396
  97. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  98. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
  99. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
  100. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
  101. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
  102. package/plugins/fancy-story-builder/purpose-template.md +42 -66
  103. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
  104. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  105. package/plugins/fancy-story-builder/references/glossary.md +15 -6
  106. package/plugins/fancy-story-builder/references/index.md +97 -6
  107. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
  108. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
  109. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
  110. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
  111. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
  112. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
  113. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
  114. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
  115. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
  116. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  117. package/plugins/fancy-story-builder/schema-setting.md +201 -293
  118. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  119. package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
  120. package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
  121. package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
  122. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  123. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
  124. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  125. package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
  126. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
  127. package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
  128. package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
  129. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  130. package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
  131. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  132. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  133. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  134. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  135. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  136. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  137. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  138. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  139. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  140. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  141. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  142. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  143. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  144. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  145. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  146. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  147. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  148. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  149. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  150. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  151. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  152. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  153. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  154. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  155. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  156. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  157. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  158. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  159. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  160. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  161. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  162. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  163. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  164. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  165. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  166. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  167. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  168. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  169. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  170. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  171. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  172. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  173. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  174. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  175. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  176. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  177. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  178. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  179. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  180. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  181. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  182. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  183. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  184. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  185. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  186. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  187. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  188. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  189. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  190. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +267 -44
  191. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +121 -19
  192. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +129 -21
  193. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
  194. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +76 -17
  195. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +56 -8
  196. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +63 -13
  197. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +34 -6
  198. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +81 -18
  199. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +202 -42
  200. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +160 -27
  201. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +217 -41
  202. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +215 -41
  203. package/plugins/fancy-story-builder/templates.js +272 -74
  204. package/plugins/fancy-story-builder/wiki-references.js +429 -93
  205. package/plugins/fancy-story-builder/writing-driver.js +336 -9
  206. package/plugins/fancy-topic/index.js +414 -61
  207. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  208. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  209. package/plugins/fancy-topic/scripts/select-topic.js +106 -63
  210. package/plugins/fancy-topic/templates.js +225 -122
  211. package/plugins/fancy-topic/tool-deploy.js +200 -0
  212. package/lib/safe-rm.js +0 -94
@@ -0,0 +1,805 @@
1
+ /**
2
+ * 故事图 → 中文 Markdown 导出。
3
+ *
4
+ * 输出目录固定为项目根下的「导出」。数据库是唯一真源;这里只做只读渲染,
5
+ * 不生成阶段 JSON,也不让 LLM 参与。
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+
11
+ const STORY = 'story:main';
12
+ const META_FIELDS = new Set([
13
+ 'id', 'payload', 'story', 'created_at', 'updated_at', 'entity_id',
14
+ 'from_id', 'to_id', 'supersedes', 'chapter_id', 'outline_id', 'scene_order',
15
+ ]);
16
+
17
+ const FIELD_ORDER = [
18
+ 'name', 'title', 'type', 'kind', 'role_slot', 'faction', 'description',
19
+ 'goal', 'flaw', 'golden_finger', 'arc', 'voice', 'traits', 'information_state', 'exit',
20
+ 'premise', 'theme', 'tone', 'world_rules', 'power_system', 'social_order', 'taboos',
21
+ 'core_imagery', 'reader_promise', 'core_question', 'core_conflict', 'structure',
22
+ 'anti_tropes', 'promise', 'start', 'turning', 'resolution', 'level', 'direction',
23
+ 'setup', 'clues', 'payoff_target', 'recovery_requirement', 'red_herrings', 'scope',
24
+ 'scale', 'payoff', 'required_setup', 'emotional_effect', 'participants', 'stakes',
25
+ 'category', 'item_function', 'owner', 'limit', 'resources', 'territory', 'internal_conflict',
26
+ 'info_name', 'who_knows', 'who_does_not_know', 'resolution_method', 'writing_ban',
27
+ 'must_write', 'must_not_write', 'continuity', 'ending', 'goal', 'obstacle', 'turn',
28
+ 'outcome', 'location', 'cast', 'content', 'word_count', 'end_location', 'end_time',
29
+ 'end_characters', 'open_threads', 'next_scene_hint', 'continuity_notes', 'status',
30
+ ];
31
+
32
+ function isEmpty(value) {
33
+ if (value === null || value === undefined || value === '') return true;
34
+ if (Array.isArray(value)) return value.length === 0;
35
+ if (typeof value === 'object') return Object.keys(value).length === 0;
36
+ return false;
37
+ }
38
+
39
+ function fieldRank(key) {
40
+ const index = FIELD_ORDER.indexOf(key);
41
+ return index === -1 ? 1000 : index;
42
+ }
43
+
44
+ const LABELS = {
45
+ title: '标题', genre: '题材', platform: '平台', target_chapters: '目标章数',
46
+ target_words: '目标字数', avg_words_per_chapter: '平均每章字数', status: '状态',
47
+ capacity: '容量', structure: '结构', quotas: '配额', rhythm_budget: '节奏预算',
48
+ distribution: '分布', constraints: '约束', formulas: '公式', derivations: '推导',
49
+ genre_profile: '题材档案', formula_version: '公式版本',
50
+ volume_id: '卷ID', act_id: '幕ID', line_id: '线索ID', node_id: '节点ID',
51
+ ending_id: '结局ID', role_id: '功能位ID', chapter_id: '章节ID', display_no: '章号',
52
+ chapter_start: '起始章', chapter_end: '结束章', goal: '目标', core_conflict: '核心冲突',
53
+ emotional_arc: '情感弧线', ending_hook: '卷末钩子', act_index: '幕序号', climax: '高潮',
54
+ name: '名称', type: '类型', kind: '类型', description: '描述', narrative_function: '叙事功能',
55
+ rules: '规则', premise: '前提', theme: '主题', tone: '语气', world_rules: '世界规则',
56
+ power_system: '力量体系', social_order: '社会秩序', taboos: '禁忌',
57
+ core_imagery: '核心意象', reader_promise: '读者承诺', core_question: '核心问题',
58
+ anti_tropes: '反套路', promise: '承诺', start: '起点', turning: '转折',
59
+ resolution: '收束', interweaves: '交织', level: '层级', direction: '方向',
60
+ required_foreshadow_recovery: '必须回收的伏笔', slot_function: '功能', quota_key: '配额键',
61
+ role_slot: '功能位', faction: '势力', flaw: '缺陷', golden_finger: '金手指',
62
+ information_state: '信息状态', arc: '弧光', voice: '说话风格', traits: '特征', exit: '退场',
63
+ resources: '资源', territory: '地盘', internal_conflict: '内部矛盾',
64
+ category: '分类', item_function: '道具功能', owner: '持有者', limit: '限制',
65
+ related_characters: '关联人物', related_conflicts: '关联冲突', stakes: '代价',
66
+ escalation_path: '升级路径', resolution_plan: '解决计划', participants: '参与者',
67
+ setup: '铺垫', clues: '线索', payoff_target: '回收目标', recovery_requirement: '回收要求',
68
+ red_herrings: '误导项', scope: '范围', initial_knowledge: '初始认知', scale: '规模',
69
+ payoff: '兑现', emotional_effect: '情绪效果', required_setup: '必需铺垫',
70
+ info_name: '信息差名称', who_knows: '知情者', who_does_not_know: '不知情者',
71
+ resolution_method: '揭晓方式', writing_ban: '写作禁令', time: '时间', why: '为什么写',
72
+ how: '怎么写', emotion: '情绪', hook: '钩子', word_target: '目标字数',
73
+ must_write: '必写', must_not_write: '禁写', continuity: '连贯性', coverage: '覆盖',
74
+ ending: '结尾', scene_order: '场景序号', obstacle: '障碍', outcome: '结果',
75
+ location: '地点', cast: '出场人物', content: '正文', word_count: '字数',
76
+ end_location: '章末地点', end_time: '章末时间', end_characters: '章末人物',
77
+ open_threads: '未结线索', next_scene_hint: '下一场提示', continuity_notes: '连贯备注',
78
+ text: '说明', reason: '理由', source: '来源', reference: '参考', due_chapter: '到期章',
79
+ hard: '必填', label: '标签', action: '动作', detail: '详情', chapter_no: '章号',
80
+ relation: '关系', change: '变化', subject_type: '对象类型', subject_id: '对象ID',
81
+ character_id: '人物ID', knowledge: '已知内容', evidence: '证据', scene: '场景',
82
+ turn: '转折', story_day: '故事日', clock: '时刻', duration_minutes: '时长(分钟)',
83
+ phase: '情绪阶段', value: '数值', strength: '强度', function_main: '本章主功能', function_aux: '本章辅助功能', ending_hook: '章末钩子',
84
+ from_prev: '承接上一章', to_next: '交给下一章',
85
+ };
86
+
87
+ function label(key) {
88
+ return LABELS[key] || key;
89
+ }
90
+
91
+ function esc(value) {
92
+ return String(value == null ? '' : value)
93
+ .replace(/\|/g, '\\|')
94
+ .replace(/\r?\n/g, ' ');
95
+ }
96
+
97
+ function table(headers, rows) {
98
+ if (!rows.length) return '(暂无)\n';
99
+ const lines = [
100
+ '| ' + headers.map(esc).join(' | ') + ' |',
101
+ '|' + headers.map(() => '---').join('|') + '|',
102
+ ];
103
+ for (const row of rows) lines.push('| ' + row.map(esc).join(' | ') + ' |');
104
+ return lines.join('\n') + '\n';
105
+ }
106
+
107
+ function inline(value) {
108
+ if (value == null || value === '') return '—';
109
+ if (Array.isArray(value)) return value.map((v) => inline(v)).join('、');
110
+ if (typeof value === 'object') {
111
+ return Object.entries(value)
112
+ .filter(([, v]) => v != null && v !== '')
113
+ .map(([k, v]) => label(k) + ':' + inline(v))
114
+ .join(';');
115
+ }
116
+ return String(value);
117
+ }
118
+
119
+ function bullets(values) {
120
+ if (!Array.isArray(values) || !values.length) return '—';
121
+ return values.map((value) => '- ' + inline(value)).join('\n');
122
+ }
123
+
124
+ function block(value) {
125
+ if (value == null || value === '') return '—\n';
126
+ if (Array.isArray(value)) return bullets(value) + '\n';
127
+ if (typeof value === 'object') {
128
+ const lines = [];
129
+ for (const [key, val] of Object.entries(value)) {
130
+ if (val == null || val === '') continue;
131
+ lines.push('- ' + label(key) + ':' + inline(val));
132
+ }
133
+ return (lines.length ? lines.join('\n') : '—') + '\n';
134
+ }
135
+ return String(value).trim() + '\n';
136
+ }
137
+
138
+ function fields(obj, level = '###') {
139
+ const lines = [];
140
+ const entries = Object.entries(obj || {})
141
+ .filter(([key, value]) => !META_FIELDS.has(key) && !isEmpty(value))
142
+ .sort(([a], [b]) => fieldRank(a) - fieldRank(b) || a.localeCompare(b));
143
+ for (const [key, value] of entries) {
144
+ lines.push('', level + ' ' + label(key), '', block(value).trimEnd());
145
+ }
146
+ return lines.join('\n');
147
+ }
148
+
149
+ function safeName(value, fallback = '未命名', max = 70) {
150
+ const text = String(value || fallback).replace(/[\\/:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim();
151
+ return (text || fallback).slice(0, max);
152
+ }
153
+
154
+ function pad3(value) {
155
+ return String(value).padStart(3, '0');
156
+ }
157
+
158
+ function ensureDir(dir) {
159
+ fs.mkdirSync(dir, { recursive: true });
160
+ }
161
+
162
+ async function count(sdb, tableName) {
163
+ const rows = await sdb.rows(`SELECT count() FROM ${tableName} WHERE story = ${STORY} GROUP ALL;`);
164
+ return Number(rows[0] && rows[0].count) || 0;
165
+ }
166
+
167
+ async function renderOverview(sdb) {
168
+ const story = await sdb.one('SELECT * FROM story:main;') || {};
169
+ const tables = [
170
+ ['volume', '卷'], ['act', '幕'], ['character', '人物'], ['faction', '势力'],
171
+ ['location', '地点'], ['item', '道具'], ['conflict', '冲突'], ['foreshadow', '伏笔'],
172
+ ['payoff', '爽点'], ['climax', '高潮'], ['information', '信息差'], ['chapter', '章节'],
173
+ ['outline', '细纲'], ['chapter_text', '正文'], ['decision', '设计决策'],
174
+ ];
175
+ const rows = [];
176
+ for (const [key, name] of tables) rows.push([name, await count(sdb, key)]);
177
+ return [
178
+ '# 故事总览',
179
+ '',
180
+ '## 基本信息',
181
+ '',
182
+ table(['字段', '内容'], [
183
+ ['书名', story.title || '未命名'],
184
+ ['题材', story.genre || '—'],
185
+ ['平台', story.platform || '—'],
186
+ ['目标章数', story.target_chapters || 0],
187
+ ['目标字数', story.target_words || 0],
188
+ ['平均每章字数', story.avg_words_per_chapter || 0],
189
+ ['状态', story.status || '—'],
190
+ ]).trimEnd(),
191
+ '',
192
+ '## 数据规模',
193
+ '',
194
+ table(['内容', '数量'], rows).trimEnd(),
195
+ '',
196
+ '## 导出说明',
197
+ '',
198
+ '本目录由 `fancy-export` 从 SurrealDB 故事图导出,内容为中文 Markdown。',
199
+ '数据库是唯一真源;修改导出文件不会回写数据库。',
200
+ '',
201
+ ].join('\n');
202
+ }
203
+
204
+ async function renderScale(sdb) {
205
+ const scale = await sdb.one('SELECT * FROM scale:main;');
206
+ const volumes = await sdb.rows(`SELECT * FROM volume WHERE story = ${STORY} ORDER BY chapter_start;`);
207
+ const acts = await sdb.rows(`SELECT * FROM act WHERE story = ${STORY} ORDER BY chapter_start;`);
208
+ const lines = ['# 规模与结构', ''];
209
+ if (!scale) {
210
+ lines.push('(暂无规模数据)', '');
211
+ return lines.join('\n');
212
+ }
213
+ lines.push('## 容量', '', fields(scale.capacity || {}, '###').trim(), '');
214
+ lines.push('## 配额', '', block(scale.quotas || {}).trim(), '');
215
+ lines.push('## 卷', '', table(
216
+ ['卷', '标题', '章节范围', '目标', '核心冲突', '情感弧线', '卷末钩子'],
217
+ volumes.map((v) => [v.volume_id, v.title, `${v.chapter_start}-${v.chapter_end}`, v.goal, v.core_conflict, v.emotional_arc, v.ending_hook]),
218
+ ).trim(), '');
219
+ lines.push('## 幕', '', table(
220
+ ['幕', '所属卷', '章节范围', '目标', '高潮'],
221
+ acts.map((a) => [a.act_id, a.volume_id, `${a.chapter_start}-${a.chapter_end}`, a.goal, a.climax]),
222
+ ).trim(), '');
223
+ return lines.join('\n');
224
+ }
225
+
226
+ async function renderMacro(sdb) {
227
+ const world = await sdb.one('SELECT * FROM world_doc WHERE story = ' + STORY + ' LIMIT 1;');
228
+ const story = await sdb.one('SELECT * FROM story_doc WHERE story = ' + STORY + ' LIMIT 1;');
229
+ const lines = ['# 世界观与故事', ''];
230
+ if (world) lines.push('## 世界观', '', fields(world, '###').trim(), '');
231
+ if (story) lines.push('## 故事核心', '', fields(story, '###').trim(), '');
232
+ const lineRows = await sdb.rows(`SELECT * FROM line WHERE story = ${STORY} ORDER BY kind, line_id;`);
233
+ const main = lineRows.filter((row) => row.kind === 'main');
234
+ const branch = lineRows.filter((row) => row.kind !== 'main');
235
+ for (const [title, rows] of [['主线', main], ['支线', branch]]) {
236
+ if (!rows.length) continue;
237
+ lines.push('## ' + title, '');
238
+ for (const line of rows) {
239
+ lines.push('### ' + (line.name || line.line_id));
240
+ lines.push('', fields({ goal: line.goal, start: line.start, turning: line.turning, resolution: line.resolution, interweaves: line.interweaves }, '####').trim(), '');
241
+ const nodes = await sdb.rows(
242
+ `SELECT * FROM line_node WHERE story = ${STORY} AND line_id = $id ORDER BY node_id;`,
243
+ { id: line.line_id },
244
+ );
245
+ if (nodes.length) {
246
+ lines.push(table(['节点', '卷', '幕', '目标', '转折'], nodes.map((n) => [n.node_id, n.volume, n.act, n.goal, n.turn])).trim(), '');
247
+ }
248
+ }
249
+ }
250
+ const endings = await sdb.rows(`SELECT * FROM ending WHERE story = ${STORY} ORDER BY ending_id;`);
251
+ if (endings.length) {
252
+ lines.push('## 结局', '');
253
+ for (const ending of endings) {
254
+ lines.push('### ' + (ending.ending_id || '') + ' ' + (ending.type || ''));
255
+ lines.push('', fields(ending, '####').trim(), '');
256
+ }
257
+ }
258
+ return lines.join('\n');
259
+ }
260
+
261
+ async function renderCharacters(sdb) {
262
+ const rows = await sdb.rows(`SELECT * FROM character WHERE story = ${STORY} ORDER BY entity_id;`);
263
+ const relations = await sdb.rows(`SELECT * FROM relation WHERE story = ${STORY};`);
264
+ const names = new Map(rows.map((row) => [row.entity_id, row.name]));
265
+ const lines = ['# 人物', ''];
266
+ for (const row of rows) {
267
+ lines.push('## ' + (row.name || row.entity_id) + '(' + row.entity_id + ')', '');
268
+ lines.push(fields(row, '###').trim(), '');
269
+ const rels = relations.filter((r) => r.from_id === row.entity_id || r.to_id === row.entity_id);
270
+ if (rels.length) {
271
+ lines.push('### 关系', '');
272
+ lines.push(table(
273
+ ['对方', '关系', '初始状态', '变化'],
274
+ rels.map((r) => {
275
+ const other = r.from_id === row.entity_id ? r.to_id : r.from_id;
276
+ return [names.get(other) || other, r.kind, r.initial_state, r.changes];
277
+ }),
278
+ ).trim(), '');
279
+ }
280
+ }
281
+ return lines.join('\n');
282
+ }
283
+
284
+ async function renderSimpleEntity(sdb, title, tableName, idField = 'entity_id', nameField = 'name') {
285
+ const rows = await sdb.rows(`SELECT * FROM ${tableName} WHERE story = ${STORY} ORDER BY ${idField};`);
286
+ const lines = ['# ' + title, ''];
287
+ for (const row of rows) {
288
+ const id = row[idField] || '';
289
+ const name = row[nameField] || id;
290
+ lines.push('## ' + name + (id ? '(' + id + ')' : ''), '');
291
+ lines.push(fields(row, '###').trim(), '');
292
+ }
293
+ return lines.join('\n');
294
+ }
295
+
296
+ async function renderRelations(sdb) {
297
+ const characters = await sdb.rows(`SELECT entity_id, name FROM character WHERE story = ${STORY};`);
298
+ const names = new Map(characters.map((row) => [row.entity_id, row.name]));
299
+ const relations = await sdb.rows(`SELECT * FROM relation WHERE story = ${STORY} ORDER BY from_id, to_id;`);
300
+ const factions = await sdb.rows(`SELECT entity_id, name FROM faction WHERE story = ${STORY};`);
301
+ const factionNames = new Map(factions.map((row) => [row.entity_id, row.name]));
302
+ const fr = await sdb.rows(`SELECT * FROM faction_relation WHERE story = ${STORY} ORDER BY from_id, to_id;`);
303
+ const membership = await sdb.rows(`SELECT * FROM membership WHERE story = ${STORY} ORDER BY from_id, to_id;`);
304
+ return [
305
+ '# 人物与势力关系',
306
+ '',
307
+ '## 人物关系',
308
+ '',
309
+ table(['从', '到', '关系', '初始状态', '变化'], relations.map((r) => [
310
+ names.get(r.from_id) || r.from_id, names.get(r.to_id) || r.to_id, r.kind, r.initial_state, r.changes,
311
+ ])).trim(),
312
+ '',
313
+ '## 势力关系',
314
+ '',
315
+ table(['从', '到', '关系', '初始状态', '变化'], fr.map((r) => [
316
+ factionNames.get(r.from_id) || r.from_id, factionNames.get(r.to_id) || r.to_id, r.kind, r.initial_state, r.changes,
317
+ ])).trim(),
318
+ '',
319
+ '## 人物所属势力',
320
+ '',
321
+ table(['人物', '势力', '角色'], membership.map((r) => [
322
+ names.get(r.from_id) || r.from_id, factionNames.get(r.to_id) || r.to_id, r.role,
323
+ ])).trim(),
324
+ '',
325
+ ].join('\n');
326
+ }
327
+
328
+ async function renderChapters(sdb) {
329
+ const rows = await sdb.rows(`SELECT * FROM chapter WHERE story = ${STORY} ORDER BY display_no;`);
330
+ return [
331
+ '# 章节地图',
332
+ '',
333
+ table(
334
+ ['章', 'ID', '标题', '卷', '幕', 'POV', '时间', '情绪', '钩子', '目标字数', '状态'],
335
+ rows.map((c) => [
336
+ c.display_no, c.chapter_id, c.title, c.volume, c.act, c.pov,
337
+ inline(c.time), inline(c.emotion), inline(c.hook), c.word_target, c.status,
338
+ ]),
339
+ ).trim(),
340
+ '',
341
+ ].join('\n');
342
+ }
343
+
344
+ function stripRecord(value) {
345
+ return String(value == null ? '' : value).split(':').pop().replace(/[⟨⟩]/g, '');
346
+ }
347
+
348
+ function indexBy(rows, key, name) {
349
+ const map = new Map();
350
+ for (const row of rows || []) {
351
+ const id = typeof key === 'function' ? key(row) : row[key];
352
+ if (id != null) map.set(String(id), row);
353
+ }
354
+ return map;
355
+ }
356
+
357
+ function groupBy(rows, key) {
358
+ const map = new Map();
359
+ for (const row of rows || []) {
360
+ const id = stripRecord(typeof key === 'function' ? key(row) : row[key]);
361
+ if (!id) continue;
362
+ if (!map.has(id)) map.set(id, []);
363
+ map.get(id).push(row);
364
+ }
365
+ return map;
366
+ }
367
+
368
+ function entityName(map, id, fallback = '') {
369
+ const row = map.get(stripRecord(id));
370
+ return row ? (row.name || row.title || row.info_name || row.entity_id || fallback) : (fallback || stripRecord(id));
371
+ }
372
+
373
+ function chapterActionVerb(kind, action) {
374
+ if (kind === 'foreshadow') return { plant: '埋设', progress: '推进', reveal: '回收' }[action] || action;
375
+ if (kind === 'payoff') return { setup: '铺垫', payoff: '兑现' }[action] || action;
376
+ if (kind === 'information') return { created: '建立', progressed: '推进', resolved: '解决' }[action] || action;
377
+ return action || '';
378
+ }
379
+
380
+ async function loadOutlineGraph(sdb) {
381
+ const query = (table, order = '') => sdb.rows(
382
+ `SELECT * FROM ${table} WHERE story = ${STORY}${order ? ' ORDER BY ' + order : ''};`,
383
+ );
384
+ const [
385
+ chapters, outlines, scenes,
386
+ chapterCasts, chapterLocations, chapterNodes, chapterBranches, chapterConflicts,
387
+ chapterClimaxes, chapterItems, outlineCasts, outlineLocations,
388
+ foreshadowActions, payoffActions, infoActions, endingActions, knowledgeEvents,
389
+ characters, locations, conflicts, lineNodes, lines, foreshadows, payoffs, information, climaxes, items, endings,
390
+ volumes, acts,
391
+ ] = await Promise.all([
392
+ query('chapter', 'display_no'),
393
+ query('outline', 'number, chapter_id'),
394
+ query('scene', 'outline_id, scene_order'),
395
+ query('chapter_cast'),
396
+ query('chapter_location'),
397
+ query('chapter_line_node'),
398
+ query('chapter_branch'),
399
+ query('chapter_conflict'),
400
+ query('chapter_climax'),
401
+ query('chapter_item'),
402
+ query('outline_cast'),
403
+ query('outline_location'),
404
+ query('foreshadow_action'),
405
+ query('payoff_action'),
406
+ query('info_action'),
407
+ query('ending_action'),
408
+ query('knowledge_event', 'chapter_no, created_at'),
409
+ query('character', 'entity_id'),
410
+ query('location', 'entity_id'),
411
+ query('conflict', 'entity_id'),
412
+ query('line_node', 'node_id'),
413
+ query('line', 'line_id'),
414
+ query('foreshadow', 'entity_id'),
415
+ query('payoff', 'entity_id'),
416
+ query('information', 'entity_id'),
417
+ query('climax', 'entity_id'),
418
+ query('item', 'entity_id'),
419
+ query('ending', 'ending_id'),
420
+ query('volume', 'chapter_start'),
421
+ query('act', 'chapter_start'),
422
+ ]);
423
+ return {
424
+ chapters: indexBy(chapters, 'chapter_id'),
425
+ outlines,
426
+ scenes: groupBy(scenes, 'outline_id'),
427
+ chapterCasts: groupBy(chapterCasts, 'from_id'),
428
+ chapterLocations: groupBy(chapterLocations, 'from_id'),
429
+ chapterNodes: groupBy(chapterNodes, 'from_id'),
430
+ chapterBranches: groupBy(chapterBranches, 'from_id'),
431
+ chapterConflicts: groupBy(chapterConflicts, 'from_id'),
432
+ chapterClimaxes: groupBy(chapterClimaxes, 'from_id'),
433
+ chapterItems: groupBy(chapterItems, 'from_id'),
434
+ outlineCasts: groupBy(outlineCasts, 'from_id'),
435
+ outlineLocations: groupBy(outlineLocations, 'from_id'),
436
+ foreshadowActions: groupBy(foreshadowActions, 'from_id'),
437
+ payoffActions: groupBy(payoffActions, 'from_id'),
438
+ infoActions: groupBy(infoActions, 'from_id'),
439
+ endingActions: groupBy(endingActions, 'from_id'),
440
+ knowledgeEvents: groupBy(knowledgeEvents, 'chapter_no'),
441
+ characters: indexBy(characters, 'entity_id'),
442
+ locations: indexBy(locations, 'entity_id'),
443
+ conflicts: indexBy(conflicts, 'entity_id'),
444
+ lineNodes: indexBy(lineNodes, 'node_id'),
445
+ lines: indexBy(lines, 'line_id'),
446
+ foreshadows: indexBy(foreshadows, 'entity_id'),
447
+ payoffs: indexBy(payoffs, 'entity_id'),
448
+ information: indexBy(information, 'entity_id'),
449
+ climaxes: indexBy(climaxes, 'entity_id'),
450
+ items: indexBy(items, 'entity_id'),
451
+ endings: indexBy(endings, 'ending_id'),
452
+ volumes: indexBy(volumes, 'volume_id'),
453
+ acts: indexBy(acts, 'act_id'),
454
+ };
455
+ }
456
+
457
+ async function renderOutlineFiles(sdb, write) {
458
+ const graph = await loadOutlineGraph(sdb);
459
+ let count = 0;
460
+ for (const outline of graph.outlines) {
461
+ const chapter = graph.chapters.get(outline.chapter_id) || {};
462
+ const no = Number(chapter.display_no || outline.number || 0);
463
+ const title = outline.title || chapter.title || outline.chapter_id;
464
+ const rel = path.join('15-细纲', `第${pad3(no)}章-${safeName(title)}.md`);
465
+ const chapterId = outline.chapter_id;
466
+ const casts = graph.chapterCasts.get(chapterId) || [];
467
+ const outlineCasts = graph.outlineCasts.get(chapterId) || [];
468
+ const locations = graph.chapterLocations.get(chapterId) || [];
469
+ const outlineLocations = graph.outlineLocations.get(chapterId) || [];
470
+ const nodeEdges = graph.chapterNodes.get(chapterId) || [];
471
+ const branchEdges = graph.chapterBranches.get(chapterId) || [];
472
+ const conflictEdges = graph.chapterConflicts.get(chapterId) || [];
473
+ const climaxEdges = graph.chapterClimaxes.get(chapterId) || [];
474
+ const itemEdges = graph.chapterItems.get(chapterId) || [];
475
+ const fsActions = graph.foreshadowActions.get(chapterId) || [];
476
+ const payActions = graph.payoffActions.get(chapterId) || [];
477
+ const infoActions = graph.infoActions.get(chapterId) || [];
478
+ const endActions = graph.endingActions.get(chapterId) || [];
479
+ const knowledge = graph.knowledgeEvents.get(String(no)) || [];
480
+ const why = chapter.why || {};
481
+ const how = chapter.how || {};
482
+ const lines = ['# 第' + no + '章 ' + title, ''];
483
+
484
+ const volume = graph.volumes.get(chapter.volume || outline.volume) || {};
485
+ const act = graph.acts.get(chapter.act || outline.act) || {};
486
+ lines.push('## 基本信息', '');
487
+ lines.push(table(['项目', '内容'], [
488
+ ['卷', (volume.volume_id || chapter.volume || '—') + (volume.title ? ' ' + volume.title : '')],
489
+ ['幕', act.act_id || chapter.act || '—'],
490
+ ['章', '第' + no + '章' + (title ? ' ' + title : '') + '(' + chapterId + ')'],
491
+ ['视角人物', entityName(graph.characters, chapter.pov || outline.pov, chapter.pov || '—')],
492
+ ['时间', inline(chapter.time || outline.time)],
493
+ ['情绪', inline(chapter.emotion)],
494
+ ['目标字数', chapter.word_target || outline.word_target || '—'],
495
+ ['状态', chapter.status || outline.status || '—'],
496
+ ]).trim(), '');
497
+
498
+ lines.push('## 卷纲', '');
499
+ lines.push('- 卷:' + (volume.volume_id || chapter.volume || '—') + (volume.title ? ' ' + volume.title : ''));
500
+ if (volume.chapter_start || volume.chapter_end) lines.push('- 章节范围:' + (volume.chapter_start || '—') + '-' + (volume.chapter_end || '—'));
501
+ if (volume.goal) lines.push('- 卷目标:' + volume.goal);
502
+ if (volume.core_conflict) lines.push('- 核心冲突:' + volume.core_conflict);
503
+ if (volume.emotional_arc) lines.push('- 情感弧线:' + volume.emotional_arc);
504
+ if (volume.ending_hook) lines.push('- 卷末钩子:' + volume.ending_hook);
505
+ lines.push('');
506
+
507
+ lines.push('## 幕纲', '');
508
+ lines.push('- 幕:' + (act.act_id || chapter.act || '—'));
509
+ if (act.chapter_start || act.chapter_end) lines.push('- 章节范围:' + (act.chapter_start || '—') + '-' + (act.chapter_end || '—'));
510
+ if (act.goal) lines.push('- 幕目标:' + act.goal);
511
+ if (act.climax) lines.push('- 幕高潮:' + act.climax);
512
+ lines.push('');
513
+
514
+ lines.push('## 章纲', '');
515
+ const chapterEndingHook = how.ending_hook || (outline.ending && (outline.ending.text || outline.ending.hook)) || inline(chapter.hook);
516
+ if (chapterEndingHook) lines.push('- 章末钩子:' + chapterEndingHook);
517
+ lines.push('');
518
+ const mainParts = [];
519
+ for (const edge of nodeEdges) {
520
+ const node = graph.lineNodes.get(stripRecord(edge.to_id));
521
+ const line = node && graph.lines.get(node.line_id);
522
+ mainParts.push('- 主线节点 `' + stripRecord(edge.to_id) + '`' + (line ? ':' + line.name : '') + (node && node.goal ? '|目标:' + node.goal : ''));
523
+ }
524
+ for (const edge of branchEdges) {
525
+ mainParts.push('- 支线 `' + stripRecord(edge.to_id) + '`:' + entityName(graph.lines, edge.to_id));
526
+ }
527
+ lines.push('### 主线与支线', '');
528
+ lines.push(mainParts.length ? mainParts.join('\n') : '(本章没有主线/支线节点边)', '');
529
+ if (why.theme || why.stakes) {
530
+ lines.push('### 本章主题与代价', '', '- 主题:' + (why.theme || '—'), '- 代价:' + (why.stakes || '—'), '');
531
+ }
532
+ if (how.function_main || how.function_aux || how.turn || how.ending_hook) {
533
+ lines.push('### 本章写法', '');
534
+ if (how.function_main) lines.push('- 主功能:' + how.function_main);
535
+ if (how.function_aux) lines.push('- 辅助功能:' + how.function_aux);
536
+ if (how.turn) lines.push('- 转折:' + how.turn);
537
+ if (how.ending_hook) lines.push('- 章末钩子:' + how.ending_hook);
538
+ lines.push('');
539
+ }
540
+
541
+ const castSource = casts.length ? casts : outlineCasts;
542
+ const castIds = new Set(castSource.map((edge) => stripRecord(edge.to_id || edge.out)));
543
+ const conflictParts = conflictEdges.map((edge) => {
544
+ const conflict = graph.conflicts.get(stripRecord(edge.to_id)) || {};
545
+ return '- `' + stripRecord(edge.to_id) + '` ' + (conflict.name || '') + ':' + (conflict.description || conflict.core_goal || '—') + (conflict.stakes ? '|代价:' + conflict.stakes : '');
546
+ });
547
+ lines.push('### 本章关联冲突(可能是背景)', '');
548
+ lines.push(conflictParts.length ? conflictParts.join('\n') : '(本章没有挂 chapter_conflict)', '');
549
+ const conflictParticipants = new Set();
550
+ for (const edge of conflictEdges) {
551
+ const conflict = graph.conflicts.get(stripRecord(edge.to_id)) || {};
552
+ for (const id of (conflict.participants || [])) conflictParticipants.add(String(id));
553
+ }
554
+ if (conflictParticipants.size) {
555
+ const participantRows = [...conflictParticipants].map((id) => {
556
+ const character = graph.characters.get(id) || {};
557
+ return [character.name || id, id, character.type || '—', castIds.has(id) ? '本章 cast' : '未在本章 cast'];
558
+ });
559
+ lines.push('#### 冲突参与者(设定)', '');
560
+ lines.push(table(['角色', 'ID', '类型', '状态'], participantRows).trim(), '');
561
+ const missing = [...conflictParticipants].filter((id) => !castIds.has(id));
562
+ if (missing.length) {
563
+ lines.push('> 注意:' + missing.map((id) => entityName(graph.characters, id)).join('、') + ' 属于该冲突的设定参与者,但未出现在本章 `chapter_cast`/`outline_cast`。如果本章确实出场,rhythm 阶段需要补 cast 边;如果只是背景,保持现状。', '');
564
+ }
565
+ }
566
+
567
+ lines.push('## 出场人物', '');
568
+ const castRows = casts.length ? casts : outlineCasts;
569
+ lines.push(table(['角色', 'ID', '定位', '功能位'], castRows.map((edge) => {
570
+ const id = stripRecord(edge.to_id || edge.out);
571
+ const character = graph.characters.get(id) || {};
572
+ return [character.name || id, id, edge.role || character.type || '—', character.role_slot || '—'];
573
+ })).trim(), '');
574
+
575
+ lines.push('## 地点', '');
576
+ const locationRows = (locations.length ? locations : outlineLocations).map((edge) => {
577
+ const id = stripRecord(edge.to_id || edge.out);
578
+ const location = graph.locations.get(id) || {};
579
+ return [location.name || id, id, location.type || '—', location.narrative_function || '—'];
580
+ });
581
+ lines.push(table(['地点', 'ID', '类型', '叙事功能'], locationRows).trim(), '');
582
+
583
+ lines.push('## 道具', '');
584
+ let itemRows = itemEdges.map((edge) => {
585
+ const id = stripRecord(edge.to_id);
586
+ const item = graph.items.get(id) || {};
587
+ return [item.name || id, id, item.category || '—', item.item_function || item.owner || '—'];
588
+ });
589
+ let itemNote = '';
590
+ if (!itemRows.length) {
591
+ const castIds = new Set(castRows.map((edge) => stripRecord(edge.to_id || edge.out)));
592
+ const conflictIds = new Set(conflictEdges.map((edge) => stripRecord(edge.to_id)));
593
+ itemRows = [...graph.items.values()].filter((item) => {
594
+ if (item.category !== 'core') return false;
595
+ if (item.owner && castIds.has(String(item.owner))) return true;
596
+ if ((item.related_characters || []).some((id) => castIds.has(String(id)))) return true;
597
+ return (item.related_conflicts || []).some((id) => conflictIds.has(String(id)));
598
+ }).map((item) => [item.name, item.entity_id, item.category || '—', item.item_function || item.owner || '—']);
599
+ if (itemRows.length) itemNote = '(按本章人物/冲突关联推断,不是 chapter_item 明确指定)';
600
+ }
601
+ lines.push(itemNote, table(['道具', 'ID', '分类', '功能/持有者'], itemRows).trim(), '');
602
+
603
+ lines.push('## 伏笔', '');
604
+ const fsLines = fsActions.map((edge) => {
605
+ const id = stripRecord(edge.to_id);
606
+ const fs = graph.foreshadows.get(id) || {};
607
+ return '- **' + chapterActionVerb('foreshadow', edge.action) + '** `' + id + '` ' + (fs.name || '') + (edge.detail ? '|本章动作:' + edge.detail : '') + (fs.setup ? '\n - 原设定:' + fs.setup : '') + (fs.recovery_requirement ? '\n - 回收要求:' + fs.recovery_requirement : '');
608
+ });
609
+ lines.push(fsLines.length ? fsLines.join('\n') : '(本章没有 foreshadow_action)', '');
610
+
611
+ lines.push('## 爽点', '');
612
+ const payLines = payActions.map((edge) => {
613
+ const id = stripRecord(edge.to_id);
614
+ const payoff = graph.payoffs.get(id) || {};
615
+ return '- **' + chapterActionVerb('payoff', edge.action) + '** `' + id + '` ' + (payoff.name || '') + (edge.detail ? '|本章动作:' + edge.detail : '') + (payoff.setup ? '\n - 原铺垫:' + payoff.setup : '') + (payoff.payoff ? '\n - 兑现:' + payoff.payoff : '');
616
+ });
617
+ lines.push(payLines.length ? payLines.join('\n') : '(本章没有 payoff_action)', '');
618
+
619
+ lines.push('## 信息差', '');
620
+ const characterNames = (ids) => (Array.isArray(ids) ? ids : [])
621
+ .map((id) => entityName(graph.characters, id))
622
+ .join('、');
623
+ const infoLines = infoActions.map((edge) => {
624
+ const id = stripRecord(edge.to_id);
625
+ const info = graph.information.get(id) || {};
626
+ return '- **' + chapterActionVerb('information', edge.action) + '** `' + id + '` ' + (info.info_name || '') + (edge.detail ? '|本章动作:' + edge.detail : '') + (info.who_knows ? '\n - 知情:' + characterNames(info.who_knows) : '') + (info.who_does_not_know ? '\n - 不知情:' + characterNames(info.who_does_not_know) : '') + (info.resolution_method ? '\n - 揭晓方式:' + info.resolution_method : '');
627
+ });
628
+ lines.push(infoLines.length ? infoLines.join('\n') : '(本章没有 info_action)', '');
629
+
630
+ lines.push('## 高潮', '');
631
+ const climaxLines = climaxEdges.map((edge) => {
632
+ const id = stripRecord(edge.to_id);
633
+ const climax = graph.climaxes.get(id) || {};
634
+ return '- `' + id + '` ' + (climax.name || '') + (climax.type ? '(' + climax.type + ')' : '') + (climax.description ? ':' + climax.description : '');
635
+ });
636
+ lines.push(climaxLines.length ? climaxLines.join('\n') : '(本章没有 chapter_climax;如果是高潮章,rhythm 阶段需要补这条边)', '');
637
+
638
+ if (endActions.length) {
639
+ lines.push('## 结局动作', '');
640
+ lines.push(endActions.map((edge) => {
641
+ const ending = graph.endings.get(stripRecord(edge.to_id)) || {};
642
+ return '- `' + stripRecord(edge.to_id) + '` ' + (ending.type || '') + (edge.detail ? ':' + edge.detail : '');
643
+ }).join('\n'), '');
644
+ }
645
+
646
+ if (knowledge.length) {
647
+ lines.push('## 知识变化', '');
648
+ lines.push(knowledge.map((row) => '- `' + row.character_id + '` 对 `' + row.subject_id + '`:' + chapterActionVerb('information', row.change) + ' → ' + row.coverage + (row.knowledge ? '|' + row.knowledge : '')).join('\n'), '');
649
+ }
650
+
651
+ lines.push('## 必写', '');
652
+ lines.push(bullets(outline.must_write).trim(), '');
653
+ lines.push('## 禁写', '');
654
+ lines.push(bullets(outline.must_not_write).trim(), '');
655
+
656
+ lines.push('## 场景', '');
657
+ const scenes = graph.scenes.get(chapterId) || [];
658
+ if (!scenes.length) {
659
+ lines.push('(暂无场景)', '');
660
+ } else {
661
+ for (const scene of scenes) {
662
+ lines.push('### 场景 ' + scene.scene_order, '');
663
+ lines.push(table(['项目', '内容'], [
664
+ ['目标', scene.goal],
665
+ ['阻碍', scene.obstacle],
666
+ ['转折', scene.turn],
667
+ ['结果', scene.outcome],
668
+ ['地点', scene.location ? entityName(graph.locations, scene.location) + '(' + scene.location + ')' : '—'],
669
+ ['出场', (scene.cast || []).map((id) => entityName(graph.characters, id)).join('、') || '—'],
670
+ ]).trim(), '');
671
+ }
672
+ }
673
+
674
+ if (outline.continuity) {
675
+ lines.push('## 连贯性', '', block(outline.continuity).trim(), '');
676
+ }
677
+ if (outline.ending) {
678
+ lines.push('## 章尾', '', block(outline.ending).trim(), '');
679
+ }
680
+ if (how.function_main || how.function_aux || how.turn || how.ending_hook) {
681
+ lines.push('## 写作提示', '', fields(how, '###').trim(), '');
682
+ }
683
+
684
+ write(rel, lines.join('\n'));
685
+ count += 1;
686
+ }
687
+ return count;
688
+ }
689
+
690
+ async function renderWritingFiles(sdb, write) {
691
+ const rows = await sdb.rows(`SELECT * FROM chapter_text WHERE story = ${STORY} ORDER BY display_no;`);
692
+ let count = 0;
693
+ for (const row of rows) {
694
+ const chapter = await sdb.one(
695
+ `SELECT display_no, title FROM chapter WHERE story = ${STORY} AND chapter_id = $id LIMIT 1;`,
696
+ { id: row.chapter_id },
697
+ );
698
+ const no = Number(row.display_no || (chapter && chapter.display_no) || 0);
699
+ const title = (chapter && chapter.title) || row.chapter_id;
700
+ const rel = path.join('16-正文', `第${pad3(no)}章-${safeName(title)}.md`);
701
+ let content = String(row.content || '').trim();
702
+ content = content.replace(/^#\s+.*\n+/, '');
703
+ // 正文文件只放正文;handoff 是写作流程元数据,不属于正文。
704
+ const lines = ['# 第' + no + '章 ' + title, '', content, ''];
705
+ write(rel, lines.join('\n'));
706
+ count += 1;
707
+ }
708
+ return count;
709
+ }
710
+
711
+ async function renderDecisions(sdb) {
712
+ const rows = await sdb.rows(`SELECT * FROM decision WHERE story = ${STORY} ORDER BY created_at;`);
713
+ const active = rows.filter((row) => row.status === 'active');
714
+ const history = rows.filter((row) => row.status !== 'active');
715
+ const make = (list) => table(
716
+ ['时间', '范围', '条目', '说明', '来源', '参考', '理由', '状态'],
717
+ list.map((row) => [row.created_at, row.scope, row.subject, row.text, row.source, row.reference, row.reason, row.status]),
718
+ ).trim();
719
+ return [
720
+ '# 设计决策',
721
+ '',
722
+ '## 当前生效',
723
+ '',
724
+ make(active),
725
+ '',
726
+ '## 历史决策',
727
+ '',
728
+ make(history),
729
+ '',
730
+ ].join('\n');
731
+ }
732
+
733
+ async function renderRequirements(sdb) {
734
+ const rows = await sdb.rows(`SELECT * FROM requirement WHERE story = ${STORY} AND status = 'pending' ORDER BY hard DESC, due_chapter, subject;`);
735
+ return [
736
+ '# 待办',
737
+ '',
738
+ table(['必填', '类型', '对象', '说明', '到期章'], rows.map((row) => [
739
+ row.hard ? '是' : '否', row.kind, row.subject, row.label, row.due_chapter,
740
+ ])).trim(),
741
+ '',
742
+ ].join('\n');
743
+ }
744
+
745
+ function prepareExportDir(root) {
746
+ const dir = path.join(root, '导出');
747
+ ensureDir(dir);
748
+ const manifestPath = path.join(dir, '.fancy-export.json');
749
+ let old = [];
750
+ try {
751
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
752
+ if (Array.isArray(parsed.generated)) old = parsed.generated;
753
+ } catch { /* 首次导出 */ }
754
+ for (const rel of old) {
755
+ if (typeof rel !== 'string') continue;
756
+ const target = path.resolve(dir, rel);
757
+ if (!target.startsWith(path.resolve(dir) + path.sep)) continue;
758
+ try { fs.rmSync(target, { force: true }); } catch { /* ignore */ }
759
+ }
760
+ return { dir, manifestPath };
761
+ }
762
+
763
+ export async function exportStoryGraph(sdb, projectRoot) {
764
+ const { dir, manifestPath } = prepareExportDir(projectRoot);
765
+ const generated = [];
766
+ const write = (rel, content) => {
767
+ const target = path.join(dir, rel);
768
+ ensureDir(path.dirname(target));
769
+ fs.writeFileSync(target, String(content).trimEnd() + '\n', 'utf8');
770
+ generated.push(rel);
771
+ };
772
+
773
+ write('00-总览.md', await renderOverview(sdb));
774
+ write('01-规模与结构.md', await renderScale(sdb));
775
+ write('02-世界观与故事.md', await renderMacro(sdb));
776
+ write('04-人物.md', await renderCharacters(sdb));
777
+ write('05-势力.md', await renderSimpleEntity(sdb, '势力', 'faction'));
778
+ write('06-地点.md', await renderSimpleEntity(sdb, '地点', 'location'));
779
+ write('07-道具.md', await renderSimpleEntity(sdb, '道具', 'item'));
780
+ write('08-冲突.md', await renderSimpleEntity(sdb, '冲突', 'conflict'));
781
+ write('09-伏笔.md', await renderSimpleEntity(sdb, '伏笔', 'foreshadow'));
782
+ write('10-爽点.md', await renderSimpleEntity(sdb, '爽点', 'payoff'));
783
+ write('11-高潮.md', await renderSimpleEntity(sdb, '高潮', 'climax'));
784
+ write('12-信息差.md', await renderSimpleEntity(sdb, '信息差', 'information', 'entity_id', 'info_name'));
785
+ write('13-人物与势力关系.md', await renderRelations(sdb));
786
+ write('14-章节地图.md', await renderChapters(sdb));
787
+ const outlines = await renderOutlineFiles(sdb, write);
788
+ const writing = await renderWritingFiles(sdb, write);
789
+ write('17-设计决策.md', await renderDecisions(sdb));
790
+ write('18-待办.md', await renderRequirements(sdb));
791
+
792
+ fs.writeFileSync(manifestPath, JSON.stringify({
793
+ generated,
794
+ generated_at: new Date().toISOString(),
795
+ outlines,
796
+ chapters: writing,
797
+ }, null, 2) + '\n', 'utf8');
798
+
799
+ return {
800
+ dir,
801
+ files: generated.length + 1,
802
+ outlines,
803
+ chapters: writing,
804
+ };
805
+ }