@cloud411716/fancy-webnovel 1.0.46 → 2.2.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 (134) hide show
  1. package/client.js +183 -0
  2. package/cordis.patch.yml +0 -14
  3. package/index.js +21 -37
  4. package/lib/chinese-num.js +111 -0
  5. package/lib/deployed-state.js +141 -0
  6. package/lib/error-codes.js +104 -0
  7. package/lib/infra.js +569 -0
  8. package/lib/llm-wiki.js +109 -0
  9. package/lib/safe-rm.js +94 -0
  10. package/lib/scraper-utils.js +165 -0
  11. package/package.json +37 -5
  12. package/plugins/fancy-analyze/command-input.js +65 -0
  13. package/plugins/fancy-analyze/index.js +973 -0
  14. package/plugins/fancy-analyze/schema-analyze.md +379 -0
  15. package/plugins/fancy-analyze/schema-write.md +178 -0
  16. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  17. package/plugins/fancy-analyze/summary-progress.js +431 -0
  18. package/plugins/fancy-analyze/templates.js +881 -0
  19. package/plugins/fancy-bootstrap/index.js +168 -0
  20. package/plugins/fancy-bootstrap/scripts/init.js +143 -0
  21. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  22. package/plugins/fancy-bootstrap/templates.js +54 -0
  23. package/plugins/fancy-import/import-core.js +1118 -0
  24. package/plugins/fancy-import/import-driver.js +581 -0
  25. package/plugins/fancy-import/index.js +730 -0
  26. package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
  27. package/plugins/fancy-import/templates.js +49 -0
  28. package/plugins/fancy-scan/index.js +319 -0
  29. package/plugins/fancy-scan/scripts/run-scan.js +520 -0
  30. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
  31. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
  32. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
  33. package/plugins/fancy-scan/templates.js +295 -0
  34. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  35. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  36. package/plugins/fancy-story-builder/flesh-output.js +155 -0
  37. package/plugins/fancy-story-builder/index.js +3152 -0
  38. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
  39. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
  40. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
  41. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
  42. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  43. package/plugins/fancy-story-builder/purpose-template.md +107 -0
  44. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  45. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
  46. package/plugins/fancy-story-builder/references/glossary.md +585 -0
  47. package/plugins/fancy-story-builder/references/index.md +490 -0
  48. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  49. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  50. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  51. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  52. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  53. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  54. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  55. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  56. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  57. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  58. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  59. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  60. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  61. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  62. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  63. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  64. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  65. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  66. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
  67. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  68. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
  69. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  70. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
  71. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  72. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
  73. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  74. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
  75. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  76. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  77. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  78. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  79. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
  80. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  81. package/plugins/fancy-story-builder/schema-setting.md +324 -0
  82. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  83. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  84. package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
  85. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  86. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  87. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
  88. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  89. package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
  90. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  91. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  92. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  93. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
  94. package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
  95. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  96. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
  97. package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
  98. package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
  99. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  100. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  101. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  102. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  103. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  104. 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 +204 -0
  105. 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
  106. 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
  107. 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
  108. 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 +66 -0
  109. 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 +76 -0
  110. 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 +72 -0
  111. 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 +37 -0
  112. 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 +53 -0
  113. 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 +40 -0
  114. 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 +79 -0
  115. 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 +132 -0
  116. 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 +133 -0
  117. 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 +160 -0
  118. 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
  119. 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 +134 -0
  120. package/plugins/fancy-story-builder/templates.js +1248 -0
  121. package/plugins/fancy-story-builder/wiki-references.js +204 -0
  122. package/plugins/fancy-story-builder/writing-driver.js +1061 -0
  123. package/plugins/fancy-topic/index.js +704 -0
  124. package/plugins/fancy-topic/scripts/select-topic.js +274 -0
  125. package/plugins/fancy-topic/templates.js +373 -0
  126. package/commands/bootstrap/index.js +0 -164
  127. package/commands/bootstrap/init-project.js +0 -121
  128. package/commands/scan/index.js +0 -283
  129. package/commands/scan/platforms/fanqie.js +0 -146
  130. package/commands/scan/platforms/jinjiang.js +0 -143
  131. package/commands/scan/platforms/qidian.js +0 -102
  132. package/commands/scan/platforms/qimao.js +0 -127
  133. package/commands/scan/scraper.js +0 -195
  134. package/infra.js +0 -160
