@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,843 @@
1
+ /**
2
+ * v2/scale.js —— 10-scale:只算数量,不写任何内容。
3
+ *
4
+ * 数量是全书唯一事实源:
5
+ * - 章/卷/幕结构
6
+ * - 人物/势力/地点/道具/冲突/伏笔/高潮/爽点/结局/信息差配额
7
+ * - 节奏预算
8
+ *
9
+ * 本模块负责三件事:
10
+ * 1. 生成 LLM 选值的参考骨架(推荐值 + 公式区间 + 题材 profile)
11
+ * 2. 补齐公式/推导结果(value / range_low / range_high / derivations)
12
+ * 3. 校验所有数量、求和、题材倍率区间和内容字段
13
+ *
14
+ * 最终 scale.json 由 LLM 选择;buildScale() 只是推荐参考,不是最终事实源。
15
+ */
16
+ import {
17
+ V2_STAGE_BY_ID,
18
+ asArray,
19
+ contentHash,
20
+ getByPath,
21
+ setByPath,
22
+ v2Error,
23
+ } from './core.js';
24
+ import {
25
+ actCountForChapters,
26
+ applyMultiplier,
27
+ distributionWeights,
28
+ formulaOverrides,
29
+ getProfile,
30
+ multiplierForCategory,
31
+ multiplierForFormula,
32
+ preferredVolumeCount,
33
+ structureVolumeRange,
34
+ } from './genre.js';
35
+
36
+ /**
37
+ * 旧公式字段路径 → 2.0 scale 路径。
38
+ * formulas-skeleton.json 里的 applies_to_field 是 1.x 路径,这里做唯一映射。
39
+ */
40
+ export const FORMULA_FIELD_TO_SCALE = {
41
+ 'quota.volume_count': 'structure.volume_count',
42
+ 'quota.branch_count': 'quotas.branches',
43
+ 'quota.character_counts.volume_supporting': 'quotas.characters.volume_supporting',
44
+ 'quota.character_counts.volume_antagonist': 'quotas.characters.volume_antagonist',
45
+ 'quota.conflict_counts.phase': 'quotas.conflicts.phase',
46
+ 'quota.conflict_counts.branch': 'quotas.conflicts.branch',
47
+ 'quota.faction_count': 'quotas.factions',
48
+ 'quota.faction_relation_count': 'quotas.faction_relations',
49
+ 'quota.location_count': 'quotas.locations',
50
+ 'quota.foreshadow_counts.small': 'quotas.foreshadows.small',
51
+ 'quota.foreshadow_counts.medium': 'quotas.foreshadows.medium',
52
+ 'quota.foreshadow_counts.large': 'quotas.foreshadows.large',
53
+ 'quota.climax_counts.small': 'quotas.climaxes.small',
54
+ 'quota.climax_counts.medium': 'quotas.climaxes.medium',
55
+ 'quota.climax_counts.large': 'quotas.climaxes.large',
56
+ 'quota.pleasure_counts.small': 'quotas.pleasures.small',
57
+ 'quota.pleasure_counts.medium': 'quotas.pleasures.medium',
58
+ 'quota.pleasure_counts.large': 'quotas.pleasures.large',
59
+ 'quota.item_counts.core': 'quotas.items.core',
60
+ 'quota.item_counts.branch': 'quotas.items.branch',
61
+ 'quota.item_counts.symbolic': 'quotas.items.symbolic',
62
+ 'quota.world_rule_counts.core': 'quotas.world_rules.core',
63
+ 'quota.world_rule_counts.information': 'quotas.world_rules.information',
64
+ 'quota.world_rule_counts.social': 'quotas.world_rules.social',
65
+ 'quota.ending_count': 'quotas.endings',
66
+ 'quota.main_line_count': 'quotas.main_lines',
67
+ 'quota.character_counts.protagonist': 'quotas.characters.protagonist',
68
+ 'quota.character_counts.core_supporting': 'quotas.characters.core_supporting',
69
+ 'quota.character_counts.core_antagonist': 'quotas.characters.core_antagonist',
70
+ 'quota.conflict_counts.core': 'quotas.conflicts.core',
71
+ };
72
+
73
+ const CONTENT_KEYS = new Set([
74
+ 'name', 'title', 'description', 'plot', 'story', 'outline', 'content',
75
+ 'protagonist_name', 'antagonist_name', 'character_name', 'faction_name',
76
+ 'location_name', 'item_name', 'chapter_title', 'summary',
77
+ ]);
78
+
79
+ function evalExpr(expr, ctx) {
80
+ if (typeof expr !== 'string' || !expr) throw new Error('表达式为空');
81
+ if (!/^[0-9a-zA-Z_.\[\]()+\-*/%,\s=<>!&|?:]+$/.test(expr)) {
82
+ throw new Error('表达式含非法字符:' + expr);
83
+ }
84
+ const keys = Object.keys(ctx);
85
+ const fn = new Function(...keys, '"use strict"; return (' + expr + ');');
86
+ return fn(...keys.map((k) => ctx[k]));
87
+ }
88
+
89
+ export function formulaContext(scale) {
90
+ const q = (scale && scale.quotas) || {};
91
+ const char = q.characters || {};
92
+ return {
93
+ T: scale && scale.capacity ? Number(scale.capacity.target_chapters) : undefined,
94
+ V: scale && scale.structure ? Number(scale.structure.volume_count) : undefined,
95
+ P: Number(char.protagonist || 0),
96
+ CS: Number(char.core_supporting || 0),
97
+ CA: Number(char.core_antagonist || 0),
98
+ BC: Number(q.branches || 0),
99
+ CC: Number((q.conflicts && q.conflicts.core) || 0),
100
+ charTotal: ['protagonist', 'core_supporting', 'volume_supporting', 'core_antagonist', 'volume_antagonist']
101
+ .reduce((sum, key) => sum + Number((char && char[key]) || 0), 0),
102
+ quota: { faction_count: Number(q.factions || 0) },
103
+ Math,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * 计算所有公式的合法区间与当前值。
109
+ * 返回 { formulas, errors }:errors 只包含公式表达式自身无法求值的情况。
110
+ */
111
+ export function computeFormulas(scale, formulasDoc, opts = {}) {
112
+ const spec = {
113
+ ...((formulasDoc && formulasDoc.formulas) || {}),
114
+ ...((formulasDoc && formulasDoc.custom_formulas) || {}),
115
+ };
116
+ const profile = opts.profile || {};
117
+ const ctx = formulaContext(scale);
118
+ const out = {};
119
+ const errors = [];
120
+ const overrides = formulaOverrides(profile);
121
+ for (const key of Object.keys(spec)) {
122
+ const f = spec[key] || {};
123
+ const field = f.scale_field || FORMULA_FIELD_TO_SCALE[f.applies_to_field] || null;
124
+ let baseLow = null;
125
+ let baseHigh = null;
126
+ try {
127
+ if (key === 'structure.volume_count' && profile.structure && Array.isArray(profile.structure.volume_chapters)) {
128
+ const r = structureVolumeRange(ctx.T, profile);
129
+ baseLow = r.low;
130
+ baseHigh = r.high;
131
+ } else {
132
+ baseLow = evalExpr(f.expr_low, ctx);
133
+ baseHigh = evalExpr(f.expr_high, ctx);
134
+ }
135
+ const override = overrides[key];
136
+ if (Array.isArray(override) && override.length >= 2) {
137
+ baseLow = Number(override[0]);
138
+ baseHigh = Number(override[1]);
139
+ } else if (typeof override === 'string' && override.includes('~')) {
140
+ const [a, b] = override.split('~').map((x) => x.trim());
141
+ baseLow = evalExpr(a, ctx);
142
+ baseHigh = evalExpr(b, ctx);
143
+ }
144
+ } catch (err) {
145
+ errors.push(v2Error('SCALE_FORMULA_EVAL', {
146
+ path: 'formulas.' + key,
147
+ message: '公式无法求值:' + err.message,
148
+ contract: '10-scale/README.md#formulas',
149
+ }));
150
+ }
151
+ const multiplier = f.multiplier_category === null
152
+ ? 1
153
+ : (f.multiplier_category
154
+ ? multiplierForCategory(f.multiplier_category, profile)
155
+ : multiplierForFormula(key, profile));
156
+ const adjusted = applyMultiplier(baseLow, baseHigh, multiplier, {
157
+ allowZero: Number(baseLow) === 0,
158
+ });
159
+ const value = field ? getByPath(scale, field) : undefined;
160
+ out[key] = {
161
+ field,
162
+ formula: f.formula || '',
163
+ note: f.note || '',
164
+ value,
165
+ base_range_low: typeof baseLow === 'number' && Number.isFinite(baseLow) ? baseLow : null,
166
+ base_range_high: typeof baseHigh === 'number' && Number.isFinite(baseHigh) ? baseHigh : null,
167
+ genre_multiplier: multiplier,
168
+ range_low: typeof adjusted.low === 'number' && Number.isFinite(adjusted.low) ? adjusted.low : null,
169
+ range_high: typeof adjusted.high === 'number' && Number.isFinite(adjusted.high) ? adjusted.high : null,
170
+ };
171
+ }
172
+ return { formulas: out, errors };
173
+ }
174
+
175
+ /** 把公式与约束写回 scale(幂等)。 */
176
+ export function finalizeScale(scale, formulasDoc, opts = {}) {
177
+ if (!scale || typeof scale !== 'object') return scale;
178
+ const profile = opts.profile || {};
179
+ const profileKey = opts.genreProfileKey || scale.genre_profile || 'default';
180
+ scale.formula_version = opts.formulaVersion || scale.formula_version || '2.1';
181
+ scale.genre_profile = profileKey;
182
+ if (!scale.capacity || typeof scale.capacity !== 'object') scale.capacity = {};
183
+ scale.capacity.base_words_per_chapter = Number(opts.baseWordsPerChapter || scale.capacity.base_words_per_chapter || 2000);
184
+ const avg = Number(scale.capacity.avg_words_per_chapter || 0);
185
+ scale.capacity.chapter_scale = avg > 0
186
+ ? Number((Number(scale.capacity.base_words_per_chapter) / avg).toFixed(4))
187
+ : null;
188
+
189
+ if (!scale.constraints || typeof scale.constraints !== 'object') scale.constraints = {};
190
+ const volumes = asArray(scale.structure && scale.structure.volumes);
191
+ const acts = asArray(scale.structure && scale.structure.acts);
192
+ const target = Number(scale.capacity && scale.capacity.target_chapters || 0);
193
+ scale.constraints.chapter_sum = volumes.reduce((sum, v) => sum + Number(v.chapters || 0), 0);
194
+ scale.constraints.act_chapter_sum = acts.reduce((sum, a) => sum + Number(a.chapters || 0), 0);
195
+ scale.constraints.target_chapters = target;
196
+ scale.constraints.volume_ratio_sum = Number(
197
+ volumes.reduce((sum, v) => sum + Number(v.ratio || 0), 0).toFixed(6)
198
+ );
199
+ scale.constraints.no_content_fields = true;
200
+
201
+ const computed = computeFormulas(scale, formulasDoc, { profile });
202
+ const existing = (scale.formulas && typeof scale.formulas === 'object') ? scale.formulas : {};
203
+ const existingDerivations = (scale.derivations && typeof scale.derivations === 'object') ? scale.derivations : {};
204
+ const llmReasons = (scale.derivation_reasons && typeof scale.derivation_reasons === 'object') ? scale.derivation_reasons : {};
205
+ scale.formulas = {};
206
+ scale.derivations = {};
207
+ for (const key of Object.keys(computed.formulas)) {
208
+ const item = computed.formulas[key];
209
+ const old = existing[key] || {};
210
+ const oldDerivation = existingDerivations[key] || {};
211
+ const reason = [
212
+ llmReasons[key],
213
+ llmReasons[item.field],
214
+ llmReasons[item.scale_field],
215
+ oldDerivation.reason,
216
+ old.deviation_reason,
217
+ ].find((x) => typeof x === 'string' && x.trim()) || '';
218
+ scale.formulas[key] = {
219
+ ...item,
220
+ deviation_reason: reason,
221
+ };
222
+ scale.derivations[key] = {
223
+ formula: item.formula,
224
+ base_range: [item.base_range_low, item.base_range_high],
225
+ genre_multiplier: item.genre_multiplier,
226
+ adjusted_range: [item.range_low, item.range_high],
227
+ chosen: item.value,
228
+ reason,
229
+ };
230
+ }
231
+ return scale;
232
+ }
233
+
234
+ function rangeOf(formulas, key) {
235
+ const f = formulas && formulas[key];
236
+ if (!f) return null;
237
+ const low = Number(f.range_low);
238
+ const high = Number(f.range_high);
239
+ if (!Number.isFinite(low) || !Number.isFinite(high)) return null;
240
+ return low <= high ? { low, high } : { low: high, high: low };
241
+ }
242
+
243
+ function choose(formulas, key, fallback = 0) {
244
+ const r = rangeOf(formulas, key);
245
+ if (!r) return fallback;
246
+ return Math.max(0, Math.round(r.low));
247
+ }
248
+
249
+ function allocate(total, parts) {
250
+ const out = [];
251
+ const n = Math.max(0, Math.floor(Number(total) || 0));
252
+ const p = Math.max(1, Math.floor(Number(parts) || 1));
253
+ const base = Math.floor(n / p);
254
+ let rem = n % p;
255
+ for (let i = 0; i < p; i += 1) {
256
+ let v = base + (rem > 0 ? 1 : 0);
257
+ if (rem > 0) rem -= 1;
258
+ out.push(v);
259
+ }
260
+ return out;
261
+ }
262
+
263
+ function distributeTotal(total, weights) {
264
+ const n = Math.max(0, Math.floor(Number(total) || 0));
265
+ const ws = (weights || []).map((w) => Math.max(0, Number(w) || 0));
266
+ const sum = ws.reduce((a, b) => a + b, 0) || ws.length || 1;
267
+ const raw = ws.map((w) => n * w / sum);
268
+ const out = raw.map((x) => Math.floor(x));
269
+ let rem = n - out.reduce((a, b) => a + b, 0);
270
+ const order = raw.map((x, i) => ({ i, frac: x - Math.floor(x) }))
271
+ .sort((a, b) => b.frac - a.frac || a.i - b.i);
272
+ for (let k = 0; k < order.length && rem > 0; k += 1) {
273
+ out[order[k].i] += 1;
274
+ rem -= 1;
275
+ }
276
+ return out;
277
+ }
278
+
279
+ function distributeWeighted(total, weights) {
280
+ const n = Math.max(0, Math.floor(Number(total) || 0));
281
+ const ws = (weights || []).map((w) => Math.max(0, Number(w) || 0));
282
+ if (!ws.length) return [];
283
+ const sum = ws.reduce((a, b) => a + b, 0) || ws.length;
284
+ const raw = ws.map((w) => n * w / sum);
285
+ const out = raw.map((x) => Math.floor(x));
286
+ let rem = n - out.reduce((a, b) => a + b, 0);
287
+ const order = raw.map((x, i) => ({ i, frac: x - Math.floor(x) }))
288
+ .sort((a, b) => b.frac - a.frac || a.i - b.i);
289
+ for (let k = 0; k < order.length && rem > 0; k += 1) {
290
+ out[order[k].i] += 1;
291
+ rem -= 1;
292
+ }
293
+ return out;
294
+ }
295
+
296
+ function pad3(n) {
297
+ return String(n).padStart(3, '0');
298
+ }
299
+
300
+ /**
301
+ * 生成 LLM 选值的参考数量表:公式区间 + 题材倍率 + 推荐结构。
302
+ * 这不是最终事实源;最终值由 LLM 在区间内选择并经 validateScale 校验。
303
+ */
304
+ export function buildScale(topicData, formulasDoc, opts = {}) {
305
+ const topic = topicData || {};
306
+ const profile = opts.profile || {};
307
+ const profileKey = opts.genreProfileKey || 'default';
308
+ const T = Math.max(1, Math.floor(Number(topic.target_chapters) || 300));
309
+ const words = Math.max(T, Math.floor(Number(topic.target_words) || T * 3000));
310
+ const avg = Math.max(1, Math.round(words / T));
311
+
312
+ const vr = structureVolumeRange(T, profile);
313
+ const lowV = vr.low;
314
+ const highV = vr.high;
315
+ const V = preferredVolumeCount(T, profile, lowV, highV);
316
+
317
+ // 先放一个临时 scale,让公式引擎能算出区间。
318
+ const scale = {
319
+ formula_version: opts.formulaVersion || '2.1',
320
+ genre_profile: profileKey,
321
+ capacity: {
322
+ target_chapters: T,
323
+ target_words: words,
324
+ avg_words_per_chapter: avg,
325
+ base_words_per_chapter: Number(opts.baseWordsPerChapter || 2000),
326
+ },
327
+ structure: { volume_count: V, total_acts: 0, volumes: [], acts: [] },
328
+ quotas: {
329
+ characters: { protagonist: 0, core_supporting: 0, volume_supporting: 0, core_antagonist: 0, volume_antagonist: 0 },
330
+ factions: 0,
331
+ faction_relations: 0,
332
+ locations: 0,
333
+ items: { core: 0, branch: 0, symbolic: 0 },
334
+ conflicts: { core: 0, phase: 0, branch: 0 },
335
+ foreshadows: { small: 0, medium: 0, large: 0 },
336
+ climaxes: { small: 0, medium: 0, large: 0 },
337
+ pleasures: { small: 0, medium: 0, large: 0 },
338
+ endings: 0,
339
+ world_rules: { core: 0, information: 0, social: 0 },
340
+ main_lines: 1,
341
+ branches: 0,
342
+ information_gaps: 0,
343
+ relations: { character: 0, faction: 0, membership: 0 },
344
+ },
345
+ rhythm_budget: {},
346
+ distribution: {},
347
+ formulas: {},
348
+ constraints: {},
349
+ };
350
+
351
+ const q = scale.quotas;
352
+ // 先把不依赖其它配额的量算出来。
353
+ let computed = computeFormulas(scale, formulasDoc, { profile }).formulas;
354
+ q.characters.protagonist = choose(computed, 'fixed.character_elements.protagonist_count', 1);
355
+ q.characters.core_supporting = choose(computed, 'fixed.character_elements.core_supporting_count', 2);
356
+ q.characters.core_antagonist = choose(computed, 'fixed.character_elements.core_antagonist_count', 1);
357
+ q.characters.volume_supporting = choose(computed, 'character_elements.volume_supporting_count', 0);
358
+ q.characters.volume_antagonist = choose(computed, 'character_elements.volume_antagonist_count', 0);
359
+ q.factions = choose(computed, 'faction_elements.faction_count', 2);
360
+ q.locations = choose(computed, 'location_elements.key_location_count', 3);
361
+ q.main_lines = 1;
362
+ q.branches = choose(computed, 'structure.branch_count', 1);
363
+ q.conflicts.core = choose(computed, 'fixed.conflict_elements.core_conflict_count', 1);
364
+ // BC / CC / n 现在才齐;重算一次,再算依赖它们的配额。
365
+ computed = computeFormulas(scale, formulasDoc, { profile }).formulas;
366
+ q.faction_relations = choose(computed, 'faction_elements.faction_relation_count', Math.max(1, q.factions - 1));
367
+ q.items.core = choose(computed, 'item_elements.core_item_count', 2);
368
+ q.items.branch = choose(computed, 'item_elements.branch_item_count', 2);
369
+ q.items.symbolic = choose(computed, 'item_elements.symbolic_item_count', 1);
370
+ q.conflicts.phase = choose(computed, 'conflict_elements.phase_conflict_count', 1);
371
+ q.conflicts.branch = choose(computed, 'conflict_elements.branch_conflict_count', 2);
372
+ q.foreshadows.small = choose(computed, 'foreshadow_elements.small_foreshadow_count', 3);
373
+ q.foreshadows.medium = choose(computed, 'foreshadow_elements.medium_foreshadow_count', 1);
374
+ q.foreshadows.large = choose(computed, 'foreshadow_elements.large_foreshadow_count', 1);
375
+ q.climaxes.small = choose(computed, 'climax_elements.small_climax_count', 5);
376
+ q.climaxes.medium = choose(computed, 'climax_elements.medium_climax_count', 1);
377
+ q.climaxes.large = choose(computed, 'climax_elements.large_climax_count', 1);
378
+ q.pleasures.small = choose(computed, 'pleasure_elements.small_pleasure_count', 8);
379
+ q.pleasures.medium = choose(computed, 'pleasure_elements.medium_pleasure_count', 3);
380
+ q.pleasures.large = choose(computed, 'pleasure_elements.large_pleasure_count', 1);
381
+ q.endings = choose(computed, 'ending_elements.ending_count', V);
382
+ q.world_rules.core = choose(computed, 'world_rule_counts.core', 1);
383
+ q.world_rules.information = choose(computed, 'world_rule_counts.information', 1);
384
+ q.world_rules.social = choose(computed, 'world_rule_counts.social', 1);
385
+ q.information_gaps = Math.max(15, Math.ceil(T / 8));
386
+ const charTotal = q.characters.protagonist + q.characters.core_supporting +
387
+ q.characters.volume_supporting + q.characters.core_antagonist + q.characters.volume_antagonist;
388
+ q.relations.character = Math.max(charTotal + 2, Math.round(charTotal * 1.75));
389
+ const custom = computeFormulas(scale, formulasDoc, { profile }).formulas;
390
+ q.information_gaps = Math.max(1, Number(custom['custom.information_gaps']?.range_low || q.information_gaps));
391
+ q.relations.character = Math.max(1, Number(custom['custom.relations_character']?.range_low || q.relations.character));
392
+ q.relations.faction = q.faction_relations;
393
+ q.relations.membership = Math.max(charTotal, q.factions);
394
+
395
+ // 卷/幕分配
396
+ const volumeChapters = allocate(T, V);
397
+ let chapterStart = 1;
398
+ const actIds = [];
399
+ for (let i = 0; i < V; i += 1) {
400
+ const volumeId = 'V' + pad3(i + 1);
401
+ const chapters = volumeChapters[i];
402
+ const actCount = actCountForChapters(chapters, profile);
403
+ const actChapters = allocate(chapters, actCount);
404
+ const acts = [];
405
+ let actStart = chapterStart;
406
+ for (let a = 0; a < actCount; a += 1) {
407
+ const actId = 'A' + pad3(actIds.length + 1);
408
+ actIds.push(actId);
409
+ acts.push({
410
+ act_id: actId,
411
+ volume_id: volumeId,
412
+ act_index: a + 1,
413
+ chapter_start: actStart,
414
+ chapter_end: actStart + actChapters[a] - 1,
415
+ chapters: actChapters[a],
416
+ });
417
+ actStart += actChapters[a];
418
+ }
419
+ scale.structure.volumes.push({
420
+ volume_id: volumeId,
421
+ chapters,
422
+ ratio: Number((chapters / T).toFixed(6)),
423
+ act_count: actCount,
424
+ chapter_start: chapterStart,
425
+ chapter_end: chapterStart + chapters - 1,
426
+ acts,
427
+ });
428
+ scale.structure.acts.push(...acts);
429
+ chapterStart += chapters;
430
+ }
431
+ scale.structure.total_acts = scale.structure.acts.length;
432
+ // 比例尾差补到最后 1 卷,保证严格等于 1。
433
+ const ratioSum = scale.structure.volumes.reduce((sum, v) => sum + v.ratio, 0);
434
+ if (scale.structure.volumes.length) {
435
+ const last = scale.structure.volumes[scale.structure.volumes.length - 1];
436
+ last.ratio = Number((last.ratio + (1 - ratioSum)).toFixed(6));
437
+ }
438
+
439
+ // 分布
440
+ const volumeIds = scale.structure.volumes.map((v) => v.volume_id);
441
+ const volumeSupporting = distributeWeighted(q.characters.volume_supporting, distributionWeights(profile, 'new_entities', V));
442
+ const volumeAntagonist = distributeWeighted(q.characters.volume_antagonist, distributionWeights(profile, 'new_entities', V));
443
+ const phaseConflicts = distributeWeighted(q.conflicts.phase, distributionWeights(profile, 'conflicts', V));
444
+ const fSmall = distributeWeighted(q.foreshadows.small, distributionWeights(profile, 'foreshadows', V));
445
+ const fMedium = distributeWeighted(q.foreshadows.medium, distributionWeights(profile, 'foreshadows', V));
446
+ const fLarge = distributeWeighted(q.foreshadows.large, distributionWeights(profile, 'foreshadows', V));
447
+ const cSmall = distributeWeighted(q.climaxes.small, distributionWeights(profile, 'climaxes', V));
448
+ const cMedium = distributeWeighted(q.climaxes.medium, distributionWeights(profile, 'climaxes', V));
449
+ const cLarge = distributeWeighted(q.climaxes.large, distributionWeights(profile, 'climaxes', V));
450
+ const pSmall = distributeWeighted(q.pleasures.small, distributionWeights(profile, 'payoffs', V));
451
+ const pMedium = distributeWeighted(q.pleasures.medium, distributionWeights(profile, 'payoffs', V));
452
+ const pLarge = distributeWeighted(q.pleasures.large, distributionWeights(profile, 'payoffs', V));
453
+ for (let i = 0; i < V; i += 1) {
454
+ const id = volumeIds[i];
455
+ scale.distribution[id] = {
456
+ chapters: volumeChapters[i],
457
+ volume_supporting: volumeSupporting[i],
458
+ volume_antagonist: volumeAntagonist[i],
459
+ phase_conflicts: phaseConflicts[i],
460
+ foreshadows: { small: fSmall[i], medium: fMedium[i], large: fLarge[i] },
461
+ climaxes: { small: cSmall[i], medium: cMedium[i], large: cLarge[i] },
462
+ pleasures: { small: pSmall[i], medium: pMedium[i], large: pLarge[i] },
463
+ };
464
+ }
465
+
466
+ const reversalMultiplier = multiplierForCategory('reversals', profile);
467
+ const reversalMinBase = Math.max(1, Math.floor(T / 12));
468
+ const reversalTargetBase = Math.max(reversalMinBase, Math.floor(T / 7));
469
+ const reversalMin = Math.max(1, Math.round(reversalMinBase * reversalMultiplier));
470
+ const reversalTarget = Math.max(reversalMin, Math.round(reversalTargetBase * reversalMultiplier));
471
+ scale.rhythm_budget = {
472
+ hooks: { min: T, target: Math.ceil(T * 1.3) },
473
+ reveals: { min: Math.floor(T / 10), target: Math.floor(T / 6) },
474
+ reversals: { min: reversalMin, target: reversalTarget },
475
+ emotional_peaks: { min: Math.floor(T / 12), target: Math.floor(T / 8) },
476
+ climax_chapters: q.climaxes.large,
477
+ payoff_chapters: q.pleasures.large + q.pleasures.medium,
478
+ golden_three: { hook_strength: '强', must_establish: ['世界观入门', '主角立住', '核心冲突抛出'] },
479
+ };
480
+ scale.distribution_weights = {
481
+ new_entities: distributionWeights(profile, 'new_entities', V),
482
+ relationships: distributionWeights(profile, 'relationships', V),
483
+ foreshadows: distributionWeights(profile, 'foreshadows', V),
484
+ payoffs: distributionWeights(profile, 'payoffs', V),
485
+ climaxes: distributionWeights(profile, 'climaxes', V),
486
+ conflicts: distributionWeights(profile, 'conflicts', V),
487
+ information: distributionWeights(profile, 'information', V),
488
+ };
489
+
490
+ finalizeScale(scale, formulasDoc, {
491
+ profile,
492
+ genreProfileKey: profileKey,
493
+ formulaVersion: opts.formulaVersion,
494
+ baseWordsPerChapter: opts.baseWordsPerChapter,
495
+ });
496
+ // 修正 total_acts 等派生字段在 finalize 之后仍保持
497
+ return scale;
498
+ }
499
+
500
+ /** 人类可读数量表(stage 内自包含文件)。 */
501
+ export function renderScaleMd(scale) {
502
+ const lines = [];
503
+ const c = scale.capacity || {};
504
+ lines.push('# 数量表(10-scale)');
505
+ lines.push('');
506
+ lines.push('> 本文件只记录数量与预算,不含人物、势力、地点和剧情。');
507
+ lines.push('');
508
+ lines.push('## 规模');
509
+ lines.push('');
510
+ lines.push('- 总章数:' + c.target_chapters);
511
+ lines.push('- 总字数:' + c.target_words);
512
+ lines.push('- 平均每章:' + c.avg_words_per_chapter);
513
+ lines.push('- 卷数:' + (scale.structure && scale.structure.volume_count));
514
+ lines.push('- 幕数:' + (scale.structure && scale.structure.total_acts));
515
+ lines.push('- 题材 profile:' + (scale.genre_profile || 'default'));
516
+ lines.push('- 公式版本:' + (scale.formula_version || '2.1'));
517
+ lines.push('- 章节字数基准:' + (c.base_words_per_chapter || 2000) + ' 字;chapter_scale=' + (c.chapter_scale ?? ''));
518
+ lines.push('');
519
+ lines.push('## 卷');
520
+ lines.push('');
521
+ lines.push('| 卷 | 章节区间 | 章数 | 幕数 | 占比 |');
522
+ lines.push('|---|---|---|---|---|');
523
+ for (const v of asArray(scale.structure && scale.structure.volumes)) {
524
+ lines.push('| ' + v.volume_id + ' | ' + v.chapter_start + '-' + v.chapter_end + ' | ' +
525
+ v.chapters + ' | ' + v.act_count + ' | ' + v.ratio + ' |');
526
+ }
527
+ lines.push('');
528
+ lines.push('## 配额');
529
+ lines.push('');
530
+ const q = scale.quotas || {};
531
+ const rows = [
532
+ ['人物', '主角', q.characters && q.characters.protagonist],
533
+ ['人物', '贯穿级核心配角', q.characters && q.characters.core_supporting],
534
+ ['人物', '卷级重要配角', q.characters && q.characters.volume_supporting],
535
+ ['人物', '贯穿级核心反派', q.characters && q.characters.core_antagonist],
536
+ ['人物', '卷级反派', q.characters && q.characters.volume_antagonist],
537
+ ['势力', '全部', q.factions],
538
+ ['势力关系', '全部', q.faction_relations],
539
+ ['地点', '全部', q.locations],
540
+ ['道具', '核心/支线/象征', [q.items && q.items.core, q.items && q.items.branch, q.items && q.items.symbolic].join(' / ')],
541
+ ['冲突', '核心/阶段/交织', [q.conflicts && q.conflicts.core, q.conflicts && q.conflicts.phase, q.conflicts && q.conflicts.branch].join(' / ')],
542
+ ['伏笔', '小/中/大', [q.foreshadows && q.foreshadows.small, q.foreshadows && q.foreshadows.medium, q.foreshadows && q.foreshadows.large].join(' / ')],
543
+ ['高潮', '小/中/大', [q.climaxes && q.climaxes.small, q.climaxes && q.climaxes.medium, q.climaxes && q.climaxes.large].join(' / ')],
544
+ ['爽点', '小/中/大', [q.pleasures && q.pleasures.small, q.pleasures && q.pleasures.medium, q.pleasures && q.pleasures.large].join(' / ')],
545
+ ['结局', '全部', q.endings],
546
+ ['主线/支线', '全部', [q.main_lines, q.branches].join(' / ')],
547
+ ['信息差', '全部', q.information_gaps],
548
+ ];
549
+ lines.push('| 类型 | 细分 | 数量 |');
550
+ lines.push('|---|---|---|');
551
+ for (const [a, b, n] of rows) lines.push('| ' + a + ' | ' + b + ' | ' + n + ' |');
552
+ lines.push('');
553
+ lines.push('## 节奏预算');
554
+ lines.push('');
555
+ for (const [key, value] of Object.entries(scale.rhythm_budget || {})) {
556
+ lines.push('- ' + key + ':' + JSON.stringify(value));
557
+ }
558
+ lines.push('');
559
+ lines.push('## 公式');
560
+ lines.push('');
561
+ lines.push('| 公式 | 值 | 合法区间 |');
562
+ lines.push('|---|---|---|');
563
+ for (const [key, f] of Object.entries(scale.formulas || {})) {
564
+ lines.push('| ' + key + ' | ' + f.value + ' | ' + f.range_low + ' ~ ' + f.range_high + ' |');
565
+ }
566
+ return lines.join('\n') + '\n';
567
+ }
568
+
569
+ /**
570
+ * 校验数量表。
571
+ * @returns {{ok:boolean, errors:Array, warnings:Array, data:Object}}
572
+ */
573
+ export function validateScale(scale, formulasDoc, opts = {}) {
574
+ const errors = [];
575
+ const warnings = [];
576
+ if (!scale || typeof scale !== 'object') {
577
+ return {
578
+ ok: false,
579
+ errors: [v2Error('SCALE_MISSING', { path: '10-scale/scale.json', message: '数量表不存在或不是 JSON 对象', contract: '10-scale/README.md' })],
580
+ warnings,
581
+ data: null,
582
+ };
583
+ }
584
+
585
+ const profilesDoc = opts.profilesDoc || null;
586
+ const profileKey = scale.genre_profile || opts.genreProfileKey || 'default';
587
+ const profile = opts.profile || (profilesDoc ? getProfile(profilesDoc, profileKey) : {});
588
+ if (profilesDoc && profilesDoc.profiles && !profilesDoc.profiles[profileKey]) {
589
+ errors.push(v2Error('SCALE_GENRE_PROFILE', {
590
+ path: 'genre_profile',
591
+ message: '题材 profile 不存在:' + profileKey,
592
+ expected: Object.keys(profilesDoc.profiles).join(' / '),
593
+ actual: profileKey,
594
+ contract: '10-scale/README.md#genre-profile',
595
+ }));
596
+ }
597
+ if (!scale.formula_version) {
598
+ warnings.push(v2Error('SCALE_FORMULA_VERSION', {
599
+ path: 'formula_version', severity: 'warning',
600
+ message: '缺少 formula_version,将按默认 2.1 处理',
601
+ contract: '10-scale/README.md#formula-version',
602
+ }));
603
+ }
604
+ const c = scale.capacity || {};
605
+ const T = Number(c.target_chapters);
606
+ const words = Number(c.target_words);
607
+ if (!Number.isInteger(T) || T <= 0) {
608
+ errors.push(v2Error('SCALE_CAPACITY', { path: 'capacity.target_chapters', message: '必须是正整数', expected: '>0', actual: c.target_chapters, contract: '10-scale/README.md#capacity' }));
609
+ }
610
+ if (!Number.isInteger(words) || words <= 0) {
611
+ errors.push(v2Error('SCALE_CAPACITY', { path: 'capacity.target_words', message: '必须是正整数', expected: '>0', actual: c.target_words, contract: '10-scale/README.md#capacity' }));
612
+ }
613
+ if (Number.isInteger(T) && Number.isInteger(words)) {
614
+ const avg = Math.round(words / T);
615
+ if (Number(c.avg_words_per_chapter) !== avg) {
616
+ errors.push(v2Error('SCALE_CAPACITY_MISMATCH', {
617
+ path: 'capacity.avg_words_per_chapter',
618
+ message: '平均每章字数必须等于 round(target_words / target_chapters)',
619
+ expected: avg, actual: c.avg_words_per_chapter,
620
+ contract: '10-scale/README.md#capacity',
621
+ }));
622
+ }
623
+ }
624
+
625
+ if (c.base_words_per_chapter !== undefined && (!Number.isFinite(Number(c.base_words_per_chapter)) || Number(c.base_words_per_chapter) <= 0)) {
626
+ errors.push(v2Error('SCALE_BASE_WORDS', { path: 'capacity.base_words_per_chapter', message: 'base_words_per_chapter 必须是正数', actual: c.base_words_per_chapter, contract: '10-scale/README.md#capacity' }));
627
+ }
628
+ if (Number.isInteger(T) && Number.isInteger(words)) {
629
+ const avg = Math.round(words / T);
630
+ const base = Number(c.base_words_per_chapter || 2000);
631
+ const expectedScale = avg > 0 ? Number((base / avg).toFixed(4)) : null;
632
+ if (c.chapter_scale !== undefined && Number(c.chapter_scale) !== expectedScale) {
633
+ warnings.push(v2Error('SCALE_CHAPTER_SCALE', { path: 'capacity.chapter_scale', severity: 'warning', message: 'chapter_scale 与 base_words_per_chapter / avg_words_per_chapter 不一致', expected: expectedScale, actual: c.chapter_scale, contract: '10-scale/README.md#capacity' }));
634
+ }
635
+ }
636
+
637
+ const s = scale.structure || {};
638
+ const vCount = Number(s.volume_count);
639
+ const volumes = asArray(s.volumes);
640
+ const acts = asArray(s.acts);
641
+ if (!Number.isInteger(vCount) || vCount <= 0) {
642
+ errors.push(v2Error('SCALE_STRUCTURE', { path: 'structure.volume_count', message: '卷数必须是正整数', contract: '10-scale/README.md#structure' }));
643
+ }
644
+ if (vCount !== volumes.length) {
645
+ errors.push(v2Error('SCALE_VOLUME_COUNT', {
646
+ path: 'structure.volume_count',
647
+ message: 'volume_count 必须等于 volumes 数组长度',
648
+ expected: volumes.length, actual: vCount,
649
+ contract: '10-scale/README.md#structure',
650
+ }));
651
+ }
652
+ let chapterSum = 0;
653
+ let ratioSum = 0;
654
+ let expectedStart = 1;
655
+ const volumeIds = new Set();
656
+ for (let i = 0; i < volumes.length; i += 1) {
657
+ const v = volumes[i] || {};
658
+ const id = v.volume_id || v.id;
659
+ if (!id) errors.push(v2Error('SCALE_VOLUME_ID', { path: 'structure.volumes[' + i + '].volume_id', message: '缺少卷 ID', contract: '10-scale/README.md#structure' }));
660
+ else if (volumeIds.has(id)) errors.push(v2Error('SCALE_VOLUME_ID_DUP', { path: 'structure.volumes[' + i + '].volume_id', message: '卷 ID 重复:' + id, contract: '10-scale/README.md#structure' }));
661
+ else volumeIds.add(id);
662
+ const chapters = Number(v.chapters);
663
+ if (!Number.isInteger(chapters) || chapters <= 0) {
664
+ errors.push(v2Error('SCALE_VOLUME_CHAPTERS', { path: 'structure.volumes[' + i + '].chapters', message: '每卷章数必须是正整数', contract: '10-scale/README.md#structure' }));
665
+ } else {
666
+ chapterSum += chapters;
667
+ }
668
+ if (Number(v.chapter_start) !== expectedStart) {
669
+ errors.push(v2Error('SCALE_VOLUME_RANGE', {
670
+ path: 'structure.volumes[' + i + '].chapter_start',
671
+ message: '卷章节区间必须连续且从 1 开始',
672
+ expected: expectedStart, actual: v.chapter_start,
673
+ contract: '10-scale/README.md#structure',
674
+ }));
675
+ }
676
+ expectedStart = Number(v.chapter_start || expectedStart) + Number(v.chapters || 0);
677
+ if (Number(v.chapter_end) !== expectedStart - 1) {
678
+ errors.push(v2Error('SCALE_VOLUME_RANGE', {
679
+ path: 'structure.volumes[' + i + '].chapter_end',
680
+ message: 'chapter_end 必须等于 chapter_start + chapters - 1',
681
+ expected: expectedStart - 1, actual: v.chapter_end,
682
+ contract: '10-scale/README.md#structure',
683
+ }));
684
+ }
685
+ ratioSum += Number(v.ratio || 0);
686
+ const localActs = asArray(v.acts);
687
+ const actChapters = localActs.reduce((sum, a) => sum + Number(a.chapters || 0), 0);
688
+ if (localActs.length && actChapters !== chapters) {
689
+ errors.push(v2Error('SCALE_ACT_CHAPTER_SUM', {
690
+ path: 'structure.volumes[' + i + '].acts',
691
+ message: '本卷各幕章数之和必须等于本卷章数',
692
+ expected: chapters, actual: actChapters,
693
+ contract: '10-scale/README.md#structure',
694
+ }));
695
+ }
696
+ if (!localActs.length) {
697
+ errors.push(v2Error('SCALE_ACTS_MISSING', { path: 'structure.volumes[' + i + '].acts', message: '每卷至少要有 1 幕', contract: '10-scale/README.md#structure' }));
698
+ }
699
+ }
700
+ if (Number.isInteger(T) && chapterSum !== T) {
701
+ errors.push(v2Error('SCALE_CHAPTER_SUM', {
702
+ path: 'structure.volumes',
703
+ message: '各卷章数之和必须等于 capacity.target_chapters',
704
+ expected: T, actual: chapterSum,
705
+ hint: '检查 structure.volumes[*].chapters',
706
+ contract: '10-scale/README.md#structure',
707
+ }));
708
+ }
709
+ if (Math.abs(ratioSum - 1) > 0.00001) {
710
+ errors.push(v2Error('SCALE_RATIO_SUM', {
711
+ path: 'structure.volumes',
712
+ message: '各卷 ratio 之和必须等于 1',
713
+ expected: 1, actual: Number(ratioSum.toFixed(6)),
714
+ contract: '10-scale/README.md#structure',
715
+ }));
716
+ }
717
+ if (acts.length !== volumes.reduce((sum, v) => sum + asArray(v.acts).length, 0)) {
718
+ errors.push(v2Error('SCALE_ACTS_FLAT_MISMATCH', { path: 'structure.acts', message: 'structure.acts 必须包含所有卷的全部幕', contract: '10-scale/README.md#structure' }));
719
+ }
720
+ if (Number(s.total_acts) !== acts.length) {
721
+ errors.push(v2Error('SCALE_TOTAL_ACTS', {
722
+ path: 'structure.total_acts',
723
+ message: 'total_acts 必须等于 structure.acts 数量',
724
+ expected: acts.length, actual: s.total_acts,
725
+ contract: '10-scale/README.md#structure',
726
+ }));
727
+ }
728
+ const actIds = new Set();
729
+ for (let i = 0; i < acts.length; i += 1) {
730
+ const a = acts[i] || {};
731
+ if (!a.act_id) errors.push(v2Error('SCALE_ACT_ID', { path: 'structure.acts[' + i + '].act_id', message: '缺少 act_id', contract: '10-scale/README.md#structure' }));
732
+ else if (actIds.has(a.act_id)) errors.push(v2Error('SCALE_ACT_ID_DUP', { path: 'structure.acts[' + i + '].act_id', message: 'act_id 重复:' + a.act_id, contract: '10-scale/README.md#structure' }));
733
+ else actIds.add(a.act_id);
734
+ if (a.volume_id && !volumeIds.has(a.volume_id)) {
735
+ errors.push(v2Error('SCALE_ACT_VOLUME', { path: 'structure.acts[' + i + '].volume_id', message: '幕引用了不存在的卷:' + a.volume_id, contract: '10-scale/README.md#structure' }));
736
+ }
737
+ }
738
+
739
+ // 配额:所有叶子必须是非负整数
740
+ const q = scale.quotas || {};
741
+ const quotaLeaves = [];
742
+ const walk = (obj, prefix) => {
743
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
744
+ for (const [k, v] of Object.entries(obj)) walk(v, prefix ? prefix + '.' + k : k);
745
+ } else {
746
+ quotaLeaves.push([prefix, obj]);
747
+ }
748
+ };
749
+ walk(q, 'quotas');
750
+ for (const [p, v] of quotaLeaves) {
751
+ if (!Number.isInteger(Number(v)) || Number(v) < 0) {
752
+ errors.push(v2Error('SCALE_QUOTA', {
753
+ path: p, message: '配额必须是非负整数', actual: v,
754
+ contract: '10-scale/README.md#quotas',
755
+ }));
756
+ }
757
+ }
758
+
759
+ // 公式
760
+ finalizeScale(scale, formulasDoc, { profile, genreProfileKey: profileKey, profilesDoc });
761
+ for (const [key, f] of Object.entries(scale.formulas || {})) {
762
+ if (!f.field) continue;
763
+ const value = Number(f.value);
764
+ const low = Number(f.range_low);
765
+ const high = Number(f.range_high);
766
+ if (!Number.isFinite(value) || !Number.isFinite(low) || !Number.isFinite(high)) continue;
767
+ if (value < low || value > high) {
768
+ const reason = String(f.deviation_reason || '').trim();
769
+ const item = {
770
+ code: 'SCALE_FORMULA_RANGE',
771
+ path: f.field,
772
+ message: '数量不在公式区间内:' + key,
773
+ expected: low + ' ~ ' + high,
774
+ actual: value,
775
+ hint: reason ? '已填写 deviation_reason:' + reason : '改为区间内的值(区间已按题材倍率调整),或在 derivation_reasons 里用 `' + key + '` 或 `' + (f.field || '') + '` 写偏离原因',
776
+ contract: '10-scale/README.md#formulas',
777
+ };
778
+ if (reason) warnings.push(item);
779
+ else errors.push(item);
780
+ }
781
+ }
782
+
783
+ // 分布必须与卷一致
784
+ const dist = scale.distribution || {};
785
+ for (const v of volumes) {
786
+ const id = v.volume_id || v.id;
787
+ if (!id) continue;
788
+ const d = dist[id];
789
+ if (!d) {
790
+ warnings.push(v2Error('SCALE_DISTRIBUTION_MISSING', {
791
+ path: 'distribution.' + id, message: '缺少本卷的数量分布', severity: 'warning', contract: '10-scale/README.md#distribution',
792
+ }));
793
+ continue;
794
+ }
795
+ if (Number(d.chapters) !== Number(v.chapters)) {
796
+ errors.push(v2Error('SCALE_DISTRIBUTION_CHAPTERS', {
797
+ path: 'distribution.' + id + '.chapters',
798
+ message: '分布里的章数必须等于卷章数', expected: v.chapters, actual: d.chapters,
799
+ contract: '10-scale/README.md#distribution',
800
+ }));
801
+ }
802
+ }
803
+ const distExtra = Object.keys(dist).filter((id) => !volumeIds.has(id));
804
+ if (distExtra.length) {
805
+ warnings.push(v2Error('SCALE_DISTRIBUTION_EXTRA', {
806
+ path: 'distribution', message: '分布里有不存在的卷:' + distExtra.join('、'), severity: 'warning', contract: '10-scale/README.md#distribution',
807
+ }));
808
+ }
809
+
810
+ // 不允许内容字段
811
+ const scanContent = (value, prefix) => {
812
+ if (Array.isArray(value)) {
813
+ value.forEach((item, i) => scanContent(item, prefix + '[' + i + ']'));
814
+ return;
815
+ }
816
+ if (!value || typeof value !== 'object') return;
817
+ for (const [key, val] of Object.entries(value)) {
818
+ if (key === '_meta') continue;
819
+ const p = prefix ? prefix + '.' + key : key;
820
+ if (CONTENT_KEYS.has(key) && typeof val === 'string' && val.trim()) {
821
+ errors.push(v2Error('SCALE_CONTENT_FIELD', {
822
+ path: p,
823
+ message: '数量阶段不允许出现内容字段(人名/标题/剧情等)',
824
+ actual: val.slice(0, 40),
825
+ hint: '把内容留到 20-macro 或 30-cast;本文件只写数字',
826
+ contract: '10-scale/README.md#forbidden',
827
+ }));
828
+ }
829
+ if (key === 'formulas') continue;
830
+ scanContent(val, p);
831
+ }
832
+ };
833
+ scanContent(scale, '');
834
+
835
+ return { ok: errors.length === 0, errors, warnings, data: scale };
836
+ }
837
+
838
+ /** 内容哈希(供 manifest 使用)。 */
839
+ export function scaleHash(scale) {
840
+ return contentHash(scale);
841
+ }
842
+
843
+ export const SCALE_STAGE = V2_STAGE_BY_ID['10-scale'];