@cloud411716/fancy-webnovel 1.0.46 → 3.0.0

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 (271) hide show
  1. package/client.js +369 -0
  2. package/cordis.patch.yml +4 -13
  3. package/docs/douyin-plugin.md +118 -0
  4. package/docs/material-library/README.md +404 -0
  5. package/docs/material-library/import.js +437 -0
  6. package/docs/material-library/prompt.md +370 -0
  7. package/docs/material-library/queries.surql +94 -0
  8. package/docs/material-library/sample.jsonl +14 -0
  9. package/docs/material-library/schema.surql +46 -0
  10. package/docs/mcp-profile.example.yml +24 -0
  11. package/docs/story-graph-design.md +602 -0
  12. package/docs/story-graph-validation.md +81 -0
  13. package/docs/workspace-mcp.md +284 -0
  14. package/index.js +26 -38
  15. package/lib/atomic-write.js +109 -0
  16. package/lib/chapter-order.js +100 -0
  17. package/lib/chinese-num.js +111 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +141 -0
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +104 -0
  23. package/lib/infra.js +594 -0
  24. package/lib/llm-wiki.js +109 -0
  25. package/lib/material-db.js +149 -0
  26. package/lib/path-guard.js +150 -0
  27. package/lib/projects.js +400 -0
  28. package/lib/scraper-utils.js +305 -0
  29. package/lib/surreal-server.js +338 -0
  30. package/lib/topic-decision.js +860 -0
  31. package/lib/topic-fields.js +251 -0
  32. package/lib/topic-schema.js +225 -0
  33. package/lib/workspace.js +501 -0
  34. package/package.json +62 -5
  35. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  36. package/plugins/fancy-analyze/command-input.js +65 -0
  37. package/plugins/fancy-analyze/index.js +2024 -0
  38. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  39. package/plugins/fancy-analyze/schema-analyze.md +627 -0
  40. package/plugins/fancy-analyze/schema-write.md +178 -0
  41. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  42. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  43. package/plugins/fancy-analyze/summary-progress.js +495 -0
  44. package/plugins/fancy-analyze/templates.js +1179 -0
  45. package/plugins/fancy-bootstrap/index.js +313 -0
  46. package/plugins/fancy-bootstrap/scripts/init.js +177 -0
  47. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  48. package/plugins/fancy-bootstrap/templates.js +71 -0
  49. package/plugins/fancy-db/index.js +172 -0
  50. package/plugins/fancy-douyin/deploy.js +134 -0
  51. package/plugins/fancy-douyin/index.js +551 -0
  52. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  53. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  54. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  55. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  56. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  57. package/plugins/fancy-douyin/state.js +197 -0
  58. package/plugins/fancy-douyin/templates.js +127 -0
  59. package/plugins/fancy-export/exporter.js +805 -0
  60. package/plugins/fancy-export/index.js +63 -0
  61. package/plugins/fancy-import/import-core.js +1118 -0
  62. package/plugins/fancy-import/import-driver.js +604 -0
  63. package/plugins/fancy-import/index.js +738 -0
  64. package/plugins/fancy-import/prompts/01-import-prompt.md +131 -0
  65. package/plugins/fancy-import/templates.js +58 -0
  66. package/plugins/fancy-material/exporter.js +153 -0
  67. package/plugins/fancy-material/genres.js +256 -0
  68. package/plugins/fancy-material/index.js +835 -0
  69. package/plugins/fancy-material/inventory.js +143 -0
  70. package/plugins/fancy-material/kinds.js +313 -0
  71. package/plugins/fancy-material/queue.js +55 -0
  72. package/plugins/fancy-material/scripts/query.js +254 -0
  73. package/plugins/fancy-material/templates.js +258 -0
  74. package/plugins/fancy-material/tool-deploy.js +138 -0
  75. package/plugins/fancy-material/validate.js +149 -0
  76. package/plugins/fancy-project/index.js +133 -0
  77. package/plugins/fancy-scan/existing-block.js +100 -0
  78. package/plugins/fancy-scan/index.js +708 -0
  79. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  80. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  81. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  82. package/plugins/fancy-scan/scripts/run-scan.js +667 -0
  83. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +923 -0
  84. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +469 -0
  85. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +629 -0
  86. package/plugins/fancy-scan/templates/blocks.js +155 -0
  87. package/plugins/fancy-scan/templates/platforms.js +372 -0
  88. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  89. package/plugins/fancy-scan/templates.js +694 -0
  90. package/plugins/fancy-scan/tool-deploy.js +335 -0
  91. package/plugins/fancy-scan/wait-turn.js +46 -0
  92. package/plugins/fancy-story-builder/db/README.md +106 -0
  93. package/plugins/fancy-story-builder/db/client.js +228 -0
  94. package/plugins/fancy-story-builder/db/commands.js +145 -0
  95. package/plugins/fancy-story-builder/db/ids.js +39 -0
  96. package/plugins/fancy-story-builder/db/import.js +798 -0
  97. package/plugins/fancy-story-builder/db/project.js +299 -0
  98. package/plugins/fancy-story-builder/db/query.js +361 -0
  99. package/plugins/fancy-story-builder/db/schema.js +417 -0
  100. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  101. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  102. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  103. package/plugins/fancy-story-builder/flesh-output.js +168 -0
  104. package/plugins/fancy-story-builder/index.js +6861 -0
  105. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  106. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +563 -0
  107. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +602 -0
  108. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +539 -0
  109. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +225 -0
  110. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  111. package/plugins/fancy-story-builder/purpose-template.md +83 -0
  112. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  113. package/plugins/fancy-story-builder/references/formulas-skeleton.json +402 -0
  114. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  115. package/plugins/fancy-story-builder/references/glossary.md +594 -0
  116. package/plugins/fancy-story-builder/references/index.md +581 -0
  117. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  118. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  119. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  120. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  121. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  122. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  123. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  124. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  125. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  126. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  127. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  128. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  129. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  130. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  131. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  132. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  133. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  134. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  135. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +181 -0
  136. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  137. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +243 -0
  138. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  139. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +185 -0
  140. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  141. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +187 -0
  142. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  143. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +209 -0
  144. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  145. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  146. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  147. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  148. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +160 -0
  149. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  150. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  151. package/plugins/fancy-story-builder/schema-setting.md +232 -0
  152. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  153. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  154. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  155. package/plugins/fancy-story-builder/scripts/content-quality.js +416 -0
  156. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  157. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  158. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1312 -0
  159. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  160. package/plugins/fancy-story-builder/scripts/graph-utils.js +1484 -0
  161. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  162. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  163. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  164. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  165. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1312 -0
  166. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  167. package/plugins/fancy-story-builder/scripts/semantic-lint.js +381 -0
  168. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  169. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1973 -0
  170. package/plugins/fancy-story-builder/scripts/stage-batches.js +1405 -0
  171. package/plugins/fancy-story-builder/scripts/stage-contract.js +429 -0
  172. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  173. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  174. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  175. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  176. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  177. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  178. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  179. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  180. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  181. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  182. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  183. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  184. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  185. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  186. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  187. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  188. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  189. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  190. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  191. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  192. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  193. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  194. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  195. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  196. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  197. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  198. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  199. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  200. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  201. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  202. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  203. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  204. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  205. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  206. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  207. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  208. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  209. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  210. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  211. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  212. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  213. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  214. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  215. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  216. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  217. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  218. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  219. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  220. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  221. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  222. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  223. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  224. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  225. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  226. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  227. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  228. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  229. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  230. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  231. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  232. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  233. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  234. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  235. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  236. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  237. 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 +427 -0
  238. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
  239. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
  240. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
  241. 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 +168 -0
  242. 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 +184 -0
  243. 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
  244. 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 +131 -0
  245. 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 +85 -0
  246. 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 +103 -0
  247. 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 +68 -0
  248. 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 +142 -0
  249. 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 +292 -0
  250. 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 +266 -0
  251. 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 +336 -0
  252. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
  253. 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 +308 -0
  254. package/plugins/fancy-story-builder/templates.js +1446 -0
  255. package/plugins/fancy-story-builder/wiki-references.js +540 -0
  256. package/plugins/fancy-story-builder/writing-driver.js +1388 -0
  257. package/plugins/fancy-topic/index.js +1057 -0
  258. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  259. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  260. package/plugins/fancy-topic/scripts/select-topic.js +317 -0
  261. package/plugins/fancy-topic/templates.js +476 -0
  262. package/plugins/fancy-topic/tool-deploy.js +200 -0
  263. package/commands/bootstrap/index.js +0 -164
  264. package/commands/bootstrap/init-project.js +0 -121
  265. package/commands/scan/index.js +0 -283
  266. package/commands/scan/platforms/fanqie.js +0 -146
  267. package/commands/scan/platforms/jinjiang.js +0 -143
  268. package/commands/scan/platforms/qidian.js +0 -102
  269. package/commands/scan/platforms/qimao.js +0 -127
  270. package/commands/scan/scraper.js +0 -195
  271. package/infra.js +0 -160