@@ -0,0 +1,1371 @@
1
+ /**
2
+ * stage-batches.js v2.0
3
+ * 阶段批次规划器 + 分片合并器 + 校验错误归因器(血肉 / 节奏共用)。
4
+ *
5
+ * 设计要点:
6
+ * - 一次性派活整个阶段(血肉真实规模 340KB、节奏细纲 320-480KB)会超出
7
+ * 单回合输出预算。2026-10 tt5 实测:结局.json 写到一半被 provider 中断
8
+ * (output new_sensitive 1027),且因为全量校验从未跑过,失败对用户完全不可见。
9
+ * 所以改成"插件逐批派活"。
10
+ * - 每一批让模型只写一个**分片**文件(纯生成,不读改写),插件负责把分片
11
+ * 合并成最终产物。合并是幂等的:目标文件始终由全部分片重新合成,
12
+ * 因此"重投某一批"只需要重写那一个分片。
13
+ * - 校验由插件侧调校验脚本完成(脚本判定通过与否),
14
+ * 错误按 path 归因回具体批次,只重投有问题的批次。
15
+ *
16
+ * 三类条目来源:
17
+ * byChapter —— 按章号切(节奏的章节规划 / 章节细纲 / hook_timeline)
18
+ * source —— 按骨架实体切(角色 / 冲突 / 伏笔 / 高潮 / 爽点 …)
19
+ * quotaDriven —— 条数由本阶段框架.json 的 quota 决定(骨架阶段的实体清单)
20
+ *
21
+ * 本模块不依赖 dsh 运行时:只做文件 IO 与纯计算,可直接用测试夹具驱动。
22
+ */
23
+
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { quotaCountsByArray, readQuota, getNested } from './skeleton-quota.js';
27
+
28
+ // ============================================================
29
+ // 常量
30
+ // ============================================================
31
+
32
+ // 单批目标字节上限。单回合输出预算是硬约束,批次必须远小于它。
33
+ export const DEFAULT_MAX_BYTES_PER_BATCH = 40 * 1024;
34
+
35
+ // 单批条目数上限:即使估算失真,也不允许一批产出过多条目。
36
+ export const DEFAULT_MAX_ITEMS_PER_BATCH = 12;
37
+
38
+ // 每个模板字段的估算字节数(正文中文 UTF-8 约 3 字节/字,一个字段约 60-80 字)。
39
+ // 这是估算值,只用于决定"切几批",不影响正确性。
40
+ export const BYTES_PER_FIELD = 200;
41
+
42
+ // 血肉阶段分片目录名(放在血肉目录下,校验器只扫顶层 .json,不会碰到子目录)
43
+ export const SHARD_DIR_NAME = '.flesh-build';
44
+
45
+ // 节奏阶段分片目录名
46
+ export const RHYTHM_SHARD_DIR_NAME = '.rhythm-build';
47
+
48
+ // 骨架阶段的分片目录。
49
+ // 骨架的分片目录跟血肉/节奏同名会互相覆盖 —— 三个阶段的产物在同一个项目目录下。
50
+ export const SKELETON_SHARD_DIR_NAME = '.skeleton-build';
51
+
52
+ // ============================================================
53
+ // 血肉 8 个文件的规格
54
+ // ============================================================
55
+
56
+ /**
57
+ * 每个文件由若干"数组组"(group)组成。
58
+ * 一个 group = 一批条目的来源 + 它要写进哪些顶层数组。
59
+ * key —— 顶层数组名(血肉模板 structure.properties 的键)
60
+ * source —— 骨架 实体.json 里的数组名
61
+ * category —— 该组只取骨架实体里 category 等于此值的条目
62
+ * detail —— 与本组条目一一对应的详情数组名(关键道具的 details)
63
+ * free —— 无骨架来源,由模型自主生成(条数由 count 决定)
64
+ */
65
+ export const FLESH_FILE_SPECS = [
66
+ {
67
+ type: '角色',
68
+ file: '角色.json',
69
+ template: '角色模板.json',
70
+ groups: [
71
+ { key: 'characters', source: 'characters' }
72
+ ]
73
+ },
74
+ {
75
+ type: '冲突',
76
+ file: '冲突.json',
77
+ template: '冲突模板.json',
78
+ groups: [
79
+ { key: 'conflicts', source: 'conflicts' }
80
+ ]
81
+ },
82
+ {
83
+ type: '势力',
84
+ file: '势力.json',
85
+ template: '势力模板.json',
86
+ groups: [
87
+ { key: 'factions', source: 'factions' }
88
+ ]
89
+ },
90
+ {
91
+ type: '剧情',
92
+ file: '剧情.json',
93
+ template: '剧情模板.json',
94
+ groups: [
95
+ { key: 'main_lines', source: 'main_lines' },
96
+ { key: 'branches', source: 'branches' }
97
+ ]
98
+ },
99
+ {
100
+ type: '地点',
101
+ file: '地点.json',
102
+ template: '地点模板.json',
103
+ groups: [
104
+ { key: 'locations', source: 'locations' }
105
+ ]
106
+ },
107
+ {
108
+ type: '结局',
109
+ file: '结局.json',
110
+ template: '结局模板.json',
111
+ groups: [
112
+ { key: 'endings', source: 'endings' }
113
+ ]
114
+ },
115
+ {
116
+ type: '世界观规则',
117
+ file: '世界观规则.json',
118
+ template: '世界观规则模板.json',
119
+ groups: [
120
+ { key: 'core_rules', source: 'world_rules', category: '核心规则' },
121
+ { key: 'information_rules', source: 'world_rules', category: '信息规则' },
122
+ { key: 'social_rules', source: 'world_rules', category: '社会规则' },
123
+ { key: 'rule_conflict_plan', free: true, count: 5 }
124
+ ]
125
+ },
126
+ {
127
+ type: '关键道具',
128
+ file: '关键道具.json',
129
+ template: '关键道具模板.json',
130
+ groups: [
131
+ { key: 'core_items', source: 'items', category: '核心道具', detail: 'details' },
132
+ { key: 'branch_items', source: 'items', category: '支线道具', detail: 'details' },
133
+ { key: 'symbolic_items', source: 'items', category: '象征道具', detail: 'details' }
134
+ ]
135
+ }
136
+ ];
137
+
138
+ // ============================================================
139
+ // 骨架 4 个文件的规格
140
+ // ============================================================
141
+
142
+ /**
143
+ * 骨架阶段的条目来源比血肉/节奏多一类:
144
+ * quotaDriven —— 条数由**本阶段自己产出的** `框架.json` 的 quota 决定。
145
+ *
146
+ * 骨架是第一个阶段,没有上游可读,实体条数只能由框架的配额推出来:
147
+ * quota.character_counts.protagonist = 2 → characters 里要有 2 个 type=主角
148
+ * quota.conflict_counts.core = 3 → conflicts 里要有 3 个 type=核心
149
+ * 校验器按同一张映射表核对(QUOTA_MISMATCH),所以批次规模也按它来。
150
+ *
151
+ * 这带来一个**段间依赖**:实体批次必须等框架.json 产出并合并后才知道规模,
152
+ * 所以骨架阶段是三段驱动(框架 → 实体 → 关系+层级)。
153
+ *
154
+ * 其余两类与血肉/节奏同构:
155
+ * source —— 按已产出的实体切(关系 / 层级的 span)
156
+ * wholeObject —— 整个文件就是一个对象(框架.json),只有一批
157
+ */
158
+ export const SKELETON_FILE_SPECS = [
159
+ {
160
+ type: '框架',
161
+ file: '框架.json',
162
+ template: '框架模板.json',
163
+ groups: [
164
+ { key: null, wholeObject: true, maxItems: 1 }
165
+ ]
166
+ },
167
+ {
168
+ type: '实体',
169
+ file: '实体.json',
170
+ template: '实体模板.json',
171
+ groups: [
172
+ { key: 'characters', quotaDriven: true, maxItems: 20 },
173
+ { key: 'conflicts', quotaDriven: true, maxItems: 20 },
174
+ { key: 'factions', quotaDriven: true, maxItems: 20 },
175
+ { key: 'locations', quotaDriven: true, maxItems: 20 },
176
+ { key: 'items', quotaDriven: true, maxItems: 20 },
177
+ { key: 'endings', quotaDriven: true, maxItems: 20 },
178
+ { key: 'foreshadows', quotaDriven: true, maxItems: 20 },
179
+ { key: 'climaxes', quotaDriven: true, maxItems: 20 },
180
+ { key: 'pleasures', quotaDriven: true, maxItems: 20 },
181
+ { key: 'world_rules', quotaDriven: true, maxItems: 20 },
182
+ { key: 'main_lines', quotaDriven: true, maxItems: 20 },
183
+ { key: 'branches', quotaDriven: true, maxItems: 20 }
184
+ ]
185
+ },
186
+ {
187
+ type: '关系',
188
+ file: '关系.json',
189
+ template: '关系模板.json',
190
+ groups: [
191
+ // 每组按"关系指向哪类实体"切:模型只需要看到自己这批实体的 id + name,
192
+ // 不必读整份实体清单(344 个实体全塞进提示词会撑爆单轮预算)。
193
+ //
194
+ // refs = 这组要填的**别类**实体的 id(引用完整性校验会逐条查这些 id 存不存在):
195
+ // 冲突参与要填角色 id,道具归属要填角色/势力 id ……
196
+ //
197
+ // ownerField = 这条关系"归属给谁"的字段:值落在本批源实体里的行归本批管。
198
+ // 它让"每批写哪些行"有唯一定义(不重不漏),
199
+ // 分片合并时不会因为两个批次都写了同一行而报 ID_DUP。
200
+ //
201
+ // rows = 这个数组的**行数口径**(tt5 实测踩过的坑,见下方三种模式):
202
+ // perEntity —— 每个源实体恰好一行,行数 = 源实体数。
203
+ // (冲突参与方、支线主角、道具归属、地点冲突、结局角色、支线挂主线)
204
+ // semantic —— 行数由关系本身决定,**不预设**。提示词不能说"写 N 条"。
205
+ // (角色关系:两个角色之间有几条关系是剧情决定的;
206
+ // 势力成员:一个角色可以属于多个势力)
207
+ // quota —— 行数由框架 quota 决定,精确值。
208
+ // (势力关系:一个无向对要存成 A→B 与 B→A 两行,
209
+ // 所以行数 = quota.faction_relation_count * 2,跟势力个数无关)
210
+ //
211
+ // 以前这三类一律按"源实体数 = 行数"处理,提示词统一写
212
+ // "本批要写的条目(N 条)… 每行一条"。后果(tt5 实测):
213
+ // · faction_relations 提示词说 8 条 → 模型写 8 行 → 校验器要 24 行
214
+ // → 白跑 23 批才返工一次;
215
+ // · main_line_branch_relation 按主线切(只有 1 条主线)→ 提示词说 1 条
216
+ // → 模型写 1 行 → 10 条支线里 9 条静默没挂主线,校验器还不报。
217
+ { key: 'character_relations', source: 'characters', ownerField: 'from', refs: ['characters'], rows: 'semantic', maxItems: 8 },
218
+ { key: 'faction_membership', source: 'factions', ownerField: 'faction', refs: ['characters', 'factions'], rows: 'semantic', maxItems: 8 },
219
+ { key: 'faction_relations', source: 'factions', ownerField: 'from', refs: ['factions'], rows: 'quota', quotaPath: 'faction_relation_count', multiplier: 2, maxItems: 8 },
220
+ { key: 'conflict_participants', source: 'conflicts', ownerField: 'conflict', refs: ['conflicts', 'characters'], rows: 'perEntity', maxItems: 8 },
221
+ { key: 'branch_protagonist', source: 'branches', ownerField: 'branch', refs: ['branches', 'characters'], rows: 'perEntity', maxItems: 8 },
222
+ { key: 'item_owner', source: 'items', ownerField: 'item', refs: ['items', 'characters', 'factions'], rows: 'perEntity', maxItems: 8 },
223
+ { key: 'location_conflict', source: 'locations', ownerField: 'location', refs: ['locations', 'conflicts'], rows: 'perEntity', maxItems: 8 },
224
+ { key: 'ending_character', source: 'endings', ownerField: 'ending', refs: ['endings', 'characters'], rows: 'perEntity', maxItems: 8 },
225
+ // 一行代表"一条支线挂到主线上" → 源实体必须是**支线**(不是主线)。
226
+ // 按主线切会只写主线条数那一行,其余支线一个都挂不上。
227
+ { key: 'main_line_branch_relation', source: 'branches', ownerField: 'branch', refs: ['main_lines', 'branches'], rows: 'perEntity', maxItems: 8 }
228
+ ]
229
+ },
230
+ {
231
+ type: '层级',
232
+ file: '层级.json',
233
+ template: '层级模板.json',
234
+ groups: [
235
+ // 卷数 = quota.volume_count(校验器拿层级自己的 volumes 长度跟它对账)
236
+ { key: 'volumes', quotaDriven: true, quotaPath: 'volume_count', maxItems: 20 },
237
+ // 幕数没有配额字段(校验器只查同卷内 act_index 不重复),
238
+ // 按每卷 3 幕估规模;模型实际写多少都合法。
239
+ { key: 'acts', quotaDriven: true, quotaPath: 'volume_count', multiplier: 3, maxItems: 30 },
240
+ { key: 'main_line_span', source: 'main_lines', ownerField: 'main_line', maxItems: 8 },
241
+ { key: 'branch_span', source: 'branches', ownerField: 'branch', maxItems: 12 }
242
+ ]
243
+ }
244
+ ];
245
+
246
+ // ============================================================
247
+ // 节奏 4 个文件的规格
248
+ // ============================================================
249
+
250
+ /**
251
+ * 节奏阶段的条目来源分三类:
252
+ * byChapter —— 按章号 1..T 切(章节规划 / 章节细纲 / hook_timeline)
253
+ * source —— 按骨架实体切(角色 / 冲突 / 伏笔 / 高潮 / 爽点 / 主线 / 支线 /
254
+ * 地点 / 道具 / 结局)
255
+ * wholeObject —— 整个文件就是一个对象(节奏参数.json),只有一批
256
+ *
257
+ * bytesPerItem 是每条的估算字节(只用于决定切几批):
258
+ * 章节规划 4 字段、hook_timeline 4 字段、章节细纲 14 字段。
259
+ * 细纲按 800 章估算总产出 320-480KB(400-600 B/章),取 800 B/章留 1.3-2 倍余量。
260
+ */
261
+ export const RHYTHM_FILE_SPECS = [
262
+ {
263
+ type: '章节规划',
264
+ file: '章节规划.json',
265
+ template: '章节规划模板.json',
266
+ groups: [
267
+ // 章节规划只有 4 个字段,一次写 40 章(约 16KB)
268
+ { key: 'chapters', byChapter: true, bytesPerItem: 400, maxItems: 40 }
269
+ ]
270
+ },
271
+ {
272
+ type: '节奏参数',
273
+ file: '节奏参数.json',
274
+ template: '节奏参数模板.json',
275
+ groups: [
276
+ { key: null, wholeObject: true, maxItems: 1 }
277
+ ]
278
+ },
279
+ {
280
+ type: '时序映射',
281
+ file: '时序映射.json',
282
+ template: '时序映射模板.json',
283
+ groups: [
284
+ { key: 'character_timeline', source: 'characters', maxItems: 50 },
285
+ { key: 'conflict_timeline', source: 'conflicts', maxItems: 50 },
286
+ { key: 'foreshadow_timeline', source: 'foreshadows', maxItems: 50 },
287
+ { key: 'climax_timeline', source: 'climaxes', maxItems: 50 },
288
+ { key: 'pleasure_timeline', source: 'pleasures', maxItems: 50 },
289
+ // hook 与章节一一对应,字段少
290
+ { key: 'hook_timeline', byChapter: true, bytesPerItem: 400, maxItems: 40 },
291
+ { key: 'main_line_timeline', source: 'main_lines', maxItems: 50 },
292
+ { key: 'branch_timeline', source: 'branches', maxItems: 50 },
293
+ { key: 'location_timeline', source: 'locations', maxItems: 50 },
294
+ { key: 'item_timeline', source: 'items', maxItems: 50 },
295
+ { key: 'ending_timeline', source: 'endings', maxItems: 50 }
296
+ ]
297
+ },
298
+ {
299
+ type: '章节细纲',
300
+ file: '章节细纲.json',
301
+ template: '章节细纲模板.json',
302
+ groups: [
303
+ // 章节细纲 14 个字段且要跟上一批承接,一次 25 章(约 20KB)。
304
+ // 只写重点章(keyChaptersOnly)——重点章集合要等时序映射生成后才知道。
305
+ { key: 'outlines', byChapter: true, keyChaptersOnly: true, bytesPerItem: 800, maxItems: 25 }
306
+ ]
307
+ }
308
+ ];
309
+
310
+ // ============================================================
311
+ // 小工具
312
+ // ============================================================
313
+
314
+ function readJson(file) {
315
+ try {
316
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
317
+ } catch (e) {
318
+ return null;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * 数一个模板节点里的叶子字段数(对象属性 + 数组元素字段)。
324
+ * 数组本身也算一个字段(数组本身要写值)。
325
+ */
326
+ export function countLeafFields(node) {
327
+ if (!node || typeof node !== 'object') return 0;
328
+ const props = node.properties;
329
+ if (!props || typeof props !== 'object') return 0;
330
+ let n = 0;
331
+ for (const key of Object.keys(props)) {
332
+ const p = props[key];
333
+ if (!p || typeof p !== 'object') continue;
334
+ const t = p.type;
335
+ if (t === 'object') {
336
+ n += countLeafFields(p);
337
+ } else if (t === 'array') {
338
+ n += 1 + countLeafFields(p.items || {});
339
+ } else {
340
+ n += 1;
341
+ }
342
+ }
343
+ return n;
344
+ }
345
+
346
+ /**
347
+ * 数组 key 在模板里占多少个叶子字段。
348
+ * 找不到就返回 0(调用方会退回到保守估算)。
349
+ */
350
+ export function arrayFieldCount(templateSpec, arrayKey) {
351
+ if (!templateSpec || !templateSpec.structure) return 0;
352
+ const props = templateSpec.structure.properties;
353
+ if (!props || !props[arrayKey]) return 0;
354
+ const arr = props[arrayKey];
355
+ if (arr.type !== 'array') return countLeafFields(arr);
356
+ return countLeafFields(arr.items || {});
357
+ }
358
+
359
+ /**
360
+ * 一条条目在目标文件里占多少字节(估算)。
361
+ * summary 数组和它的详情数组都要写,所以字段数相加。
362
+ */
363
+ export function estimateItemBytes(templateSpec, arrayKeys) {
364
+ let fields = 0;
365
+ for (const key of arrayKeys) fields += arrayFieldCount(templateSpec, key);
366
+ return Math.max(1, fields) * BYTES_PER_FIELD;
367
+ }
368
+
369
+ // ============================================================
370
+ // 批次规划
371
+ // ============================================================
372
+
373
+ /**
374
+ * 按 category 从骨架实体里挑出本组条目。
375
+ */
376
+ function pickSkeletonItems(skeletonEntities, group) {
377
+ const list = Array.isArray(skeletonEntities[group.source]) ? skeletonEntities[group.source] : [];
378
+ if (!group.category) return list;
379
+ return list.filter((e) => e && e.category === group.category);
380
+ }
381
+
382
+ /**
383
+ * 规划血肉阶段的批次。
384
+ *
385
+ * @param {Object} opts
386
+ * @param {string} opts.skeletonDir 骨架目录(读 实体.json)
387
+ * @param {string} opts.templateDir 模板目录(读 *模板.json)
388
+ * @param {string} [opts.buildDir] 分片目录(绝对路径;给了就把 shardPath 填成绝对路径)
389
+ * @param {number} [opts.maxBytes] 单批目标字节上限
390
+ * @param {number} [opts.maxItems] 单批条目数上限
391
+ * @returns {Object} { ok, error, files, batches }
392
+ */
393
+ export function planFleshBatches(opts) {
394
+ return planStageBatches(Object.assign({ stage: 'flesh' }, opts || {}));
395
+ }
396
+
397
+ /**
398
+ * 规划骨架阶段的批次。
399
+ *
400
+ * 骨架阶段没有上游可读:实体条数来自本阶段框架.json 的 quota,
401
+ * 所以调用时 `skeletonDir` 要指向**骨架阶段自己的输出目录**
402
+ * (框架.json / 实体.json 都是那一阶段产出的)。
403
+ *
404
+ * @param {Object} opts
405
+ * @param {string} opts.skeletonDir 骨架阶段输出目录(读 框架.json 的 quota、实体.json 的清单)
406
+ * @param {string} opts.templateDir 模板目录(读 *模板.json)
407
+ * @param {string[]} [opts.only] 只要这几个文件(分段驱动用)
408
+ * @returns {Object} { ok, error, files, batches }
409
+ */
410
+ export function planSkeletonBatches(opts) {
411
+ return planStageBatches(Object.assign({ stage: 'skeleton' }, opts || {}));
412
+ }
413
+
414
+ /**
415
+ * 规划节奏阶段的批次。
416
+ *
417
+ * @param {Object} opts
418
+ * @param {string} opts.skeletonDir 骨架目录(读 实体.json + 框架.json 的 capacity)
419
+ * @param {string} opts.templateDir 模板目录(读 *模板.json)
420
+ * @param {string} [opts.buildDir] 分片目录(绝对路径)
421
+ * @param {number} [opts.chapters] 目标章数(不传就读骨架 capacity.target_chapters)
422
+ * @param {number} [opts.maxBytes] 单批目标字节上限
423
+ * @param {number} [opts.maxItems] 单批条目数上限
424
+ * @returns {Object} { ok, error, files, batches, chapters }
425
+ */
426
+ export function planRhythmBatches(opts) {
427
+ const o = Object.assign({ stage: 'rhythm' }, opts || {});
428
+ const chapters = o.chapters || readTargetChapters(o.skeletonDir);
429
+ if (!chapters || chapters < 1) {
430
+ return {
431
+ ok: false,
432
+ error: '读不到目标章数(骨架 框架.json 的 capacity.target_chapters):' + (o.skeletonDir || ''),
433
+ files: [],
434
+ batches: [],
435
+ chapters: 0
436
+ };
437
+ }
438
+ const plan = planStageBatches(o);
439
+ plan.chapters = chapters;
440
+ return plan;
441
+ }
442
+
443
+ /**
444
+ * 读骨架的目标章数。
445
+ */
446
+ export function readTargetChapters(skeletonDir) {
447
+ const data = readJson(path.join(skeletonDir, '框架.json'));
448
+ if (!data) return null;
449
+ const cap = data.capacity && typeof data.capacity === 'object' ? data.capacity : null;
450
+ const t = cap ? cap.target_chapters : null;
451
+ if (typeof t !== 'number' || !isFinite(t) || t < 1) return null;
452
+ return Math.floor(t);
453
+ }
454
+
455
+ /**
456
+ * 通用批次规划(血肉 / 节奏共用)。
457
+ */
458
+ export function planStageBatches(opts) {
459
+ const o = opts || {};
460
+ const stage = o.stage || 'flesh';
461
+ const skeletonDir = o.skeletonDir;
462
+ const templateDir = o.templateDir;
463
+ const maxBytes = o.maxBytes || DEFAULT_MAX_BYTES_PER_BATCH;
464
+ const maxItems = o.maxItems || DEFAULT_MAX_ITEMS_PER_BATCH;
465
+ const chapters = o.chapters || readTargetChapters(skeletonDir) || 0;
466
+
467
+ if (!skeletonDir || !templateDir) {
468
+ return { ok: false, error: '缺少骨架目录或模板目录', files: [], batches: [] };
469
+ }
470
+
471
+ const skeletonEntities = readJson(path.join(skeletonDir, '实体.json'));
472
+
473
+ const allSpecs = stage === 'rhythm' ? RHYTHM_FILE_SPECS : (stage === 'skeleton' ? SKELETON_FILE_SPECS : FLESH_FILE_SPECS);
474
+ // only: 只要这几个文件(节奏 / 骨架阶段分段驱动:先基础文件,后引用文件)
475
+ const specs = Array.isArray(o.only) && o.only.length
476
+ ? allSpecs.filter((sp) => o.only.indexOf(sp.type) >= 0)
477
+ : allSpecs;
478
+
479
+ // 骨架阶段的第一段(框架)还没有实体.json —— 实体清单要到第二段才产出。
480
+ // 所以这里不硬报错,等到真的要按实体切批次时再报(下面 pickSkeletonItems 处)。
481
+ const needsEntities = specs.some((sp) => sp.groups.some((g) =>
482
+ !g.wholeObject && !g.byChapter && !g.free && !g.quotaDriven));
483
+ if (needsEntities && !skeletonEntities) {
484
+ return { ok: false, error: '读不到骨架 实体.json:' + path.join(skeletonDir, '实体.json'), files: [], batches: [] };
485
+ }
486
+
487
+ const files = [];
488
+ const batches = [];
489
+ let globalIndex = 0;
490
+
491
+ for (let fileIndex = 0; fileIndex < specs.length; fileIndex++) {
492
+ const spec = specs[fileIndex];
493
+ const templateSpec = readJson(path.join(templateDir, spec.template));
494
+ if (!templateSpec) {
495
+ return {
496
+ ok: false,
497
+ error: '读不到模板:' + path.join(templateDir, spec.template),
498
+ files: [],
499
+ batches: []
500
+ };
501
+ }
502
+
503
+ const fileBatches = [];
504
+
505
+ for (let groupIndex = 0; groupIndex < spec.groups.length; groupIndex++) {
506
+ const group = spec.groups[groupIndex];
507
+
508
+ // 整文件就是一个对象(节奏参数.json):只有一批,条目是整个对象
509
+ if (group.wholeObject) {
510
+ globalIndex += 1;
511
+ const batch = makeBatch({
512
+ spec, group, groupIndex, globalIndex, fileIndex,
513
+ items: [],
514
+ start: 0,
515
+ count: 1,
516
+ bytesPerItem: estimateItemBytes(templateSpec, []),
517
+ // 整文件型产物的顶层键(从模板读):模型没写这些键时,
518
+ // 校验器报的 "branch_chapter_total 必填" 之类错误也要能归因回本批次。
519
+ objectKeys: Object.keys((templateSpec.structure && templateSpec.structure.properties) || {}),
520
+ buildDir: o.buildDir,
521
+ stage
522
+ });
523
+ batches.push(batch);
524
+ fileBatches.push(batch);
525
+ continue;
526
+ }
527
+
528
+ const arrayKeys = [group.key];
529
+ if (group.detail) arrayKeys.push(group.detail);
530
+
531
+ let items;
532
+ let groupQuotaCounts = null;
533
+ // 配额驱动的数组实际要写几条(配额为 0 时是 0,与 items.length 不同)
534
+ let groupTotal = null;
535
+ // 行数口径(骨架关系数组用):perEntity / semantic / quota
536
+ let rowsMode = group.rows || null;
537
+ let rowsSpec = null;
538
+ if (group.byChapter) {
539
+ // 按章号切。默认 1..T 全部;给了 keyChapters 就只切重点章
540
+ // (章节细纲只为重点章写,这个集合要等时序映射生成后才知道)。
541
+ const chapterList = group.keyChaptersOnly && Array.isArray(o.keyChapters)
542
+ ? o.keyChapters
543
+ : rangeChapters(chapters);
544
+ items = chapterList.map((ch) => ({ ch: ch }));
545
+ } else if (group.free) {
546
+ // 无骨架来源:模型自主生成固定条数
547
+ items = [];
548
+ for (let i = 0; i < (group.count || 1); i++) items.push({ index: i + 1 });
549
+ } else if (group.quotaDriven) {
550
+ // 条数由本阶段的 框架.json 的 quota 决定(骨架阶段专用):
551
+ // 没有上游可读,实体写几条要先由模型在框架里定配额,再照配额写。
552
+ const quota = readQuota(skeletonDir);
553
+ let counts = {};
554
+ let total = 0;
555
+ if (group.quotaPath) {
556
+ // 直接取某个 quota 字段(层级.json 的卷数/幕数不在 QUOTA_MAP 里,
557
+ // 校验器是拿层级自己的条数跟 quota.volume_count 对账的)
558
+ const v = getNested(quota, group.quotaPath);
559
+ const n = typeof v === 'number' && isFinite(v) ? v : 0;
560
+ total = n * (group.multiplier || 1);
561
+ counts[group.quotaPath] = total;
562
+ } else {
563
+ counts = quotaCountsByArray(quota)[group.key] || {};
564
+ total = Object.keys(counts).reduce((n, k) => n + counts[k], 0);
565
+ }
566
+ items = [];
567
+ for (let i = 0; i < total; i++) items.push({ index: i + 1 });
568
+ // 配额为 0 的数组**也要有分片**:键必须存在(写成空数组),
569
+ // 缺键的话校验器报 REQUIRED("必填字段不得为空"),
570
+ // 而这个错归不到任何批次 → 整阶段反复重跑,永远修不好。
571
+ if (!items.length) items.push({ index: 0 });
572
+ // 分项配额带到批次上,提示词里要逐项告诉模型写几个
573
+ groupQuotaCounts = counts;
574
+ // 这个数组实际要写几条(配额为 0 时是 0,跟 items.length 不是一回事)
575
+ groupTotal = total;
576
+ } else if (rowsMode === 'quota') {
577
+ // 行数由框架 quota 决定(骨架关系数组专用):
578
+ // faction_relations 的行数 = quota.faction_relation_count * 2
579
+ // (无向关系对存成双向行 A→B 与 B→A,各一行)
580
+ // 这里取的是**精确总行数**,不是源实体个数 —— 两者不是一回事。
581
+ const quota = readQuota(skeletonDir);
582
+ const v = getNested(quota, group.quotaPath);
583
+ const pairs = typeof v === 'number' && isFinite(v) ? v : 0;
584
+ const mult = group.multiplier || 1;
585
+ groupTotal = pairs * mult;
586
+ // 源实体只是关系的两端(可引用的 id),不是要写的行数
587
+ items = pickSkeletonItems(skeletonEntities, group).map((e) => ({
588
+ id: e.id,
589
+ name: e.name,
590
+ type: e.type,
591
+ category: e.category
592
+ }));
593
+ rowsSpec = {
594
+ mode: 'quota',
595
+ quotaPath: group.quotaPath,
596
+ pairs: pairs,
597
+ multiplier: mult,
598
+ total: groupTotal
599
+ };
600
+ } else {
601
+ items = pickSkeletonItems(skeletonEntities, group).map((e) => ({
602
+ id: e.id,
603
+ name: e.name,
604
+ type: e.type,
605
+ category: e.category
606
+ }));
607
+ }
608
+
609
+ const bytesPerItem = group.bytesPerItem || estimateItemBytes(templateSpec, arrayKeys);
610
+ let perBatch = Math.floor(maxBytes / bytesPerItem);
611
+ if (!isFinite(perBatch) || perBatch < 1) perBatch = 1;
612
+ // 条目数上限是"估算失真"的安全阀,不是主要约束:
613
+ // 血肉的条目是大对象(角色 45 字段),12 条就接近单批字节上限;
614
+ // 节奏按章的条目很小(细纲一章 ~800B),用同一个上限会把批次切得过碎
615
+ // (tt5 实测 800 章被切成 232 批)。所以上限按 group 单独定。
616
+ const itemCap = group.maxItems !== undefined ? group.maxItems : maxItems;
617
+ if (perBatch > itemCap) perBatch = itemCap;
618
+ // 配额驱动的数组**不切分**:配额是按类型给的(主角 2 个、核心配角 6 个…),
619
+ // 切开之后每批该分到几个"主角"就说不清了 —— 而校验器核对的是
620
+ // 整个数组里各类型的总条数(QUOTA_MISMATCH)。
621
+ // 实体条目很小(id + name + 枚举字段,~120B),一个数组一批完全装得下。
622
+ if (group.quotaDriven) perBatch = Math.max(1, items.length);
623
+ // quota 行数口径的数组**也不切分**:它的行数是精确值(quota × 倍数),
624
+ // 拆开之后每批该写几行就没法说了 —— 而且"哪些势力互相对立"是模型自己选的,
625
+ // 跨批切分会让同一对关系写两遍或漏掉。关系行很短(几十字节),一批装得下。
626
+ if (rowsMode === 'quota') perBatch = Math.max(1, items.length);
627
+
628
+ for (let start = 0; start < items.length; start += perBatch) {
629
+ const slice = items.slice(start, start + perBatch);
630
+ globalIndex += 1;
631
+ const batch = makeBatch({
632
+ spec, group, groupIndex, globalIndex, fileIndex,
633
+ items: slice,
634
+ start,
635
+ count: groupTotal !== null ? groupTotal : slice.length,
636
+ bytesPerItem,
637
+ quotaCounts: groupQuotaCounts,
638
+ rowsMode: rowsMode,
639
+ rowsSpec: rowsSpec,
640
+ buildDir: o.buildDir,
641
+ stage
642
+ });
643
+ batches.push(batch);
644
+ fileBatches.push(batch);
645
+ }
646
+ }
647
+
648
+ // 一个批次都切不出来 = 这一段没法写。
649
+ // 不报错的话,合并阶段只会抛一句"合并参数不完整"(天书),
650
+ // 而真正的原因是"实体清单里没有 characters"这类上游缺失 —— 必须说清楚。
651
+ if (!fileBatches.length) {
652
+ const why = spec.groups.map((g) => {
653
+ const key = g.key || '整个文件';
654
+ if (g.quotaDriven) return key + '(框架 quota 里这一项是 0)';
655
+ if (g.source) return key + '(实体清单里没有 ' + g.source + ')';
656
+ if (g.byChapter) return key + '(章数为 0)';
657
+ return key;
658
+ }).join('、');
659
+ return {
660
+ ok: false,
661
+ error: spec.file + ' 切不出任何批次:' + why,
662
+ files: [],
663
+ batches: []
664
+ };
665
+ }
666
+
667
+ files.push({
668
+ type: spec.type,
669
+ file: spec.file,
670
+ template: spec.template,
671
+ fileIndex: fileIndex + 1,
672
+ batches: fileBatches,
673
+ totalItems: fileBatches.reduce((n, b) => n + b.count, 0)
674
+ });
675
+ }
676
+
677
+ return { ok: true, error: null, files: files, batches: batches, chapters: chapters };
678
+ }
679
+
680
+ function rangeChapters(n) {
681
+ const out = [];
682
+ for (let ch = 1; ch <= n; ch++) out.push(ch);
683
+ return out;
684
+ }
685
+
686
+ /**
687
+ * 算"重点章"——需要写章节细纲的章。
688
+ *
689
+ * 依据(与 prompts/03-rhythm-prompt.md §7.2.4 的定义一致):
690
+ * 1. 第 1 章
691
+ * 2. 每卷的第 1 章和最后 1 章
692
+ * 3. 大高潮对应的章
693
+ * 4. 结局对应的章
694
+ * 5. 有钩子的章(细纲的 hook_head/hook_tail 必须与时序映射对齐)
695
+ *
696
+ * 这个集合依赖时序映射(高潮章 / 结局章 / 钩子章都是模型生成的),
697
+ * 所以必须在时序映射生成完之后才能算 —— 这就是节奏阶段要分两批驱动的原因。
698
+ *
699
+ * @param {Object} opts
700
+ * @param {Array} opts.chapters 章节规划的 chapters 数组
701
+ * @param {Object} opts.timeline 时序映射(合并后的对象)
702
+ * @param {Object} opts.climaxTypes climax_id -> type(来自骨架 climaxes)
703
+ * @returns {number[]} 升序去重的章号
704
+ */
705
+ export function computeKeyChapters(opts) {
706
+ const o = opts || {};
707
+ const chapters = Array.isArray(o.chapters) ? o.chapters : [];
708
+ const timeline = o.timeline && typeof o.timeline === 'object' ? o.timeline : {};
709
+ const climaxTypes = o.climaxTypes && typeof o.climaxTypes === 'object' ? o.climaxTypes : {};
710
+
711
+ const set = new Set();
712
+ if (chapters.length) set.add(1);
713
+
714
+ // 每卷的首尾章
715
+ const byVolume = new Map();
716
+ for (const c of chapters) {
717
+ if (!c || typeof c !== 'object') continue;
718
+ const v = c.volume;
719
+ const ch = c.ch;
720
+ if (typeof ch !== 'number') continue;
721
+ if (!byVolume.has(v)) byVolume.set(v, []);
722
+ byVolume.get(v).push(ch);
723
+ }
724
+ for (const list of byVolume.values()) {
725
+ list.sort((a, b) => a - b);
726
+ set.add(list[0]);
727
+ set.add(list[list.length - 1]);
728
+ }
729
+
730
+ // 大高潮章
731
+ const climaxTimeline = Array.isArray(timeline.climax_timeline) ? timeline.climax_timeline : [];
732
+ for (const c of climaxTimeline) {
733
+ if (!c || typeof c !== 'object') continue;
734
+ if (climaxTypes[c.climax_id] !== '大高潮') continue;
735
+ if (typeof c.chapter === 'number') set.add(c.chapter);
736
+ }
737
+
738
+ // 结局章
739
+ const endingTimeline = Array.isArray(timeline.ending_timeline) ? timeline.ending_timeline : [];
740
+ for (const e of endingTimeline) {
741
+ if (e && typeof e.chapter === 'number') set.add(e.chapter);
742
+ }
743
+
744
+ // 钩子章
745
+ const hookTimeline = Array.isArray(timeline.hook_timeline) ? timeline.hook_timeline : [];
746
+ for (const h of hookTimeline) {
747
+ if (h && typeof h.ch === 'number') set.add(h.ch);
748
+ }
749
+
750
+ return Array.from(set).filter((ch) => Number.isFinite(ch)).sort((a, b) => a - b);
751
+ }
752
+
753
+ /**
754
+ * 从骨架实体里取 climax_id -> type 的映射。
755
+ */
756
+ export function buildClimaxTypes(skeletonEntities) {
757
+ const map = {};
758
+ if (!skeletonEntities || typeof skeletonEntities !== 'object') return map;
759
+ for (const c of (skeletonEntities.climaxes || [])) {
760
+ if (c && c.id) map[c.id] = c.type;
761
+ }
762
+ return map;
763
+ }
764
+
765
+ function makeBatch(ctx) {
766
+ const spec = ctx.spec;
767
+ const group = ctx.group;
768
+ const globalIndex = ctx.globalIndex;
769
+ const shard = shardName(spec, group, globalIndex, ctx.stage);
770
+ return {
771
+ key: spec.file + '#' + (group.key || 'whole') + '#' + ctx.groupIndex + '#' + globalIndex,
772
+ index: globalIndex,
773
+ fileIndex: ctx.fileIndex + 1,
774
+ type: spec.type,
775
+ file: spec.file,
776
+ template: spec.template,
777
+ groupIndex: ctx.groupIndex,
778
+ groupKey: group.key,
779
+ wholeObject: !!group.wholeObject,
780
+ byChapter: !!group.byChapter,
781
+ // 整文件型产物的顶层键(从模板读):模型没写这些键时,
782
+ // 校验器报的 "branch_chapter_total 必填" 之类错误也要能归因回本批次。
783
+ objectKeys: ctx.objectKeys || [],
784
+ // details 必须跟着 summary 一起合并:校验器要求两者 id 一一对应,
785
+ // 只合并 summary 会让关键道具.json 永远缺 details(FLESH_DETAILS_ORPHAN)。
786
+ arrays: group.key ? (group.detail ? [group.key, group.detail] : [group.key]) : [],
787
+ summaryArray: group.key,
788
+ detailArray: group.detail || null,
789
+ shard: shard,
790
+ shardPath: ctx.buildDir ? path.join(ctx.buildDir, shard) : null,
791
+ start: ctx.start,
792
+ count: ctx.count,
793
+ items: ctx.items,
794
+ // 配额分项(骨架阶段专用):这批要写几个"主角"、几个"核心配角"…
795
+ // 提示词里要逐项写明,模型才知道每类写几个。
796
+ quotaCounts: ctx.quotaCounts || null,
797
+ // 行数口径(骨架关系数组专用):
798
+ // 'perEntity' —— 每个源实体一行,行数 = count(= 本批实体数)
799
+ // 'semantic' —— 行数由关系本身决定,**不预设**,提示词不能说"写 N 条"
800
+ // 'quota' —— 行数 = rowsSpec.total(quota × 倍数),精确值
801
+ // 以前这三类混为一谈,提示词统一写"本批要写的条目(N 条)…每行一条",
802
+ // 模型就把 N 当成真实行数(tt5 实测:faction_relations 要 24 行只写了 8 行)。
803
+ rowsMode: ctx.rowsMode || null,
804
+ rowsSpec: ctx.rowsSpec || null,
805
+ // 这批要引用的实体类型(骨架阶段的关系类批次用):
806
+ // 提示词里要给对应的 id 表,否则模型只能凭空编 id(SKELETON_REF_MISSING)。
807
+ refs: (group.refs || []).slice(),
808
+ // 这批负责的关系行归属字段(骨架阶段的关系/层级类批次用):
809
+ // 行里该字段的值落在本批源实体里 → 这条行归本批管。
810
+ // 没有它,"每批写哪些行"就没有唯一定义(模型在两批里写同一行 → 合并后 ID_DUP)。
811
+ ownerField: group.ownerField || null,
812
+ bytesPerItem: ctx.bytesPerItem,
813
+ estBytes: ctx.bytesPerItem * ctx.count,
814
+ free: !!group.free
815
+ };
816
+ }
817
+
818
+ /**
819
+ * 分片文件名:角色.b01.characters.json
820
+ * 全局批序号保证唯一,数组名保证可读。
821
+ */
822
+ function shardName(spec, group, globalIndex, stage) {
823
+ const seq = globalIndex < 10 ? '0' + globalIndex : String(globalIndex);
824
+ const prefix = stage === 'rhythm' ? '节奏' : (stage === 'skeleton' ? '骨架' : '血肉');
825
+ const name = spec.type + '.b' + seq + '.' + (group.key || 'whole') + '.json';
826
+ return prefix + '-' + name;
827
+ }
828
+
829
+ // ============================================================
830
+ // 分片合并
831
+ // ============================================================
832
+
833
+ /**
834
+ * 把一个文件的全部分片合并成最终产物文件。
835
+ *
836
+ * 合并是幂等的:目标文件始终由全部分片重新合成,
837
+ * 所以"重投某一批" = 重写那一个分片 -> 重新合并。
838
+ *
839
+ * @param {Object} opts
840
+ * @param {string} opts.stageDir 阶段目录(目标文件写这里)
841
+ * @param {string} opts.buildDir 分片目录
842
+ * @param {Array} opts.batches 该文件的批次(按顺序)
843
+ * @returns {Object} { ok, error, path, layout, missing }
844
+ */
845
+ /**
846
+ * 清掉中途校验留下的半成品(.partial-*.json)。
847
+ *
848
+ * 半成品的名字是 '.partial-' + 文件名:中途校验要把"目前写了一半的产物"
849
+ * 落到正式文件之外,免得半成品盖掉正式产物。但它们留在产物目录里会让人
850
+ * 以为那是产物的一部分(tt5 实测 2026-10-25:产物目录里躺着
851
+ * .partial-框架.json / .partial-实体.json)。
852
+ *
853
+ * 按目录实际内容清,而不是写死几个文件名 —— 哪一段都可能留下半成品。
854
+ *
855
+ * @param {string} stageDir - 产物目录
856
+ * @returns {string[]} 被清掉的文件名
857
+ */
858
+ export function cleanupPartialFiles(stageDir) {
859
+ const removed = [];
860
+ let entries = [];
861
+ try {
862
+ entries = fs.readdirSync(stageDir);
863
+ } catch (e) {
864
+ return removed;
865
+ }
866
+ for (const f of entries) {
867
+ if (!f.startsWith('.partial-') || !f.endsWith('.json')) continue;
868
+ try {
869
+ fs.unlinkSync(path.join(stageDir, f));
870
+ removed.push(f);
871
+ } catch (e) { /* 删不掉就留着,下一轮再清 */ }
872
+ }
873
+ return removed;
874
+ }
875
+
876
+ export function mergeFleshShards(opts) {
877
+ const o = opts || {};
878
+ const stageDir = o.stageDir;
879
+ const buildDir = o.buildDir;
880
+ const batches = o.batches || [];
881
+ if (!stageDir || !buildDir || !batches.length) {
882
+ return { ok: false, error: '合并参数不完整', path: null, layout: {}, missing: [] };
883
+ }
884
+
885
+ const target = {};
886
+ const layout = {};
887
+ const missing = [];
888
+ // 行归属越界的行(ownerField 不在本批实体里)
889
+ const ownershipViolations = [];
890
+
891
+ // 中途校验用:allowMissing = 还没生成的批次分片不存在,**跳过而不是失败**;
892
+ // outPath = 写到别处(中途的半成品不能覆盖正式产物)。
893
+ const allowMissing = !!o.allowMissing;
894
+
895
+ for (const batch of batches) {
896
+ const shardPath = path.join(buildDir, batch.shard);
897
+ const shard = readJson(shardPath);
898
+ if (!shard) {
899
+ // 分片还没生成(中途校验时正常):记下来,最后看要不要当失败
900
+ missing.push(batch.shard);
901
+ continue;
902
+ }
903
+
904
+ // 整文件就是一个对象(节奏参数.json):分片内容直接就是目标内容
905
+ if (batch.wholeObject) {
906
+ // 模板声明的顶层键也要登记:模型整个键漏写时,
907
+ // 校验器报的 "branch_chapter_total 必填" 之类错误同样要归因回本批次。
908
+ if (!layout.__byName) layout.__byName = {};
909
+ for (const k of (batch.objectKeys || [])) {
910
+ if (!Array.isArray(layout[k])) layout.__byName[k] = batch.key;
911
+ }
912
+ for (const k of Object.keys(shard)) {
913
+ target[k] = shard[k];
914
+ // 顶层字段名也要登记:校验器报的路径有两类
915
+ // "节奏参数.overlap_chapter_count" —— 文件类型名 + 字段
916
+ // "derivations.branch_chapter_feasible" —— 顶层键 + 子键
917
+ // 第二类只有登记了顶层键才能归因回本批次,否则错误会被丢掉。
918
+ if (!Array.isArray(layout[k])) layout.__byName[k] = batch.key;
919
+ }
920
+ layout.__whole = { batchKey: batch.key };
921
+ // 整文件型产物(如节奏参数.json)的错误 path 形如
922
+ // "节奏参数.overlap_chapter_count" / "derivations.xxx",
923
+ // 这里把文件类型名登记下来,归因时直接落到这个批次。
924
+ if (!layout.__byName) layout.__byName = {};
925
+ layout.__byName[batch.type] = batch.key;
926
+ layout.__byName[batch.file] = batch.key;
927
+ continue;
928
+ }
929
+
930
+ for (const arrayKey of batch.arrays) {
931
+ // 声明登记:即使分片里这个数组是空的,也要记住"这个数组归哪些批次管"。
932
+ // 模型整个数组漏写时(合并结果里根本没有这个键),
933
+ // 校验器报的 "chapters 必填" 才能归因回批次而不是变成归不了的错。
934
+ if (!layout.__declared) layout.__declared = {};
935
+ if (!Array.isArray(layout.__declared[arrayKey])) layout.__declared[arrayKey] = [];
936
+ if (layout.__declared[arrayKey].indexOf(batch.key) < 0) {
937
+ layout.__declared[arrayKey].push(batch.key);
938
+ }
939
+
940
+ if (!Array.isArray(shard[arrayKey])) continue;
941
+ if (!Array.isArray(target[arrayKey])) target[arrayKey] = [];
942
+ if (!Array.isArray(layout[arrayKey])) layout[arrayKey] = [];
943
+ if (!Array.isArray(layout.__arrays)) layout.__arrays = {};
944
+ if (!layout.__arrays[arrayKey]) layout.__arrays[arrayKey] = [];
945
+ // 行归属校验(ownerField):这一行"归谁管"由 ownerField 唯一确定 ——
946
+ // ownerField 的值必须落在本批的源实体里。
947
+ // 以前 ownerField 只是写在批次上没人读,"每批写哪些行"全靠提示词一句话;
948
+ // 模型写串行时没有任何东西拦得住(tt5 实测:提示词说 8 条、实际要 24 行)。
949
+ const mine = ownerIdSet(batch);
950
+ for (const item of shard[arrayKey]) {
951
+ const idx = target[arrayKey].length;
952
+ target[arrayKey].push(item);
953
+ layout.__arrays[arrayKey].push(batch.key);
954
+ layout[arrayKey].push({
955
+ batchKey: batch.key,
956
+ // 2026-10-27:原来叫 indexInBatch,但它记的是**合并后目标数组的全局行号**,
957
+ // 不是批次内下标(第 45 行 ≠ 本批第 45 行)。名字误导会让人以为
958
+ // "本批写了 45 行"。改名成 indexInGlobal。
959
+ // 生产代码只读 batchKey / id / keys,不读这个字段,所以改名是安全的。
960
+ indexInGlobal: layout[arrayKey].length,
961
+ // 关系 / 归属类行没有 id 字段,合成一个定位键,
962
+ // 否则这类错误归因不到具体批次(见 synthRowId 的说明)。
963
+ id: item && item.id ? item.id : synthRowId(item),
964
+ keys: entryKeys(item)
965
+ });
966
+ if (mine && batch.ownerField) {
967
+ const v = item && item[batch.ownerField];
968
+ if (typeof v !== 'string' || !v || !mine.has(v)) {
969
+ ownershipViolations.push({
970
+ code: 'BATCH_OWNER_MISMATCH',
971
+ path: arrayKey + '[' + idx + '].' + batch.ownerField,
972
+ message: '这一行的 ' + batch.ownerField + '="' + (v || '(空)') + '" 不属于本批(' +
973
+ batch.shard + ' 负责 ' + batch.ownerField + ' ∈ ' + (batch.items || []).map((x) => x.id).join('/') + ')',
974
+ hint: '每批只写归属到本批实体的行;' + batch.ownerField + ' 不在本批名单里的行留给对应的批次写',
975
+ batchKey: batch.key
976
+ });
977
+ }
978
+ }
979
+ }
980
+ }
981
+ }
982
+
983
+ if (missing.length && !allowMissing) {
984
+ return {
985
+ ok: false,
986
+ error: '缺少分片:' + missing.join('、'),
987
+ path: null,
988
+ layout: layout,
989
+ missing: missing,
990
+ ownershipViolations: ownershipViolations
991
+ };
992
+ }
993
+
994
+ const outPath = o.outPath || path.join(stageDir, batches[0].file);
995
+ try {
996
+ fs.mkdirSync(stageDir, { recursive: true });
997
+ const tmp = outPath + '.tmp-' + process.pid;
998
+ fs.writeFileSync(tmp, JSON.stringify(target, null, 2), 'utf-8');
999
+ fs.renameSync(tmp, outPath);
1000
+ } catch (e) {
1001
+ return { ok: false, error: '写目标文件失败:' + e.message, path: outPath, layout: layout, missing: [], ownershipViolations: ownershipViolations };
1002
+ }
1003
+
1004
+ return {
1005
+ ok: true,
1006
+ error: null,
1007
+ path: outPath,
1008
+ layout: layout,
1009
+ missing: missing,
1010
+ ownershipViolations: ownershipViolations,
1011
+ // 已经写进去的数组名(中途校验时用它决定校验哪几个数组)
1012
+ writtenArrays: Object.keys(target)
1013
+ };
1014
+ }
1015
+
1016
+ /**
1017
+ * 本批"拥有"的实体 id 集合(按 ownerField 判定行归属)。
1018
+ * 没有 ownerField 或没有 items 的批次不参与归属校验(返回 null)。
1019
+ */
1020
+ function ownerIdSet(batch) {
1021
+ if (!batch || !batch.ownerField) return null;
1022
+ if (!Array.isArray(batch.items) || !batch.items.length) return null;
1023
+ const set = new Set();
1024
+ for (const it of batch.items) {
1025
+ if (it && it.id) set.add(it.id);
1026
+ }
1027
+ return set.size ? set : null;
1028
+ }
1029
+
1030
+ /**
1031
+ * 取一个条目的所有标识值(id / ch / xxx_id)。
1032
+ * 归因时用 path 里的标识反查条目,所以要把各种命名的 id 都收进来。
1033
+ */
1034
+ /**
1035
+ * 关系 / 归属类行的定位键。
1036
+ *
1037
+ * 这类行本身没有 id 字段(character_relations 的行是 {from,to,relation_kind},
1038
+ * faction_membership 是 {character,faction},item_owner 是 {item,character}…),
1039
+ * 而 entryKeys 只收 id / ch / *_id → 关系行一个键都没有。
1040
+ *
1041
+ * 后果:校验器报 `character_relations.C011→C013` 时,归因层一个形态都匹配不上
1042
+ * (entry.id 为 null、entry.keys 为空)→ 只能退化成"该数组的所有批次",
1043
+ * 模型被派去改 7 个批次,其中大部分根本没有那条错 → 改不动 → NO_CHANGE →
1044
+ * 反复重投(tt5 实测 2026-10-25 session-39fec2c0:b04 连续两次 NO_CHANGE)。
1045
+ *
1046
+ * 这里合成的键与校验器的 path 格式**逐字一致**:
1047
+ * character_relations / faction_relations → from→to
1048
+ * faction_membership → character@faction
1049
+ * branch_protagonist → branch
1050
+ * item_owner → item
1051
+ * location_conflict → location
1052
+ *
1053
+ * @param {Object} item 行对象
1054
+ * @returns {string|null}
1055
+ */
1056
+ export function synthRowId(item) {
1057
+ if (!item || typeof item !== 'object') return null;
1058
+ if (typeof item.from === 'string' && item.from && typeof item.to === 'string' && item.to) {
1059
+ return item.from + '→' + item.to;
1060
+ }
1061
+ if (typeof item.character === 'string' && item.character
1062
+ && typeof item.faction === 'string' && item.faction) {
1063
+ return item.character + '@' + item.faction;
1064
+ }
1065
+ if (typeof item.branch === 'string' && item.branch) return item.branch;
1066
+ if (typeof item.item === 'string' && item.item) return item.item;
1067
+ if (typeof item.location === 'string' && item.location) return item.location;
1068
+ return null;
1069
+ }
1070
+
1071
+ export function entryKeys(item) {
1072
+ if (!item || typeof item !== 'object') return [];
1073
+ const out = [];
1074
+ for (const key of Object.keys(item)) {
1075
+ const v = item[key];
1076
+ if (key === 'id' || key === 'ch' || /_id$/.test(key)) {
1077
+ if (typeof v === 'string' && v) out.push(v);
1078
+ else if (typeof v === 'number' && isFinite(v)) out.push(String(v));
1079
+ }
1080
+ }
1081
+ return out;
1082
+ }
1083
+
1084
+ // ============================================================
1085
+ // 错误归因
1086
+ // ============================================================
1087
+
1088
+ /**
1089
+ * 跨文件错误码 → 涉及的顶层键。
1090
+ *
1091
+ * 节奏阶段有一批错误**天然跨文件**:脚本判的是"两个文件合起来对不对",
1092
+ * 单看 path 只能定位到其中一边(例如 RHYTHM_OVERLAP_COUNT 的 path 是
1093
+ * "节奏参数.overlap_chapter_count",但真正要改的往往是 main_line_timeline
1094
+ * 或 branch_timeline)。只按 path 归因会导致模型去改错的那一边,
1095
+ * 下一轮还是同样的错。
1096
+ *
1097
+ * 这里显式列出"这个错误牵涉哪些顶层键",归因时把这些键的全部批次都拉进来重投,
1098
+ * 让模型自己判断该改哪边(错误消息里带着实际值与期望值)。
1099
+ */
1100
+ export const RHYTHM_CROSS_FILE_OWNERS = {
1101
+ // 主线 key_nodes 数 ≠ main_progress_chapter_count
1102
+ RHYTHM_NODES_COUNT: ['main_line_timeline', '节奏参数'],
1103
+ // 支线章数之和 ≠ branch_chapter_total
1104
+ RHYTHM_BRANCH_TOTAL: ['branch_timeline', '节奏参数'],
1105
+ // 支线章集 ∩ 主线 key_node 章集 ≠ overlap_chapter_count
1106
+ RHYTHM_OVERLAP_COUNT: ['main_line_timeline', 'branch_timeline', '节奏参数'],
1107
+ // 结局章不在主线 key_nodes 里
1108
+ RHYTHM_COVERAGE: ['ending_timeline', 'main_line_timeline'],
1109
+ // 全书级结局没回收所有大伏笔
1110
+ RHYTHM_LARGE_FS_NOT_RECOVERED: ['ending_timeline', 'foreshadow_timeline'],
1111
+ // 主线节点章节重复
1112
+ RHYTHM_NODE_DUP: ['main_line_timeline'],
1113
+ // 支线区间重叠
1114
+ RHYTHM_BRANCH_OVERLAP: ['branch_timeline'],
1115
+ // 钩子数低于下限
1116
+ RHYTHM_HOOK_MIN: ['hook_timeline', '节奏参数'],
1117
+ // 三个参数不满足 7 条约束
1118
+ RHYTHM_INFEASIBLE: ['节奏参数'],
1119
+ RHYTHM_CONSTRAINT_EVAL: ['节奏参数'],
1120
+ RHYTHM_DERIVATION_MISSING: ['节奏参数'],
1121
+ RHYTHM_DERIVATION_EXTRA: ['节奏参数']
1122
+ };
1123
+
1124
+ /**
1125
+ * 把校验错误按 path 归因到具体批次。
1126
+ *
1127
+ * flesh-validate 的 path 形态(实测枚举):
1128
+ * characters[3].name —— 数组名 + 下标
1129
+ * items.P001 —— 数组名 + id
1130
+ * 骨架关系 C001→C002 —— 跨文件,无法按条目归因
1131
+ *
1132
+ * rhythm-validate 的 path 形态(实测枚举):
1133
+ * chapters[12].act —— 数组名 + 下标
1134
+ * outlines[40].word_target
1135
+ * hook_timeline.300 —— 数组名 + 章号(条目标识不是 id,是 ch)
1136
+ * ending_timeline.E001.chapter
1137
+ * 节奏参数.overlap_chapter_count —— 顶层对象字段
1138
+ * ch:412 —— 章号(RHYTHM_CHAPTER_UNREF)
1139
+ *
1140
+ * @param {Array} errors 校验器返回的 errors[]
1141
+ * @param {Object} layout mergeFleshShards 返回的 layout
1142
+ * @param {Object} [opts]
1143
+ * @param {Object} [opts.codeOwners] 错误码 → 涉及的顶层键列表(见 RHYTHM_CROSS_FILE_OWNERS)
1144
+ * @param {Function} [opts.pathKeys] (path) => 顶层键|null
1145
+ * 专用路径解析器,**优先于通用形态匹配**。
1146
+ * 存在的理由:有些错误的 path 指向"声明方",根因却在"被核对方"。
1147
+ * 例如骨架的 QUOTA_MISMATCH,path 是 "quota.character_counts.protagonist"
1148
+ * (声明在框架里),但真正要改的是实体清单里少写了一个主角。
1149
+ * 按通用规则会归到框架批次,模型就会去改 quota —— 下一轮还是同样的错。
1150
+ * @returns {Object} { byBatch: {batchKey: errors[]}, unattributed: errors[] }
1151
+ */
1152
+ export function attributeErrors(errors, layout, opts) {
1153
+ const o = opts || {};
1154
+ const codeOwners = o.codeOwners || null;
1155
+ const pathKeys = typeof o.pathKeys === 'function' ? o.pathKeys : null;
1156
+ const byBatch = {};
1157
+ const unattributed = [];
1158
+ const list = Array.isArray(errors) ? errors : [];
1159
+
1160
+ for (const err of list) {
1161
+ const hit = attributeOne(err, layout, pathKeys);
1162
+ const keys = [];
1163
+ if (hit && hit.length) {
1164
+ for (const k of hit) if (keys.indexOf(k) < 0) keys.push(k);
1165
+ }
1166
+ // 跨文件错误:path 只能定位到一边,把牵涉的顶层键的批次一起拉进来
1167
+ const owners = codeOwners && err && err.code ? codeOwners[err.code] : null;
1168
+ if (owners) {
1169
+ for (const ownerKey of owners) {
1170
+ for (const k of batchesOfKey(layout, ownerKey)) {
1171
+ if (keys.indexOf(k) < 0) keys.push(k);
1172
+ }
1173
+ }
1174
+ }
1175
+ if (!keys.length) {
1176
+ unattributed.push(err);
1177
+ continue;
1178
+ }
1179
+ for (const batchKey of keys) {
1180
+ if (!byBatch[batchKey]) byBatch[batchKey] = [];
1181
+ byBatch[batchKey].push(err);
1182
+ }
1183
+ }
1184
+
1185
+ return { byBatch: byBatch, unattributed: unattributed };
1186
+ }
1187
+
1188
+ /**
1189
+ * 取某个顶层键涉及的全部批次 key。
1190
+ * 数组键 → 该数组所有条目所在的批次;整文件型产物 → 那一个批次。
1191
+ *
1192
+ * 三级兜底,空了就往下查(不能因为"有登记但登记的是空数组"就提前返回):
1193
+ * __arrays —— 实际有条目的批次(最精确)
1194
+ * __declared —— 声明该数组的批次(模型整个数组漏写时靠它兜底)
1195
+ * __byName —— 整文件型产物(顶层键 → 唯一一个批次)
1196
+ */
1197
+ function batchesOfKey(layout, key) {
1198
+ const arrays = (layout && layout.__arrays) || {};
1199
+ if (arrays[key] && arrays[key].length) return arrays[key].slice();
1200
+ const declared = (layout && layout.__declared) || {};
1201
+ if (declared[key] && declared[key].length) return declared[key].slice();
1202
+ const byName = (layout && layout.__byName) || {};
1203
+ if (byName[key]) return [byName[key]];
1204
+ return [];
1205
+ }
1206
+
1207
+ /**
1208
+ * 把一条错误定位到批次。
1209
+ *
1210
+ * path 形态(血肉 / 节奏实测枚举):
1211
+ * key[i].field —— 数组名 + 下标,精确到条目
1212
+ * key.标识 —— 数组名 + id/章号,精确到条目
1213
+ * key.标识.field —— 数组名 + id + 子字段(ending_timeline.E001.chapter)
1214
+ * ch:123 —— 章号(该章在 hook_timeline 里没有覆盖)
1215
+ * key —— 整个数组(如"hook 有重复"),落到该数组的所有批次
1216
+ * 其它 / 空 —— 归不了(跨文件错误),留给调用方兜底
1217
+ *
1218
+ * @returns {string[]|null} 批次 key 列表;null 表示归不了
1219
+ */
1220
+ function attributeOne(err, layout, pathKeys) {
1221
+ const p = err && typeof err.path === 'string' ? err.path : '';
1222
+ if (!p) return null;
1223
+
1224
+ // 形态零:专用路径解析器优先(骨架的 quota.<字段> → 实体数组)
1225
+ if (pathKeys) {
1226
+ const key = pathKeys(p);
1227
+ if (key) {
1228
+ const keys = batchesOfKey(layout, key);
1229
+ if (keys.length) return keys;
1230
+ }
1231
+ }
1232
+
1233
+ // 形态零·补:key[标识].field —— 标识命中即算
1234
+ // (character_timeline[C001].arc_stages[0].chapter)
1235
+ // 节奏阶段的错误 path 用**业务主键**而不是合并后的全局下标:
1236
+ // 下标模型看不懂(不知道第 3 条是哪个角色),业务键才定位得准。
1237
+ // 这个形态只认「标识能命中 entry.id / entry.keys」的情况,
1238
+ // 认不出就继续往下走(形态一负责 key[数字])。
1239
+ const m0b = p.match(/^([^.[\]]+)\[([^\[\]]+)\]/);
1240
+ if (m0b) {
1241
+ const arr = layout[m0b[1]];
1242
+ if (Array.isArray(arr)) {
1243
+ for (const entry of arr) {
1244
+ if (entry.id && entry.id === m0b[2]) return [entry.batchKey];
1245
+ if (Array.isArray(entry.keys) && entry.keys.indexOf(m0b[2]) >= 0) return [entry.batchKey];
1246
+ }
1247
+ }
1248
+ }
1249
+
1250
+ // 形态一:key[i].field(键名允许中文)
1251
+ const m1 = p.match(/^([^.[\]]+)\[(\d+)\]/);
1252
+ if (m1) {
1253
+ const arr = layout[m1[1]];
1254
+ if (Array.isArray(arr)) {
1255
+ const idx = Number(m1[2]);
1256
+ if (idx >= 0 && idx < arr.length) return [arr[idx].batchKey];
1257
+ // 下标越界 = 这个条目根本不存在(合并结果里没那么多条),
1258
+ // 落到任何一个批次都是错的 → 明确归不了,让调用方兜底。
1259
+ return null;
1260
+ }
1261
+ // 键名不在布局里(如校验器报了另一个文件/另一个阶段的键):
1262
+ // **继续往下走**(形态二~六),形态六还能按顶层键退化到
1263
+ // "该数组的所有批次";直接 return null 会让这条错归不到 →
1264
+ // 触发整段重跑(白烧额度)。
1265
+ }
1266
+
1267
+ // 形态二:key.标识.field —— 标识命中即算(ending_timeline.E001.chapter)
1268
+ const m2b = p.match(/^([^.[\]]+)\.([^.[\]]+)\./);
1269
+ if (m2b) {
1270
+ const arr = layout[m2b[1]];
1271
+ if (Array.isArray(arr)) {
1272
+ for (const entry of arr) {
1273
+ if (entry.id && entry.id === m2b[2]) return [entry.batchKey];
1274
+ if (Array.isArray(entry.keys) && entry.keys.indexOf(m2b[2]) >= 0) return [entry.batchKey];
1275
+ }
1276
+ // 标识认不出来 → 别急着放弃,往下走形态五/六(按顶层键退化)。
1277
+ }
1278
+ }
1279
+
1280
+ // 形态三:key.标识(id / 章号 / 名称)
1281
+ const m2 = p.match(/^([^.[\]]+)\.([^.[\]]+)$/);
1282
+ if (m2) {
1283
+ const arr = layout[m2[1]];
1284
+ if (Array.isArray(arr)) {
1285
+ for (const entry of arr) {
1286
+ if (entry.id && entry.id === m2[2]) return [entry.batchKey];
1287
+ if (Array.isArray(entry.keys) && entry.keys.indexOf(m2[2]) >= 0) return [entry.batchKey];
1288
+ }
1289
+ // 标识认不出来(**名称**型标识就是这种情况,见形态六)→ 往下退化。
1290
+ } else {
1291
+ // 整文件型产物(节奏参数.json)
1292
+ const byName = layout.__byName || {};
1293
+ if (byName[m2[1]]) return [byName[m2[1]]];
1294
+ // 往下退化。
1295
+ }
1296
+ }
1297
+
1298
+ // 形态三·补:裸 id(path 整体就是一个 id,如 "C001")
1299
+ //
1300
+ // 哪些错误长这样:
1301
+ // G_ISOLATED(骨架图孤立节点)—— path 直接是节点 key(skeleton-validate.js: path = n.key)
1302
+ // 关系类错误里 from/to 只有一个被写出来时也会这样
1303
+ // 之前一个形态都匹配不上(形态一要 `key[i]`、形态二/三要带点)→
1304
+ // 归不到批次 → 整段重跑。
1305
+ if (!/[.[]/.test(p)) {
1306
+ for (const arr of Object.values(layout)) {
1307
+ if (!Array.isArray(arr)) continue;
1308
+ for (const entry of arr) {
1309
+ if (entry.id && entry.id === p) return [entry.batchKey];
1310
+ if (Array.isArray(entry.keys) && entry.keys.indexOf(p) >= 0) return [entry.batchKey];
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ // 形态四:ch:123 —— 该章没有任何事件/角色/伏笔/钩子引用。
1316
+ // 章号的唯一真源是时序映射里的 hook_timeline,所以先找覆盖该章的那个批次;
1317
+ // 找不到说明这一章压根没被 hook_timeline 覆盖,负责 hook_timeline 的批次都要重投。
1318
+ const m4 = p.match(/^ch:(\d+)$/);
1319
+ if (m4) {
1320
+ const ch = m4[1];
1321
+ const arr = layout.hook_timeline;
1322
+ if (Array.isArray(arr)) {
1323
+ for (const entry of arr) {
1324
+ if (Array.isArray(entry.keys) && entry.keys.indexOf(ch) >= 0) return [entry.batchKey];
1325
+ }
1326
+ }
1327
+ const owners = (layout.__arrays || {}).hook_timeline;
1328
+ if (owners && owners.length) return owners.slice();
1329
+ return null;
1330
+ }
1331
+
1332
+ // 形态五:整个数组
1333
+ // 声明兜底:模型整个数组漏写时(合并结果里没这个键),
1334
+ // 错误要归到"负责这个数组的批次"上,而不是变成归不了的错。
1335
+ const wholeKey = batchesOfKey(layout, p);
1336
+ if (wholeKey.length) return wholeKey;
1337
+
1338
+ // 形态六:顶层键退化 —— path 的顶层键是一个已知数组时,归到该数组的所有批次。
1339
+ //
1340
+ // 为什么必须有这一层(tt5 骨架实测):
1341
+ // 骨架校验器的 NAME_DUP 路径是 **名称** 而不是 id
1342
+ // (skeleton-validate.js: path = key + '.' + item.name),
1343
+ // 而布局里每条只登记了 id(entryKeys 只收 id/ch/*_id),
1344
+ // 于是 "pleasures.修复技艺碾压质疑者" 这种路径**一个条目都匹配不上**。
1345
+ //
1346
+ // 归不了批次的代价极大:stage-driver 的兜底会把同一份错误发给
1347
+ // **所有**批次并整阶段重跑(tt5 实测:13 个批次各收到同一份 15 条
1348
+ // pleasures 重名,模型去改根本不属于它的批次,白烧额度还引入新错)。
1349
+ //
1350
+ // 退化成"这个数组的所有批次"是对的:重名/重 ID 的成因在数组内部,
1351
+ // 该数组的批次全都可能牵涉(同名条目可能分属不同批次)。
1352
+ const top = p.split(/[.[]/)[0];
1353
+ if (top && top !== p) {
1354
+ const byTop = batchesOfKey(layout, top);
1355
+ if (byTop.length) return byTop;
1356
+ }
1357
+
1358
+ return null;
1359
+ }
1360
+
1361
+ /**
1362
+ * 找出某个文件里还缺哪些分片(用于判断"这一批写没写出来")。
1363
+ */
1364
+ export function missingShards(buildDir, batches) {
1365
+ const missing = [];
1366
+ for (const batch of batches || []) {
1367
+ if (!fs.existsSync(path.join(buildDir, batch.shard))) missing.push(batch.shard);
1368
+ }
1369
+ return missing;
1370
+ }
1371
+