@cloud411716/fancy-webnovel 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/client.js +201 -15
  2. package/cordis.patch.yml +5 -0
  3. package/docs/douyin-plugin.md +118 -0
  4. package/docs/material-library/README.md +404 -0
  5. package/docs/material-library/import.js +437 -0
  6. package/docs/material-library/prompt.md +370 -0
  7. package/docs/material-library/queries.surql +94 -0
  8. package/docs/material-library/sample.jsonl +14 -0
  9. package/docs/material-library/schema.surql +46 -0
  10. package/docs/mcp-profile.example.yml +24 -0
  11. package/docs/story-graph-design.md +602 -0
  12. package/docs/story-graph-validation.md +81 -0
  13. package/docs/workspace-mcp.md +284 -0
  14. package/index.js +11 -7
  15. package/lib/atomic-write.js +109 -0
  16. package/lib/chapter-order.js +100 -0
  17. package/lib/confirmation.js +80 -0
  18. package/lib/db-init.js +123 -0
  19. package/lib/deployed-state.js +11 -11
  20. package/lib/direction-labels.js +265 -0
  21. package/lib/error-codes.js +31 -31
  22. package/lib/infra.js +29 -4
  23. package/lib/material-db.js +149 -0
  24. package/lib/path-guard.js +150 -0
  25. package/lib/projects.js +400 -0
  26. package/lib/scraper-utils.js +140 -0
  27. package/lib/surreal-server.js +338 -0
  28. package/lib/topic-decision.js +860 -0
  29. package/lib/topic-fields.js +251 -0
  30. package/lib/topic-schema.js +225 -0
  31. package/lib/workspace.js +501 -0
  32. package/package.json +29 -4
  33. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  34. package/plugins/fancy-analyze/index.js +1128 -77
  35. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  36. package/plugins/fancy-analyze/schema-analyze.md +355 -107
  37. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  38. package/plugins/fancy-analyze/summary-progress.js +71 -7
  39. package/plugins/fancy-analyze/templates.js +437 -139
  40. package/plugins/fancy-bootstrap/index.js +276 -131
  41. package/plugins/fancy-bootstrap/scripts/init.js +59 -25
  42. package/plugins/fancy-bootstrap/templates.js +35 -18
  43. package/plugins/fancy-db/index.js +172 -0
  44. package/plugins/fancy-douyin/deploy.js +134 -0
  45. package/plugins/fancy-douyin/index.js +551 -0
  46. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  47. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  48. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  49. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  50. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  51. package/plugins/fancy-douyin/state.js +197 -0
  52. package/plugins/fancy-douyin/templates.js +127 -0
  53. package/plugins/fancy-export/exporter.js +805 -0
  54. package/plugins/fancy-export/index.js +63 -0
  55. package/plugins/fancy-import/import-driver.js +29 -6
  56. package/plugins/fancy-import/index.js +13 -5
  57. package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
  58. package/plugins/fancy-import/templates.js +17 -8
  59. package/plugins/fancy-material/exporter.js +153 -0
  60. package/plugins/fancy-material/genres.js +256 -0
  61. package/plugins/fancy-material/index.js +835 -0
  62. package/plugins/fancy-material/inventory.js +143 -0
  63. package/plugins/fancy-material/kinds.js +313 -0
  64. package/plugins/fancy-material/queue.js +55 -0
  65. package/plugins/fancy-material/scripts/query.js +254 -0
  66. package/plugins/fancy-material/templates.js +258 -0
  67. package/plugins/fancy-material/tool-deploy.js +138 -0
  68. package/plugins/fancy-material/validate.js +149 -0
  69. package/plugins/fancy-project/index.js +133 -0
  70. package/plugins/fancy-scan/existing-block.js +100 -0
  71. package/plugins/fancy-scan/index.js +410 -21
  72. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  73. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  74. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  75. package/plugins/fancy-scan/scripts/run-scan.js +169 -22
  76. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
  77. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
  78. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
  79. package/plugins/fancy-scan/templates/blocks.js +155 -0
  80. package/plugins/fancy-scan/templates/platforms.js +372 -0
  81. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  82. package/plugins/fancy-scan/templates.js +545 -146
  83. package/plugins/fancy-scan/tool-deploy.js +335 -0
  84. package/plugins/fancy-scan/wait-turn.js +46 -0
  85. package/plugins/fancy-story-builder/db/README.md +106 -0
  86. package/plugins/fancy-story-builder/db/client.js +228 -0
  87. package/plugins/fancy-story-builder/db/commands.js +145 -0
  88. package/plugins/fancy-story-builder/db/ids.js +39 -0
  89. package/plugins/fancy-story-builder/db/import.js +798 -0
  90. package/plugins/fancy-story-builder/db/project.js +299 -0
  91. package/plugins/fancy-story-builder/db/query.js +361 -0
  92. package/plugins/fancy-story-builder/db/schema.js +417 -0
  93. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  94. package/plugins/fancy-story-builder/flesh-output.js +19 -6
  95. package/plugins/fancy-story-builder/index.js +4105 -396
  96. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  97. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
  98. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
  99. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
  100. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
  101. package/plugins/fancy-story-builder/purpose-template.md +42 -66
  102. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
  103. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  104. package/plugins/fancy-story-builder/references/glossary.md +15 -6
  105. package/plugins/fancy-story-builder/references/index.md +97 -6
  106. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
  107. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
  108. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
  109. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
  110. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
  111. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
  112. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
  113. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
  114. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
  115. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  116. package/plugins/fancy-story-builder/schema-setting.md +201 -293
  117. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  118. package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
  119. package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
  120. package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
  121. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  122. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
  123. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  124. package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
  125. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
  126. package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
  127. package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
  128. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  129. package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
  130. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  131. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  132. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  133. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  134. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  135. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  136. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  137. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  138. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  139. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  140. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  141. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  142. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  143. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  144. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  145. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  146. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  147. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  148. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  149. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  150. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  151. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  152. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  153. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  154. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  155. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  156. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  157. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  158. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  159. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  160. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  161. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  162. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  163. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  164. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  165. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  166. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  167. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  168. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  169. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  170. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  171. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  172. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  173. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  174. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  175. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  176. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  177. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  178. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  179. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  180. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  181. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  182. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  183. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  184. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  185. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  186. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  187. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  188. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  189. 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
  190. 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
  191. 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
  192. 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
  193. 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
  194. 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
  195. 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
  196. 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
  197. 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
  198. 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
  199. 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
  200. 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
  201. 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
  202. package/plugins/fancy-story-builder/templates.js +272 -74
  203. package/plugins/fancy-story-builder/wiki-references.js +429 -93
  204. package/plugins/fancy-story-builder/writing-driver.js +336 -9
  205. package/plugins/fancy-topic/index.js +414 -61
  206. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  207. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  208. package/plugins/fancy-topic/scripts/select-topic.js +106 -63
  209. package/plugins/fancy-topic/templates.js +225 -122
  210. package/plugins/fancy-topic/tool-deploy.js +200 -0
  211. package/lib/safe-rm.js +0 -94
