@cloud411716/fancy-webnovel 1.0.46 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +369 -0
- package/cordis.patch.yml +4 -13
- package/docs/douyin-plugin.md +118 -0
- package/docs/material-library/README.md +404 -0
- package/docs/material-library/import.js +437 -0
- package/docs/material-library/prompt.md +370 -0
- package/docs/material-library/queries.surql +94 -0
- package/docs/material-library/sample.jsonl +14 -0
- package/docs/material-library/schema.surql +46 -0
- package/docs/mcp-profile.example.yml +24 -0
- package/docs/story-graph-design.md +602 -0
- package/docs/story-graph-validation.md +81 -0
- package/docs/workspace-mcp.md +284 -0
- package/index.js +26 -38
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/chinese-num.js +111 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +141 -0
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +104 -0
- package/lib/infra.js +594 -0
- package/lib/llm-wiki.js +109 -0
- package/lib/material-db.js +149 -0
- package/lib/path-guard.js +150 -0
- package/lib/projects.js +400 -0
- package/lib/scraper-utils.js +305 -0
- package/lib/surreal-server.js +338 -0
- package/lib/topic-decision.js +860 -0
- package/lib/topic-fields.js +251 -0
- package/lib/topic-schema.js +225 -0
- package/lib/workspace.js +501 -0
- package/package.json +62 -5
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/command-input.js +65 -0
- package/plugins/fancy-analyze/index.js +2024 -0
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +627 -0
- package/plugins/fancy-analyze/schema-write.md +178 -0
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/scripts/split_source.js +292 -0
- package/plugins/fancy-analyze/summary-progress.js +495 -0
- package/plugins/fancy-analyze/templates.js +1179 -0
- package/plugins/fancy-bootstrap/index.js +313 -0
- package/plugins/fancy-bootstrap/scripts/init.js +177 -0
- package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
- package/plugins/fancy-bootstrap/templates.js +71 -0
- package/plugins/fancy-db/index.js +172 -0
- package/plugins/fancy-douyin/deploy.js +134 -0
- package/plugins/fancy-douyin/index.js +551 -0
- package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
- package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
- package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
- package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
- package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
- package/plugins/fancy-douyin/state.js +197 -0
- package/plugins/fancy-douyin/templates.js +127 -0
- package/plugins/fancy-export/exporter.js +805 -0
- package/plugins/fancy-export/index.js +63 -0
- package/plugins/fancy-import/import-core.js +1118 -0
- package/plugins/fancy-import/import-driver.js +604 -0
- package/plugins/fancy-import/index.js +738 -0
- package/plugins/fancy-import/prompts/01-import-prompt.md +131 -0
- package/plugins/fancy-import/templates.js +58 -0
- package/plugins/fancy-material/exporter.js +153 -0
- package/plugins/fancy-material/genres.js +256 -0
- package/plugins/fancy-material/index.js +835 -0
- package/plugins/fancy-material/inventory.js +143 -0
- package/plugins/fancy-material/kinds.js +313 -0
- package/plugins/fancy-material/queue.js +55 -0
- package/plugins/fancy-material/scripts/query.js +254 -0
- package/plugins/fancy-material/templates.js +258 -0
- package/plugins/fancy-material/tool-deploy.js +138 -0
- package/plugins/fancy-material/validate.js +149 -0
- package/plugins/fancy-project/index.js +133 -0
- package/plugins/fancy-scan/existing-block.js +100 -0
- package/plugins/fancy-scan/index.js +708 -0
- package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
- package/plugins/fancy-scan/scripts/append-block.js +725 -0
- package/plugins/fancy-scan/scripts/report-validate.js +824 -0
- package/plugins/fancy-scan/scripts/run-scan.js +667 -0
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +923 -0
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +469 -0
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +629 -0
- package/plugins/fancy-scan/templates/blocks.js +155 -0
- package/plugins/fancy-scan/templates/platforms.js +372 -0
- package/plugins/fancy-scan/templates/report-schema.js +90 -0
- package/plugins/fancy-scan/templates.js +694 -0
- package/plugins/fancy-scan/tool-deploy.js +335 -0
- package/plugins/fancy-scan/wait-turn.js +46 -0
- package/plugins/fancy-story-builder/db/README.md +106 -0
- package/plugins/fancy-story-builder/db/client.js +228 -0
- package/plugins/fancy-story-builder/db/commands.js +145 -0
- package/plugins/fancy-story-builder/db/ids.js +39 -0
- package/plugins/fancy-story-builder/db/import.js +798 -0
- package/plugins/fancy-story-builder/db/project.js +299 -0
- package/plugins/fancy-story-builder/db/query.js +361 -0
- package/plugins/fancy-story-builder/db/schema.js +417 -0
- package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
- package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
- package/plugins/fancy-story-builder/deslop-driver.js +821 -0
- package/plugins/fancy-story-builder/flesh-output.js +168 -0
- package/plugins/fancy-story-builder/index.js +6861 -0
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +563 -0
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +602 -0
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +539 -0
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +225 -0
- package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
- package/plugins/fancy-story-builder/purpose-template.md +83 -0
- package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +402 -0
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +594 -0
- package/plugins/fancy-story-builder/references/index.md +581 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +181 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +243 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +185 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +187 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +209 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +160 -0
- package/plugins/fancy-story-builder/rhythm-output.js +140 -0
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +232 -0
- package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +416 -0
- package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
- package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +1312 -0
- package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
- package/plugins/fancy-story-builder/scripts/graph-utils.js +1484 -0
- package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
- package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
- package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
- package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1312 -0
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +381 -0
- package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1973 -0
- package/plugins/fancy-story-builder/scripts/stage-batches.js +1405 -0
- package/plugins/fancy-story-builder/scripts/stage-contract.js +429 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
- package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
- package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
- package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
- package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
- package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
- package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
- package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
- package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
- package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
- package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
- package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
- package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
- package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
- package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
- package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
- package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
- package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
- package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
- package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
- package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
- package/plugins/fancy-story-builder/skeleton-output.js +199 -0
- package/plugins/fancy-story-builder/stage-driver.js +1049 -0
- package/plugins/fancy-story-builder/storydb/README.md +271 -0
- package/plugins/fancy-story-builder/storydb/cli.js +458 -0
- package/plugins/fancy-story-builder/storydb/client.js +351 -0
- package/plugins/fancy-story-builder/storydb/compile.js +452 -0
- package/plugins/fancy-story-builder/storydb/context.js +288 -0
- package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
- package/plugins/fancy-story-builder/storydb/driver.js +106 -0
- package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
- package/plugins/fancy-story-builder/storydb/export.js +175 -0
- package/plugins/fancy-story-builder/storydb/ids.js +31 -0
- package/plugins/fancy-story-builder/storydb/impact.js +170 -0
- package/plugins/fancy-story-builder/storydb/import.js +559 -0
- package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
- package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
- package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
- package/plugins/fancy-story-builder/storydb/stage.js +426 -0
- package/plugins/fancy-story-builder/storydb/store.js +891 -0
- package/plugins/fancy-story-builder/storydb/tools.js +592 -0
- package/plugins/fancy-story-builder/storydb/validation.js +183 -0
- package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
- package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
- package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +427 -0
- 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
- 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
- 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
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +168 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +184 -0
- 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
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +131 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +85 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +103 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +68 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +142 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +292 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +266 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +336 -0
- 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
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +308 -0
- package/plugins/fancy-story-builder/templates.js +1446 -0
- package/plugins/fancy-story-builder/wiki-references.js +540 -0
- package/plugins/fancy-story-builder/writing-driver.js +1388 -0
- package/plugins/fancy-topic/index.js +1057 -0
- package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
- package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
- package/plugins/fancy-topic/scripts/select-topic.js +317 -0
- package/plugins/fancy-topic/templates.js +476 -0
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/commands/bootstrap/index.js +0 -164
- package/commands/bootstrap/init-project.js +0 -121
- package/commands/scan/index.js +0 -283
- package/commands/scan/platforms/fanqie.js +0 -146
- package/commands/scan/platforms/jinjiang.js +0 -143
- package/commands/scan/platforms/qidian.js +0 -102
- package/commands/scan/platforms/qimao.js +0 -127
- package/commands/scan/scraper.js +0 -195
- package/infra.js +0 -160
|
@@ -0,0 +1,1973 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 骨架阶段校验脚本 v2.5
|
|
4
|
+
* 源码中零反引号(所有字符串使用单/双引号或拼接),避免 markdown 渲染问题。
|
|
5
|
+
*
|
|
6
|
+
* v2.5 变更:
|
|
7
|
+
* - 新增 NAME_ALIAS_SUSPECT:名称归一化(去括号/标点/常见机构后缀)后
|
|
8
|
+
* 相同,但字面不同 → warning,提示可能是同一实体的全称/简称/别名。
|
|
9
|
+
* - CROSS_TYPE_NAME_DUP 改用同一套归一化口径,能抓「企鹅集团 vs 企鹅」
|
|
10
|
+
* 这种跨类型软重复。
|
|
11
|
+
*
|
|
12
|
+
* v2.4 变更(修复批次 P0-2 / P2-4 / P2-8):
|
|
13
|
+
* - P0-2:evalExpr 正则允许 = < > ! & | ? :(原版不允许,导致
|
|
14
|
+
* formulas-skeleton.json 的 ending_elements.ending_count 的 expr_low/expr_high
|
|
15
|
+
* 含 === 和三元运算符,求值必失败,DERIVATION_EVAL 阻断骨架全量校验)
|
|
16
|
+
* - P2-4:新增 faction_relation_count 的 QUOTA 一致性检查(需要关系.json)
|
|
17
|
+
* - P2-8:buildGraph 删除无效的 volumes 循环(实体.json 无 volumes 字段)
|
|
18
|
+
*
|
|
19
|
+
* 保留 v2.3 的 P3-4b 修复:
|
|
20
|
+
* - NO_SELF_RELATION / EMPTY_HARD_CONSTRAINTS / CROSS_TYPE_NAME_DUP
|
|
21
|
+
* 保留 v2.2 的 P0-2 / P2-2 修复:
|
|
22
|
+
* - QUOTA_MISMATCH / BOOK_ENDING_COUNT / VOLUME_COUNT_MISMATCH / ACT_INDEX_DUP
|
|
23
|
+
* - SPAN_RATIO_RANGE / 关系组合重复检查
|
|
24
|
+
*
|
|
25
|
+
* 用法:
|
|
26
|
+
* 单文件:node skeleton-validate.js --input=<json> --template=<模板> --file=<类型> [--upstream=<目录>]
|
|
27
|
+
* 全量: node skeleton-validate.js --stage-dir=<目录> --template-dir=<目录> --all
|
|
28
|
+
* 退出码:0=通过 1=内容错误 2=脚本异常
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { evalExpr, buildFormulaContext, formulaRange } from './formula-engine.js';
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import {
|
|
36
|
+
safeCheck,
|
|
37
|
+
SimpleGraph,
|
|
38
|
+
getIsolatedNodes,
|
|
39
|
+
getDisconnectedCoreNodes,
|
|
40
|
+
checkBidirectionalSymmetry,
|
|
41
|
+
parseGlossaryChecked,
|
|
42
|
+
mdEscape,
|
|
43
|
+
cnLabelCtx,
|
|
44
|
+
renderObject,
|
|
45
|
+
renderArray,
|
|
46
|
+
relationKindCompatible,
|
|
47
|
+
assertRelationKindMeta
|
|
48
|
+
} from './graph-utils.js';
|
|
49
|
+
import { runContentQualityChecks, collectEnumLeaves } from './content-quality.js';
|
|
50
|
+
import { QUOTA_MAP } from './skeleton-quota.js';
|
|
51
|
+
|
|
52
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
53
|
+
|
|
54
|
+
// ============================================================
|
|
55
|
+
// 参数解析
|
|
56
|
+
// ============================================================
|
|
57
|
+
function parseArgs() {
|
|
58
|
+
const args = process.argv.slice(2);
|
|
59
|
+
const result = { upstream: [] };
|
|
60
|
+
for (const arg of args) {
|
|
61
|
+
const m = arg.match(/^--([\w-]+)=(.+)$/);
|
|
62
|
+
if (m) {
|
|
63
|
+
if (m[1] === 'upstream') {
|
|
64
|
+
result.upstream.push(...m[2].split(',').map(s => s.trim()).filter(Boolean));
|
|
65
|
+
} else {
|
|
66
|
+
result[m[1]] = m[2];
|
|
67
|
+
}
|
|
68
|
+
} else if (arg === '--all') {
|
|
69
|
+
result.all = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============================================================
|
|
76
|
+
// IO
|
|
77
|
+
// ============================================================
|
|
78
|
+
function readJson(p) {
|
|
79
|
+
if (!fs.existsSync(p)) return { error: '文件不存在: ' + p };
|
|
80
|
+
try {
|
|
81
|
+
return { data: JSON.parse(fs.readFileSync(p, 'utf-8')) };
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { error: 'JSON 解析失败: ' + err.message };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readText(p) {
|
|
88
|
+
if (!fs.existsSync(p)) return { error: '文件不存在: ' + p };
|
|
89
|
+
try {
|
|
90
|
+
return { data: fs.readFileSync(p, 'utf-8') };
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return { error: '读取失败: ' + err.message };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ============================================================
|
|
97
|
+
// glossary / formulas 加载
|
|
98
|
+
// ============================================================
|
|
99
|
+
function loadGlossary() {
|
|
100
|
+
const r = readText(path.join(__dirname, 'glossary.md'));
|
|
101
|
+
if (r.error) return { error: 'glossary.md 读取失败: ' + r.error };
|
|
102
|
+
return parseGlossaryChecked(r.data);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function loadFormulas() {
|
|
106
|
+
const r = readJson(path.join(__dirname, 'formulas-skeleton.json'));
|
|
107
|
+
if (r.error) return { error: 'formulas-skeleton.json 读取失败: ' + r.error };
|
|
108
|
+
return { formulas: r.data.formulas || {} };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================
|
|
112
|
+
// 模板校验
|
|
113
|
+
// ============================================================
|
|
114
|
+
function validateNode(data, spec, p, enums, errors) {
|
|
115
|
+
if (typeof spec === 'string') spec = { type: spec };
|
|
116
|
+
if (spec.required && (data === undefined || data === null || data === '')) {
|
|
117
|
+
errors.push({ code: 'REQUIRED', path: p, message: '必填字段不得为空', hint: null });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (data === undefined || data === null) return;
|
|
121
|
+
|
|
122
|
+
const actualType = Array.isArray(data) ? 'array' : typeof data;
|
|
123
|
+
if (spec.type === 'array' && !Array.isArray(data)) {
|
|
124
|
+
errors.push({ code: 'TYPE', path: p, message: '应为 array,实际为 ' + actualType, hint: null });
|
|
125
|
+
} else if (spec.type === 'object' && (typeof data !== 'object' || Array.isArray(data))) {
|
|
126
|
+
errors.push({ code: 'TYPE', path: p, message: '应为 object,实际为 ' + actualType, hint: null });
|
|
127
|
+
} else if (spec.type === 'string' && typeof data !== 'string') {
|
|
128
|
+
errors.push({ code: 'TYPE', path: p, message: '应为 string,实际为 ' + actualType, hint: null });
|
|
129
|
+
} else if (spec.type === 'number' && typeof data !== 'number') {
|
|
130
|
+
errors.push({ code: 'TYPE', path: p, message: '应为 number,实际为 ' + actualType, hint: null });
|
|
131
|
+
} else if (spec.type === 'boolean' && typeof data !== 'boolean') {
|
|
132
|
+
errors.push({ code: 'TYPE', path: p, message: '应为 boolean,实际为 ' + actualType, hint: null });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (spec.pattern && typeof data === 'string') {
|
|
136
|
+
try {
|
|
137
|
+
if (!new RegExp(spec.pattern).test(data)) {
|
|
138
|
+
errors.push({ code: 'PATTERN', path: p, message: '不匹配正则 ' + spec.pattern, hint: null });
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (spec.enumRef) {
|
|
144
|
+
const allowed = enums[spec.enumRef];
|
|
145
|
+
if (!allowed) {
|
|
146
|
+
errors.push({ code: 'ENUM_MISSING', path: p, message: '枚举引用 ' + spec.enumRef + ' 未在 glossary 中定义', hint: null });
|
|
147
|
+
} else if (!allowed.includes(data)) {
|
|
148
|
+
errors.push({ code: 'ENUM', path: p, message: '值 "' + data + '" 不在允许的枚举范围内', hint: allowed.join(' / ') });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (spec.properties && typeof data === 'object' && !Array.isArray(data)) {
|
|
153
|
+
const allowed = new Set(Object.keys(spec.properties));
|
|
154
|
+
for (const key of Object.keys(data)) {
|
|
155
|
+
if (!allowed.has(key)) {
|
|
156
|
+
errors.push({
|
|
157
|
+
code: 'EXTRA_FIELD',
|
|
158
|
+
path: p ? (p + '.' + key) : key,
|
|
159
|
+
message: '字段 "' + key + '" 不在模板中',
|
|
160
|
+
hint: '数据文件不得出现模板 properties 之外的字段'
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const [k, child] of Object.entries(spec.properties)) {
|
|
165
|
+
validateNode(data[k], child, p ? (p + '.' + k) : k, enums, errors);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (spec.type === 'array' && spec.items && Array.isArray(data)) {
|
|
170
|
+
data.forEach((item, i) => validateNode(item, spec.items, p + '[' + i + ']', enums, errors));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================================
|
|
175
|
+
// 章节号越界检查
|
|
176
|
+
// ============================================================
|
|
177
|
+
function checkChapterForbidden(data, scalarSet, containerSet, errors) {
|
|
178
|
+
const walk = (node, p) => {
|
|
179
|
+
if (!node || typeof node !== 'object') return;
|
|
180
|
+
if (Array.isArray(node)) {
|
|
181
|
+
node.forEach((v, i) => walk(v, p + '[' + i + ']'));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
for (const [k, v] of Object.entries(node)) {
|
|
185
|
+
const curPath = p ? (p + '.' + k) : k;
|
|
186
|
+
if (containerSet.has(k)) {
|
|
187
|
+
errors.push({
|
|
188
|
+
code: 'SKELETON_CHAPTER_FORBIDDEN',
|
|
189
|
+
path: curPath,
|
|
190
|
+
message: '骨架阶段不允许出现章节容器 "' + k + '"',
|
|
191
|
+
hint: '章节号属于节奏阶段'
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (scalarSet.has(k) && typeof v === 'number') {
|
|
196
|
+
errors.push({
|
|
197
|
+
code: 'SKELETON_CHAPTER_FORBIDDEN',
|
|
198
|
+
path: curPath,
|
|
199
|
+
message: '骨架阶段不允许出现章节号字段 "' + k + '"(值 ' + v + ')',
|
|
200
|
+
hint: '章节号属于节奏阶段'
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
walk(v, curPath);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
walk(data, '');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================
|
|
210
|
+
// 字符集 / 占位符
|
|
211
|
+
// ============================================================
|
|
212
|
+
const KOREAN_RE = /[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F]/;
|
|
213
|
+
const KANA_RE = /[\u3040-\u309F\u30A0-\u30FF]/;
|
|
214
|
+
const PLACEHOLDER_RE = /\[[XYZW]\]|待确认|待定|N\/A/;
|
|
215
|
+
|
|
216
|
+
function charsetAndForbiddenCheck(data, errors) {
|
|
217
|
+
const walk = (node, p) => {
|
|
218
|
+
if (typeof node === 'string') {
|
|
219
|
+
if (KOREAN_RE.test(node)) {
|
|
220
|
+
errors.push({ code: 'CHARSET_KO', path: p, message: '含韩文: "' + node.slice(0, 30) + '"', hint: null });
|
|
221
|
+
}
|
|
222
|
+
if (KANA_RE.test(node)) {
|
|
223
|
+
errors.push({ code: 'CHARSET_JP', path: p, message: '含日文假名: "' + node.slice(0, 30) + '"', hint: null });
|
|
224
|
+
}
|
|
225
|
+
if (PLACEHOLDER_RE.test(node)) {
|
|
226
|
+
errors.push({ code: 'PLACEHOLDER', path: p, message: '含占位符: "' + node.slice(0, 30) + '"', hint: null });
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (Array.isArray(node)) {
|
|
231
|
+
node.forEach((v, i) => walk(v, p + '[' + i + ']'));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (node && typeof node === 'object') {
|
|
235
|
+
for (const [k, v] of Object.entries(node)) walk(v, p ? (p + '.' + k) : k);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
walk(data, '');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ============================================================
|
|
242
|
+
// ID / 名称唯一性
|
|
243
|
+
// ============================================================
|
|
244
|
+
function idUniquenessCheck(data, fileType, errors) {
|
|
245
|
+
const ID_ARRAYS = {
|
|
246
|
+
'实体': ['characters', 'other_entities', 'conflicts', 'factions', 'locations', 'items', 'endings', 'foreshadows', 'climaxes', 'pleasures', 'world_rules', 'main_lines', 'branches'],
|
|
247
|
+
'层级': ['volumes']
|
|
248
|
+
};
|
|
249
|
+
// 记出现次数:光说"重复"模型不知道该删几条
|
|
250
|
+
const seen = new Map();
|
|
251
|
+
for (const key of ID_ARRAYS[fileType] || []) {
|
|
252
|
+
for (const item of data[key] || []) {
|
|
253
|
+
if (!item.id) continue;
|
|
254
|
+
const n = (seen.get(item.id) || 0) + 1;
|
|
255
|
+
seen.set(item.id, n);
|
|
256
|
+
if (n > 1) {
|
|
257
|
+
errors.push({
|
|
258
|
+
code: 'ID_DUP',
|
|
259
|
+
path: key + '.' + item.id,
|
|
260
|
+
message: 'ID ' + item.id + ' 重复(出现 ' + n + ' 次)',
|
|
261
|
+
hint: 'ID 必须唯一:' + item.id + ' 在本文件里出现了 ' + n + ' 次,'
|
|
262
|
+
+ '请只保留一条(其余的删掉或改成别的 id)'
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
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
|
+
|
|
304
|
+
function nameUniquenessCheck(data, fileType, errors) {
|
|
305
|
+
for (const key of NAME_ARRAYS_BY_FILE[fileType] || []) {
|
|
306
|
+
const seen = new Map();
|
|
307
|
+
for (const item of data[key] || []) {
|
|
308
|
+
const n = item.name;
|
|
309
|
+
if (!n) continue;
|
|
310
|
+
if (seen.has(n)) {
|
|
311
|
+
errors.push({ code: 'NAME_DUP', path: key + '.' + n, message: '名称 "' + n + '" 重复', hint: null });
|
|
312
|
+
} else {
|
|
313
|
+
seen.set(n, item.id);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
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
|
+
|
|
348
|
+
// ============================================================
|
|
349
|
+
// 自环关系检查
|
|
350
|
+
// ============================================================
|
|
351
|
+
function selfRelationCheck(data, errors) {
|
|
352
|
+
for (let i = 0; i < (data.character_relations || []).length; i++) {
|
|
353
|
+
const r = data.character_relations[i];
|
|
354
|
+
if (r.from && r.to && r.from === r.to) {
|
|
355
|
+
errors.push({
|
|
356
|
+
code: 'NO_SELF_RELATION',
|
|
357
|
+
path: 'character_relations[' + i + ']',
|
|
358
|
+
message: '角色关系 from 和 to 不能相同(' + r.from + ')',
|
|
359
|
+
hint: '自环关系无意义。如果是要表达角色的自我矛盾,用角色档案的 description / core_flaw 表达'
|
|
360
|
+
});
|
|
361
|
+
}
|
|
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
|
+
}
|
|
374
|
+
for (let i = 0; i < (data.faction_relations || []).length; i++) {
|
|
375
|
+
const r = data.faction_relations[i];
|
|
376
|
+
if (r.from && r.to && r.from === r.to) {
|
|
377
|
+
errors.push({
|
|
378
|
+
code: 'NO_SELF_RELATION',
|
|
379
|
+
path: 'faction_relations[' + i + ']',
|
|
380
|
+
message: '势力关系 from 和 to 不能相同(' + r.from + ')',
|
|
381
|
+
hint: '势力不能与自己对立/合作'
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
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
|
+
|
|
417
|
+
// ============================================================
|
|
418
|
+
// 关系数组组合重复检查(7 类)
|
|
419
|
+
// ============================================================
|
|
420
|
+
function relationDupCheck(data, errors) {
|
|
421
|
+
const crSet = new Map();
|
|
422
|
+
for (const r of (data.character_relations || [])) {
|
|
423
|
+
const k = r.from + '→' + r.to;
|
|
424
|
+
if (crSet.has(k)) {
|
|
425
|
+
const prev = crSet.get(k);
|
|
426
|
+
if (prev !== r.relation_kind) {
|
|
427
|
+
// 同一有序对写了两种互斥的关系类型:这不是"重复",是关系语义冲突,
|
|
428
|
+
// 用独立错误码让调用方能区分"删一条"和"改一条"。
|
|
429
|
+
errors.push({
|
|
430
|
+
code: 'REL_KIND_CONFLICT',
|
|
431
|
+
path: 'character_relations.' + k,
|
|
432
|
+
message: '角色关系 ' + k + ' 同时声明了「' + prev + '」与「' + r.relation_kind + '」两种关系类型',
|
|
433
|
+
hint: '同一有序对只能保留一种关系类型;另一种关系请在血肉阶段该角色的 relationships[].description 中展开'
|
|
434
|
+
});
|
|
435
|
+
} else {
|
|
436
|
+
errors.push({
|
|
437
|
+
code: 'ID_DUP',
|
|
438
|
+
path: 'character_relations.' + k,
|
|
439
|
+
message: '角色关系 ' + k + ' 重复',
|
|
440
|
+
// 不写清楚模型只能猜:tt5 实测这条反复出现 5 次都没改对。
|
|
441
|
+
hint: '同一对角色只能有一条关系记录(' + k + ' 与反向的 '
|
|
442
|
+
+ r.from + '→' + r.to + ' 算**同一对**,算重复):'
|
|
443
|
+
+ '请删掉其中一条,不要两条都留'
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
crSet.set(k, r.relation_kind);
|
|
448
|
+
}
|
|
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
|
+
|
|
464
|
+
const frSet = new Set();
|
|
465
|
+
for (const r of (data.faction_relations || [])) {
|
|
466
|
+
const k = r.from + '→' + r.to;
|
|
467
|
+
if (frSet.has(k)) {
|
|
468
|
+
errors.push({
|
|
469
|
+
code: 'ID_DUP',
|
|
470
|
+
path: 'faction_relations.' + k,
|
|
471
|
+
message: '势力关系 ' + k + ' 重复',
|
|
472
|
+
hint: '同一对势力只能有一条关系记录(' + k + ' 与反向的 '
|
|
473
|
+
+ r.from + '→' + r.to + ' 算**同一对**,算重复):请删掉其中一条'
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
frSet.add(k);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const fmSet = new Set();
|
|
480
|
+
for (const r of (data.faction_membership || [])) {
|
|
481
|
+
const k = r.character + '@' + r.faction;
|
|
482
|
+
if (fmSet.has(k)) {
|
|
483
|
+
errors.push({
|
|
484
|
+
code: 'ID_DUP',
|
|
485
|
+
path: 'faction_membership.' + k,
|
|
486
|
+
message: '势力成员关系 ' + k + ' 重复',
|
|
487
|
+
hint: '同一个角色在同一个势力里只能有一条成员关系(' + k + ' 重复):请删掉其中一条'
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
fmSet.add(k);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const cpSet = new Set();
|
|
494
|
+
for (const r of (data.conflict_participants || [])) {
|
|
495
|
+
if (!r.conflict) continue;
|
|
496
|
+
if (cpSet.has(r.conflict)) {
|
|
497
|
+
errors.push({
|
|
498
|
+
code: 'ID_DUP',
|
|
499
|
+
path: 'conflict_participants.' + r.conflict,
|
|
500
|
+
message: '冲突 ' + r.conflict + ' 的参与方重复声明',
|
|
501
|
+
hint: '一个冲突只能有一条 conflict_participants 记录'
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
cpSet.add(r.conflict);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const bpSet = new Set();
|
|
508
|
+
for (const r of (data.branch_protagonist || [])) {
|
|
509
|
+
if (!r.branch) continue;
|
|
510
|
+
if (bpSet.has(r.branch)) {
|
|
511
|
+
errors.push({
|
|
512
|
+
code: 'ID_DUP',
|
|
513
|
+
path: 'branch_protagonist.' + r.branch,
|
|
514
|
+
message: '支线 ' + r.branch + ' 的主角重复声明',
|
|
515
|
+
hint: '每条支线只能有一个主角(' + r.branch + ' 重复声明):请删掉其中一条'
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
bpSet.add(r.branch);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const ioSet = new Set();
|
|
522
|
+
for (const r of (data.item_owner || [])) {
|
|
523
|
+
if (!r.item) continue;
|
|
524
|
+
if (ioSet.has(r.item)) {
|
|
525
|
+
errors.push({
|
|
526
|
+
code: 'ID_DUP',
|
|
527
|
+
path: 'item_owner.' + r.item,
|
|
528
|
+
message: '道具 ' + r.item + ' 的归属重复声明',
|
|
529
|
+
hint: '每个道具只能有一个归属(' + r.item + ' 重复声明):请删掉其中一条'
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
ioSet.add(r.item);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const lcSet = new Set();
|
|
536
|
+
for (const r of (data.location_conflict || [])) {
|
|
537
|
+
if (!r.location) continue;
|
|
538
|
+
if (lcSet.has(r.location)) {
|
|
539
|
+
errors.push({
|
|
540
|
+
code: 'ID_DUP',
|
|
541
|
+
path: 'location_conflict.' + r.location,
|
|
542
|
+
message: '地点 ' + r.location + ' 的冲突关联重复声明',
|
|
543
|
+
hint: '每个地点只能关联一条冲突(' + r.location + ' 重复声明):请删掉其中一条'
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
lcSet.add(r.location);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ============================================================
|
|
551
|
+
// 公式求值
|
|
552
|
+
// ============================================================
|
|
553
|
+
// 求值器搬到 scripts/formula-engine.js:导入阶段从旧稿反推 quota 时
|
|
554
|
+
// 要用**同一套**公式与区间,否则导入出来的 quota 会掉出本校验器的
|
|
555
|
+
// 合法区间 —— 表现为"导入完立刻校验失败",而错在 quota 上,看不出根因。
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
// ============================================================
|
|
559
|
+
// derivations 检查
|
|
560
|
+
// ============================================================
|
|
561
|
+
function derivationCheck(data, formulas, errors, warnings) {
|
|
562
|
+
const der = data.derivations || {};
|
|
563
|
+
// v2 的 custom.* 公式只用于 10-scale 的 scale.json;旧骨架阶段不消费它们。
|
|
564
|
+
const formulaKeys = new Set(Object.keys(formulas).filter((k) => !k.startsWith('custom.')));
|
|
565
|
+
const derKeys = new Set(Object.keys(der));
|
|
566
|
+
|
|
567
|
+
for (const k of formulaKeys) {
|
|
568
|
+
if (!derKeys.has(k)) {
|
|
569
|
+
errors.push({
|
|
570
|
+
code: 'DERIVATION_MISSING',
|
|
571
|
+
path: 'derivations.' + k,
|
|
572
|
+
message: '缺少推导记录',
|
|
573
|
+
hint: '公式: ' + formulas[k].formula
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
for (const k of derKeys) {
|
|
579
|
+
if (!formulaKeys.has(k)) {
|
|
580
|
+
errors.push({
|
|
581
|
+
code: 'DERIVATION_EXTRA',
|
|
582
|
+
path: 'derivations.' + k,
|
|
583
|
+
message: '多余的推导记录(formulas-skeleton.json 中不存在 ' + k + ')',
|
|
584
|
+
hint: '删除该 key 或检查 formulas-skeleton.json'
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const ctx = buildFormulaContext(data);
|
|
590
|
+
for (const [k, spec] of Object.entries(formulas)) {
|
|
591
|
+
if (k.startsWith('custom.')) continue;
|
|
592
|
+
const d = der[k];
|
|
593
|
+
if (!d) continue;
|
|
594
|
+
let realLow, realHigh;
|
|
595
|
+
try {
|
|
596
|
+
realLow = evalExpr(spec.expr_low, ctx);
|
|
597
|
+
realHigh = evalExpr(spec.expr_high, ctx);
|
|
598
|
+
} catch (e) {
|
|
599
|
+
errors.push({
|
|
600
|
+
code: 'DERIVATION_EVAL',
|
|
601
|
+
path: 'derivations.' + k,
|
|
602
|
+
message: '公式求值失败: ' + e.message,
|
|
603
|
+
hint: null
|
|
604
|
+
});
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
// 区间为空(下界 > 上界)时**先说清楚是"没有合法解"**:
|
|
608
|
+
// 光说 "range 应为 [16, 15]" 模型看不懂,只知道"填什么都不对",
|
|
609
|
+
// 于是反复乱改 —— 这一条就足以把重投次数耗尽。
|
|
610
|
+
if (realLow > realHigh) {
|
|
611
|
+
errors.push({
|
|
612
|
+
code: 'DERIVATION_RANGE_MISMATCH',
|
|
613
|
+
path: 'derivations.' + k,
|
|
614
|
+
message: '公式区间为空([' + realLow + ', ' + realHigh + '],下界 > 上界):当前 quota 取值下这条公式没有合法解',
|
|
615
|
+
hint: '公式: ' + spec.formula + ' —— 说明当前的卷数/支线数与这条公式互相矛盾,请先调 quota 里相关的字段再重算'
|
|
616
|
+
});
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (d.range_low !== realLow || d.range_high !== realHigh) {
|
|
620
|
+
errors.push({
|
|
621
|
+
code: 'DERIVATION_RANGE_MISMATCH',
|
|
622
|
+
path: 'derivations.' + k,
|
|
623
|
+
message: 'range 应为 [' + realLow + ', ' + realHigh + '],实际 [' + d.range_low + ', ' + d.range_high + ']',
|
|
624
|
+
hint: '公式: ' + spec.formula
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
if (typeof d.formula !== 'string' || d.formula !== spec.formula) {
|
|
628
|
+
warnings.push({
|
|
629
|
+
code: 'DERIVATION_FORMULA_TEXT',
|
|
630
|
+
path: 'derivations.' + k + '.formula',
|
|
631
|
+
message: 'formula 文本与标准不一致',
|
|
632
|
+
hint: '标准: ' + spec.formula
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
const actualPath = spec.applies_to_field;
|
|
636
|
+
const actual = getNested(data, actualPath);
|
|
637
|
+
if (typeof actual === 'number') {
|
|
638
|
+
if (actual < realLow || actual > realHigh) {
|
|
639
|
+
warnings.push({
|
|
640
|
+
code: 'QUANTITY_MISMATCH',
|
|
641
|
+
path: actualPath,
|
|
642
|
+
message: actualPath + '=' + actual + ' 不在区间 [' + realLow + ', ' + realHigh + '] 内',
|
|
643
|
+
hint: spec.note || ''
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function getNested(obj, p) {
|
|
651
|
+
if (!p) return obj;
|
|
652
|
+
return p.split('.').reduce((o, k) => {
|
|
653
|
+
if (o === undefined || o === null) return undefined;
|
|
654
|
+
return o[k];
|
|
655
|
+
}, obj);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ============================================================
|
|
659
|
+
// 框架.json 专项检查
|
|
660
|
+
// ============================================================
|
|
661
|
+
function frameworkSpecificCheck(data, errors, warnings) {
|
|
662
|
+
const quota = data.quota || {};
|
|
663
|
+
const T = data.capacity?.target_chapters;
|
|
664
|
+
const V = quota.volume_count;
|
|
665
|
+
|
|
666
|
+
if (typeof T === 'number' && T > 0 &&
|
|
667
|
+
typeof data.capacity?.target_words === 'number' &&
|
|
668
|
+
typeof data.capacity?.avg_words_per_chapter === 'number') {
|
|
669
|
+
const expected = Math.round(data.capacity.target_words / T);
|
|
670
|
+
if (Math.abs(data.capacity.avg_words_per_chapter - expected) > 1) {
|
|
671
|
+
errors.push({
|
|
672
|
+
code: 'CAPACITY_MISMATCH',
|
|
673
|
+
path: 'capacity.avg_words_per_chapter',
|
|
674
|
+
message: 'avg_words_per_chapter=' + data.capacity.avg_words_per_chapter + ',期望 ' + expected,
|
|
675
|
+
hint: 'avg_words_per_chapter 应等于 round(target_words / target_chapters)'
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const vr = data.volume_ratio || [];
|
|
681
|
+
if (typeof V === 'number' && vr.length !== V) {
|
|
682
|
+
errors.push({
|
|
683
|
+
code: 'VOLUME_RATIO_LENGTH',
|
|
684
|
+
path: 'volume_ratio',
|
|
685
|
+
message: 'volume_ratio 长度 ' + vr.length + ' ≠ quota.volume_count ' + V,
|
|
686
|
+
hint: null
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
const sum = vr.reduce((s, x) => s + (typeof x.chapter_ratio === 'number' ? x.chapter_ratio : 0), 0);
|
|
690
|
+
if (vr.length > 0 && Math.abs(sum - 1.0) > 0.01) {
|
|
691
|
+
errors.push({
|
|
692
|
+
code: 'VOLUME_RATIO_SUM',
|
|
693
|
+
path: 'volume_ratio',
|
|
694
|
+
message: 'volume_ratio 之和 ' + sum + ' ≠ 1.0(容差 0.01)',
|
|
695
|
+
hint: null
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const vrIds = new Set();
|
|
700
|
+
for (const x of vr) {
|
|
701
|
+
if (!x.volume_id) continue;
|
|
702
|
+
if (vrIds.has(x.volume_id)) {
|
|
703
|
+
errors.push({
|
|
704
|
+
code: 'ID_DUP',
|
|
705
|
+
path: 'volume_ratio.' + x.volume_id,
|
|
706
|
+
message: 'volume_id ' + x.volume_id + ' 重复',
|
|
707
|
+
hint: 'volume_id 必须唯一(' + x.volume_id + ' 重复):请只保留一条'
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
vrIds.add(x.volume_id);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// 每项比例必须在 [0,1]。
|
|
714
|
+
//
|
|
715
|
+
// 只查"总和 = 1"是**不够**的:负数项也能凑出总和 1
|
|
716
|
+
// (导入侧旧实现就是"最后一项吃差额",把差额吃成负数)。
|
|
717
|
+
// 负比例会让节奏阶段算出"期望约 -2 章",一路传下去很难查。
|
|
718
|
+
for (const x of vr) {
|
|
719
|
+
const r = x && x.chapter_ratio;
|
|
720
|
+
if (typeof r !== 'number' || !isFinite(r)) continue;
|
|
721
|
+
if (r < 0 || r > 1) {
|
|
722
|
+
errors.push({
|
|
723
|
+
code: 'VOLUME_RATIO_RANGE',
|
|
724
|
+
path: 'volume_ratio.' + (x.volume_id || '(无 id)'),
|
|
725
|
+
message: 'chapter_ratio=' + r + ' 不在 [0, 1] 内',
|
|
726
|
+
hint: '每卷占的章数比例必须在 0~1 之间:负数会让节奏阶段算出负的期望章数,' +
|
|
727
|
+
'大于 1 说明这一卷比全书还长。请按各卷实际章数重新分配比例(各项加起来要等于 1)'
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (data.input_snapshot) {
|
|
733
|
+
const hc = data.input_snapshot.hard_constraints;
|
|
734
|
+
const at = data.input_snapshot.anti_tropes;
|
|
735
|
+
if (!Array.isArray(hc) || hc.length === 0) {
|
|
736
|
+
warnings.push({
|
|
737
|
+
code: 'EMPTY_HARD_CONSTRAINTS',
|
|
738
|
+
path: 'input_snapshot.hard_constraints',
|
|
739
|
+
message: '硬门禁列表为空。如果不适用,建议至少写一条(如"无"),避免下游无法确认硬约束',
|
|
740
|
+
hint: null
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
if (!Array.isArray(at) || at.length === 0) {
|
|
744
|
+
warnings.push({
|
|
745
|
+
code: 'EMPTY_HARD_CONSTRAINTS',
|
|
746
|
+
path: 'input_snapshot.anti_tropes',
|
|
747
|
+
message: '反套路列表为空。如果不适用,建议至少写一条(如"无")',
|
|
748
|
+
hint: null
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ============================================================
|
|
755
|
+
// 关系.json 引用完整性
|
|
756
|
+
// ============================================================
|
|
757
|
+
function relationRefCheck(data, entityData, errors) {
|
|
758
|
+
if (!entityData) return;
|
|
759
|
+
const ids = {
|
|
760
|
+
characters: new Set((entityData.characters || []).map(c => c.id)),
|
|
761
|
+
conflicts: new Set((entityData.conflicts || []).map(c => c.id)),
|
|
762
|
+
factions: new Set((entityData.factions || []).map(f => f.id)),
|
|
763
|
+
locations: new Set((entityData.locations || []).map(l => l.id)),
|
|
764
|
+
items: new Set((entityData.items || []).map(i => i.id)),
|
|
765
|
+
endings: new Set((entityData.endings || []).map(e => e.id)),
|
|
766
|
+
branches: new Set((entityData.branches || []).map(b => b.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))
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
const ref = (val, set, label, pathStr) => {
|
|
772
|
+
if (!val) return;
|
|
773
|
+
if (!set.has(val)) {
|
|
774
|
+
errors.push({
|
|
775
|
+
code: 'SKELETON_REF_MISSING',
|
|
776
|
+
path: pathStr,
|
|
777
|
+
message: '引用 ' + label + ' "' + val + '" 不存在于 实体.json',
|
|
778
|
+
hint: null
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
for (const [i, r] of (data.character_relations || []).entries()) {
|
|
784
|
+
ref(r.from, ids.characters, '角色', 'character_relations[' + i + '].from');
|
|
785
|
+
ref(r.to, ids.characters, '角色', 'character_relations[' + i + '].to');
|
|
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
|
+
}
|
|
792
|
+
for (const [i, r] of (data.faction_membership || []).entries()) {
|
|
793
|
+
ref(r.character, ids.characters, '角色', 'faction_membership[' + i + '].character');
|
|
794
|
+
ref(r.faction, ids.factions, '势力', 'faction_membership[' + i + '].faction');
|
|
795
|
+
}
|
|
796
|
+
for (const [i, r] of (data.faction_relations || []).entries()) {
|
|
797
|
+
ref(r.from, ids.factions, '势力', 'faction_relations[' + i + '].from');
|
|
798
|
+
ref(r.to, ids.factions, '势力', 'faction_relations[' + i + '].to');
|
|
799
|
+
}
|
|
800
|
+
for (const [i, r] of (data.conflict_participants || []).entries()) {
|
|
801
|
+
ref(r.conflict, ids.conflicts, '冲突', 'conflict_participants[' + i + '].conflict');
|
|
802
|
+
for (const c of (r.protagonist_side || [])) ref(c, ids.characters, '角色', 'conflict_participants[' + i + '].protagonist_side');
|
|
803
|
+
ref(r.main_antagonist, ids.characters, '角色', 'conflict_participants[' + i + '].main_antagonist');
|
|
804
|
+
for (const c of (r.secondary_antagonist || [])) ref(c, ids.characters, '角色', 'conflict_participants[' + i + '].secondary_antagonist');
|
|
805
|
+
for (const c of (r.third_party || [])) ref(c, ids.characters, '角色', 'conflict_participants[' + i + '].third_party');
|
|
806
|
+
}
|
|
807
|
+
for (const [i, r] of (data.branch_protagonist || []).entries()) {
|
|
808
|
+
ref(r.branch, ids.branches, '支线', 'branch_protagonist[' + i + '].branch');
|
|
809
|
+
ref(r.character, ids.characters, '角色', 'branch_protagonist[' + i + '].character');
|
|
810
|
+
}
|
|
811
|
+
for (const [i, r] of (data.item_owner || []).entries()) {
|
|
812
|
+
ref(r.item, ids.items, '道具', 'item_owner[' + i + '].item');
|
|
813
|
+
if (r.owner_kind === 'character') ref(r.owner_id, ids.characters, '角色', 'item_owner[' + i + '].owner_id');
|
|
814
|
+
else if (r.owner_kind === 'faction') ref(r.owner_id, ids.factions, '势力', 'item_owner[' + i + '].owner_id');
|
|
815
|
+
}
|
|
816
|
+
for (const [i, r] of (data.location_conflict || []).entries()) {
|
|
817
|
+
ref(r.location, ids.locations, '地点', 'location_conflict[' + i + '].location');
|
|
818
|
+
for (const c of (r.conflicts || [])) ref(c, ids.conflicts, '冲突', 'location_conflict[' + i + '].conflicts');
|
|
819
|
+
}
|
|
820
|
+
for (const [i, r] of (data.ending_character || []).entries()) {
|
|
821
|
+
ref(r.ending, ids.endings, '结局', 'ending_character[' + i + '].ending');
|
|
822
|
+
ref(r.character, ids.characters, '角色', 'ending_character[' + i + '].character');
|
|
823
|
+
}
|
|
824
|
+
for (const [i, r] of (data.main_line_branch_relation || []).entries()) {
|
|
825
|
+
ref(r.main_line, ids.main_lines, '主线', 'main_line_branch_relation[' + i + '].main_line');
|
|
826
|
+
ref(r.branch, ids.branches, '支线', 'main_line_branch_relation[' + i + '].branch');
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ============================================================
|
|
831
|
+
// 支线挂主线的覆盖度
|
|
832
|
+
// ============================================================
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* 支线必须挂到主线上(覆盖度检查,不是引用完整性)。
|
|
836
|
+
*
|
|
837
|
+
* 为什么必须查(tt5 实测的真实缺陷):
|
|
838
|
+
* `main_line_branch_relation` 之前只查"main_line / branch 这两个 id 存不存在",
|
|
839
|
+
* 不查"每条支线有没有被挂上去"。结果模型只写 1 行(M001→B003),
|
|
840
|
+
* 10 条支线里 9 条一条关系都没有 —— 校验器照样 PASSED,
|
|
841
|
+
* 产物带着 9 条孤儿支线一路流到血肉/节奏阶段,下游才发现结构是断的。
|
|
842
|
+
*
|
|
843
|
+
* 口径:
|
|
844
|
+
* · 每条支线至少挂到一条主线上(BRANCH_MAINLINE_UNLINKED)
|
|
845
|
+
* · 每条主线至少挂到一条支线上(MAINLINE_BRANCH_UNLINKED)
|
|
846
|
+
* —— 支线数为 0 时不查这一条(没有支线的主线不算错)
|
|
847
|
+
*/
|
|
848
|
+
function checkBranchMainLineCoverage(entityData, relationData, errors) {
|
|
849
|
+
if (!entityData || !relationData) return;
|
|
850
|
+
const branches = Array.isArray(entityData.branches) ? entityData.branches : [];
|
|
851
|
+
const mainLines = Array.isArray(entityData.main_lines) ? entityData.main_lines : [];
|
|
852
|
+
if (!branches.length && !mainLines.length) return;
|
|
853
|
+
const rows = Array.isArray(relationData.main_line_branch_relation)
|
|
854
|
+
? relationData.main_line_branch_relation
|
|
855
|
+
: [];
|
|
856
|
+
|
|
857
|
+
const linkedBranches = new Set();
|
|
858
|
+
const usedMainLines = new Set();
|
|
859
|
+
for (const r of rows) {
|
|
860
|
+
if (!r || typeof r !== 'object') continue;
|
|
861
|
+
if (typeof r.branch === 'string' && r.branch) linkedBranches.add(r.branch);
|
|
862
|
+
if (typeof r.main_line === 'string' && r.main_line) usedMainLines.add(r.main_line);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const missingBranches = branches
|
|
866
|
+
.filter((b) => b && b.id && !linkedBranches.has(b.id))
|
|
867
|
+
.map((b) => b.id);
|
|
868
|
+
if (missingBranches.length) {
|
|
869
|
+
errors.push({
|
|
870
|
+
code: 'BRANCH_MAINLINE_UNLINKED',
|
|
871
|
+
path: 'main_line_branch_relation',
|
|
872
|
+
message: '有 ' + missingBranches.length + ' 条支线没有挂到任何主线:' + missingBranches.join('、'),
|
|
873
|
+
hint: 'main_line_branch_relation 一行代表"一条支线挂到主线上",每条支线都要有一行'
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// 反向:主线一条支线都没挂。没有支线时不查(支线为 0 是合法配置)
|
|
878
|
+
if (branches.length) {
|
|
879
|
+
const deadMainLines = mainLines
|
|
880
|
+
.filter((m) => m && m.id && !usedMainLines.has(m.id))
|
|
881
|
+
.map((m) => m.id);
|
|
882
|
+
if (deadMainLines.length) {
|
|
883
|
+
errors.push({
|
|
884
|
+
code: 'MAINLINE_BRANCH_UNLINKED',
|
|
885
|
+
path: 'main_line_branch_relation',
|
|
886
|
+
message: '有 ' + deadMainLines.length + ' 条主线没有挂任何支线:' + deadMainLines.join('、'),
|
|
887
|
+
hint: '每条主线都要有支线挂上来,否则这条主线在结构上是空的'
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ============================================================
|
|
894
|
+
// 层级.json 引用完整性
|
|
895
|
+
// ============================================================
|
|
896
|
+
function hierarchyRefCheck(data, entityData, frameworkData, errors) {
|
|
897
|
+
const volumeCount = frameworkData?.quota?.volume_count;
|
|
898
|
+
const validVolumes = new Set((frameworkData?.volume_ratio || []).map(x => x.volume_id));
|
|
899
|
+
|
|
900
|
+
if (typeof volumeCount === 'number' && (data.volumes || []).length !== volumeCount) {
|
|
901
|
+
errors.push({
|
|
902
|
+
code: 'VOLUME_COUNT_MISMATCH',
|
|
903
|
+
path: 'volumes',
|
|
904
|
+
message: 'volumes 长度 ' + (data.volumes || []).length + ' ≠ quota.volume_count ' + volumeCount,
|
|
905
|
+
hint: null
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
for (const [i, v] of (data.volumes || []).entries()) {
|
|
910
|
+
if (v.id && validVolumes.size > 0 && !validVolumes.has(v.id)) {
|
|
911
|
+
errors.push({
|
|
912
|
+
code: 'SKELETON_REF_MISSING',
|
|
913
|
+
path: 'volumes[' + i + '].id',
|
|
914
|
+
message: '卷 ' + v.id + ' 不在框架 volume_ratio 中',
|
|
915
|
+
hint: null
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const volIds = new Set((data.volumes || []).map(v => v.id));
|
|
921
|
+
for (const [i, a] of (data.acts || []).entries()) {
|
|
922
|
+
if (a.volume && !volIds.has(a.volume)) {
|
|
923
|
+
errors.push({
|
|
924
|
+
code: 'SKELETON_REF_MISSING',
|
|
925
|
+
path: 'acts[' + i + '].volume',
|
|
926
|
+
message: '幕引用的卷 ' + a.volume + ' 不存在于 volumes',
|
|
927
|
+
hint: null
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const actSeen = new Map();
|
|
933
|
+
for (const [i, a] of (data.acts || []).entries()) {
|
|
934
|
+
if (!a.volume || typeof a.act_index !== 'number') continue;
|
|
935
|
+
const key = a.volume + '#' + a.act_index;
|
|
936
|
+
if (actSeen.has(key)) {
|
|
937
|
+
errors.push({
|
|
938
|
+
code: 'ACT_INDEX_DUP',
|
|
939
|
+
path: 'acts[' + i + '].act_index',
|
|
940
|
+
message: '卷 ' + a.volume + ' 的 act_index=' + a.act_index + ' 重复',
|
|
941
|
+
hint: null
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
actSeen.set(key, i);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (typeof volumeCount === 'number') {
|
|
948
|
+
for (const [i, s] of (data.main_line_span || []).entries()) {
|
|
949
|
+
if ((s.volume_ratio || []).length !== volumeCount) {
|
|
950
|
+
errors.push({
|
|
951
|
+
code: 'SPAN_LENGTH_MISMATCH',
|
|
952
|
+
path: 'main_line_span[' + i + '].volume_ratio',
|
|
953
|
+
message: '长度 ' + (s.volume_ratio || []).length + ' ≠ volume_count ' + volumeCount,
|
|
954
|
+
hint: null
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
for (const [j, v] of (s.volume_ratio || []).entries()) {
|
|
958
|
+
if (typeof v !== 'number' || v < 0 || v > 1) {
|
|
959
|
+
errors.push({
|
|
960
|
+
code: 'SPAN_RATIO_RANGE',
|
|
961
|
+
path: 'main_line_span[' + i + '].volume_ratio[' + j + ']',
|
|
962
|
+
message: 'volume_ratio 元素 ' + v + ' 不在 [0, 1] 内',
|
|
963
|
+
hint: null
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
for (const [i, s] of (data.branch_span || []).entries()) {
|
|
969
|
+
if ((s.volume_ratio || []).length !== volumeCount) {
|
|
970
|
+
errors.push({
|
|
971
|
+
code: 'SPAN_LENGTH_MISMATCH',
|
|
972
|
+
path: 'branch_span[' + i + '].volume_ratio',
|
|
973
|
+
message: '长度 ' + (s.volume_ratio || []).length + ' ≠ volume_count ' + volumeCount,
|
|
974
|
+
hint: null
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
for (const [j, v] of (s.volume_ratio || []).entries()) {
|
|
978
|
+
if (typeof v !== 'number' || v < 0 || v > 1) {
|
|
979
|
+
errors.push({
|
|
980
|
+
code: 'SPAN_RATIO_RANGE',
|
|
981
|
+
path: 'branch_span[' + i + '].volume_ratio[' + j + ']',
|
|
982
|
+
message: 'volume_ratio 元素 ' + v + ' 不在 [0, 1] 内',
|
|
983
|
+
hint: null
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (entityData) {
|
|
991
|
+
const mlIds = new Set((entityData.main_lines || []).map(m => m.id));
|
|
992
|
+
const bIds = new Set((entityData.branches || []).map(b => b.id));
|
|
993
|
+
for (const [i, s] of (data.main_line_span || []).entries()) {
|
|
994
|
+
if (s.main_line && !mlIds.has(s.main_line)) {
|
|
995
|
+
errors.push({ code: 'SKELETON_REF_MISSING', path: 'main_line_span[' + i + '].main_line', message: '主线 ' + s.main_line + ' 不存在于 实体.json', hint: null });
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
for (const [i, s] of (data.branch_span || []).entries()) {
|
|
999
|
+
if (s.branch && !bIds.has(s.branch)) {
|
|
1000
|
+
errors.push({ code: 'SKELETON_REF_MISSING', path: 'branch_span[' + i + '].branch', message: '支线 ' + s.branch + ' 不存在于 实体.json', hint: null });
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// ============================================================
|
|
1007
|
+
// 全量:quota 一致性检查(P2-4:加 faction_relation_count)
|
|
1008
|
+
// ============================================================
|
|
1009
|
+
|
|
1010
|
+
function checkQuotaConsistency(frameworkData, entityData, hierarchyData, relationData, errors, formulas) {
|
|
1011
|
+
if (!frameworkData || !entityData) return;
|
|
1012
|
+
const quota = frameworkData.quota || {};
|
|
1013
|
+
|
|
1014
|
+
for (const [quotaPath, spec] of Object.entries(QUOTA_MAP)) {
|
|
1015
|
+
const expected = getNested(quota, quotaPath);
|
|
1016
|
+
if (typeof expected !== 'number') continue;
|
|
1017
|
+
const arr = entityData[spec.arr] || [];
|
|
1018
|
+
const actual = arr.filter(spec.filter).length;
|
|
1019
|
+
if (expected !== actual) {
|
|
1020
|
+
errors.push({
|
|
1021
|
+
code: 'QUOTA_MISMATCH',
|
|
1022
|
+
path: 'quota.' + quotaPath,
|
|
1023
|
+
message: 'quota.' + quotaPath + '=' + expected + ',实际 ' + actual + '(数组 ' + spec.arr + ' 过滤后)',
|
|
1024
|
+
hint: '调整 quota 声明值或实体数量'
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (hierarchyData && typeof quota.volume_count === 'number') {
|
|
1030
|
+
const actualVolumes = (hierarchyData.volumes || []).length;
|
|
1031
|
+
if (actualVolumes !== quota.volume_count) {
|
|
1032
|
+
errors.push({
|
|
1033
|
+
code: 'QUOTA_MISMATCH',
|
|
1034
|
+
path: 'quota.volume_count',
|
|
1035
|
+
message: 'quota.volume_count=' + quota.volume_count + ',层级.json volumes.length=' + actualVolumes,
|
|
1036
|
+
hint: null
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// P2-4:faction_relation_count 特判(数据在关系.json,不在实体.json)
|
|
1042
|
+
checkFactionRelationRowCount(frameworkData, relationData, errors, formulas);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* `quota.faction_relation_count` 与 `faction_relations` 实际行数的对账。
|
|
1047
|
+
*
|
|
1048
|
+
* 注意:quota 含义是"无向关系对数"(按 faction_relation_count 公式 max(1,n-1) ~ n*(n-1)/2),
|
|
1049
|
+
* 但 关系.json 实际存储是双向行(A→B + B→A 两条)。所以实际行数应为 2 * quota。
|
|
1050
|
+
* 兼容历史数据:如果 quota*2 != actual 且 quota != actual,则按 quota*2 检查。
|
|
1051
|
+
*
|
|
1052
|
+
* 单独抽出来是为了让**局部校验模式**(--arrays)也能跑这一条 ——
|
|
1053
|
+
* 中途校验时文件还没写完,但"势力关系行数对不对"当场就能判。
|
|
1054
|
+
*/
|
|
1055
|
+
function checkFactionRelationRowCount(frameworkData, relationData, errors, formulas) {
|
|
1056
|
+
if (!frameworkData || !relationData) return;
|
|
1057
|
+
const quota = frameworkData.quota || {};
|
|
1058
|
+
const expectedFR = getNested(quota, 'faction_relation_count');
|
|
1059
|
+
if (typeof expectedFR !== 'number') return;
|
|
1060
|
+
const actualFR = (relationData.faction_relations || []).length;
|
|
1061
|
+
const expectedRows = expectedFR * 2;
|
|
1062
|
+
if (expectedFR === actualFR || expectedRows === actualFR) return;
|
|
1063
|
+
errors.push({
|
|
1064
|
+
code: 'QUOTA_MISMATCH',
|
|
1065
|
+
path: 'quota.faction_relation_count',
|
|
1066
|
+
message: 'quota.faction_relation_count=' + expectedFR + '(无向关系对数),关系.json 实际 ' + actualFR + ' 行(双向行应为 ' + expectedRows + ')',
|
|
1067
|
+
hint: factionRelationHint(frameworkData, formulas, expectedFR, actualFR, expectedRows)
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* `quota.faction_relation_count` 对不上时的修复建议。
|
|
1073
|
+
*
|
|
1074
|
+
* 以前只有一个笼统的 hint:"要么把 quota 改为 X,要么把 faction_relations 改为 N 行"。
|
|
1075
|
+
* 其中"改 quota"那个分支是**陷阱**(tt5 实测):quota 的合法区间由公式决定
|
|
1076
|
+
* (`max(1, n-1) ~ n*(n-1)/2`),而实际行数反推出来的对数常常落在区间外
|
|
1077
|
+
* (tt5:8 个势力 → 合法区间 [7, 28],反推出 4 —— 改 quota 只会换来一个
|
|
1078
|
+
* `QUANTITY_MISMATCH` warning,而且结构上是错的)。
|
|
1079
|
+
*
|
|
1080
|
+
* 所以这里只给**一定可行**的改法:把行数改成 quota 声明的行数;
|
|
1081
|
+
* 只有当反推出的 quota 值确实落在公式区间内时,才把"改 quota"也列出来。
|
|
1082
|
+
*/
|
|
1083
|
+
function factionRelationHint(frameworkData, formulas, expectedFR, actualFR, expectedRows) {
|
|
1084
|
+
// **只给"改关系行数"这一条路**,永远不建议改 quota。
|
|
1085
|
+
//
|
|
1086
|
+
// quota 是 框架.json 里的**定稿内容**(框架段早已 PASSED):
|
|
1087
|
+
// · 改它等于篡改上游产物 —— 提示词明确要求模型只写分片文件;
|
|
1088
|
+
// · 下一轮合并分片会把它覆盖回去,改了也白改;
|
|
1089
|
+
// · quota 与 derivations、其它配额互相约束,改一个数会引出一串新错。
|
|
1090
|
+
// tt5 实测(2026-10-25 09:xx):这里原本在"反推值落在公式区间内"时
|
|
1091
|
+
// 会给出"或者把 quota 改成 X",模型照着把 框架.json 的
|
|
1092
|
+
// faction_relation_count 从 14 改成 28,错误从 3 条涨到 126 条,
|
|
1093
|
+
// 重投到上限,关系段 18 个分片没生成。
|
|
1094
|
+
//
|
|
1095
|
+
// 公式区间只用来**说明为什么不能靠改 quota 绕过去**,不给第二条路。
|
|
1096
|
+
const parts = [];
|
|
1097
|
+
parts.push('把 faction_relations 改为 ' + expectedRows + ' 行(A→B 与 B→A 各一)');
|
|
1098
|
+
const range = factionRelationQuotaRange(frameworkData, formulas);
|
|
1099
|
+
const altQuota = Math.ceil(actualFR / 2);
|
|
1100
|
+
if (range) {
|
|
1101
|
+
if (altQuota >= range.low && altQuota <= range.high) {
|
|
1102
|
+
parts.push('不要改 quota(quota 是框架.json 里已定稿的值,改了会被分片覆盖回来,'
|
|
1103
|
+
+ '而且会引出新的错):要改的是 faction_relations 的行数');
|
|
1104
|
+
} else {
|
|
1105
|
+
parts.push('不要改 quota(改成 ' + altQuota + ' 会掉出公式区间 ['
|
|
1106
|
+
+ range.low + ', ' + range.high + '],只会换一个错):要改的是 faction_relations 的行数');
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return parts.join(';');
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* `quota.faction_relation_count` 的合法区间(按公式算)。
|
|
1114
|
+
* 拿不到公式就返回 null(此时不给"改 quota"的建议)。
|
|
1115
|
+
*/
|
|
1116
|
+
function factionRelationQuotaRange(frameworkData, formulas) {
|
|
1117
|
+
const spec = formulas && formulas['faction_elements.faction_relation_count'];
|
|
1118
|
+
if (!spec) return null;
|
|
1119
|
+
try {
|
|
1120
|
+
const ctx = buildFormulaContext(frameworkData);
|
|
1121
|
+
const low = evalExpr(spec.expr_low, ctx);
|
|
1122
|
+
const high = evalExpr(spec.expr_high, ctx);
|
|
1123
|
+
if (typeof low !== 'number' || typeof high !== 'number') return null;
|
|
1124
|
+
if (!isFinite(low) || !isFinite(high)) return null;
|
|
1125
|
+
return { low: low, high: high };
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ============================================================
|
|
1132
|
+
// 全书级结局数量检查
|
|
1133
|
+
// ============================================================
|
|
1134
|
+
function checkBookEndingLevel(entityData, errors) {
|
|
1135
|
+
if (!entityData) return;
|
|
1136
|
+
const endings = entityData.endings || [];
|
|
1137
|
+
const bookLevel = endings.filter(e => e.level === '全书级');
|
|
1138
|
+
if (bookLevel.length !== 1) {
|
|
1139
|
+
errors.push({
|
|
1140
|
+
code: 'BOOK_ENDING_COUNT',
|
|
1141
|
+
path: 'endings',
|
|
1142
|
+
message: '全书级结局(level="全书级")数量为 ' + bookLevel.length + ',必须恰好 1 个',
|
|
1143
|
+
hint: '每本书必须有且仅有 1 个 level="全书级" 的结局;单卷作品不应有卷级结局'
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// ============================================================
|
|
1149
|
+
// 跨阶段骨架参数矛盾检查
|
|
1150
|
+
// 检查节奏阶段的 branch_chapter_total 区间是否可行
|
|
1151
|
+
// 公式:lower = max(BC*3, floor(T*0.3)), upper = min(BC*30, ceil(T*0.5))
|
|
1152
|
+
// 若 lower > upper,说明骨架阶段 T/BC 选值不合理
|
|
1153
|
+
// ============================================================
|
|
1154
|
+
function checkRhythmFeasibility(frameworkData, errors, formulas) {
|
|
1155
|
+
if (!frameworkData || !frameworkData.capacity) return;
|
|
1156
|
+
const T = frameworkData.capacity.target_chapters;
|
|
1157
|
+
const quota = frameworkData.quota || {};
|
|
1158
|
+
const BC = quota.branch_count;
|
|
1159
|
+
if (typeof T !== 'number' || typeof BC !== 'number') return;
|
|
1160
|
+
const lower = Math.max(BC * 3, Math.floor(T * 0.3));
|
|
1161
|
+
const upper = Math.min(BC * 30, Math.ceil(T * 0.5));
|
|
1162
|
+
if (lower > upper) {
|
|
1163
|
+
// 两个区间必须相交,否则节奏阶段无解:
|
|
1164
|
+
// [BC*3, BC*30] —— 每条支线 3~30 章
|
|
1165
|
+
// [floor(T*0.3), ceil(T*0.5)] —— 支线总章数占全书 30%~50%
|
|
1166
|
+
//
|
|
1167
|
+
// 不相交有**两种原因,调整方向相反**,必须分开说:
|
|
1168
|
+
// tt5 实测(2026-10-24 22:00)原来只写"一般 T 小而 BC 大时出现",
|
|
1169
|
+
// 模型撞的却是相反的那种(T=800、BC=6),于是把 BC 6→4 越改越错,
|
|
1170
|
+
// 3 轮重投用尽,阶段失败。
|
|
1171
|
+
const floor03 = Math.floor(T * 0.3);
|
|
1172
|
+
const ceil05 = Math.ceil(T * 0.5);
|
|
1173
|
+
const bcCeiling = Math.floor(ceil05 / 3); // 节奏侧允许的最大 BC
|
|
1174
|
+
|
|
1175
|
+
// 书太短:连 1 条支线都放不下(每条支线至少 3 章)。
|
|
1176
|
+
// 这时"把 branch_count 调小"是**无效建议** —— BC 最小就是 1。
|
|
1177
|
+
// 必须明说"这本书装不下支线",否则模型会照着调成 0 然后继续失败。
|
|
1178
|
+
if (bcCeiling < 1) {
|
|
1179
|
+
errors.push({
|
|
1180
|
+
code: 'RHYTHM_INFEASIBLE_PRECHECK',
|
|
1181
|
+
path: 'quota.branch_count',
|
|
1182
|
+
message: '骨架参数矛盾:全书只有 ' + T + ' 章,装不下一条支线' +
|
|
1183
|
+
'(每条支线至少 3 章,而支线总章数只能落在 ' + floor03 + ' ~ ' + ceil05 + ' 之间)',
|
|
1184
|
+
hint: '支线数最少也是 1 条,而 1 条支线至少占 3 章,超过全书上限 ' + ceil05 + ' 章。' +
|
|
1185
|
+
'这不是调 branch_count 能解决的:请把 capacity.target_chapters 提到 5 章以上' +
|
|
1186
|
+
'(按每卷 40~120 章排卷,一本这么短的书实际建议至少 20 章),' +
|
|
1187
|
+
'或改用 /fancy-story-builder 从骨架重新起稿。'
|
|
1188
|
+
});
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const tooMany = BC * 3 > ceil05; // 支线太多:支线最少要占的章数超了上限
|
|
1193
|
+
const need = Math.ceil(floor03 / 30); // 支线太少时,BC 至少要这么多才铺得满
|
|
1194
|
+
// 提示里的可行区间必须来自**公式本身**(formulas-skeleton.json 的
|
|
1195
|
+
// structure.branch_count),不能自己算一套上限:
|
|
1196
|
+
// 手算的 maxBc = floor(ceil(T*0.5)/3) 只管节奏侧,不管公式侧
|
|
1197
|
+
// (公式还有 min(V*3, 15) 这层约束)→ 提示说"调大到 3~50",
|
|
1198
|
+
// 模型照着填 50 会撞 QUANTITY_MISMATCH,白烧一轮重投。
|
|
1199
|
+
const range = formulas
|
|
1200
|
+
? formulaRange(formulas, 'quota.branch_count', buildFormulaContext(frameworkData))
|
|
1201
|
+
: null;
|
|
1202
|
+
const fLow = range ? range.low : need;
|
|
1203
|
+
const fHigh = range ? range.high : bcCeiling;
|
|
1204
|
+
errors.push({
|
|
1205
|
+
code: 'RHYTHM_INFEASIBLE_PRECHECK',
|
|
1206
|
+
path: 'quota.branch_count',
|
|
1207
|
+
message: '骨架参数矛盾:节奏阶段支线章数区间不可行([' + lower + ', ' + upper + '],下界 > 上界)',
|
|
1208
|
+
hint: tooMany
|
|
1209
|
+
? '支线数太多:每条支线至少 3 章,' + BC + ' 条支线至少要占 ' + (BC * 3) +
|
|
1210
|
+
' 章,超过全书上限 ' + ceil05 + ' 章。请把 branch_count 调小到 ' +
|
|
1211
|
+
Math.min(bcCeiling, fHigh) + ' 以内(公式允许的上限是 ' + fHigh + ')。'
|
|
1212
|
+
: '支线数太少:支线总章数至少要 floor(T×0.3)=' + floor03 +
|
|
1213
|
+
' 章,每条支线最多 30 章,所以 branch_count 至少要 ' + need +
|
|
1214
|
+
'(当前 ' + BC + ')。请把 branch_count 调大到 ' + fLow + '~' + fHigh + ' 之间' +
|
|
1215
|
+
(fLow > fHigh ? '(这两个值已经倒挂了:公式本身在当前卷数下无解,得先调 volume_count 或 target_chapters)' : '。')
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// ============================================================
|
|
1221
|
+
// 世界观规则 id 前缀与 category 一致性
|
|
1222
|
+
// R 前缀 → 核心规则;I 前缀 → 信息规则;SR 前缀 → 社会规则
|
|
1223
|
+
// ============================================================
|
|
1224
|
+
const WORLD_RULE_CATEGORY_BY_PREFIX = [
|
|
1225
|
+
{ prefix: /^R\d{3}$/, category: '核心规则' },
|
|
1226
|
+
{ prefix: /^I\d{3}$/, category: '信息规则' },
|
|
1227
|
+
{ prefix: /^SR\d{3}$/, category: '社会规则' }
|
|
1228
|
+
];
|
|
1229
|
+
|
|
1230
|
+
function checkWorldRuleCategory(entityData, errors) {
|
|
1231
|
+
if (!entityData) return;
|
|
1232
|
+
const rules = entityData.world_rules || [];
|
|
1233
|
+
for (const [i, r] of rules.entries()) {
|
|
1234
|
+
if (!r.id || !r.category) continue;
|
|
1235
|
+
const match = WORLD_RULE_CATEGORY_BY_PREFIX.find(m => m.prefix.test(r.id));
|
|
1236
|
+
if (!match) continue; // 模式未匹配已在骨架模板 PATTERN 检查中拦截
|
|
1237
|
+
if (match.category !== r.category) {
|
|
1238
|
+
errors.push({
|
|
1239
|
+
code: 'WORLD_RULE_CATEGORY_MISMATCH',
|
|
1240
|
+
path: 'world_rules[' + i + ']',
|
|
1241
|
+
message: 'id "' + r.id + '" 的前缀应对应 ' + match.category + ',实际 category 是 "' + r.category + '"',
|
|
1242
|
+
hint: 'R 前缀 → 核心规则;I 前缀 → 信息规则;SR 前缀 → 社会规则'
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// ============================================================
|
|
1249
|
+
// 跨实体类型同名检查
|
|
1250
|
+
// ============================================================
|
|
1251
|
+
function crossTypeNameDupCheck(entityData, warnings) {
|
|
1252
|
+
if (!entityData) return;
|
|
1253
|
+
const types = ['characters', 'other_entities', 'factions', 'conflicts', 'locations'];
|
|
1254
|
+
const typeLabels = { characters: '角色', other_entities: '其他实体', factions: '势力', conflicts: '冲突', locations: '地点' };
|
|
1255
|
+
const nameMap = new Map();
|
|
1256
|
+
for (const t of types) {
|
|
1257
|
+
for (const item of (entityData[t] || [])) {
|
|
1258
|
+
if (!item.name) continue;
|
|
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) });
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
for (const [normalized, entries] of nameMap) {
|
|
1266
|
+
if (entries.length <= 1) continue;
|
|
1267
|
+
const typeSet = new Set(entries.map(e => e.type));
|
|
1268
|
+
if (typeSet.size <= 1) continue;
|
|
1269
|
+
const typesInvolved = [...typeSet].map(t => typeLabels[t]).join(' / ');
|
|
1270
|
+
const names = [...new Set(entries.map((e) => e.name))];
|
|
1271
|
+
warnings.push({
|
|
1272
|
+
code: 'CROSS_TYPE_NAME_DUP',
|
|
1273
|
+
path: 'name: ' + names.join(' / '),
|
|
1274
|
+
message: '名称 ' + names.map((n) => '"' + n + '"').join('、') +
|
|
1275
|
+
' 在多个实体类型中重复出现(' + typesInvolved + ';归一化 key=' + normalized + ')',
|
|
1276
|
+
hint: '不同类型同名可能导致引用歧义(如 faction.members[].name 与 faction.relations[].target_name 无法靠名字区分),建议改名'
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// ============================================================
|
|
1282
|
+
// 图构建(P2-8:删除无效的 volumes 循环)
|
|
1283
|
+
// ============================================================
|
|
1284
|
+
function buildGraph(entityData, relationData, errors, warnings) {
|
|
1285
|
+
const graph = new SimpleGraph();
|
|
1286
|
+
if (!entityData) return;
|
|
1287
|
+
|
|
1288
|
+
for (const [arr, type] of [
|
|
1289
|
+
['characters', 'character'], ['conflicts', 'conflict'], ['factions', 'faction'],
|
|
1290
|
+
['locations', 'location'], ['items', 'item'], ['endings', 'ending'],
|
|
1291
|
+
['foreshadows', 'foreshadow'], ['climaxes', 'climax'], ['pleasures', 'pleasure'],
|
|
1292
|
+
['main_lines', 'main_line'], ['branches', 'branch'],
|
|
1293
|
+
['world_rules', 'world_rule'], ['other_entities', 'other_entity']
|
|
1294
|
+
]) {
|
|
1295
|
+
for (const x of (entityData[arr] || [])) {
|
|
1296
|
+
graph.addNode(x.id, { type: type, name: x.name });
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
// P2-8:删除原 v2.x 中无效的 volumes 循环
|
|
1300
|
+
// (实体.json 无 volumes 字段,volumes 在层级.json,本函数不读层级.json)
|
|
1301
|
+
|
|
1302
|
+
if (!relationData) return;
|
|
1303
|
+
|
|
1304
|
+
const addEdge = (from, to, rel) => {
|
|
1305
|
+
if (!graph.hasNode(from) || !graph.hasNode(to)) return;
|
|
1306
|
+
graph.addEdgeWithKey(from + '--' + rel + '--' + to, from, to, { relation: rel });
|
|
1307
|
+
};
|
|
1308
|
+
|
|
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');
|
|
1311
|
+
for (const r of (relationData.faction_membership || [])) addEdge(r.character, r.faction, 'member_of');
|
|
1312
|
+
for (const r of (relationData.faction_relations || [])) addEdge(r.from, r.to, 'faction_related');
|
|
1313
|
+
for (const r of (relationData.conflict_participants || [])) {
|
|
1314
|
+
for (const c of (r.protagonist_side || [])) addEdge(c, r.conflict, 'participates_in');
|
|
1315
|
+
if (r.main_antagonist) addEdge(r.main_antagonist, r.conflict, 'antagonist_of');
|
|
1316
|
+
for (const c of (r.secondary_antagonist || [])) addEdge(c, r.conflict, 'participates_in');
|
|
1317
|
+
for (const c of (r.third_party || [])) addEdge(c, r.conflict, 'participates_in');
|
|
1318
|
+
}
|
|
1319
|
+
for (const r of (relationData.branch_protagonist || [])) addEdge(r.character, r.branch, 'protagonist_of');
|
|
1320
|
+
for (const r of (relationData.item_owner || [])) addEdge(r.item, r.owner_id, 'owned_by');
|
|
1321
|
+
for (const r of (relationData.location_conflict || [])) {
|
|
1322
|
+
for (const c of (r.conflicts || [])) addEdge(r.location, c, 'hosts');
|
|
1323
|
+
}
|
|
1324
|
+
for (const r of (relationData.ending_character || [])) addEdge(r.character, r.ending, 'destined_in');
|
|
1325
|
+
for (const r of (relationData.main_line_branch_relation || [])) addEdge(r.main_line, r.branch, 'interweaves');
|
|
1326
|
+
|
|
1327
|
+
// 骨架阶段:foreshadow/climax/pleasure/world_rule 实体在 关系.json
|
|
1328
|
+
// 模板中没有引用数组,无法建立边。它们的实体间关系在血肉阶段才补齐,
|
|
1329
|
+
// 因此骨架阶段豁免这四类 + volume 的孤立节点检查。
|
|
1330
|
+
const isolated = getIsolatedNodes(graph, ['volume', 'foreshadow', 'climax', 'pleasure', 'world_rule']);
|
|
1331
|
+
for (const n of isolated) {
|
|
1332
|
+
errors.push({
|
|
1333
|
+
code: 'G_ISOLATED',
|
|
1334
|
+
path: n.key,
|
|
1335
|
+
message: '节点 ' + n.key + '(' + n.type + ')没有任何入边或出边(孤立节点)',
|
|
1336
|
+
hint: '该实体没有被任何关系引用,也没有引用任何实体'
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const disconnected = getDisconnectedCoreNodes(graph, ['character', 'conflict', 'faction']);
|
|
1341
|
+
if (disconnected.length > 0) {
|
|
1342
|
+
warnings.push({
|
|
1343
|
+
code: 'G_DISCONNECTED',
|
|
1344
|
+
path: 'core-subgraph',
|
|
1345
|
+
message: '核心子图(character+conflict+faction)不连通,' + disconnected.length + ' 个节点不在主连通分量中',
|
|
1346
|
+
hint: '孤立节点:' + disconnected.join(', ')
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// ============================================================
|
|
1352
|
+
// 内容质量检查
|
|
1353
|
+
// ============================================================
|
|
1354
|
+
// 此前骨架阶段只校验结构,"实体名是「主角1」""描述只有四个字"这类空内容一律放行。
|
|
1355
|
+
// 这里按文件分派:框架.json 只查 input_snapshot(derivations 里是公式字符串,
|
|
1356
|
+
// 不是叙述内容,不能按文本长度判);其余文件整体检查。
|
|
1357
|
+
function contentQualityCheck(fileType, data, errors, structure) {
|
|
1358
|
+
const enumLeaves = collectEnumLeaves(structure);
|
|
1359
|
+
if (fileType === '框架') {
|
|
1360
|
+
if (data && data.input_snapshot && typeof data.input_snapshot === 'object') {
|
|
1361
|
+
runContentQualityChecks(data.input_snapshot, errors, { enumLeaves });
|
|
1362
|
+
}
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (fileType === '关系') return; // 纯 ID 关系表,无叙述内容
|
|
1366
|
+
runContentQualityChecks(data, errors, { enumLeaves });
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// ============================================================
|
|
1370
|
+
// 势力关系对称性
|
|
1371
|
+
// ============================================================
|
|
1372
|
+
function factionRelationSymmetryCheck(relationData, errors) {
|
|
1373
|
+
const result = checkBidirectionalSymmetry(relationData.faction_relations || [], {
|
|
1374
|
+
fromKey: 'from',
|
|
1375
|
+
toKey: 'to',
|
|
1376
|
+
typeKey: 'relation_type',
|
|
1377
|
+
mustBeBidirectionalTypes: ['对立']
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
// path 用 `faction_relations.<from>→<to>` 格式:
|
|
1381
|
+
// 之前写的是"势力关系 F001 → F002",既没有数组名也没有结构化分隔,
|
|
1382
|
+
// 归因层一个形态都匹配不上 → 这条错永远归不到批次 →
|
|
1383
|
+
// 只能整段重跑(把同一份错发给所有批次,白烧额度)。
|
|
1384
|
+
// 现在顶层键是 faction_relations,归因层能退化到"负责该数组的批次"。
|
|
1385
|
+
for (const r of result.oneWay) {
|
|
1386
|
+
errors.push({
|
|
1387
|
+
code: 'G_REL_ONE_WAY',
|
|
1388
|
+
path: 'faction_relations.' + r.from + '→' + r.to,
|
|
1389
|
+
message: r.from + '→' + r.to + ' 为"对立",但 ' + r.to + '→' + r.from + ' 未声明',
|
|
1390
|
+
hint: '对立关系必须双向声明'
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
for (const r of result.asymmetric) {
|
|
1394
|
+
errors.push({
|
|
1395
|
+
code: 'G_REL_ASYMMETRIC',
|
|
1396
|
+
path: 'faction_relations.' + r.from + '→' + r.to,
|
|
1397
|
+
message: r.from + '→' + r.to + ' 是 "' + r.type + '",但反向是 "' + r.reverseType + '"',
|
|
1398
|
+
hint: '"对立"关系必须双向一致'
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// ============================================================
|
|
1404
|
+
// 角色关系双向相容性
|
|
1405
|
+
// ============================================================
|
|
1406
|
+
// 背景:骨架 `character_relations` 是有向的(from 对 to),同一有序对只能有一条,
|
|
1407
|
+
// 但正反两个方向可以同时存在。此前没有任何检查比对这两个方向的关系类型,
|
|
1408
|
+
// 于是"骨架写 A 服从 B,血肉写 A 与 B 是上下级关系"这类语义不一致完全逃逸。
|
|
1409
|
+
//
|
|
1410
|
+
// 判定范围严格限定为增量:只有正反两个方向都声明了才比较,
|
|
1411
|
+
// 不强制"无向关系必须双向声明",因此不改变合法骨架的数据结构边界。
|
|
1412
|
+
// 相容性规则见 graph-utils.js 的 relationKindCompatible。
|
|
1413
|
+
function characterRelationSymmetryCheck(relationData, errors) {
|
|
1414
|
+
const rels = relationData.character_relations || [];
|
|
1415
|
+
const forward = new Map();
|
|
1416
|
+
for (const r of rels) {
|
|
1417
|
+
if (!r || !r.from || !r.to) continue;
|
|
1418
|
+
forward.set(r.from + '→' + r.to, r.relation_kind);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
const reported = new Set();
|
|
1422
|
+
for (const r of rels) {
|
|
1423
|
+
if (!r || !r.from || !r.to) continue;
|
|
1424
|
+
const revKey = r.to + '→' + r.from;
|
|
1425
|
+
if (!forward.has(revKey)) continue; // 单向声明不比较
|
|
1426
|
+
// 同一对角色的正反两个方向都会命中,用无序对去重,保证每对只报一次
|
|
1427
|
+
const pairKey = [r.from, r.to].sort().join('↔');
|
|
1428
|
+
if (reported.has(pairKey)) continue;
|
|
1429
|
+
reported.add(pairKey);
|
|
1430
|
+
const revKind = forward.get(revKey);
|
|
1431
|
+
const verdict = relationKindCompatible(r.relation_kind, revKind);
|
|
1432
|
+
if (verdict.ok) continue;
|
|
1433
|
+
errors.push({
|
|
1434
|
+
code: 'REL_ASYMMETRIC_INCOMPATIBLE',
|
|
1435
|
+
path: '角色关系 ' + r.from + ' → ' + r.to,
|
|
1436
|
+
message: r.from + '→' + r.to + ' 是 "' + r.relation_kind + '",但 ' + r.to + '→' + r.from + ' 是 "' + revKind + '":' + verdict.reason,
|
|
1437
|
+
hint: '同一对角色的正反两个方向的关系类型必须语义相容;请统一为一个意思,或改用词表内语义正确的类型'
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// ============================================================
|
|
1443
|
+
// 关系类型词表与元数据同步检查
|
|
1444
|
+
// ============================================================
|
|
1445
|
+
// 词表(glossary.md 的 relation_kind)与机器判定元数据(RELATION_KIND_META)
|
|
1446
|
+
// 分处两处,必须有闸门防漂移,否则新加的词表值会被静默漏判。
|
|
1447
|
+
function relationKindMetaCheck(enums, errors) {
|
|
1448
|
+
const drift = assertRelationKindMeta(enums);
|
|
1449
|
+
if (!drift) return;
|
|
1450
|
+
errors.push({
|
|
1451
|
+
code: drift.code,
|
|
1452
|
+
path: '',
|
|
1453
|
+
message: drift.message,
|
|
1454
|
+
hint: drift.hint
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// ============================================================
|
|
1459
|
+
// md 生成(结构化 markdown,零反引号)— 渲染函数从 graph-utils 导入
|
|
1460
|
+
// ============================================================
|
|
1461
|
+
const BT = String.fromCharCode(96);
|
|
1462
|
+
const BT3 = BT + BT + BT;
|
|
1463
|
+
|
|
1464
|
+
// 通用 markdown 渲染(骨架阶段的 dispatcher)
|
|
1465
|
+
|
|
1466
|
+
// 框架文件的特殊渲染(顶层是 capacity / input_snapshot / quota / volume_ratio / derivations)
|
|
1467
|
+
function renderFramework(data) {
|
|
1468
|
+
let out = '## 容量\n\n' + renderObject(data.capacity, 0) + '\n';
|
|
1469
|
+
out += '## 输入快照\n\n' + renderObject(data.input_snapshot, 0) + '\n';
|
|
1470
|
+
out += '## 数量配额\n\n' + renderObject(data.quota, 0) + '\n';
|
|
1471
|
+
out += '## 卷比例\n\n';
|
|
1472
|
+
if (Array.isArray(data.volume_ratio) && data.volume_ratio.length > 0) {
|
|
1473
|
+
out += renderArray(data.volume_ratio, 0) + '\n';
|
|
1474
|
+
} else {
|
|
1475
|
+
out += '_(无)_\n';
|
|
1476
|
+
}
|
|
1477
|
+
out += '## 公式推导\n\n';
|
|
1478
|
+
if (data.derivations && typeof data.derivations === 'object') {
|
|
1479
|
+
// derivations 是 key→obj 字典:key 为公式名;用 H3 分组(每条公式一节)
|
|
1480
|
+
const entries = Object.entries(data.derivations);
|
|
1481
|
+
for (const [k, v] of entries) {
|
|
1482
|
+
out += '### ' + k + '\n\n';
|
|
1483
|
+
if (v && typeof v === 'object') {
|
|
1484
|
+
// 子字段走 cnLabelCtx(公式推导是 derivations.* 下的字段)
|
|
1485
|
+
for (const [vk, vv] of Object.entries(v)) {
|
|
1486
|
+
const label = (typeof cnLabelCtx === 'function' ? cnLabelCtx(vk, 'derivations') : vk);
|
|
1487
|
+
out += '- **' + label + '**(' + vk + '): ' + mdEscape(vv) + '\n';
|
|
1488
|
+
}
|
|
1489
|
+
} else {
|
|
1490
|
+
out += '- ' + mdEscape(v) + '\n';
|
|
1491
|
+
}
|
|
1492
|
+
out += '\n';
|
|
1493
|
+
}
|
|
1494
|
+
} else {
|
|
1495
|
+
out += '_(无)_\n';
|
|
1496
|
+
}
|
|
1497
|
+
return out;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// 实体文件的渲染(12 类实体数组)
|
|
1501
|
+
const ENTITY_ARRAYS = [
|
|
1502
|
+
['characters', '角色'],
|
|
1503
|
+
['other_entities', '其他实体'],
|
|
1504
|
+
['conflicts', '冲突'],
|
|
1505
|
+
['factions', '势力'],
|
|
1506
|
+
['locations', '地点'],
|
|
1507
|
+
['items', '道具'],
|
|
1508
|
+
['endings', '结局'],
|
|
1509
|
+
['foreshadows', '伏笔'],
|
|
1510
|
+
['climaxes', '高潮'],
|
|
1511
|
+
['pleasures', '爽点'],
|
|
1512
|
+
['world_rules', '世界观规则'],
|
|
1513
|
+
['main_lines', '主线'],
|
|
1514
|
+
['branches', '支线']
|
|
1515
|
+
];
|
|
1516
|
+
|
|
1517
|
+
function renderEntity(data) {
|
|
1518
|
+
let out = '';
|
|
1519
|
+
for (const [key, label] of ENTITY_ARRAYS) {
|
|
1520
|
+
const arr = data[key];
|
|
1521
|
+
if (!Array.isArray(arr) || arr.length === 0) continue;
|
|
1522
|
+
out += '## ' + label + '(' + arr.length + ' 项)\n\n';
|
|
1523
|
+
out += renderArray(arr, 0, key) + '\n';
|
|
1524
|
+
}
|
|
1525
|
+
return out;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// 关系文件的渲染(9 类关系数组)
|
|
1529
|
+
const RELATION_ARRAYS = [
|
|
1530
|
+
['character_relations', '角色关系'],
|
|
1531
|
+
['other_relations', '其他关系'],
|
|
1532
|
+
['faction_membership', '势力成员'],
|
|
1533
|
+
['faction_relations', '势力关系'],
|
|
1534
|
+
['conflict_participants', '冲突参与'],
|
|
1535
|
+
['branch_protagonist', '支线主角'],
|
|
1536
|
+
['item_owner', '道具归属'],
|
|
1537
|
+
['location_conflict', '地点冲突'],
|
|
1538
|
+
['ending_character', '结局角色'],
|
|
1539
|
+
['main_line_branch_relation', '主线-支线关系']
|
|
1540
|
+
];
|
|
1541
|
+
|
|
1542
|
+
function renderRelation(data) {
|
|
1543
|
+
let out = '';
|
|
1544
|
+
for (const [key, label] of RELATION_ARRAYS) {
|
|
1545
|
+
const arr = data[key];
|
|
1546
|
+
if (!Array.isArray(arr) || arr.length === 0) continue;
|
|
1547
|
+
out += '## ' + label + '(' + arr.length + ' 条)\n\n';
|
|
1548
|
+
out += renderArray(arr, 0, key) + '\n';
|
|
1549
|
+
}
|
|
1550
|
+
return out;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// 层级文件的渲染(volumes / acts / main_line_span / branch_span)
|
|
1554
|
+
function renderHierarchy(data) {
|
|
1555
|
+
let out = '';
|
|
1556
|
+
if (Array.isArray(data.volumes) && data.volumes.length > 0) {
|
|
1557
|
+
out += '## 卷(' + data.volumes.length + ' 个)\n\n';
|
|
1558
|
+
out += renderArray(data.volumes, 0, 'volumes') + '\n';
|
|
1559
|
+
}
|
|
1560
|
+
if (Array.isArray(data.acts) && data.acts.length > 0) {
|
|
1561
|
+
out += '## 幕(' + data.acts.length + ' 个)\n\n';
|
|
1562
|
+
out += renderArray(data.acts, 0, 'acts') + '\n';
|
|
1563
|
+
}
|
|
1564
|
+
if (Array.isArray(data.main_line_span) && data.main_line_span.length > 0) {
|
|
1565
|
+
out += '## 主线卷分布(' + data.main_line_span.length + ' 条)\n\n';
|
|
1566
|
+
out += renderArray(data.main_line_span, 0, 'main_line_span') + '\n';
|
|
1567
|
+
}
|
|
1568
|
+
if (Array.isArray(data.branch_span) && data.branch_span.length > 0) {
|
|
1569
|
+
out += '## 支线卷分布(' + data.branch_span.length + ' 条)\n\n';
|
|
1570
|
+
out += renderArray(data.branch_span, 0, 'branch_span') + '\n';
|
|
1571
|
+
}
|
|
1572
|
+
return out;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// 骨架阶段 4 类文件的 dispatcher
|
|
1576
|
+
function jsonToMd(data, fileType) {
|
|
1577
|
+
let body = '';
|
|
1578
|
+
if (fileType === '框架') body = renderFramework(data);
|
|
1579
|
+
else if (fileType === '实体') body = renderEntity(data);
|
|
1580
|
+
else if (fileType === '关系') body = renderRelation(data);
|
|
1581
|
+
else if (fileType === '层级') body = renderHierarchy(data);
|
|
1582
|
+
else body = renderObject(data, 0); // 兜底:通用对象渲染
|
|
1583
|
+
return '# ' + fileType + '\n\n> 自动生成于 ' + new Date().toISOString() +
|
|
1584
|
+
'\n\n' + body + '\n---\n\n## 源数据\n\n' +
|
|
1585
|
+
BT3 + 'json\n' + JSON.stringify(data, null, 2) + '\n' + BT3 + '\n';
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function regenerateMd(stageDir) {
|
|
1589
|
+
if (!fs.existsSync(stageDir)) return;
|
|
1590
|
+
// 排除 .stage-status.json:它是校验状态记账文件,不是业务数据,
|
|
1591
|
+
// 不应该被渲染成 Markdown(此前会多出一个 .stage-status.md)
|
|
1592
|
+
const files = fs.readdirSync(stageDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
|
|
1593
|
+
for (const f of files) {
|
|
1594
|
+
const jsonPath = path.join(stageDir, f);
|
|
1595
|
+
const mdPath = jsonPath.replace(/\.json$/, '.md');
|
|
1596
|
+
try {
|
|
1597
|
+
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
|
1598
|
+
const fileType = f.replace(/\.json$/, '');
|
|
1599
|
+
fs.writeFileSync(mdPath, jsonToMd(data, fileType), 'utf-8');
|
|
1600
|
+
} catch (e) {}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function updateStageStatus(stageDir, fileType, status, passedTypes) {
|
|
1605
|
+
const statusPath = path.join(stageDir, '.stage-status.json');
|
|
1606
|
+
let s = { stage: '骨架', last_check: null, files: {}, all_check: { status: 'PENDING', rounds: 0 } };
|
|
1607
|
+
if (fs.existsSync(statusPath)) {
|
|
1608
|
+
try { s = JSON.parse(fs.readFileSync(statusPath, 'utf-8')); } catch (e) {}
|
|
1609
|
+
}
|
|
1610
|
+
// 兜底:旧 status 文件可能缺 files / all_check 字段
|
|
1611
|
+
s.stage = s.stage || '骨架';
|
|
1612
|
+
s.files = s.files || {};
|
|
1613
|
+
s.all_check = s.all_check || { status: 'PENDING', rounds: 0 };
|
|
1614
|
+
s.last_check = new Date().toISOString();
|
|
1615
|
+
if (fileType === 'ALL') {
|
|
1616
|
+
s.all_check = { status: status === 0 ? 'PASSED' : 'FAILED', rounds: (s.all_check.rounds || 0) + 1 };
|
|
1617
|
+
// 全量通过时,把每个文件也登记成 PASSED。
|
|
1618
|
+
//
|
|
1619
|
+
// 为什么需要:续跑的判据是 readSkeletonSettledFiles 读 .stage-status.json 的
|
|
1620
|
+
// files[type].status(skeleton-output.js:105-109)。而骨架段三(关系+层级)
|
|
1621
|
+
// 只跑 --all,不跑单文件校验 → files['关系'] / files['层级'] 永远不会被写 →
|
|
1622
|
+
// 这两个文件永远判不了"已定稿" → 重跑骨架阶段时关系 27 批 + 层级 4 批
|
|
1623
|
+
// 全部重派(tt5 实测骨架一次 40+ 批),正是续跑机制要避免的。
|
|
1624
|
+
//
|
|
1625
|
+
// 为什么 --all 通过就能登记:validateAll 跑的是**单文件检查的超集**——
|
|
1626
|
+
// 四个文件的通用检查 + 各自的专属检查一个不缺,还多跑了跨文件检查,
|
|
1627
|
+
// 而且它自己就加载了实体/框架做上游(loaded['实体'] / loaded['框架']),
|
|
1628
|
+
// 不依赖 --upstream 参数。所以 --all 过 = 每个文件都过了自己的检查。
|
|
1629
|
+
//
|
|
1630
|
+
// --all 失败时不登记:失败无法定位到具体文件,保守起见不动已有的单文件
|
|
1631
|
+
// 记录(那个文件重做,已通过的文件照样定稿)。
|
|
1632
|
+
if (status === 0 && Array.isArray(passedTypes)) {
|
|
1633
|
+
for (const t of passedTypes) {
|
|
1634
|
+
const prev = s.files[t] || { retries: 0 };
|
|
1635
|
+
s.files[t] = { status: 'PASSED', retries: prev.retries || 0, last_check: s.last_check };
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
} else {
|
|
1639
|
+
const prev = s.files[fileType] || { retries: 0 };
|
|
1640
|
+
s.files[fileType] = { status: status === 0 ? 'PASSED' : 'FAILED', retries: (prev.retries || 0) + (status === 1 ? 1 : 0), last_check: new Date().toISOString() };
|
|
1641
|
+
// 单文件一旦被改动/失败,之前的全量结论就不再可信(全量含跨文件检查:
|
|
1642
|
+
// 配额一致性、图连通性、引用完整性等),必须作废,等重新跑 --all。
|
|
1643
|
+
if (s.all_check && s.all_check.status === 'PASSED') {
|
|
1644
|
+
s.all_check = {
|
|
1645
|
+
status: 'STALE',
|
|
1646
|
+
rounds: s.all_check.rounds || 0,
|
|
1647
|
+
stale_reason: '文件 ' + fileType + ' 在 ' + s.last_check + ' 被单文件校验改动,全量结论已作废,请重新执行全量校验',
|
|
1648
|
+
stale_at: s.last_check
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
try { fs.writeFileSync(statusPath, JSON.stringify(s, null, 2), 'utf-8'); } catch (e) {}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function emit(status, file, errors, warnings) {
|
|
1656
|
+
const payload = { status: status, file: file, errors: errors, warnings: warnings };
|
|
1657
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
1658
|
+
process.exitCode = status;
|
|
1659
|
+
// 返回载荷对象:调用方可能用返回值做守卫(如 `if (err) return err;`),
|
|
1660
|
+
// 返回 undefined 会让守卫失效导致继续执行并二次输出。
|
|
1661
|
+
return payload;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// ============================================================
|
|
1665
|
+
// 单文件模式
|
|
1666
|
+
// ============================================================
|
|
1667
|
+
function validateSingle(args) {
|
|
1668
|
+
const input = args.input;
|
|
1669
|
+
const template = args.template;
|
|
1670
|
+
const file = args.file;
|
|
1671
|
+
const errors = [];
|
|
1672
|
+
const warnings = [];
|
|
1673
|
+
|
|
1674
|
+
const gl = loadGlossary();
|
|
1675
|
+
if (gl.error) return emit(2, file, [{ code: 'GLOSSARY_LOAD', path: '', message: gl.error, hint: null }], []);
|
|
1676
|
+
const fm = loadFormulas();
|
|
1677
|
+
if (fm.error) return emit(2, file, [{ code: 'FORMULAS_LOAD', path: '', message: fm.error, hint: null }], []);
|
|
1678
|
+
|
|
1679
|
+
safeCheck('relationKindMetaCheck', () => relationKindMetaCheck(gl.enums, errors), errors);
|
|
1680
|
+
|
|
1681
|
+
const inputRes = readJson(input);
|
|
1682
|
+
if (inputRes.error) return emit(2, file, [{ code: 'READ', path: input, message: inputRes.error, hint: null }], []);
|
|
1683
|
+
const data = inputRes.data;
|
|
1684
|
+
|
|
1685
|
+
const tplRes = readJson(template);
|
|
1686
|
+
if (tplRes.error) return emit(2, file, [{ code: 'TEMPLATE', path: template, message: tplRes.error, hint: null }], []);
|
|
1687
|
+
|
|
1688
|
+
let upstreamEntities = null;
|
|
1689
|
+
let upstreamFramework = null;
|
|
1690
|
+
if (args.upstream && args.upstream.length > 0) {
|
|
1691
|
+
for (const upDir of args.upstream) {
|
|
1692
|
+
const entRes = readJson(path.join(upDir, '实体.json'));
|
|
1693
|
+
if (entRes.data) upstreamEntities = entRes.data;
|
|
1694
|
+
const fwRes = readJson(path.join(upDir, '框架.json'));
|
|
1695
|
+
if (fwRes.data) upstreamFramework = fwRes.data;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
const scalarSet = new Set(gl.chapterScalarFields);
|
|
1700
|
+
const containerSet = new Set(gl.chapterContainerFields);
|
|
1701
|
+
|
|
1702
|
+
safeCheck('checkChapterForbidden', () => checkChapterForbidden(data, scalarSet, containerSet, errors), errors);
|
|
1703
|
+
safeCheck('validateNode', () => validateNode(data, tplRes.data.structure || {}, '', gl.enums, errors), errors);
|
|
1704
|
+
safeCheck('charsetAndForbiddenCheck', () => charsetAndForbiddenCheck(data, errors), errors);
|
|
1705
|
+
safeCheck('idUniquenessCheck', () => idUniquenessCheck(data, file, errors), errors);
|
|
1706
|
+
safeCheck('nameUniquenessCheck', () => nameUniquenessCheck(data, file, errors), errors);
|
|
1707
|
+
safeCheck('nameAliasCheck', () => nameAliasCheck(data, file, warnings), errors);
|
|
1708
|
+
|
|
1709
|
+
if (file === '框架') {
|
|
1710
|
+
safeCheck('frameworkSpecificCheck', () => frameworkSpecificCheck(data, errors, warnings), errors);
|
|
1711
|
+
safeCheck('derivationCheck', () => derivationCheck(data, fm.formulas, errors, warnings), errors);
|
|
1712
|
+
safeCheck('checkRhythmFeasibility', () => checkRhythmFeasibility(data, errors, fm.formulas), errors);
|
|
1713
|
+
} else if (file === '关系') {
|
|
1714
|
+
safeCheck('selfRelationCheck', () => selfRelationCheck(data, errors), errors);
|
|
1715
|
+
safeCheck('relationDupCheck', () => relationDupCheck(data, errors), errors);
|
|
1716
|
+
safeCheck('relationLabelCheck', () => relationLabelCheck(data, errors), errors);
|
|
1717
|
+
safeCheck('relationRefCheck', () => relationRefCheck(data, upstreamEntities, errors), errors);
|
|
1718
|
+
// 支线挂主线的覆盖度:关系文件对着实体清单查(tt5 实测的静默缺失)
|
|
1719
|
+
safeCheck('checkBranchMainLineCoverage', () => checkBranchMainLineCoverage(upstreamEntities, data, errors), errors);
|
|
1720
|
+
safeCheck('characterRelationSymmetryCheck', () => characterRelationSymmetryCheck(data, errors), errors);
|
|
1721
|
+
} else if (file === '层级') {
|
|
1722
|
+
safeCheck('hierarchyRefCheck', () => hierarchyRefCheck(data, upstreamEntities, upstreamFramework, errors), errors);
|
|
1723
|
+
} else if (file === '实体') {
|
|
1724
|
+
safeCheck('checkBookEndingLevel', () => checkBookEndingLevel(data, errors), errors);
|
|
1725
|
+
safeCheck('checkWorldRuleCategory', () => checkWorldRuleCategory(data, errors), errors);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
safeCheck('contentQualityCheck', () => contentQualityCheck(file, data, errors, tplRes.data.structure), errors);
|
|
1729
|
+
|
|
1730
|
+
const stageDir = path.dirname(input);
|
|
1731
|
+
updateStageStatus(stageDir, file, errors.length === 0 ? 0 : 1);
|
|
1732
|
+
regenerateMd(stageDir);
|
|
1733
|
+
|
|
1734
|
+
// 单文件模式不做跨文件检查(配额一致性/图连通性/跨文件引用等),
|
|
1735
|
+
// 必须在本阶段全部文件单检完成后跑一次 --all 才算通过。提醒一下。
|
|
1736
|
+
if (errors.length === 0) {
|
|
1737
|
+
warnings.push({
|
|
1738
|
+
code: 'NEED_ALL_CHECK',
|
|
1739
|
+
path: '',
|
|
1740
|
+
message: '本文件单检通过;跨文件一致性检查在 --all 模式下进行,全部文件单检完成后仍需执行一次全量校验(--all)',
|
|
1741
|
+
hint: null
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
emit(errors.length === 0 ? 0 : 1, file, errors, warnings);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// ============================================================
|
|
1749
|
+
// 局部模式(--arrays):只校验指定的几个数组
|
|
1750
|
+
// ============================================================
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* 局部校验:只判"这几个数组"上**当场就能判**的错。
|
|
1754
|
+
*
|
|
1755
|
+
* 为什么需要它(tt5 实测):
|
|
1756
|
+
* 骨架的关系+层级段是 31 个批次**连续派活**,全部写完才跑一次全量校验。
|
|
1757
|
+
* 结果第 8 批(faction_relations)写错了行数,要等 23 批都写完才发现,
|
|
1758
|
+
* 返工时整段重来一遍。模型在没有反馈通道的那 31 轮里,
|
|
1759
|
+
* 自己造了 45 个检查脚本在猜校验器的判据(`_chk2.mjs` … `_chk46.mjs`)。
|
|
1760
|
+
*
|
|
1761
|
+
* 中途插入一次局部校验就能把反馈提前到"第 8 批写完"。
|
|
1762
|
+
* 但**全量校验在半成品上不可判**:文件还缺别的数组(REQUIRED 会误报)、
|
|
1763
|
+
* 图还不连通(G_ISOLATED 会误报)。所以中途校验必须是"局部口径"。
|
|
1764
|
+
*
|
|
1765
|
+
* 局部模式判这些(都只依赖指定数组本身 + 上游实体清单):
|
|
1766
|
+
* · 模板子树:类型 / 枚举 / 模板外字段
|
|
1767
|
+
* · 引用完整性:引用的 id 在实体清单里存不存在
|
|
1768
|
+
* · 重复 / 自环 / 正反对称性
|
|
1769
|
+
* · 行数对账:faction_relations 的行数 vs quota
|
|
1770
|
+
*
|
|
1771
|
+
* 局部模式**不判**(要等文件写完才有意义):
|
|
1772
|
+
* · REQUIRED(别的数组还没写)
|
|
1773
|
+
* · 图连通性 / 孤立节点(关系网还不完整)
|
|
1774
|
+
* · 支线挂主线的覆盖度(别的支线还没写)
|
|
1775
|
+
*
|
|
1776
|
+
* 用法:
|
|
1777
|
+
* node skeleton-validate.js --input=关系.json --template=关系模板.json \
|
|
1778
|
+
* --file=关系 --upstream=<骨架目录> --arrays=faction_relations,faction_membership
|
|
1779
|
+
*
|
|
1780
|
+
* 局部模式**不改 .stage-status.json、不重新生成 .md** ——
|
|
1781
|
+
* 它是中途的一脚,不是阶段结论。
|
|
1782
|
+
*/
|
|
1783
|
+
function validatePartial(args) {
|
|
1784
|
+
const input = args.input;
|
|
1785
|
+
const template = args.template;
|
|
1786
|
+
const file = args.file;
|
|
1787
|
+
const arrays = String(args.arrays || '')
|
|
1788
|
+
.split(',')
|
|
1789
|
+
.map((s) => s.trim())
|
|
1790
|
+
.filter(Boolean);
|
|
1791
|
+
const errors = [];
|
|
1792
|
+
const warnings = [];
|
|
1793
|
+
|
|
1794
|
+
if (!arrays.length) {
|
|
1795
|
+
return emit(2, file, [{ code: 'ARG', path: '--arrays', message: '局部模式需要 --arrays(逗号分隔的数组名)', hint: null }], []);
|
|
1796
|
+
}
|
|
1797
|
+
if (!input || !template || !file) {
|
|
1798
|
+
return emit(2, file || 'UNKNOWN', [{ code: 'ARG', path: '', message: '局部模式需要 --input 和 --template 和 --file', hint: null }], []);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const gl = loadGlossary();
|
|
1802
|
+
if (gl.error) return emit(2, file, [{ code: 'GLOSSARY_LOAD', path: '', message: gl.error, hint: null }], []);
|
|
1803
|
+
const fm = loadFormulas();
|
|
1804
|
+
if (fm.error) return emit(2, file, [{ code: 'FORMULAS_LOAD', path: '', message: fm.error, hint: null }], []);
|
|
1805
|
+
|
|
1806
|
+
safeCheck('relationKindMetaCheck', () => relationKindMetaCheck(gl.enums, errors), errors);
|
|
1807
|
+
|
|
1808
|
+
const inputRes = readJson(input);
|
|
1809
|
+
if (inputRes.error) return emit(2, file, [{ code: 'READ', path: input, message: inputRes.error, hint: null }], []);
|
|
1810
|
+
const data = inputRes.data || {};
|
|
1811
|
+
|
|
1812
|
+
const tplRes = readJson(template);
|
|
1813
|
+
if (tplRes.error) return emit(2, file, [{ code: 'TEMPLATE', path: template, message: tplRes.error, hint: null }], []);
|
|
1814
|
+
|
|
1815
|
+
let upstreamEntities = null;
|
|
1816
|
+
let upstreamFramework = null;
|
|
1817
|
+
for (const upDir of (args.upstream || [])) {
|
|
1818
|
+
const entRes = readJson(path.join(upDir, '实体.json'));
|
|
1819
|
+
if (entRes.data) upstreamEntities = entRes.data;
|
|
1820
|
+
const fwRes = readJson(path.join(upDir, '框架.json'));
|
|
1821
|
+
if (fwRes.data) upstreamFramework = fwRes.data;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// 只保留被点名的数组:现有检查函数按数组名取数据,裁剪后的视图能直接复用它们
|
|
1825
|
+
const scoped = {};
|
|
1826
|
+
for (const k of arrays) {
|
|
1827
|
+
scoped[k] = Array.isArray(data[k]) ? data[k] : [];
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
const props = (tplRes.data.structure || {}).properties || {};
|
|
1831
|
+
for (const k of arrays) {
|
|
1832
|
+
if (!props[k]) {
|
|
1833
|
+
errors.push({ code: 'ARG', path: k, message: '模板里没有这个数组:' + k, hint: null });
|
|
1834
|
+
continue;
|
|
1835
|
+
}
|
|
1836
|
+
// 逐数组走模板子树:只查类型/枚举/模板外字段,不查顶层 REQUIRED
|
|
1837
|
+
safeCheck('validateNode:' + k, () => validateNode(scoped[k], props[k], k, gl.enums, errors), errors);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
if (file === '关系') {
|
|
1841
|
+
safeCheck('selfRelationCheck', () => selfRelationCheck(scoped, errors), errors);
|
|
1842
|
+
safeCheck('relationDupCheck', () => relationDupCheck(scoped, errors), errors);
|
|
1843
|
+
safeCheck('relationLabelCheck', () => relationLabelCheck(scoped, errors), errors);
|
|
1844
|
+
safeCheck('relationRefCheck', () => relationRefCheck(scoped, upstreamEntities, errors), errors);
|
|
1845
|
+
safeCheck('characterRelationSymmetryCheck', () => characterRelationSymmetryCheck(scoped, errors), errors);
|
|
1846
|
+
safeCheck('factionRelationSymmetryCheck', () => factionRelationSymmetryCheck(scoped, errors), errors);
|
|
1847
|
+
// 行数对账:势力关系写了几行,当场就能判
|
|
1848
|
+
safeCheck('checkFactionRelationRowCount', () => {
|
|
1849
|
+
if (arrays.indexOf('faction_relations') < 0) return;
|
|
1850
|
+
checkFactionRelationRowCount(upstreamFramework, scoped, errors, fm.formulas);
|
|
1851
|
+
}, errors);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
emit(errors.length === 0 ? 0 : 1, file, errors, warnings);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// ============================================================
|
|
1858
|
+
// 全量模式
|
|
1859
|
+
// ============================================================
|
|
1860
|
+
function validateAll(args) {
|
|
1861
|
+
const stageDir = args['stage-dir'];
|
|
1862
|
+
const templateDir = args['template-dir'];
|
|
1863
|
+
const errors = [];
|
|
1864
|
+
const warnings = [];
|
|
1865
|
+
|
|
1866
|
+
const gl = loadGlossary();
|
|
1867
|
+
if (gl.error) return emit(2, 'ALL', [{ code: 'GLOSSARY_LOAD', path: '', message: gl.error, hint: null }], []);
|
|
1868
|
+
const fm = loadFormulas();
|
|
1869
|
+
if (fm.error) return emit(2, 'ALL', [{ code: 'FORMULAS_LOAD', path: '', message: fm.error, hint: null }], []);
|
|
1870
|
+
|
|
1871
|
+
safeCheck('relationKindMetaCheck', () => relationKindMetaCheck(gl.enums, errors), errors);
|
|
1872
|
+
|
|
1873
|
+
if (!fs.existsSync(stageDir)) {
|
|
1874
|
+
return emit(2, 'ALL', [{ code: 'ARG', path: stageDir, message: '阶段目录不存在', hint: null }], []);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
const FILES = [
|
|
1878
|
+
{ type: '框架', name: '框架.json', tpl: '框架模板.json' },
|
|
1879
|
+
{ type: '实体', name: '实体.json', tpl: '实体模板.json' },
|
|
1880
|
+
{ type: '关系', name: '关系.json', tpl: '关系模板.json' },
|
|
1881
|
+
{ type: '层级', name: '层级.json', tpl: '层级模板.json' }
|
|
1882
|
+
];
|
|
1883
|
+
|
|
1884
|
+
const loaded = {};
|
|
1885
|
+
const scalarSet = new Set(gl.chapterScalarFields);
|
|
1886
|
+
const containerSet = new Set(gl.chapterContainerFields);
|
|
1887
|
+
|
|
1888
|
+
for (const f of FILES) {
|
|
1889
|
+
const dataPath = path.join(stageDir, f.name);
|
|
1890
|
+
const tplPath = path.join(templateDir, f.tpl);
|
|
1891
|
+
const inputRes = readJson(dataPath);
|
|
1892
|
+
if (inputRes.error) {
|
|
1893
|
+
errors.push({ code: 'READ', path: dataPath, message: inputRes.error, hint: null });
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1896
|
+
const tplRes = readJson(tplPath);
|
|
1897
|
+
if (tplRes.error) {
|
|
1898
|
+
errors.push({ code: 'TEMPLATE', path: tplPath, message: tplRes.error, hint: null });
|
|
1899
|
+
continue;
|
|
1900
|
+
}
|
|
1901
|
+
loaded[f.type] = inputRes.data;
|
|
1902
|
+
const data = inputRes.data;
|
|
1903
|
+
|
|
1904
|
+
safeCheck('checkChapterForbidden', () => checkChapterForbidden(data, scalarSet, containerSet, errors), errors);
|
|
1905
|
+
safeCheck('validateNode', () => validateNode(data, tplRes.data.structure || {}, '', gl.enums, errors), errors);
|
|
1906
|
+
safeCheck('charsetAndForbiddenCheck', () => charsetAndForbiddenCheck(data, errors), errors);
|
|
1907
|
+
safeCheck('idUniquenessCheck', () => idUniquenessCheck(data, f.type, errors), errors);
|
|
1908
|
+
safeCheck('nameUniquenessCheck', () => nameUniquenessCheck(data, f.type, errors), errors);
|
|
1909
|
+
safeCheck('nameAliasCheck', () => nameAliasCheck(data, f.type, warnings), errors);
|
|
1910
|
+
|
|
1911
|
+
if (f.type === '框架') {
|
|
1912
|
+
safeCheck('frameworkSpecificCheck', () => frameworkSpecificCheck(data, errors, warnings), errors);
|
|
1913
|
+
safeCheck('derivationCheck', () => derivationCheck(data, fm.formulas, errors, warnings), errors);
|
|
1914
|
+
} else if (f.type === '关系') {
|
|
1915
|
+
safeCheck('selfRelationCheck', () => selfRelationCheck(data, errors), errors);
|
|
1916
|
+
safeCheck('relationDupCheck', () => relationDupCheck(data, errors), errors);
|
|
1917
|
+
safeCheck('relationLabelCheck', () => relationLabelCheck(data, errors), errors);
|
|
1918
|
+
safeCheck('relationRefCheck', () => relationRefCheck(data, loaded['实体'], errors), errors);
|
|
1919
|
+
// 支线挂主线的覆盖度(tt5 实测:只写 1 行也 PASSED,9 条支线静默孤儿)
|
|
1920
|
+
safeCheck('checkBranchMainLineCoverage', () => checkBranchMainLineCoverage(loaded['实体'], data, errors), errors);
|
|
1921
|
+
} else if (f.type === '层级') {
|
|
1922
|
+
safeCheck('hierarchyRefCheck', () => hierarchyRefCheck(data, loaded['实体'], loaded['框架'], errors), errors);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
safeCheck('contentQualityCheck', () => contentQualityCheck(f.type, data, errors, tplRes.data.structure), errors);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
if (loaded['实体'] && loaded['关系']) {
|
|
1929
|
+
safeCheck('buildGraph', () => buildGraph(loaded['实体'], loaded['关系'], errors, warnings), errors);
|
|
1930
|
+
safeCheck('factionRelationSymmetryCheck', () => factionRelationSymmetryCheck(loaded['关系'], errors), errors);
|
|
1931
|
+
safeCheck('characterRelationSymmetryCheck', () => characterRelationSymmetryCheck(loaded['关系'], errors), errors);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// P2-4:传入 loaded['关系'] 供 faction_relation_count 检查
|
|
1935
|
+
safeCheck('checkQuotaConsistency', () => checkQuotaConsistency(loaded['框架'], loaded['实体'], loaded['层级'], loaded['关系'], errors, fm.formulas), errors);
|
|
1936
|
+
safeCheck('checkBookEndingLevel', () => checkBookEndingLevel(loaded['实体'], errors), errors);
|
|
1937
|
+
safeCheck('checkWorldRuleCategory', () => checkWorldRuleCategory(loaded['实体'], errors), errors);
|
|
1938
|
+
safeCheck('checkRhythmFeasibility', () => checkRhythmFeasibility(loaded['框架'], errors, fm.formulas), errors);
|
|
1939
|
+
safeCheck('crossTypeNameDupCheck', () => crossTypeNameDupCheck(loaded['实体'], warnings), errors);
|
|
1940
|
+
|
|
1941
|
+
updateStageStatus(stageDir, 'ALL', errors.length === 0 ? 0 : 1, FILES.map((f) => f.type));
|
|
1942
|
+
regenerateMd(stageDir);
|
|
1943
|
+
emit(errors.length === 0 ? 0 : 1, 'ALL', errors, warnings);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// ============================================================
|
|
1947
|
+
// 入口
|
|
1948
|
+
// ============================================================
|
|
1949
|
+
function main() {
|
|
1950
|
+
const args = parseArgs();
|
|
1951
|
+
|
|
1952
|
+
if (args.all) {
|
|
1953
|
+
if (!args['stage-dir'] || !args['template-dir']) {
|
|
1954
|
+
return emit(2, 'ALL', [{ code: 'ARG', path: '', message: '全量模式需要 --stage-dir 和 --template-dir', hint: null }], []);
|
|
1955
|
+
}
|
|
1956
|
+
return validateAll(args);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
if (args.input) {
|
|
1960
|
+
if (args.arrays) {
|
|
1961
|
+
// 局部模式:只判指定数组(中途校验用,不改 .stage-status.json)
|
|
1962
|
+
return validatePartial(args);
|
|
1963
|
+
}
|
|
1964
|
+
if (!args.template || !args.file) {
|
|
1965
|
+
return emit(2, args.file || 'UNKNOWN', [{ code: 'ARG', path: '', message: '单文件模式需要 --template 和 --file', hint: null }], []);
|
|
1966
|
+
}
|
|
1967
|
+
return validateSingle(args);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
return emit(2, 'UNKNOWN', [{ code: 'ARG', path: '', message: '需要 --input 或 --all', hint: null }], []);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
main();
|