@@ -0,0 +1,798 @@
1
+ /**
2
+ * Stage JSON -> SurrealDB.
3
+ *
4
+ * The LLM still writes JSON as its generation output, but that JSON is only a
5
+ * transport buffer. This module is the only place that turns it into story
6
+ * data; after a successful import the database is the source of truth.
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { dbErrorMessage, StoryDbError } from './client.js';
12
+ import { surrealString, recordLiteral } from './ids.js';
13
+
14
+ const STORY = 'story:main';
15
+
16
+ const STAGE_DIR = {
17
+ '10-scale': '10-scale',
18
+ '20-macro': '20-macro',
19
+ '30-cast': '30-cast',
20
+ '40-rhythm': '40-rhythm',
21
+ '50-writing': '50-writing',
22
+ };
23
+
24
+ export function stageDir(projectRoot, stageId, sub) {
25
+ return path.join(projectRoot, '设定', STAGE_DIR[stageId] || stageId, sub || '');
26
+ }
27
+
28
+ function readJson(file, fallback = null) {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
31
+ } catch {
32
+ return fallback;
33
+ }
34
+ }
35
+
36
+ function asArray(value) {
37
+ return Array.isArray(value) ? value : [];
38
+ }
39
+
40
+ function withoutMeta(obj) {
41
+ if (!obj || typeof obj !== 'object') return obj;
42
+ const copy = { ...obj };
43
+ delete copy._meta;
44
+ return copy;
45
+ }
46
+
47
+ function compact(fields) {
48
+ const out = {};
49
+ for (const [key, value] of Object.entries(fields)) {
50
+ if (value !== undefined) out[key] = value;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ async function upsert(sdb, table, id, fields) {
56
+ const clean = compact(fields);
57
+ delete clean.story; // always the literal story:main, never a bound string
58
+ const keys = Object.keys(clean);
59
+ const sets = ['story = story:main', ...keys.map((key) => key + ' = $' + key)].join(', ');
60
+ await sdb.query(
61
+ `UPSERT type::record(${surrealString(table)}, $id) SET ${sets};`,
62
+ { id: String(id), ...clean },
63
+ );
64
+ }
65
+
66
+ async function upsertStageDoc(sdb, stage, name, payload) {
67
+ await sdb.query(
68
+ `UPSERT stage_doc SET story = ${STORY}, stage = $stage, name = $name, payload = $payload
69
+ WHERE story = ${STORY} AND stage = $stage AND name = $name;`,
70
+ { stage, name, payload },
71
+ );
72
+ }
73
+
74
+ async function deleteNotIn(sdb, table, ids) {
75
+ if (!Array.isArray(ids) || !ids.length) return;
76
+ await sdb.query(
77
+ `DELETE ${table} WHERE story = ${STORY} AND entity_id NOT IN $ids;`,
78
+ { ids: ids.map(String) },
79
+ );
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // scale
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export async function importScale(sdb, projectRoot) {
87
+ const file = stageDir(projectRoot, '10-scale', 'scale.json');
88
+ const scale = readJson(file, null);
89
+ if (!scale) throw new StoryDbError('STORYDB_IMPORT', '缺少 10-scale/scale.json');
90
+
91
+ await sdb.query(
92
+ `UPSERT scale:main SET
93
+ story = ${STORY},
94
+ capacity = $capacity,
95
+ quotas = $quotas,
96
+ rhythm_budget = $rhythm_budget,
97
+ distribution = $distribution,
98
+ constraints = $constraints,
99
+ formulas = $formulas,
100
+ derivations = $derivations,
101
+ genre_profile = $genre_profile,
102
+ formula_version = $formula_version,
103
+ payload = $payload;`,
104
+ {
105
+ capacity: scale.capacity || {},
106
+ quotas: scale.quotas || {},
107
+ rhythm_budget: scale.rhythm_budget || {},
108
+ distribution: scale.distribution || {},
109
+ constraints: scale.constraints || [],
110
+ formulas: scale.formulas || {},
111
+ derivations: scale.derivations || {},
112
+ genre_profile: scale.genre_profile || undefined,
113
+ formula_version: scale.formula_version || undefined,
114
+ payload: scale,
115
+ },
116
+ );
117
+
118
+ await sdb.query(`DELETE volume WHERE story = ${STORY};`);
119
+ await sdb.query(`DELETE act WHERE story = ${STORY};`);
120
+
121
+ const volumes = asArray(scale.structure && scale.structure.volumes);
122
+ for (const volume of volumes) {
123
+ await upsert(sdb, 'volume', volume.volume_id || volume.id, {
124
+ story: STORY,
125
+ volume_id: volume.volume_id || volume.id,
126
+ title: volume.title || undefined,
127
+ chapter_start: Number(volume.chapter_start) || 0,
128
+ chapter_end: Number(volume.chapter_end) || 0,
129
+ goal: volume.goal || undefined,
130
+ core_conflict: volume.core_conflict || undefined,
131
+ emotional_arc: volume.emotional_arc || undefined,
132
+ ending_hook: volume.ending_hook || undefined,
133
+ payload: withoutMeta(volume),
134
+ });
135
+ for (const act of asArray(volume.acts)) {
136
+ await upsert(sdb, 'act', act.act_id || act.id, {
137
+ story: STORY,
138
+ act_id: act.act_id || act.id,
139
+ volume_id: volume.volume_id || volume.id,
140
+ act_index: Number(act.act_index) || 0,
141
+ chapter_start: Number(act.chapter_start) || 0,
142
+ chapter_end: Number(act.chapter_end) || 0,
143
+ goal: act.goal || undefined,
144
+ payload: withoutMeta(act),
145
+ });
146
+ }
147
+ }
148
+ for (const act of asArray(scale.structure && scale.structure.acts)) {
149
+ await upsert(sdb, 'act', act.act_id || act.id, {
150
+ story: STORY,
151
+ act_id: act.act_id || act.id,
152
+ volume_id: act.volume_id || '',
153
+ act_index: Number(act.act_index) || 0,
154
+ chapter_start: Number(act.chapter_start) || 0,
155
+ chapter_end: Number(act.chapter_end) || 0,
156
+ goal: act.goal || undefined,
157
+ payload: withoutMeta(act),
158
+ });
159
+ }
160
+
161
+ await upsertStageDoc(sdb, '10-scale', 'scale', scale);
162
+ return { stage: '10-scale', ok: true };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // macro
167
+ // ---------------------------------------------------------------------------
168
+
169
+ const MACRO_FILES = ['world', 'story', 'volumes', 'lines', 'endings', 'emotion', 'roles'];
170
+
171
+ export async function importMacro(sdb, projectRoot) {
172
+ const docs = {};
173
+ for (const name of MACRO_FILES) {
174
+ const doc = readJson(stageDir(projectRoot, '20-macro', name + '.json'), null);
175
+ if (!doc) throw new StoryDbError('STORYDB_IMPORT', '缺少 20-macro/' + name + '.json');
176
+ docs[name] = doc;
177
+ await upsertStageDoc(sdb, '20-macro', name, doc);
178
+ }
179
+
180
+ // volumes / acts: enrich the scale skeleton with macro details.
181
+ for (const volume of asArray(docs.volumes.volumes)) {
182
+ await sdb.query(
183
+ `UPSERT volume SET
184
+ story = ${STORY}, volume_id = $volume_id, title = $title,
185
+ chapter_start = $chapter_start, chapter_end = $chapter_end,
186
+ goal = $goal, core_conflict = $core_conflict,
187
+ emotional_arc = $emotional_arc, ending_hook = $ending_hook,
188
+ payload = $payload
189
+ WHERE story = ${STORY} AND volume_id = $volume_id;`,
190
+ {
191
+ volume_id: volume.id || volume.volume_id,
192
+ title: volume.title || undefined,
193
+ chapter_start: Number(volume.chapter_start) || 0,
194
+ chapter_end: Number(volume.chapter_end) || 0,
195
+ goal: volume.goal || undefined,
196
+ core_conflict: volume.core_conflict || undefined,
197
+ emotional_arc: volume.emotional_arc || undefined,
198
+ ending_hook: volume.ending_hook || undefined,
199
+ payload: withoutMeta(volume),
200
+ },
201
+ );
202
+ for (const act of asArray(volume.acts)) {
203
+ await sdb.query(
204
+ `UPSERT act SET
205
+ story = ${STORY}, act_id = $act_id, volume_id = $volume_id,
206
+ act_index = $act_index, chapter_start = $chapter_start,
207
+ chapter_end = $chapter_end, goal = $goal, payload = $payload
208
+ WHERE story = ${STORY} AND act_id = $act_id;`,
209
+ {
210
+ act_id: act.act_id || act.id,
211
+ volume_id: volume.id || volume.volume_id,
212
+ act_index: Number(act.act_index) || 0,
213
+ chapter_start: Number(act.chapter_start) || 0,
214
+ chapter_end: Number(act.chapter_end) || 0,
215
+ goal: act.goal || undefined,
216
+ payload: withoutMeta(act),
217
+ },
218
+ );
219
+ }
220
+ }
221
+
222
+ // lines / nodes
223
+ await sdb.query(`DELETE line_node WHERE story = ${STORY};`);
224
+ await sdb.query(`DELETE line WHERE story = ${STORY};`);
225
+ const main = docs.lines.main_line || {};
226
+ if (main.id || main.name) {
227
+ await upsert(sdb, 'line', main.id || 'M001', {
228
+ story: STORY,
229
+ line_id: main.id || 'M001',
230
+ kind: 'main',
231
+ name: main.name || '主线',
232
+ payload: withoutMeta(main),
233
+ });
234
+ }
235
+ for (const node of asArray(main.nodes)) {
236
+ await upsert(sdb, 'line_node', node.id, {
237
+ story: STORY,
238
+ node_id: node.id,
239
+ line_id: main.id || 'M001',
240
+ volume: node.volume || undefined,
241
+ act: node.act || undefined,
242
+ goal: node.goal || undefined,
243
+ turn: node.turn || undefined,
244
+ payload: withoutMeta(node),
245
+ });
246
+ }
247
+ for (const branch of asArray(docs.lines.branches)) {
248
+ await upsert(sdb, 'line', branch.id, {
249
+ story: STORY,
250
+ line_id: branch.id,
251
+ kind: 'branch',
252
+ name: branch.name || branch.id,
253
+ payload: withoutMeta(branch),
254
+ });
255
+ for (const nodeId of asArray(branch.interweaves)) {
256
+ await upsert(sdb, 'line_node', nodeId, {
257
+ story: STORY,
258
+ node_id: nodeId,
259
+ line_id: branch.id,
260
+ payload: { id: nodeId },
261
+ });
262
+ }
263
+ }
264
+
265
+ // endings
266
+ await sdb.query(`DELETE ending WHERE story = ${STORY};`);
267
+ for (const ending of asArray(docs.endings.endings)) {
268
+ await upsert(sdb, 'ending', ending.id, {
269
+ story: STORY,
270
+ ending_id: ending.id,
271
+ level: ending.level || undefined,
272
+ type: ending.type || undefined,
273
+ direction: ending.direction || undefined,
274
+ payload: withoutMeta(ending),
275
+ });
276
+ }
277
+
278
+ return { stage: '20-macro', ok: true };
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // cast
283
+ // ---------------------------------------------------------------------------
284
+
285
+ const ENTITY_SPECS = {
286
+ characters: {
287
+ table: 'character',
288
+ list: 'characters',
289
+ fields: ['role_slot', 'faction', 'description', 'goal', 'flaw', 'arc', 'traits'],
290
+ },
291
+ factions: {
292
+ table: 'faction',
293
+ list: 'factions',
294
+ fields: ['description', 'goal', 'resources', 'territory', 'internal_conflict'],
295
+ },
296
+ locations: {
297
+ table: 'location',
298
+ list: 'locations',
299
+ fields: ['description', 'narrative_function', 'rules'],
300
+ },
301
+ items: {
302
+ table: 'item',
303
+ list: 'items',
304
+ fields: ['category', 'description', 'item_function', 'owner', 'limit'],
305
+ },
306
+ conflicts: {
307
+ table: 'conflict',
308
+ list: 'conflicts',
309
+ fields: ['description', 'core_goal', 'stakes', 'escalation_path', 'resolution_plan', 'participants'],
310
+ },
311
+ foreshadows: {
312
+ table: 'foreshadow',
313
+ list: 'foreshadows',
314
+ fields: ['setup', 'clues', 'payoff_target', 'recovery_requirement', 'red_herrings', 'scope', 'initial_knowledge'],
315
+ },
316
+ payoffs: {
317
+ table: 'payoff',
318
+ list: 'payoffs',
319
+ fields: ['scale', 'setup', 'payoff', 'emotional_effect', 'required_setup'],
320
+ },
321
+ climaxes: {
322
+ table: 'climax',
323
+ list: 'climaxes',
324
+ fields: ['description', 'payoff', 'participants'],
325
+ },
326
+ };
327
+
328
+ export async function importCast(sdb, projectRoot) {
329
+ const files = {};
330
+ for (const name of [...Object.keys(ENTITY_SPECS), 'relations', 'information']) {
331
+ const doc = readJson(stageDir(projectRoot, '30-cast', name + '.json'), null);
332
+ if (!doc) throw new StoryDbError('STORYDB_IMPORT', '缺少 30-cast/' + name + '.json');
333
+ files[name] = doc;
334
+ }
335
+
336
+ for (const [fileKey, spec] of Object.entries(ENTITY_SPECS)) {
337
+ const rows = asArray(files[fileKey][spec.list]);
338
+ const ids = rows.map((row) => String(row.id));
339
+ for (const row of rows) {
340
+ const fields = {
341
+ story: STORY,
342
+ entity_id: String(row.id),
343
+ name: row.name || row.id,
344
+ type: row.type || undefined,
345
+ payload: withoutMeta(row),
346
+ };
347
+ for (const key of spec.fields) {
348
+ if (row[key] !== undefined) fields[key] = row[key];
349
+ }
350
+ await upsert(sdb, spec.table, row.id, fields);
351
+ }
352
+ await deleteNotIn(sdb, spec.table, ids);
353
+ }
354
+
355
+ const gaps = asArray(files.information.information_gaps);
356
+ for (const gap of gaps) {
357
+ await upsert(sdb, 'information', gap.id, {
358
+ story: STORY,
359
+ entity_id: String(gap.id),
360
+ info_name: gap.info_name || gap.name || gap.id,
361
+ conflict: gap.conflict || undefined,
362
+ who_knows: gap.who_knows || [],
363
+ who_does_not_know: gap.who_does_not_know || [],
364
+ resolution_method: gap.resolution_method || undefined,
365
+ writing_ban: gap.writing_ban || undefined,
366
+ initial_knowledge: gap.initial_knowledge || [],
367
+ payload: withoutMeta(gap),
368
+ });
369
+ }
370
+ await deleteNotIn(sdb, 'information', gaps.map((gap) => String(gap.id)));
371
+
372
+ // Rebuild all cast edges from scratch; they are derived from the documents.
373
+ for (const table of ['relation', 'faction_relation', 'membership', 'conflict_participant', 'climax_participant', 'foreshadow_payoff']) {
374
+ await sdb.query(`DELETE ${table} WHERE story = ${STORY};`);
375
+ }
376
+
377
+ const rels = files.relations;
378
+ for (const rel of asArray(rels.character_relations)) {
379
+ const from = rel.from || rel.from_id;
380
+ const to = rel.to || rel.to_id;
381
+ if (!from || !to) continue;
382
+ await sdb.query(
383
+ `RELATE ${recordLiteral('character', from)}->relation->${recordLiteral('character', to)} SET
384
+ story = ${STORY}, from_id = $from, to_id = $to,
385
+ kind = $kind, initial_state = $initial_state, changes = $changes, payload = $payload;`,
386
+ {
387
+ from: String(from),
388
+ to: String(to),
389
+ kind: rel.kind || rel.type || '未知',
390
+ initial_state: rel.initial_state || undefined,
391
+ changes: rel.changes || [],
392
+ payload: withoutMeta(rel),
393
+ },
394
+ );
395
+ }
396
+
397
+ for (const rel of asArray(rels.faction_relations)) {
398
+ const from = rel.from || rel.from_id;
399
+ const to = rel.to || rel.to_id;
400
+ if (!from || !to) continue;
401
+ await sdb.query(
402
+ `RELATE ${recordLiteral('faction', from)}->faction_relation->${recordLiteral('faction', to)} SET
403
+ story = ${STORY}, from_id = $from, to_id = $to, kind = $kind, payload = $payload;`,
404
+ {
405
+ from: String(from),
406
+ to: String(to),
407
+ kind: rel.kind || rel.type || '未知',
408
+ payload: withoutMeta(rel),
409
+ },
410
+ );
411
+ }
412
+
413
+ for (const member of asArray(rels.memberships)) {
414
+ const character = member.character || member.from || member.character_id;
415
+ const faction = member.faction || member.to || member.faction_id;
416
+ if (!character || !faction) continue;
417
+ await sdb.query(
418
+ `RELATE ${recordLiteral('character', character)}->membership->${recordLiteral('faction', faction)} SET
419
+ story = ${STORY}, role = $role, payload = $payload;`,
420
+ {
421
+ character: String(character),
422
+ faction: String(faction),
423
+ role: member.role || undefined,
424
+ payload: withoutMeta(member),
425
+ },
426
+ );
427
+ }
428
+
429
+ for (const conflict of asArray(files.conflicts.conflicts)) {
430
+ for (const participant of asArray(conflict.participants)) {
431
+ await sdb.query(
432
+ `RELATE ${recordLiteral('conflict', conflict.id)}->conflict_participant->${recordLiteral('character', participant)} SET story = ${STORY};`,
433
+ { conflict: String(conflict.id), character: String(participant) },
434
+ );
435
+ }
436
+ }
437
+
438
+ for (const climax of asArray(files.climaxes.climaxes)) {
439
+ for (const participant of asArray(climax.participants)) {
440
+ await sdb.query(
441
+ `RELATE ${recordLiteral('climax', climax.id)}->climax_participant->${recordLiteral('character', participant)} SET story = ${STORY};`,
442
+ { climax: String(climax.id), character: String(participant) },
443
+ );
444
+ }
445
+ }
446
+
447
+ const payoffIds = new Set(asArray(files.payoffs.payoffs).map((row) => String(row.id)));
448
+ for (const fs of asArray(files.foreshadows.foreshadows)) {
449
+ if (fs.payoff_target && payoffIds.has(String(fs.payoff_target))) {
450
+ await sdb.query(
451
+ `RELATE ${recordLiteral('foreshadow', fs.id)}->foreshadow_payoff->${recordLiteral('payoff', fs.payoff_target)} SET story = ${STORY}, relation = $relation;`,
452
+ { fs: String(fs.id), payoff: String(fs.payoff_target), relation: fs.recovery_requirement || undefined },
453
+ );
454
+ }
455
+ }
456
+
457
+ return { stage: '30-cast', ok: true };
458
+ }
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // rhythm
462
+ // ---------------------------------------------------------------------------
463
+
464
+ function actionState(value, fallback) {
465
+ return value || fallback;
466
+ }
467
+
468
+ export async function importRhythm(sdb, projectRoot) {
469
+ const doc = readJson(stageDir(projectRoot, '40-rhythm', 'chapter_map.json'), null);
470
+ if (!doc || !Array.isArray(doc.chapters)) {
471
+ throw new StoryDbError('STORYDB_IMPORT', '缺少 40-rhythm/chapter_map.json');
472
+ }
473
+ const chapters = [...doc.chapters].sort((a, b) => Number(a.display_no) - Number(b.display_no));
474
+
475
+ // Rebuild the timeline from scratch so the knowledge replay is deterministic.
476
+ await sdb.query(`DELETE knowledge_event WHERE story = ${STORY};`);
477
+ await sdb.query(`DELETE knowledge_state WHERE story = ${STORY};`);
478
+ for (const table of ['chapter_cast', 'chapter_location', 'foreshadow_action', 'payoff_action', 'info_action', 'ending_action']) {
479
+ await sdb.query(`DELETE ${table} WHERE story = ${STORY};`);
480
+ }
481
+ await sdb.query(`DELETE chapter_metric WHERE story = ${STORY};`);
482
+
483
+ const chapterIds = chapters.map((c) => String(c.chapter_id));
484
+ // Remove chapters that no longer exist, together with their dependents.
485
+ const old = await sdb.rows(`SELECT chapter_id FROM chapter WHERE story = ${STORY};`);
486
+ for (const row of old) {
487
+ if (!chapterIds.includes(String(row.chapter_id))) {
488
+ await sdb.query(`DELETE outline WHERE story = ${STORY} AND chapter_id = $id;`, { id: row.chapter_id });
489
+ await sdb.query(`DELETE chapter_text WHERE story = ${STORY} AND chapter_id = $id;`, { id: row.chapter_id });
490
+ await sdb.query(`DELETE ledger_entry WHERE story = ${STORY} AND chapter_id = $id;`, { id: row.chapter_id });
491
+ await sdb.query(`DELETE chapter WHERE story = ${STORY} AND chapter_id = $id;`, { id: row.chapter_id });
492
+ }
493
+ }
494
+
495
+ for (const chapter of chapters) {
496
+ const displayNo = Number(chapter.display_no);
497
+ await sdb.query(
498
+ `UPSERT type::record('chapter', $chapter_id) SET
499
+ story = ${STORY}, chapter_id = $chapter_id, display_no = $display_no,
500
+ title = $title, volume = $volume, act = $act, pov = $pov,
501
+ word_target = $word_target,
502
+ emotion_value = $emotion_value, hook_type = $hook_type, hook_strength = $hook_strength,
503
+ time = $time, why = $why, how = $how, emotion = $emotion, hook = $hook,
504
+ payload = $payload;`,
505
+ {
506
+ chapter_id: String(chapter.chapter_id),
507
+ display_no: displayNo,
508
+ title: chapter.title || '第 ' + displayNo + ' 章',
509
+ volume: chapter.volume || '',
510
+ act: chapter.act || '',
511
+ pov: chapter.pov || undefined,
512
+ word_target: Number(chapter.word_target) || 0,
513
+ emotion_value: Number(chapter.emotion && chapter.emotion.value) || undefined,
514
+ hook_type: chapter.hook && chapter.hook.type || undefined,
515
+ hook_strength: chapter.hook && chapter.hook.strength || undefined,
516
+ time: chapter.time || {},
517
+ why: chapter.why || {},
518
+ how: chapter.how || {},
519
+ emotion: chapter.emotion || {},
520
+ hook: chapter.hook || {},
521
+ payload: withoutMeta(chapter),
522
+ },
523
+ );
524
+
525
+ const chapThing = `type::record('chapter', ${surrealString(String(chapter.chapter_id))})`;
526
+ const chapRec = recordLiteral('chapter', chapter.chapter_id);
527
+
528
+ const cast = [...new Set(asArray(chapter.cast).map(String))];
529
+ for (const character of cast) {
530
+ const role = chapter.pov && String(chapter.pov) === character ? 'pov' : 'cast';
531
+ await sdb.query(
532
+ `RELATE ${chapRec}->chapter_cast->${recordLiteral('character', character)} SET story = ${STORY}, role = $role;`,
533
+ { character, role },
534
+ );
535
+ }
536
+ if (chapter.pov && !cast.includes(String(chapter.pov))) {
537
+ await sdb.query(
538
+ `RELATE ${chapRec}->chapter_cast->${recordLiteral('character', chapter.pov)} SET story = ${STORY}, role = 'pov';`,
539
+ { character: String(chapter.pov) },
540
+ );
541
+ }
542
+ for (const character of asArray(chapter.mentioned).map(String)) {
543
+ await sdb.query(
544
+ `RELATE ${chapRec}->chapter_cast->${recordLiteral('character', character)} SET story = ${STORY}, role = 'mentioned';`,
545
+ { character },
546
+ );
547
+ }
548
+
549
+ for (const location of [...new Set(asArray(chapter.locations).map(String))]) {
550
+ await sdb.query(
551
+ `RELATE ${chapRec}->chapter_location->${recordLiteral('location', location)} SET story = ${STORY};`,
552
+ { location },
553
+ );
554
+ }
555
+
556
+ for (const action of asArray(chapter.foreshadow_actions)) {
557
+ if (!action || !action.id) continue;
558
+ await sdb.query(
559
+ `RELATE ${chapRec}->foreshadow_action->${recordLiteral('foreshadow', action.id)} SET
560
+ story = ${STORY}, chapter_no = $chapter_no, foreshadow_id = $id,
561
+ action = $action, detail = $detail, payload = $payload;`,
562
+ {
563
+ id: String(action.id),
564
+ chapter_no: displayNo,
565
+ action: action.action || '埋设',
566
+ detail: action.detail || undefined,
567
+ payload: action,
568
+ },
569
+ );
570
+ }
571
+
572
+ for (const action of asArray(chapter.payoff_actions)) {
573
+ if (!action || !action.id) continue;
574
+ await sdb.query(
575
+ `RELATE ${chapRec}->payoff_action->${recordLiteral('payoff', action.id)} SET
576
+ story = ${STORY}, chapter_no = $chapter_no, payoff_id = $id,
577
+ action = $action, detail = $detail, payload = $payload;`,
578
+ {
579
+ id: String(action.id),
580
+ chapter_no: displayNo,
581
+ action: action.action || '铺垫',
582
+ detail: action.detail || undefined,
583
+ payload: action,
584
+ },
585
+ );
586
+ }
587
+
588
+ for (const gap of asArray(chapter.information_gaps)) {
589
+ if (!gap || !gap.id) continue;
590
+ await sdb.query(
591
+ `RELATE ${chapRec}->info_action->${recordLiteral('information', gap.id)} SET
592
+ story = ${STORY}, chapter_no = $chapter_no, info_id = $id,
593
+ state = $state, detail = $detail, payload = $payload;`,
594
+ {
595
+ id: String(gap.id),
596
+ chapter_no: displayNo,
597
+ state: actionState(gap.state, 'progressed'),
598
+ detail: gap.detail || undefined,
599
+ payload: gap,
600
+ },
601
+ );
602
+ }
603
+
604
+ for (const action of asArray(chapter.ending_actions)) {
605
+ if (!action || !action.id) continue;
606
+ await sdb.query(
607
+ `RELATE ${chapRec}->ending_action->${recordLiteral('ending', action.id)} SET
608
+ story = ${STORY}, chapter_no = $chapter_no, ending_id = $id,
609
+ action = $action, detail = $detail, payload = $payload;`,
610
+ {
611
+ id: String(action.id),
612
+ chapter_no: displayNo,
613
+ action: action.action || '收束',
614
+ detail: action.detail || undefined,
615
+ payload: action,
616
+ },
617
+ );
618
+ }
619
+
620
+ await sdb.query(
621
+ `UPSERT chapter_metric SET
622
+ story = ${STORY}, chapter = ${chapThing}, chapter_no = $chapter_no,
623
+ emotion_value = $emotion_value, hook_strength = $hook_strength,
624
+ word_target = $word_target
625
+ WHERE story = ${STORY} AND chapter_no = $chapter_no;`,
626
+ {
627
+ chapter_no: displayNo,
628
+ emotion_value: Number(chapter.emotion && chapter.emotion.value) || undefined,
629
+ hook_strength: chapter.hook && chapter.hook.strength || undefined,
630
+ word_target: Number(chapter.word_target) || 0,
631
+ },
632
+ );
633
+
634
+ // Knowledge events are inserted in chapter/array order. The DB event
635
+ // replays them against knowledge_state and rejects contradictions.
636
+ for (const event of asArray(chapter.knowledge_events)) {
637
+ if (!event || !event.subject_id || !event.character) continue;
638
+ const characterId = String(event.character);
639
+ const sourceId = event.source_character ? String(event.source_character) : undefined;
640
+ await sdb.query(
641
+ `CREATE knowledge_event SET
642
+ story = ${STORY},
643
+ chapter = ${chapThing},
644
+ chapter_no = $chapter_no,
645
+ subject_type = $subject_type,
646
+ subject_id = $subject_id,
647
+ character = type::record('character', $character_id),
648
+ character_id = $character_id,
649
+ change = $change,
650
+ coverage = $coverage,
651
+ knowledge = $knowledge,
652
+ how = $how,
653
+ source_character = IF $source_id = NONE THEN NONE ELSE type::record('character', $source_id) END,
654
+ scene = $scene,
655
+ evidence = $evidence,
656
+ payload = $payload;`,
657
+ {
658
+ chapter_no: displayNo,
659
+ subject_type: event.subject_type || 'information',
660
+ subject_id: String(event.subject_id),
661
+ character_id: characterId,
662
+ change: event.change || 'learns',
663
+ coverage: event.coverage || 'partial',
664
+ knowledge: event.knowledge || '',
665
+ how: event.how || 'other',
666
+ source_id: sourceId || undefined,
667
+ scene: event.scene || '',
668
+ evidence: event.evidence || '',
669
+ payload: event,
670
+ },
671
+ );
672
+ }
673
+ }
674
+
675
+ await upsertStageDoc(sdb, '40-rhythm', 'chapter_map', doc);
676
+ return { stage: '40-rhythm', ok: true, chapters: chapters.length };
677
+ }
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // outlines
681
+ // ---------------------------------------------------------------------------
682
+
683
+ export async function importOutlines(sdb, projectRoot) {
684
+ const dir = stageDir(projectRoot, '40-rhythm', 'outlines');
685
+ if (!fs.existsSync(dir)) throw new StoryDbError('STORYDB_IMPORT', '缺少 40-rhythm/outlines/');
686
+ const names = fs.readdirSync(dir).filter((name) => name.endsWith('.json'));
687
+ if (!names.length) throw new StoryDbError('STORYDB_IMPORT', '40-rhythm/outlines/ 为空');
688
+
689
+ for (const name of names) {
690
+ const outline = readJson(path.join(dir, name), null);
691
+ if (!outline) continue;
692
+ const chapterId = outline.chapter_id || path.basename(name, '.json');
693
+ const chapter = await sdb.one(
694
+ `SELECT * FROM chapter WHERE story = ${STORY} AND chapter_id = $chapter_id LIMIT 1;`,
695
+ { chapter_id: chapterId },
696
+ );
697
+ if (!chapter) {
698
+ throw new StoryDbError('STORYDB_IMPORT', '细纲引用了不存在的章节:' + chapterId);
699
+ }
700
+ await sdb.query(
701
+ `UPSERT outline SET
702
+ story = ${STORY}, chapter = type::record('chapter', $chapter_id),
703
+ chapter_id = $chapter_id, payload = $payload
704
+ WHERE story = ${STORY} AND chapter_id = $chapter_id;`,
705
+ { chapter_id: chapterId, payload: withoutMeta(outline) },
706
+ );
707
+ }
708
+ return { stage: 'outline', ok: true, outlines: names.length };
709
+ }
710
+
711
+ // ---------------------------------------------------------------------------
712
+ // writing
713
+ // ---------------------------------------------------------------------------
714
+
715
+ function chapterFileName(displayNo) {
716
+ return '第' + String(displayNo).padStart(3, '0') + '章.md';
717
+ }
718
+
719
+ export async function importWriting(sdb, projectRoot) {
720
+ const dir = stageDir(projectRoot, '50-writing');
721
+ const ledger = readJson(path.join(dir, 'ledger.json'), { chapters: [] });
722
+ const rows = asArray(ledger.chapters);
723
+ for (const row of rows) {
724
+ const chapterId = row.chapter_id || ('CH' + String(row.ch).padStart(3, '0'));
725
+ const chapter = await sdb.one(
726
+ `SELECT * FROM chapter WHERE story = ${STORY} AND chapter_id = $chapter_id LIMIT 1;`,
727
+ { chapter_id: chapterId },
728
+ );
729
+ if (!chapter) continue;
730
+ await sdb.query(
731
+ `UPSERT ledger_entry SET
732
+ story = ${STORY}, chapter = type::record('chapter', $chapter_id),
733
+ chapter_id = $chapter_id, payload = $payload
734
+ WHERE story = ${STORY} AND chapter_id = $chapter_id;`,
735
+ { chapter_id: chapterId, payload: withoutMeta(row) },
736
+ );
737
+ }
738
+
739
+ const chaptersDir = path.join(dir, 'chapters');
740
+ const names = fs.existsSync(chaptersDir)
741
+ ? fs.readdirSync(chaptersDir).filter((name) => name.endsWith('.md'))
742
+ : [];
743
+ for (const name of names) {
744
+ const match = /第(\d+)章\.md$/.exec(name);
745
+ if (!match) continue;
746
+ const displayNo = Number(match[1]);
747
+ const chapter = await sdb.one(
748
+ `SELECT * FROM chapter WHERE story = ${STORY} AND display_no = $display_no LIMIT 1;`,
749
+ { display_no: displayNo },
750
+ );
751
+ if (!chapter) continue;
752
+ const content = fs.readFileSync(path.join(chaptersDir, name), 'utf8');
753
+ const wordCount = content.replace(/\s/g, '').length;
754
+ await sdb.query(
755
+ `UPSERT chapter_text SET
756
+ story = ${STORY}, chapter = type::record('chapter', $chapter_id),
757
+ chapter_id = $chapter_id, display_no = $display_no,
758
+ content = $content, word_count = $word_count
759
+ WHERE story = ${STORY} AND chapter_id = $chapter_id;`,
760
+ {
761
+ chapter_id: chapter.chapter_id,
762
+ display_no: displayNo,
763
+ content,
764
+ word_count: wordCount,
765
+ },
766
+ );
767
+ await sdb.query(
768
+ `UPDATE chapter_metric SET word_count = $word_count WHERE story = ${STORY} AND chapter_no = $display_no;`,
769
+ { word_count: wordCount, display_no: displayNo },
770
+ );
771
+ }
772
+
773
+ return { stage: '50-writing', ok: true, chapters: names.length };
774
+ }
775
+
776
+ // ---------------------------------------------------------------------------
777
+ // dispatch
778
+ // ---------------------------------------------------------------------------
779
+
780
+ export async function importStage(sdb, stageId, projectRoot, opts = {}) {
781
+ try {
782
+ switch (stageId) {
783
+ case '10-scale': return await importScale(sdb, projectRoot);
784
+ case '20-macro': return await importMacro(sdb, projectRoot);
785
+ case '30-cast': return await importCast(sdb, projectRoot);
786
+ case '40-rhythm': return await importRhythm(sdb, projectRoot);
787
+ case 'outline': return await importOutlines(sdb, projectRoot);
788
+ case '50-writing': return await importWriting(sdb, projectRoot);
789
+ default: return { stage: stageId, ok: true, skipped: true };
790
+ }
791
+ } catch (err) {
792
+ if (err instanceof StoryDbError) throw err;
793
+ throw new StoryDbError('STORYDB_IMPORT', '写入故事数据库失败:' + dbErrorMessage(err), {
794
+ stage: stageId,
795
+ cause: dbErrorMessage(err),
796
+ });
797
+ }
798
+ }