@@ -293,6 +293,7 @@ export function buildSemanticLintPrompt(ctx) {
293
293
  export function collectFleshEntities(fleshDir) {
294
294
  const FILES = {
295
295
  '角色.json': ['characters'],
296
+ '其他实体.json': ['other_entities'],
296
297
  '冲突.json': ['conflicts'],
297
298
  '势力.json': ['factions'],
298
299
  '剧情.json': ['main_lines', 'branches'],
@@ -342,7 +343,10 @@ export function collectSkeletonRelations(skeletonDir) {
342
343
  }
343
344
  const out = [];
344
345
  for (const r of (data.character_relations || [])) {
345
- if (r && r.from && r.to) out.push({ from: r.from, to: r.to, kind: r.relation_kind || '' });
346
+ if (r && r.from && r.to) out.push({ from: r.from, to: r.to, kind: (r.relation_kind || '') + (r.relation_label ? '/' + r.relation_label : '') });
347
+ }
348
+ for (const r of (data.other_relations || [])) {
349
+ if (r && r.from && r.to) out.push({ from: r.from, to: r.to, kind: r.relation_label || '' });
346
350
  }
347
351
  for (const r of (data.faction_relations || [])) {
348
352
  if (r && r.from && r.to) out.push({ from: r.from, to: r.to, kind: r.relation_type || '' });
@@ -1,8 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * 骨架阶段校验脚本 v2.4
3
+ * 骨架阶段校验脚本 v2.5
4
4
  * 源码中零反引号(所有字符串使用单/双引号或拼接),避免 markdown 渲染问题。
5
5
  *
6
+ * v2.5 变更:
7
+ * - 新增 NAME_ALIAS_SUSPECT:名称归一化(去括号/标点/常见机构后缀)后
8
+ * 相同,但字面不同 → warning,提示可能是同一实体的全称/简称/别名。
9
+ * - CROSS_TYPE_NAME_DUP 改用同一套归一化口径,能抓「企鹅集团 vs 企鹅」
10
+ * 这种跨类型软重复。
11
+ *
6
12
  * v2.4 变更(修复批次 P0-2 / P2-4 / P2-8):
7
13
  * - P0-2:evalExpr 正则允许 = < > ! & | ? :(原版不允许,导致
8
14
  * formulas-skeleton.json 的 ending_elements.ending_count 的 expr_low/expr_high
@@ -237,7 +243,7 @@ function charsetAndForbiddenCheck(data, errors) {
237
243
  // ============================================================
238
244
  function idUniquenessCheck(data, fileType, errors) {
239
245
  const ID_ARRAYS = {
240
- '实体': ['characters', 'conflicts', 'factions', 'locations', 'items', 'endings', 'foreshadows', 'climaxes', 'pleasures', 'world_rules', 'main_lines', 'branches'],
246
+ '实体': ['characters', 'other_entities', 'conflicts', 'factions', 'locations', 'items', 'endings', 'foreshadows', 'climaxes', 'pleasures', 'world_rules', 'main_lines', 'branches'],
241
247
  '层级': ['volumes']
242
248
  };
243
249
  // 记出现次数:光说"重复"模型不知道该删几条
@@ -260,12 +266,43 @@ function idUniquenessCheck(data, fileType, errors) {
260
266
  }
261
267
  }
262
268
 
269
+ const NAME_ARRAYS_BY_FILE = {
270
+ '实体': ['characters', 'other_entities', 'conflicts', 'factions', 'locations', 'items', 'endings', 'foreshadows', 'climaxes', 'pleasures', 'world_rules', 'main_lines', 'branches'],
271
+ '层级': ['volumes']
272
+ };
273
+
274
+ /**
275
+ * 归一化实体名,用于发现“同一实体换了写法”:
276
+ * - 去空白 / 标点 / 括号注释;
277
+ * - 去常见机构后缀(集团/公司/大学/协会…),
278
+ * 这样「企鹅集团」和「企鹅」会落到同一个 key;
279
+ * - 不做拼音/翻译,跨语言别名仍交给 LLM 审计。
280
+ */
281
+ const GENERIC_NAME_SUFFIXES = [
282
+ '股份有限公司', '有限公司', '集团', '公司', '大学', '学院', '学校',
283
+ '医院', '银行', '协会', '商会', '联盟', '家族', '世家', '王朝',
284
+ '帝国', '组织', '机构', '政府', '部门'
285
+ ];
286
+
287
+ function normalizeEntityName(name) {
288
+ let s = String(name || '').trim().toLowerCase();
289
+ s = s.replace(/[((][^))]*[))]/g, '');
290
+ s = s.replace(/[\s·・,,。.、::;;!!??'"“”‘’《》<>【】\[\]()()\-—_/\\|]/g, '');
291
+ let changed = true;
292
+ while (changed) {
293
+ changed = false;
294
+ for (const suffix of GENERIC_NAME_SUFFIXES) {
295
+ if (s.length > suffix.length + 1 && s.endsWith(suffix)) {
296
+ s = s.slice(0, -suffix.length);
297
+ changed = true;
298
+ }
299
+ }
300
+ }
301
+ return s;
302
+ }
303
+
263
304
  function nameUniquenessCheck(data, fileType, errors) {
264
- const NAME_ARRAYS = {
265
- '实体': ['characters', 'conflicts', 'factions', 'locations', 'items', 'endings', 'foreshadows', 'climaxes', 'pleasures', 'world_rules', 'main_lines', 'branches'],
266
- '层级': ['volumes']
267
- };
268
- for (const key of NAME_ARRAYS[fileType] || []) {
305
+ for (const key of NAME_ARRAYS_BY_FILE[fileType] || []) {
269
306
  const seen = new Map();
270
307
  for (const item of data[key] || []) {
271
308
  const n = item.name;
@@ -279,6 +316,35 @@ function nameUniquenessCheck(data, fileType, errors) {
279
316
  }
280
317
  }
281
318
 
319
+ /**
320
+ * 软重复检查:名称归一化后相同,但字面不同。
321
+ * 只报 warning,不自动合并 —— 可能是同一实体,也可能是“阳城”与“阳城大学”
322
+ * 这种上下位关系,必须让作者/模型确认。
323
+ */
324
+ function nameAliasCheck(data, fileType, warnings) {
325
+ for (const key of NAME_ARRAYS_BY_FILE[fileType] || []) {
326
+ const byNormalized = new Map();
327
+ for (const item of data[key] || []) {
328
+ if (!item.name) continue;
329
+ const normalized = normalizeEntityName(item.name);
330
+ if (!normalized) continue;
331
+ if (!byNormalized.has(normalized)) byNormalized.set(normalized, []);
332
+ byNormalized.get(normalized).push({ id: item.id, name: String(item.name) });
333
+ }
334
+ for (const [normalized, entries] of byNormalized) {
335
+ const distinctNames = [...new Set(entries.map((e) => e.name))];
336
+ if (distinctNames.length <= 1) continue;
337
+ warnings.push({
338
+ code: 'NAME_ALIAS_SUSPECT',
339
+ path: key + '.' + distinctNames.join(' / '),
340
+ message: '名称 ' + distinctNames.map((n) => '"' + n + '"').join('、') +
341
+ ' 归一化后相同(' + normalized + '),可能是同一实体的全称/简称/别名',
342
+ hint: '确认是同一实体时只保留一个正式名,把另一个的设定合并过去;不要把称呼、简称、集团/公司后缀单独建成新实体'
343
+ });
344
+ }
345
+ }
346
+ }
347
+
282
348
  // ============================================================
283
349
  // 自环关系检查
284
350
  // ============================================================
@@ -294,6 +360,17 @@ function selfRelationCheck(data, errors) {
294
360
  });
295
361
  }
296
362
  }
363
+ for (let i = 0; i < (data.other_relations || []).length; i++) {
364
+ const r = data.other_relations[i];
365
+ if (r.from && r.to && r.from === r.to) {
366
+ errors.push({
367
+ code: 'NO_SELF_RELATION',
368
+ path: 'other_relations[' + i + ']',
369
+ message: '其他实体关系 from 和 to 不能相同(' + r.from + ')',
370
+ hint: '自环关系无意义;改为指向人物或另一个实体'
371
+ });
372
+ }
373
+ }
297
374
  for (let i = 0; i < (data.faction_relations || []).length; i++) {
298
375
  const r = data.faction_relations[i];
299
376
  if (r.from && r.to && r.from === r.to) {
@@ -307,6 +384,36 @@ function selfRelationCheck(data, errors) {
307
384
  }
308
385
  }
309
386
 
387
+ // ============================================================
388
+ // 扩展关系标签检查
389
+ // ============================================================
390
+ // `其他` 是词表外的扩展出口。只写"其他"等于没写关系;
391
+ // 必须用 relation_label 说清是附身、寄生、契约还是别的。
392
+ function relationLabelCheck(data, errors) {
393
+ for (const [i, r] of (data.character_relations || []).entries()) {
394
+ if (!r || r.relation_kind !== '其他') continue;
395
+ const label = typeof r.relation_label === 'string' ? r.relation_label.trim() : '';
396
+ if (!label) {
397
+ errors.push({
398
+ code: 'REL_LABEL_REQUIRED',
399
+ path: 'character_relations[' + i + '].relation_label',
400
+ message: 'relation_kind="其他" 时必须填 relation_label,写清具体关系(如 附身/寄生/契约/共生)',
401
+ hint: 'relation_label 是词表外关系的唯一语义来源;只写"其他"下游无法判断该怎么写'
402
+ });
403
+ }
404
+ }
405
+ for (const [i, r] of (data.other_relations || []).entries()) {
406
+ if (!r || typeof r.relation_label !== 'string' || !r.relation_label.trim()) {
407
+ errors.push({
408
+ code: 'REL_LABEL_REQUIRED',
409
+ path: 'other_relations[' + i + '].relation_label',
410
+ message: 'other_relations 必须填 relation_label,写清具体关系(如 附身/寄宿/共生/镇压)',
411
+ hint: '非人物实体的关系没有词表可依,relation_label 是唯一语义来源'
412
+ });
413
+ }
414
+ }
415
+ }
416
+
310
417
  // ============================================================
311
418
  // 关系数组组合重复检查(7 类)
312
419
  // ============================================================
@@ -340,6 +447,20 @@ function relationDupCheck(data, errors) {
340
447
  crSet.set(k, r.relation_kind);
341
448
  }
342
449
 
450
+ const orSet = new Set();
451
+ for (const r of (data.other_relations || [])) {
452
+ const k = r.from + '→' + r.to;
453
+ if (orSet.has(k)) {
454
+ errors.push({
455
+ code: 'ID_DUP',
456
+ path: 'other_relations.' + k,
457
+ message: '其他实体关系 ' + k + ' 重复',
458
+ hint: '同一对存在只能保留一条关系;不同关系请用 relation_label 写清,不要重复建行'
459
+ });
460
+ }
461
+ orSet.add(k);
462
+ }
463
+
343
464
  const frSet = new Set();
344
465
  for (const r of (data.faction_relations || [])) {
345
466
  const k = r.from + '→' + r.to;
@@ -439,7 +560,8 @@ function relationDupCheck(data, errors) {
439
560
  // ============================================================
440
561
  function derivationCheck(data, formulas, errors, warnings) {
441
562
  const der = data.derivations || {};
442
- const formulaKeys = new Set(Object.keys(formulas));
563
+ // v2 的 custom.* 公式只用于 10-scale 的 scale.json;旧骨架阶段不消费它们。
564
+ const formulaKeys = new Set(Object.keys(formulas).filter((k) => !k.startsWith('custom.')));
443
565
  const derKeys = new Set(Object.keys(der));
444
566
 
445
567
  for (const k of formulaKeys) {
@@ -466,6 +588,7 @@ function derivationCheck(data, formulas, errors, warnings) {
466
588
 
467
589
  const ctx = buildFormulaContext(data);
468
590
  for (const [k, spec] of Object.entries(formulas)) {
591
+ if (k.startsWith('custom.')) continue;
469
592
  const d = der[k];
470
593
  if (!d) continue;
471
594
  let realLow, realHigh;
@@ -641,7 +764,8 @@ function relationRefCheck(data, entityData, errors) {
641
764
  items: new Set((entityData.items || []).map(i => i.id)),
642
765
  endings: new Set((entityData.endings || []).map(e => e.id)),
643
766
  branches: new Set((entityData.branches || []).map(b => b.id)),
644
- main_lines: new Set((entityData.main_lines || []).map(m => m.id))
767
+ main_lines: new Set((entityData.main_lines || []).map(m => m.id)),
768
+ other_entities: new Set((entityData.other_entities || []).map(o => o.id))
645
769
  };
646
770
 
647
771
  const ref = (val, set, label, pathStr) => {
@@ -660,6 +784,11 @@ function relationRefCheck(data, entityData, errors) {
660
784
  ref(r.from, ids.characters, '角色', 'character_relations[' + i + '].from');
661
785
  ref(r.to, ids.characters, '角色', 'character_relations[' + i + '].to');
662
786
  }
787
+ for (const [i, r] of (data.other_relations || []).entries()) {
788
+ ref(r.from, ids.other_entities, '其他实体', 'other_relations[' + i + '].from');
789
+ if (String(r.to || '').startsWith('C')) ref(r.to, ids.characters, '角色', 'other_relations[' + i + '].to');
790
+ else ref(r.to, ids.other_entities, '其他实体', 'other_relations[' + i + '].to');
791
+ }
663
792
  for (const [i, r] of (data.faction_membership || []).entries()) {
664
793
  ref(r.character, ids.characters, '角色', 'faction_membership[' + i + '].character');
665
794
  ref(r.faction, ids.factions, '势力', 'faction_membership[' + i + '].faction');
@@ -1121,25 +1250,29 @@ function checkWorldRuleCategory(entityData, errors) {
1121
1250
  // ============================================================
1122
1251
  function crossTypeNameDupCheck(entityData, warnings) {
1123
1252
  if (!entityData) return;
1124
- const types = ['characters', 'factions', 'conflicts', 'locations'];
1125
- const typeLabels = { characters: '角色', factions: '势力', conflicts: '冲突', locations: '地点' };
1253
+ const types = ['characters', 'other_entities', 'factions', 'conflicts', 'locations'];
1254
+ const typeLabels = { characters: '角色', other_entities: '其他实体', factions: '势力', conflicts: '冲突', locations: '地点' };
1126
1255
  const nameMap = new Map();
1127
1256
  for (const t of types) {
1128
1257
  for (const item of (entityData[t] || [])) {
1129
1258
  if (!item.name) continue;
1130
- if (!nameMap.has(item.name)) nameMap.set(item.name, []);
1131
- nameMap.get(item.name).push({ type: t, id: item.id });
1259
+ const key = normalizeEntityName(item.name);
1260
+ if (!key) continue;
1261
+ if (!nameMap.has(key)) nameMap.set(key, []);
1262
+ nameMap.get(key).push({ type: t, id: item.id, name: String(item.name) });
1132
1263
  }
1133
1264
  }
1134
- for (const [name, entries] of nameMap) {
1265
+ for (const [normalized, entries] of nameMap) {
1135
1266
  if (entries.length <= 1) continue;
1136
1267
  const typeSet = new Set(entries.map(e => e.type));
1137
1268
  if (typeSet.size <= 1) continue;
1138
1269
  const typesInvolved = [...typeSet].map(t => typeLabels[t]).join(' / ');
1270
+ const names = [...new Set(entries.map((e) => e.name))];
1139
1271
  warnings.push({
1140
1272
  code: 'CROSS_TYPE_NAME_DUP',
1141
- path: 'name: ' + name,
1142
- message: '名称 "' + name + '" 在多个实体类型中重复出现(' + typesInvolved + ')',
1273
+ path: 'name: ' + names.join(' / '),
1274
+ message: '名称 ' + names.map((n) => '"' + n + '"').join('、') +
1275
+ ' 在多个实体类型中重复出现(' + typesInvolved + ';归一化 key=' + normalized + ')',
1143
1276
  hint: '不同类型同名可能导致引用歧义(如 faction.members[].name 与 faction.relations[].target_name 无法靠名字区分),建议改名'
1144
1277
  });
1145
1278
  }
@@ -1157,7 +1290,7 @@ function buildGraph(entityData, relationData, errors, warnings) {
1157
1290
  ['locations', 'location'], ['items', 'item'], ['endings', 'ending'],
1158
1291
  ['foreshadows', 'foreshadow'], ['climaxes', 'climax'], ['pleasures', 'pleasure'],
1159
1292
  ['main_lines', 'main_line'], ['branches', 'branch'],
1160
- ['world_rules', 'world_rule']
1293
+ ['world_rules', 'world_rule'], ['other_entities', 'other_entity']
1161
1294
  ]) {
1162
1295
  for (const x of (entityData[arr] || [])) {
1163
1296
  graph.addNode(x.id, { type: type, name: x.name });
@@ -1174,6 +1307,7 @@ function buildGraph(entityData, relationData, errors, warnings) {
1174
1307
  };
1175
1308
 
1176
1309
  for (const r of (relationData.character_relations || [])) addEdge(r.from, r.to, 'related_to');
1310
+ for (const r of (relationData.other_relations || [])) addEdge(r.from, r.to, r.relation_label || 'related_to');
1177
1311
  for (const r of (relationData.faction_membership || [])) addEdge(r.character, r.faction, 'member_of');
1178
1312
  for (const r of (relationData.faction_relations || [])) addEdge(r.from, r.to, 'faction_related');
1179
1313
  for (const r of (relationData.conflict_participants || [])) {
@@ -1366,6 +1500,7 @@ function renderFramework(data) {
1366
1500
  // 实体文件的渲染(12 类实体数组)
1367
1501
  const ENTITY_ARRAYS = [
1368
1502
  ['characters', '角色'],
1503
+ ['other_entities', '其他实体'],
1369
1504
  ['conflicts', '冲突'],
1370
1505
  ['factions', '势力'],
1371
1506
  ['locations', '地点'],
@@ -1393,6 +1528,7 @@ function renderEntity(data) {
1393
1528
  // 关系文件的渲染(9 类关系数组)
1394
1529
  const RELATION_ARRAYS = [
1395
1530
  ['character_relations', '角色关系'],
1531
+ ['other_relations', '其他关系'],
1396
1532
  ['faction_membership', '势力成员'],
1397
1533
  ['faction_relations', '势力关系'],
1398
1534
  ['conflict_participants', '冲突参与'],
@@ -1568,6 +1704,7 @@ function validateSingle(args) {
1568
1704
  safeCheck('charsetAndForbiddenCheck', () => charsetAndForbiddenCheck(data, errors), errors);
1569
1705
  safeCheck('idUniquenessCheck', () => idUniquenessCheck(data, file, errors), errors);
1570
1706
  safeCheck('nameUniquenessCheck', () => nameUniquenessCheck(data, file, errors), errors);
1707
+ safeCheck('nameAliasCheck', () => nameAliasCheck(data, file, warnings), errors);
1571
1708
 
1572
1709
  if (file === '框架') {
1573
1710
  safeCheck('frameworkSpecificCheck', () => frameworkSpecificCheck(data, errors, warnings), errors);
@@ -1576,6 +1713,7 @@ function validateSingle(args) {
1576
1713
  } else if (file === '关系') {
1577
1714
  safeCheck('selfRelationCheck', () => selfRelationCheck(data, errors), errors);
1578
1715
  safeCheck('relationDupCheck', () => relationDupCheck(data, errors), errors);
1716
+ safeCheck('relationLabelCheck', () => relationLabelCheck(data, errors), errors);
1579
1717
  safeCheck('relationRefCheck', () => relationRefCheck(data, upstreamEntities, errors), errors);
1580
1718
  // 支线挂主线的覆盖度:关系文件对着实体清单查(tt5 实测的静默缺失)
1581
1719
  safeCheck('checkBranchMainLineCoverage', () => checkBranchMainLineCoverage(upstreamEntities, data, errors), errors);
@@ -1702,6 +1840,7 @@ function validatePartial(args) {
1702
1840
  if (file === '关系') {
1703
1841
  safeCheck('selfRelationCheck', () => selfRelationCheck(scoped, errors), errors);
1704
1842
  safeCheck('relationDupCheck', () => relationDupCheck(scoped, errors), errors);
1843
+ safeCheck('relationLabelCheck', () => relationLabelCheck(scoped, errors), errors);
1705
1844
  safeCheck('relationRefCheck', () => relationRefCheck(scoped, upstreamEntities, errors), errors);
1706
1845
  safeCheck('characterRelationSymmetryCheck', () => characterRelationSymmetryCheck(scoped, errors), errors);
1707
1846
  safeCheck('factionRelationSymmetryCheck', () => factionRelationSymmetryCheck(scoped, errors), errors);
@@ -1767,6 +1906,7 @@ function validateAll(args) {
1767
1906
  safeCheck('charsetAndForbiddenCheck', () => charsetAndForbiddenCheck(data, errors), errors);
1768
1907
  safeCheck('idUniquenessCheck', () => idUniquenessCheck(data, f.type, errors), errors);
1769
1908
  safeCheck('nameUniquenessCheck', () => nameUniquenessCheck(data, f.type, errors), errors);
1909
+ safeCheck('nameAliasCheck', () => nameAliasCheck(data, f.type, warnings), errors);
1770
1910
 
1771
1911
  if (f.type === '框架') {
1772
1912
  safeCheck('frameworkSpecificCheck', () => frameworkSpecificCheck(data, errors, warnings), errors);
@@ -1774,6 +1914,7 @@ function validateAll(args) {
1774
1914
  } else if (f.type === '关系') {
1775
1915
  safeCheck('selfRelationCheck', () => selfRelationCheck(data, errors), errors);
1776
1916
  safeCheck('relationDupCheck', () => relationDupCheck(data, errors), errors);
1917
+ safeCheck('relationLabelCheck', () => relationLabelCheck(data, errors), errors);
1777
1918
  safeCheck('relationRefCheck', () => relationRefCheck(data, loaded['实体'], errors), errors);
1778
1919
  // 支线挂主线的覆盖度(tt5 实测:只写 1 行也 PASSED,9 条支线静默孤儿)
1779
1920
  safeCheck('checkBranchMainLineCoverage', () => checkBranchMainLineCoverage(loaded['实体'], data, errors), errors);
@@ -71,6 +71,15 @@ export const FLESH_FILE_SPECS = [
71
71
  { key: 'characters', source: 'characters' }
72
72
  ]
73
73
  },
74
+ {
75
+ type: '其他实体',
76
+ file: '其他实体.json',
77
+ template: '其他实体模板.json',
78
+ optionalSource: 'other_entities',
79
+ groups: [
80
+ { key: 'other_entities', source: 'other_entities' }
81
+ ]
82
+ },
74
83
  {
75
84
  type: '冲突',
76
85
  file: '冲突.json',
@@ -169,7 +178,9 @@ export const SKELETON_FILE_SPECS = [
169
178
  file: '实体.json',
170
179
  template: '实体模板.json',
171
180
  groups: [
172
- { key: 'characters', quotaDriven: true, maxItems: 20 },
181
+ // 角色批次顺带写可选的题材扩展实体(灵魂/神魂/器灵…)。
182
+ // other_entities 不在 quota 里:没有就写空数组,有就从 O001 起编号。
183
+ { key: 'characters', quotaDriven: true, maxItems: 20, extraArrays: ['other_entities'], optionalArrays: ['other_entities'] },
173
184
  { key: 'conflicts', quotaDriven: true, maxItems: 20 },
174
185
  { key: 'factions', quotaDriven: true, maxItems: 20 },
175
186
  { key: 'locations', quotaDriven: true, maxItems: 20 },
@@ -215,6 +226,8 @@ export const SKELETON_FILE_SPECS = [
215
226
  // · main_line_branch_relation 按主线切(只有 1 条主线)→ 提示词说 1 条
216
227
  // → 模型写 1 行 → 10 条支线里 9 条静默没挂主线,校验器还不报。
217
228
  { key: 'character_relations', source: 'characters', ownerField: 'from', refs: ['characters'], rows: 'semantic', maxItems: 8 },
229
+ // 非人物实体参与的关系(附身/寄宿/共生/镇压…)。没有 other_entities 时整组跳过。
230
+ { key: 'other_relations', source: 'other_entities', ownerField: 'from', refs: ['other_entities', 'characters'], rows: 'semantic', maxItems: 8, optionalSource: 'other_entities' },
218
231
  { key: 'faction_membership', source: 'factions', ownerField: 'faction', refs: ['characters', 'factions'], rows: 'semantic', maxItems: 8 },
219
232
  { key: 'faction_relations', source: 'factions', ownerField: 'from', refs: ['factions'], rows: 'quota', quotaPath: 'faction_relation_count', multiplier: 2, maxItems: 8 },
220
233
  { key: 'conflict_participants', source: 'conflicts', ownerField: 'conflict', refs: ['conflicts', 'characters'], rows: 'perEntity', maxItems: 8 },
@@ -282,6 +295,9 @@ export const RHYTHM_FILE_SPECS = [
282
295
  template: '时序映射模板.json',
283
296
  groups: [
284
297
  { key: 'character_timeline', source: 'characters', maxItems: 50 },
298
+ // 角色关系的阶段落点:按关系起点角色切批,每批只写 from 属于本批的行。
299
+ { key: 'relationship_timeline', source: 'characters', ownerField: 'from', rows: 'optional', maxItems: 8 },
300
+ { key: 'faction_relationship_timeline', source: 'factions', ownerField: 'from', rows: 'optional', maxItems: 8 },
285
301
  { key: 'conflict_timeline', source: 'conflicts', maxItems: 50 },
286
302
  { key: 'foreshadow_timeline', source: 'foreshadows', maxItems: 50 },
287
303
  { key: 'climax_timeline', source: 'climaxes', maxItems: 50 },
@@ -490,6 +506,11 @@ export function planStageBatches(opts) {
490
506
 
491
507
  for (let fileIndex = 0; fileIndex < specs.length; fileIndex++) {
492
508
  const spec = specs[fileIndex];
509
+ // 题材扩展文件是条件产物:骨架没有 other_entities 时整份文件不生成,
510
+ // 不能把"不需要写"误报成"切不出批次"。
511
+ if (spec.optionalSource && (!skeletonEntities || !Array.isArray(skeletonEntities[spec.optionalSource]) || skeletonEntities[spec.optionalSource].length === 0)) {
512
+ continue;
513
+ }
493
514
  const templateSpec = readJson(path.join(templateDir, spec.template));
494
515
  if (!templateSpec) {
495
516
  return {
@@ -504,6 +525,10 @@ export function planStageBatches(opts) {
504
525
 
505
526
  for (let groupIndex = 0; groupIndex < spec.groups.length; groupIndex++) {
506
527
  const group = spec.groups[groupIndex];
528
+ // 可选关系组:源实体为空时跳过,不要生成"0 条"的空批次。
529
+ if (group.optionalSource && (!skeletonEntities || !Array.isArray(skeletonEntities[group.optionalSource]) || skeletonEntities[group.optionalSource].length === 0)) {
530
+ continue;
531
+ }
507
532
 
508
533
  // 整文件就是一个对象(节奏参数.json):只有一批,条目是整个对象
509
534
  if (group.wholeObject) {
@@ -741,6 +766,11 @@ export function computeKeyChapters(opts) {
741
766
  if (e && typeof e.chapter === 'number') set.add(e.chapter);
742
767
  }
743
768
 
769
+ // 关系变化章
770
+ for (const r of (Array.isArray(timeline.relationship_timeline) ? timeline.relationship_timeline : [])) {
771
+ if (r && typeof r.start_chapter === 'number') set.add(r.start_chapter);
772
+ }
773
+
744
774
  // 钩子章
745
775
  const hookTimeline = Array.isArray(timeline.hook_timeline) ? timeline.hook_timeline : [];
746
776
  for (const h of hookTimeline) {
@@ -783,7 +813,11 @@ function makeBatch(ctx) {
783
813
  objectKeys: ctx.objectKeys || [],
784
814
  // details 必须跟着 summary 一起合并:校验器要求两者 id 一一对应,
785
815
  // 只合并 summary 会让关键道具.json 永远缺 details(FLESH_DETAILS_ORPHAN)。
786
- arrays: group.key ? (group.detail ? [group.key, group.detail] : [group.key]) : [],
816
+ arrays: group.key
817
+ ? [group.key, ...(group.detail ? [group.detail] : []), ...(group.extraArrays || [])]
818
+ : [],
819
+ extraArrays: (group.extraArrays || []).slice(),
820
+ optionalArrays: (group.optionalArrays || []).slice(),
787
821
  summaryArray: group.key,
788
822
  detailArray: group.detail || null,
789
823
  shard: shard,
@@ -89,6 +89,7 @@ export function pickContractSections(content, names) {
89
89
  */
90
90
  const RELATION_TABLES = {
91
91
  character_relations: { match: ['from', 'to'], anchor: ['from'] },
92
+ other_relations: { match: ['from', 'to'], anchor: null },
92
93
  faction_membership: { match: ['character', 'faction'], anchor: null },
93
94
  faction_relations: { match: ['from', 'to'], anchor: ['from'] },
94
95
  conflict_participants: { match: ['conflict', 'protagonist_side', 'main_antagonist', 'secondary_antagonist', 'third_party'], anchor: null },
@@ -258,6 +259,12 @@ export function buildRhythmChapterRefs(opts) {
258
259
  mainNodesByCh.get(n.chapter).push({ main_id: m.main_id, event_ref: n.event_ref });
259
260
  }
260
261
  }
262
+ const relationshipByCh = new Map();
263
+ for (const r of (timeline.relationship_timeline || [])) {
264
+ if (!r || typeof r.start_chapter !== 'number') continue;
265
+ if (!relationshipByCh.has(r.start_chapter)) relationshipByCh.set(r.start_chapter, []);
266
+ relationshipByCh.get(r.start_chapter).push(r);
267
+ }
261
268
  const branchByCh = new Map();
262
269
  for (const b of (timeline.branch_timeline || [])) {
263
270
  if (!b || typeof b !== 'object') continue;
@@ -284,6 +291,11 @@ export function buildRhythmChapterRefs(opts) {
284
291
  for (const n of (mainNodesByCh.get(ch) || [])) {
285
292
  parts.push('主线节点 ' + n.main_id + nameOf(n.main_id, names) + ':' + str(n.event_ref));
286
293
  }
294
+ for (const r of (relationshipByCh.get(ch) || [])) {
295
+ parts.push('关系阶段 ' + r.from + nameOf(r.from, names) + ' → ' + r.to + nameOf(r.to, names) +
296
+ ':' + str(r.stage) + ' / ' + str(r.state) +
297
+ (r.trigger_event ? '(触发:' + str(r.trigger_event) + ')' : ''));
298
+ }
287
299
  for (const b of (branchByCh.get(ch) || [])) {
288
300
  parts.push('支线 ' + b);
289
301
  }