@cloud411716/fancy-webnovel 1.0.45 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +183 -0
- package/cordis.patch.yml +0 -14
- package/index.js +21 -37
- package/lib/chinese-num.js +111 -0
- package/lib/deployed-state.js +141 -0
- package/lib/error-codes.js +104 -0
- package/lib/infra.js +569 -0
- package/lib/llm-wiki.js +109 -0
- package/lib/safe-rm.js +94 -0
- package/lib/scraper-utils.js +165 -0
- package/package.json +37 -5
- package/plugins/fancy-analyze/command-input.js +65 -0
- package/plugins/fancy-analyze/index.js +973 -0
- package/plugins/fancy-analyze/schema-analyze.md +379 -0
- package/plugins/fancy-analyze/schema-write.md +178 -0
- package/plugins/fancy-analyze/scripts/split_source.js +292 -0
- package/plugins/fancy-analyze/summary-progress.js +431 -0
- package/plugins/fancy-analyze/templates.js +881 -0
- package/plugins/fancy-bootstrap/index.js +168 -0
- package/plugins/fancy-bootstrap/scripts/init.js +143 -0
- package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
- package/plugins/fancy-bootstrap/templates.js +54 -0
- package/plugins/fancy-import/import-core.js +1118 -0
- package/plugins/fancy-import/import-driver.js +581 -0
- package/plugins/fancy-import/index.js +730 -0
- package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
- package/plugins/fancy-import/templates.js +49 -0
- package/plugins/fancy-scan/index.js +319 -0
- package/plugins/fancy-scan/scripts/run-scan.js +520 -0
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
- package/plugins/fancy-scan/templates.js +295 -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 +155 -0
- package/plugins/fancy-story-builder/index.js +3152 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
- package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
- package/plugins/fancy-story-builder/purpose-template.md +107 -0
- package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
- package/plugins/fancy-story-builder/references/glossary.md +585 -0
- package/plugins/fancy-story-builder/references/index.md +490 -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 +163 -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 +239 -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 +181 -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 +177 -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 +208 -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 +142 -0
- package/plugins/fancy-story-builder/rhythm-output.js +140 -0
- package/plugins/fancy-story-builder/schema-setting.md +324 -0
- package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
- package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +413 -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 +1189 -0
- package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
- package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -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/rhythm-validate.js +1216 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
- package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
- package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
- package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -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/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +204 -0
- 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 +66 -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 +76 -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 +72 -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 +37 -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 +53 -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 +40 -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 +79 -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 +132 -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 +133 -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 +160 -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 +134 -0
- package/plugins/fancy-story-builder/templates.js +1248 -0
- package/plugins/fancy-story-builder/wiki-references.js +204 -0
- package/plugins/fancy-story-builder/writing-driver.js +1061 -0
- package/plugins/fancy-topic/index.js +704 -0
- package/plugins/fancy-topic/scripts/select-topic.js +274 -0
- package/plugins/fancy-topic/templates.js +373 -0
- package/commands/bootstrap/index.js +0 -160
- package/commands/bootstrap/init-project.js +0 -121
- package/commands/scan/index.js +0 -275
- 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,1189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 血肉阶段校验脚本 v1.2
|
|
4
|
+
* 源码中零反引号,避免 markdown 渲染问题。
|
|
5
|
+
*
|
|
6
|
+
* v1.2 变更(修复批次 P0-3 / P2-10):
|
|
7
|
+
* - P0-3:completenessCheck 的 directChecks 用"文件类型"作 fleshData 的 key
|
|
8
|
+
* (原版用 'characters' 等数组名作 key,fleshData 里实际是 '角色' 等文件类型,
|
|
9
|
+
* 导致 5 类直接映射实体的 FLESH_INCOMPLETE 全部误报刷屏)
|
|
10
|
+
* - P2-10:itemRefCheck 先校验 id 存在于骨架,再加入 fleshItemIds
|
|
11
|
+
* (原版先加入后校验,非法 id 会触发多余的 FLESH_DETAILS_ORPHAN)
|
|
12
|
+
*
|
|
13
|
+
* 保留 v1.1 的 P2-3 修复:
|
|
14
|
+
* - FLESH_GROUP_CATEGORY_MISMATCH / FLESH_FREETEXT_CHAPTER_REF / completenessCheck 扩展
|
|
15
|
+
*
|
|
16
|
+
* 用法:
|
|
17
|
+
* 单文件:node flesh-validate.js --input=<json> --template=<模板> --file=<类型> --upstream=<骨架目录>
|
|
18
|
+
* 全量: node flesh-validate.js --stage-dir=<血肉目录> --template-dir=<模板/血肉> --upstream=<骨架目录> --all
|
|
19
|
+
* 退出码:0=通过 1=内容错误 2=脚本异常
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { parseGlossaryChecked, mdEscape, renderObject, renderArray, renderFleshByFileType, readStageGate, safeCheck, RELATION_KIND_META, suggestClosestName } from './graph-utils.js';
|
|
26
|
+
import { runContentQualityChecks, collectEnumLeaves } from './content-quality.js';
|
|
27
|
+
|
|
28
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
|
|
30
|
+
// ============================================================
|
|
31
|
+
// 参数解析
|
|
32
|
+
// ============================================================
|
|
33
|
+
function parseArgs() {
|
|
34
|
+
const args = process.argv.slice(2);
|
|
35
|
+
const result = { upstream: [] };
|
|
36
|
+
for (const arg of args) {
|
|
37
|
+
const m = arg.match(/^--([\w-]+)=(.+)$/);
|
|
38
|
+
if (m) {
|
|
39
|
+
if (m[1] === 'upstream') {
|
|
40
|
+
result.upstream.push(...m[2].split(',').map(s => s.trim()).filter(Boolean));
|
|
41
|
+
} else {
|
|
42
|
+
result[m[1]] = m[2];
|
|
43
|
+
}
|
|
44
|
+
} else if (arg === '--all') {
|
|
45
|
+
result.all = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ============================================================
|
|
52
|
+
// IO
|
|
53
|
+
// ============================================================
|
|
54
|
+
function readJson(p) {
|
|
55
|
+
if (!fs.existsSync(p)) return { error: '文件不存在: ' + p };
|
|
56
|
+
try {
|
|
57
|
+
return { data: JSON.parse(fs.readFileSync(p, 'utf-8')) };
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return { error: 'JSON 解析失败: ' + err.message };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readText(p) {
|
|
64
|
+
if (!fs.existsSync(p)) return { error: '文件不存在: ' + p };
|
|
65
|
+
try {
|
|
66
|
+
return { data: fs.readFileSync(p, 'utf-8') };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return { error: '读取失败: ' + err.message };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================================
|
|
73
|
+
// glossary 加载
|
|
74
|
+
// ============================================================
|
|
75
|
+
function loadGlossary() {
|
|
76
|
+
const r = readText(path.join(__dirname, 'glossary.md'));
|
|
77
|
+
if (r.error) return { error: 'glossary.md 读取失败: ' + r.error };
|
|
78
|
+
return parseGlossaryChecked(r.data);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function loadSkeleton(upstreamDirs) {
|
|
82
|
+
const result = { entity: null, framework: null, hierarchy: null, relations: null };
|
|
83
|
+
for (const dir of upstreamDirs) {
|
|
84
|
+
const entRes = readJson(path.join(dir, '实体.json'));
|
|
85
|
+
if (entRes.data) result.entity = entRes.data;
|
|
86
|
+
const fwRes = readJson(path.join(dir, '框架.json'));
|
|
87
|
+
if (fwRes.data) result.framework = fwRes.data;
|
|
88
|
+
const hiRes = readJson(path.join(dir, '层级.json'));
|
|
89
|
+
if (hiRes.data) result.hierarchy = hiRes.data;
|
|
90
|
+
const relRes = readJson(path.join(dir, '关系.json'));
|
|
91
|
+
if (relRes.data) result.relations = relRes.data;
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ============================================================
|
|
97
|
+
// 自环关系检查
|
|
98
|
+
// ============================================================
|
|
99
|
+
// 骨架阶段的 关系.json 有 NO_SELF_RELATION 拦截自环,血肉阶段此前没有对等检查,
|
|
100
|
+
// 于是"角色把自己列成自己的关系对象"可以一路写进角色档案。
|
|
101
|
+
function fleshSelfRelationCheck(data, fileType, errors) {
|
|
102
|
+
const selfCheck = (ownerName, targetName, path, label) => {
|
|
103
|
+
if (!targetName || !ownerName) return;
|
|
104
|
+
if (targetName === ownerName) {
|
|
105
|
+
errors.push({
|
|
106
|
+
code: 'FLESH_SELF_RELATION',
|
|
107
|
+
path: path,
|
|
108
|
+
message: label + ' "' + ownerName + '" 把自身列为关系对象',
|
|
109
|
+
hint: '自环关系无意义;角色的自我矛盾请写进 description / core_flaw'
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (fileType === '角色') {
|
|
115
|
+
for (const [i, c] of (data.characters || []).entries()) {
|
|
116
|
+
if (!c) continue;
|
|
117
|
+
const aliases = new Set([c.id, c.name].filter(Boolean));
|
|
118
|
+
for (const [j, r] of (c.relationships || []).entries()) {
|
|
119
|
+
if (!r) continue;
|
|
120
|
+
if (aliases.has(r.target_name)) {
|
|
121
|
+
selfCheck(c.name || c.id, r.target_name, 'characters[' + i + '].relationships[' + j + '].target_name', '角色');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} else if (fileType === '势力') {
|
|
126
|
+
for (const [i, f] of (data.factions || []).entries()) {
|
|
127
|
+
if (!f) continue;
|
|
128
|
+
const aliases = new Set([f.id, f.name].filter(Boolean));
|
|
129
|
+
for (const [j, r] of (f.relations || []).entries()) {
|
|
130
|
+
if (!r) continue;
|
|
131
|
+
if (aliases.has(r.target_name)) {
|
|
132
|
+
selfCheck(f.name || f.id, r.target_name, 'factions[' + i + '].relations[' + j + '].target_name', '势力');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ============================================================
|
|
140
|
+
// 跨阶段:血肉关系 ↔ 骨架关系一致性
|
|
141
|
+
// ============================================================
|
|
142
|
+
// 骨架 `关系.json` 的 `character_relations` 是「from 对 to 的关系类型」,
|
|
143
|
+
// 血肉 `角色.json` 的 `relationships[]` 是「本角色对 target_name 的关系」,
|
|
144
|
+
// 方向语义与骨架一致。此前两个阶段之间没有任何关系语义的比对,
|
|
145
|
+
// 于是「骨架写 A 服从 B,血肉写 A 与 B 是上下级关系」这类不一致完全逃逸:
|
|
146
|
+
// 实体和边的拓扑都对得上,语义却对不上。
|
|
147
|
+
//
|
|
148
|
+
// 判定规则(有序对 S→T 表示「S 对 T 的关系」):
|
|
149
|
+
// 1. 骨架有 S→T=K,血肉也声明 S→T=L 且 L≠K → REL_TYPE_MISMATCH
|
|
150
|
+
// 2. 骨架只有反向 T→S=K,血肉声明 S→T=L:
|
|
151
|
+
// K 为对称关系且 L=K → 等价表述,放行
|
|
152
|
+
// K 为对称关系且 L≠K → REL_TYPE_MISMATCH
|
|
153
|
+
// K 为有向关系 → REL_DIRECTION_FLIP(有向关系被写反了)
|
|
154
|
+
// 3. 骨架正反两个方向都没有,血肉却声明了 → REL_NOT_IN_SKELETON
|
|
155
|
+
// 4. 骨架有 S→T=K,血肉正反两个方向都没声明 → REL_UNDECLARED(warning)
|
|
156
|
+
//
|
|
157
|
+
// 规则 4 只报 warning:血肉阶段允许先铺开主干关系,再补齐其余关系。
|
|
158
|
+
// 其余三条都是 error —— 它们是语义冲突,不是覆盖度问题。
|
|
159
|
+
function skeletonRelationConsistencyCheck(fleshData, sk, errors, warnings, opts) {
|
|
160
|
+
if (!sk || !sk.entity || !sk.relations) return;
|
|
161
|
+
const checkCoverage = !opts || opts.checkCoverage !== false;
|
|
162
|
+
|
|
163
|
+
// 骨架角色:id → id,name → id
|
|
164
|
+
const idByKey = new Map();
|
|
165
|
+
for (const c of (sk.entity.characters || [])) {
|
|
166
|
+
if (!c) continue;
|
|
167
|
+
if (c.id) idByKey.set(c.id, c.id);
|
|
168
|
+
if (c.name) idByKey.set(c.name, c.id);
|
|
169
|
+
}
|
|
170
|
+
const resolve = (v) => (v && idByKey.has(v) ? idByKey.get(v) : null);
|
|
171
|
+
|
|
172
|
+
// 骨架关系:'A→B' → relation_kind
|
|
173
|
+
const skRel = new Map();
|
|
174
|
+
for (const r of (sk.relations.character_relations || [])) {
|
|
175
|
+
if (!r || !r.from || !r.to) continue;
|
|
176
|
+
skRel.set(r.from + '→' + r.to, r.relation_kind);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 血肉关系:有序对 → { kind, path },并记录血肉已覆盖的无序对
|
|
180
|
+
const fleshRel = new Map();
|
|
181
|
+
const coveredPairs = new Set();
|
|
182
|
+
for (const [i, c] of (fleshData.characters || []).entries()) {
|
|
183
|
+
if (!c) continue;
|
|
184
|
+
const srcId = resolve(c.id) || resolve(c.name);
|
|
185
|
+
for (const [j, r] of (c.relationships || []).entries()) {
|
|
186
|
+
if (!r) continue;
|
|
187
|
+
const p = 'characters[' + i + '].relationships[' + j + ']';
|
|
188
|
+
const tgtId = resolve(r.target_name);
|
|
189
|
+
if (!srcId || !tgtId) continue; // 解析不出来由 xrefCheck / entityRefCheck 负责
|
|
190
|
+
const key = srcId + '→' + tgtId;
|
|
191
|
+
const pairKey = [srcId, tgtId].sort().join('↔');
|
|
192
|
+
coveredPairs.add(pairKey);
|
|
193
|
+
fleshRel.set(key, { kind: r.relationship_type, path: p, target: r.target_name, src: c.name || c.id });
|
|
194
|
+
|
|
195
|
+
const fwd = skRel.get(key);
|
|
196
|
+
if (fwd !== undefined) {
|
|
197
|
+
if (r.relationship_type !== fwd) {
|
|
198
|
+
errors.push({
|
|
199
|
+
code: 'REL_TYPE_MISMATCH',
|
|
200
|
+
path: p + '.relationship_type',
|
|
201
|
+
message: '骨架 ' + srcId + '→' + tgtId + ' 的关系类型是 "' + fwd + '",血肉写的是 "' + r.relationship_type + '"',
|
|
202
|
+
hint: '血肉阶段的关系类型必须与骨架一致;若语义确实变了,请在 description 里写清"关系在剧情中如何演变",而不是换掉类型'
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const rev = skRel.get(tgtId + '→' + srcId);
|
|
209
|
+
if (rev === undefined) {
|
|
210
|
+
errors.push({
|
|
211
|
+
code: 'REL_NOT_IN_SKELETON',
|
|
212
|
+
path: p,
|
|
213
|
+
message: '血肉声明了 ' + (c.name || c.id) + ' → ' + r.target_name + ' 的关系,但骨架没有这一对角色的任何关系',
|
|
214
|
+
hint: '骨架 `关系.json` 的 `character_relations` 里补上这一对,或删掉血肉里的这条关系'
|
|
215
|
+
});
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const meta = RELATION_KIND_META[rev];
|
|
220
|
+
if (meta && meta.direction === 'symmetric') {
|
|
221
|
+
if (r.relationship_type !== rev) {
|
|
222
|
+
errors.push({
|
|
223
|
+
code: 'REL_TYPE_MISMATCH',
|
|
224
|
+
path: p + '.relationship_type',
|
|
225
|
+
message: '骨架 ' + tgtId + '→' + srcId + ' 的关系类型是 "' + rev + '",血肉这边写的是 "' + r.relationship_type + '"',
|
|
226
|
+
hint: '对称关系写成反方向仍须是同一个类型;请与骨架保持一致'
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
errors.push({
|
|
231
|
+
code: 'REL_DIRECTION_FLIP',
|
|
232
|
+
path: p,
|
|
233
|
+
message: '骨架声明的是 ' + tgtId + '→' + srcId + '「' + rev + '」(有向关系),血肉却写成 ' + (c.name || c.id) + ' → ' + r.target_name + '「' + r.relationship_type + '」',
|
|
234
|
+
hint: '有向关系的方向不能反:该声明写在 ' + tgtId + ' 对应的角色文件里,关系类型与骨架保持一致'
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 骨架里声明了、但血肉正反两个方向都没写的关系
|
|
241
|
+
if (!checkCoverage) return;
|
|
242
|
+
for (const [key, kind] of skRel) {
|
|
243
|
+
const parts = key.split('→');
|
|
244
|
+
const pairKey = [parts[0], parts[1]].sort().join('↔');
|
|
245
|
+
if (coveredPairs.has(pairKey)) continue;
|
|
246
|
+
warnings.push({
|
|
247
|
+
code: 'REL_UNDECLARED',
|
|
248
|
+
path: '骨架关系 ' + key,
|
|
249
|
+
message: '骨架 ' + key + '("' + kind + '")在血肉阶段没有对应的 relationships 条目',
|
|
250
|
+
hint: '该对角色的关系尚未血肉化;若刻意留白可忽略'
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ============================================================
|
|
256
|
+
// 模板校验
|
|
257
|
+
// ============================================================
|
|
258
|
+
function validateNode(data, spec, p, enums, errors) {
|
|
259
|
+
if (typeof spec === 'string') spec = { type: spec };
|
|
260
|
+
if (spec.required && (data === undefined || data === null || data === '')) {
|
|
261
|
+
errors.push({ code: 'REQUIRED', path: p, message: '必填字段不得为空', hint: null });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (data === undefined || data === null) return;
|
|
265
|
+
|
|
266
|
+
const actualType = Array.isArray(data) ? 'array' : typeof data;
|
|
267
|
+
if (spec.type === 'array' && !Array.isArray(data)) {
|
|
268
|
+
errors.push({ code: 'TYPE', path: p, message: '应为 array,实际为 ' + actualType, hint: null });
|
|
269
|
+
} else if (spec.type === 'object' && (typeof data !== 'object' || Array.isArray(data))) {
|
|
270
|
+
errors.push({ code: 'TYPE', path: p, message: '应为 object,实际为 ' + actualType, hint: null });
|
|
271
|
+
} else if (spec.type === 'string' && typeof data !== 'string') {
|
|
272
|
+
errors.push({ code: 'TYPE', path: p, message: '应为 string,实际为 ' + actualType, hint: null });
|
|
273
|
+
} else if (spec.type === 'number' && typeof data !== 'number') {
|
|
274
|
+
errors.push({ code: 'TYPE', path: p, message: '应为 number,实际为 ' + actualType, hint: null });
|
|
275
|
+
} else if (spec.type === 'boolean' && typeof data !== 'boolean') {
|
|
276
|
+
errors.push({ code: 'TYPE', path: p, message: '应为 boolean,实际为 ' + actualType, hint: null });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (spec.pattern && typeof data === 'string') {
|
|
280
|
+
try {
|
|
281
|
+
if (!new RegExp(spec.pattern).test(data)) {
|
|
282
|
+
errors.push({ code: 'PATTERN', path: p, message: '不匹配正则 ' + spec.pattern, hint: null });
|
|
283
|
+
}
|
|
284
|
+
} catch (e) {}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (spec.enumRef) {
|
|
288
|
+
const allowed = enums[spec.enumRef];
|
|
289
|
+
if (!allowed) {
|
|
290
|
+
errors.push({ code: 'ENUM_MISSING', path: p, message: '枚举引用 ' + spec.enumRef + ' 未在 glossary 中定义', hint: null });
|
|
291
|
+
} else if (!allowed.includes(data)) {
|
|
292
|
+
errors.push({ code: 'ENUM', path: p, message: '值 "' + data + '" 不在允许的枚举范围内', hint: allowed.join(' / ') });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (spec.properties && typeof data === 'object' && !Array.isArray(data)) {
|
|
297
|
+
const allowed = new Set(Object.keys(spec.properties));
|
|
298
|
+
for (const key of Object.keys(data)) {
|
|
299
|
+
if (!allowed.has(key)) {
|
|
300
|
+
errors.push({
|
|
301
|
+
code: 'EXTRA_FIELD',
|
|
302
|
+
path: p ? (p + '.' + key) : key,
|
|
303
|
+
message: '字段 "' + key + '" 不在模板中',
|
|
304
|
+
hint: '数据文件不得出现模板 properties 之外的字段'
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
for (const [k, child] of Object.entries(spec.properties)) {
|
|
309
|
+
validateNode(data[k], child, p ? (p + '.' + k) : k, enums, errors);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (spec.type === 'array' && spec.items && Array.isArray(data)) {
|
|
314
|
+
data.forEach((item, i) => validateNode(item, spec.items, p + '[' + i + ']', enums, errors));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ============================================================
|
|
319
|
+
// 章节号越界检查
|
|
320
|
+
// ============================================================
|
|
321
|
+
function checkChapterForbidden(data, scalarSet, containerSet, errors) {
|
|
322
|
+
// 这些路径下的同名字段是文本描述,不是章节号
|
|
323
|
+
// 路径中的字段 segment 可被 `.` 或 `[` 包裹
|
|
324
|
+
const TEXT_DESCRIPTOR_PATH_REGEXES = [
|
|
325
|
+
/(?:^|\.|\[)arc_change_descriptions(?:\.|\[|$)/
|
|
326
|
+
];
|
|
327
|
+
const isUnderTextDescriptor = (path) => {
|
|
328
|
+
if (!path) return false;
|
|
329
|
+
return TEXT_DESCRIPTOR_PATH_REGEXES.some(re => re.test(path));
|
|
330
|
+
};
|
|
331
|
+
const walk = (node, p) => {
|
|
332
|
+
if (!node || typeof node !== 'object') return;
|
|
333
|
+
if (Array.isArray(node)) {
|
|
334
|
+
node.forEach((v, i) => walk(v, p + '[' + i + ']'));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
for (const [k, v] of Object.entries(node)) {
|
|
338
|
+
const curPath = p ? (p + '.' + k) : k;
|
|
339
|
+
// 文本描述字段路径前缀豁免
|
|
340
|
+
if (!isUnderTextDescriptor(curPath)) {
|
|
341
|
+
if (containerSet.has(k)) {
|
|
342
|
+
errors.push({
|
|
343
|
+
code: 'FLESH_CHAPTER_FORBIDDEN',
|
|
344
|
+
path: curPath,
|
|
345
|
+
message: '血肉阶段不允许出现章节容器 "' + k + '"',
|
|
346
|
+
hint: '章节号属于节奏阶段'
|
|
347
|
+
});
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (scalarSet.has(k) && typeof v === 'number') {
|
|
351
|
+
errors.push({
|
|
352
|
+
code: 'FLESH_CHAPTER_FORBIDDEN',
|
|
353
|
+
path: curPath,
|
|
354
|
+
message: '血肉阶段不允许出现章节号字段 "' + k + '"(值 ' + v + ')',
|
|
355
|
+
hint: '章节号属于节奏阶段'
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
walk(v, curPath);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
walk(data, '');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ============================================================
|
|
366
|
+
// 自由文本字段的章节号引用检查
|
|
367
|
+
// ============================================================
|
|
368
|
+
const CHAPTER_REF_RE = /第\s*(?:\d+|[一二三四五六七八九十百千万]+)\s*章/;
|
|
369
|
+
|
|
370
|
+
const FREE_TEXT_FIELDS = new Set([
|
|
371
|
+
'description', 'content', 'notes', 'note',
|
|
372
|
+
'function', 'function_detail', 'limit', 'origin', 'appearance',
|
|
373
|
+
'symbolic_meaning', 'usage_scenario', 'relation',
|
|
374
|
+
'method', 'residual_impact', 'resolution_method',
|
|
375
|
+
'goal', 'core_goal', 'influence_scope',
|
|
376
|
+
'surface_contradiction', 'deep_contradiction', 'ultimate_contradiction',
|
|
377
|
+
'core_flaw', 'golden_finger', 'known_secrets', 'unknown_secrets', 'false_beliefs',
|
|
378
|
+
'short_term', 'medium_term', 'long_term',
|
|
379
|
+
'relationship_type', 'stage', 'main_relation', 'expected_outcome',
|
|
380
|
+
'layout', 'narrative_role_description',
|
|
381
|
+
'conflict_summary', 'conflict_solution',
|
|
382
|
+
'special_remaining', 'emotion_trajectory', 'core_emotion', 'final_point',
|
|
383
|
+
'climax_design', 'turning_design', 'cost_design', 'resolution_design',
|
|
384
|
+
'suppression_method', 'resource_seizure_path', 'main_line_connection',
|
|
385
|
+
'internal_structure', 'position_description', 'relation_description',
|
|
386
|
+
'core_drive',
|
|
387
|
+
'applicable_scope', 'exception', 'impact_on_protagonist',
|
|
388
|
+
'violation_consequence', 'impact_on_plot',
|
|
389
|
+
'contradiction', 'solution',
|
|
390
|
+
'catchphrase', 'speech_rhythm', 'info_preference', 'stance',
|
|
391
|
+
'identity_wording', 'personality_tone', 'relationship_stage_tone',
|
|
392
|
+
'personality',
|
|
393
|
+
// 以下字段虽然通常作 ID/枚举,但也可能写"第 N 章"提及,加入警告
|
|
394
|
+
'name', 'info_name', 'who_knows', 'who_does_not_know',
|
|
395
|
+
'foreshadow_id', 'related_characters', 'related_conflicts',
|
|
396
|
+
'target_name', 'from', 'to'
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
function freeTextChapterCheck(data, warnings) {
|
|
400
|
+
const walk = (node, p, fieldName) => {
|
|
401
|
+
if (typeof node === 'string') {
|
|
402
|
+
if (fieldName && FREE_TEXT_FIELDS.has(fieldName) && CHAPTER_REF_RE.test(node)) {
|
|
403
|
+
warnings.push({
|
|
404
|
+
code: 'FLESH_FREETEXT_CHAPTER_REF',
|
|
405
|
+
path: p,
|
|
406
|
+
message: '自由文本中出现"第 N 章"引用,可能语义越界(章节号属于节奏阶段)',
|
|
407
|
+
hint: '如需表达时间位置,请改为"某阶段/某卷",或在节奏阶段处理'
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (Array.isArray(node)) {
|
|
413
|
+
node.forEach((v, i) => walk(v, p + '[' + i + ']', fieldName));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (node && typeof node === 'object') {
|
|
417
|
+
for (const [k, v] of Object.entries(node)) {
|
|
418
|
+
walk(v, p ? (p + '.' + k) : k, k);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
walk(data, '', null);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ============================================================
|
|
426
|
+
// 字符集 / 占位符
|
|
427
|
+
// ============================================================
|
|
428
|
+
const KOREAN_RE = /[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F]/;
|
|
429
|
+
const KANA_RE = /[\u3040-\u309F\u30A0-\u30FF]/;
|
|
430
|
+
const PLACEHOLDER_RE = /\[[XYZW]\]|待确认|待定|N\/A/;
|
|
431
|
+
|
|
432
|
+
function charsetAndForbiddenCheck(data, errors) {
|
|
433
|
+
const walk = (node, p) => {
|
|
434
|
+
if (typeof node === 'string') {
|
|
435
|
+
if (KOREAN_RE.test(node)) {
|
|
436
|
+
errors.push({ code: 'CHARSET_KO', path: p, message: '含韩文: "' + node.slice(0, 30) + '"', hint: null });
|
|
437
|
+
}
|
|
438
|
+
if (KANA_RE.test(node)) {
|
|
439
|
+
errors.push({ code: 'CHARSET_JP', path: p, message: '含日文假名: "' + node.slice(0, 30) + '"', hint: null });
|
|
440
|
+
}
|
|
441
|
+
if (PLACEHOLDER_RE.test(node)) {
|
|
442
|
+
errors.push({ code: 'PLACEHOLDER', path: p, message: '含占位符: "' + node.slice(0, 30) + '"', hint: null });
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (Array.isArray(node)) {
|
|
447
|
+
node.forEach((v, i) => walk(v, p + '[' + i + ']'));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (node && typeof node === 'object') {
|
|
451
|
+
for (const [k, v] of Object.entries(node)) walk(v, p ? (p + '.' + k) : k);
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
walk(data, '');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ============================================================
|
|
458
|
+
// ID 唯一性
|
|
459
|
+
// ============================================================
|
|
460
|
+
const FLESH_ID_ARRAYS = {
|
|
461
|
+
'角色': ['characters'],
|
|
462
|
+
'冲突': ['conflicts'],
|
|
463
|
+
'势力': ['factions'],
|
|
464
|
+
'剧情': ['main_lines', 'branches'],
|
|
465
|
+
'地点': ['locations'],
|
|
466
|
+
'结局': ['endings'],
|
|
467
|
+
'世界观规则': ['core_rules', 'information_rules', 'social_rules'],
|
|
468
|
+
// 关键道具:'details' 是每个 summary 数组里道具的详情副本,id 与 summary 故意重复。
|
|
469
|
+
// 跨数组唯一性只检查 3 个 summary 数组;details 通过 itemRefCheck 单独校验其 id 来自 summary。
|
|
470
|
+
'关键道具': ['core_items', 'branch_items', 'symbolic_items']
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
function idUniquenessCheck(data, fileType, errors) {
|
|
474
|
+
const seen = new Set();
|
|
475
|
+
for (const key of FLESH_ID_ARRAYS[fileType] || []) {
|
|
476
|
+
for (const item of data[key] || []) {
|
|
477
|
+
if (!item.id) continue;
|
|
478
|
+
if (seen.has(item.id)) {
|
|
479
|
+
errors.push({ code: 'ID_DUP', path: key + '.' + item.id, message: 'ID ' + item.id + ' 重复', hint: null });
|
|
480
|
+
}
|
|
481
|
+
seen.add(item.id);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ============================================================
|
|
487
|
+
// 骨架引用与名称一致性
|
|
488
|
+
// ============================================================
|
|
489
|
+
const FLESH_TO_SKELETON = {
|
|
490
|
+
'角色': { fleshKey: 'characters', skeletonKey: 'characters' },
|
|
491
|
+
'冲突': { fleshKey: 'conflicts', skeletonKey: 'conflicts' },
|
|
492
|
+
'势力': { fleshKey: 'factions', skeletonKey: 'factions' },
|
|
493
|
+
'地点': { fleshKey: 'locations', skeletonKey: 'locations' },
|
|
494
|
+
'结局': { fleshKey: 'endings', skeletonKey: 'endings' }
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
function entityRefCheck(data, fileType, skeletonEntity, errors) {
|
|
498
|
+
const mapping = FLESH_TO_SKELETON[fileType];
|
|
499
|
+
if (!mapping) return;
|
|
500
|
+
const skeletonList = skeletonEntity[mapping.skeletonKey] || [];
|
|
501
|
+
const skMap = new Map(skeletonList.map(e => [e.id, e.name]));
|
|
502
|
+
for (const [i, item] of (data[mapping.fleshKey] || []).entries()) {
|
|
503
|
+
if (!item.id) continue;
|
|
504
|
+
if (!skMap.has(item.id)) {
|
|
505
|
+
errors.push({
|
|
506
|
+
code: 'FLESH_REF_MISSING',
|
|
507
|
+
path: mapping.fleshKey + '[' + i + '].id',
|
|
508
|
+
message: '血肉 id ' + item.id + ' 不存在于骨架 ' + mapping.skeletonKey,
|
|
509
|
+
hint: '血肉必须建立在骨架之上'
|
|
510
|
+
});
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (item.name && skMap.get(item.id) !== item.name) {
|
|
514
|
+
errors.push({
|
|
515
|
+
code: 'FLESH_NAME_MISMATCH',
|
|
516
|
+
path: mapping.fleshKey + '[' + i + '].name',
|
|
517
|
+
message: 'id ' + item.id + ' 的 name "' + item.name + '" 与骨架 "' + skMap.get(item.id) + '" 不一致',
|
|
518
|
+
hint: '同一 id 的 name 必须与骨架一致'
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* 结局的伏笔回收计划:foreshadow_id 必须真的存在于骨架的 foreshadows 里。
|
|
526
|
+
*
|
|
527
|
+
* 空白来源(2026-10 复查):结局模板里 foreshadow_recovery_plan 是必填,
|
|
528
|
+
* foreshadow_id 有格式约束(^FS\d{3}$),PATTERN 错误码能挡住格式错;
|
|
529
|
+
* 但"FS047 这个 id 在骨架的 31 条伏笔里到底存不存在"从来没人查过。
|
|
530
|
+
* 骨架只到 FS031 时,LLM 填一个格式正确但悬空的 FS047,
|
|
531
|
+
* 后面节奏阶段去回收它就永远对不上。
|
|
532
|
+
*/
|
|
533
|
+
function foreshadowRefCheck(data, skeletonEntity, errors) {
|
|
534
|
+
const skList = skeletonEntity.foreshadows || [];
|
|
535
|
+
if (!skList.length) return;
|
|
536
|
+
const skIds = new Set(skList.map((e) => e && e.id).filter(Boolean));
|
|
537
|
+
for (const [i, e] of (data.endings || []).entries()) {
|
|
538
|
+
const plans = e.foreshadow_recovery_plan;
|
|
539
|
+
if (!Array.isArray(plans)) continue;
|
|
540
|
+
for (const [j, plan] of plans.entries()) {
|
|
541
|
+
const fid = plan && plan.foreshadow_id;
|
|
542
|
+
if (!fid) continue;
|
|
543
|
+
if (!skIds.has(fid)) {
|
|
544
|
+
errors.push({
|
|
545
|
+
code: 'FLESH_FORESHADOW_REF_MISSING',
|
|
546
|
+
path: 'endings[' + i + '].foreshadow_recovery_plan[' + j + '].foreshadow_id',
|
|
547
|
+
message: '伏笔 id "' + fid + '" 不存在于骨架的 foreshadows',
|
|
548
|
+
hint: '伏笔 id 必须来自骨架 实体.json 的 foreshadows(可用 upstream-query.js --stage=骨架 --file=实体 查)'
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function storyRefCheck(data, skeletonEntity, errors) {
|
|
556
|
+
const mlMap = new Map((skeletonEntity.main_lines || []).map(e => [e.id, e.name]));
|
|
557
|
+
const bMap = new Map((skeletonEntity.branches || []).map(e => [e.id, e.name]));
|
|
558
|
+
for (const [i, item] of (data.main_lines || []).entries()) {
|
|
559
|
+
if (!item.id) continue;
|
|
560
|
+
if (!mlMap.has(item.id)) {
|
|
561
|
+
errors.push({ code: 'FLESH_REF_MISSING', path: 'main_lines[' + i + '].id', message: '主线 id ' + item.id + ' 不存在于骨架', hint: null });
|
|
562
|
+
} else if (item.name && mlMap.get(item.id) !== item.name) {
|
|
563
|
+
errors.push({ code: 'FLESH_NAME_MISMATCH', path: 'main_lines[' + i + '].name', message: '主线 id ' + item.id + ' 的 name 不一致', hint: null });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
for (const [i, item] of (data.branches || []).entries()) {
|
|
567
|
+
if (!item.id) continue;
|
|
568
|
+
if (!bMap.has(item.id)) {
|
|
569
|
+
errors.push({ code: 'FLESH_REF_MISSING', path: 'branches[' + i + '].id', message: '支线 id ' + item.id + ' 不存在于骨架', hint: null });
|
|
570
|
+
} else if (item.name && bMap.get(item.id) !== item.name) {
|
|
571
|
+
errors.push({ code: 'FLESH_NAME_MISMATCH', path: 'branches[' + i + '].name', message: '支线 id ' + item.id + ' 的 name 不一致', hint: null });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ============================================================
|
|
577
|
+
// 世界观规则:引用 + 前缀 + 分组 category 一致性
|
|
578
|
+
// ============================================================
|
|
579
|
+
const WORLD_RULE_GROUP_MAP = {
|
|
580
|
+
'core_rules': { prefix: /^R\d{3}$/, category: '核心规则' },
|
|
581
|
+
'information_rules': { prefix: /^I\d{3}$/, category: '信息规则' },
|
|
582
|
+
'social_rules': { prefix: /^SR\d{3}$/, category: '社会规则' }
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
function worldRuleRefCheck(data, skeletonEntity, errors) {
|
|
586
|
+
const skMap = new Map((skeletonEntity.world_rules || []).map(e => [e.id, e]));
|
|
587
|
+
|
|
588
|
+
for (const [key, spec] of Object.entries(WORLD_RULE_GROUP_MAP)) {
|
|
589
|
+
for (const [i, item] of (data[key] || []).entries()) {
|
|
590
|
+
if (!item.id) continue;
|
|
591
|
+
|
|
592
|
+
if (!spec.prefix.test(item.id)) {
|
|
593
|
+
errors.push({
|
|
594
|
+
code: 'FLESH_WORLD_RULE_CATEGORY',
|
|
595
|
+
path: key + '[' + i + '].id',
|
|
596
|
+
message: key + ' 的 id 应为 ' + spec.category + ' 对应的前缀,实际 "' + item.id + '"',
|
|
597
|
+
hint: null
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const skItem = skMap.get(item.id);
|
|
602
|
+
if (!skItem) {
|
|
603
|
+
errors.push({
|
|
604
|
+
code: 'FLESH_REF_MISSING',
|
|
605
|
+
path: key + '[' + i + '].id',
|
|
606
|
+
message: '规则 id ' + item.id + ' 不存在于骨架 world_rules',
|
|
607
|
+
hint: null
|
|
608
|
+
});
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (item.name && skItem.name !== item.name) {
|
|
613
|
+
errors.push({
|
|
614
|
+
code: 'FLESH_NAME_MISMATCH',
|
|
615
|
+
path: key + '[' + i + '].name',
|
|
616
|
+
message: '规则 id ' + item.id + ' 的 name 不一致',
|
|
617
|
+
hint: null
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (skItem.category && skItem.category !== spec.category) {
|
|
622
|
+
errors.push({
|
|
623
|
+
code: 'FLESH_GROUP_CATEGORY_MISMATCH',
|
|
624
|
+
path: key + '[' + i + '].id',
|
|
625
|
+
message: '骨架中 ' + item.id + ' 的 category 是 "' + skItem.category + '",不应放在血肉的 ' + key,
|
|
626
|
+
hint: '按骨架的 category 把 ' + item.id + ' 移到对应数组'
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ============================================================
|
|
634
|
+
// 关键道具:引用 + 分组 category 一致性 + details 对应(P2-10 顺序修正)
|
|
635
|
+
// ============================================================
|
|
636
|
+
const ITEM_GROUP_MAP = {
|
|
637
|
+
'core_items': '核心道具',
|
|
638
|
+
'branch_items': '支线道具',
|
|
639
|
+
'symbolic_items': '象征道具'
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
function itemRefCheck(data, skeletonEntity, errors) {
|
|
643
|
+
const skMap = new Map((skeletonEntity.items || []).map(e => [e.id, e]));
|
|
644
|
+
const fleshItemIds = new Set();
|
|
645
|
+
|
|
646
|
+
// 道具 id → 它所在的分组数组名(core_items / branch_items / symbolic_items),
|
|
647
|
+
// 报错定位用。
|
|
648
|
+
const itemGroup = new Map();
|
|
649
|
+
|
|
650
|
+
for (const [key, expectedCategory] of Object.entries(ITEM_GROUP_MAP)) {
|
|
651
|
+
for (const [i, item] of (data[key] || []).entries()) {
|
|
652
|
+
if (!item.id) continue;
|
|
653
|
+
itemGroup.set(item.id, key);
|
|
654
|
+
|
|
655
|
+
const skItem = skMap.get(item.id);
|
|
656
|
+
if (!skItem) {
|
|
657
|
+
errors.push({ code: 'FLESH_REF_MISSING', path: key + '[' + i + '].id', message: '道具 id ' + item.id + ' 不存在于骨架 items', hint: null });
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// P2-10:先校验存在,再加入 fleshItemIds
|
|
662
|
+
fleshItemIds.add(item.id);
|
|
663
|
+
|
|
664
|
+
if (item.name && skItem.name !== item.name) {
|
|
665
|
+
errors.push({ code: 'FLESH_NAME_MISMATCH', path: key + '[' + i + '].name', message: '道具 id ' + item.id + ' 的 name 不一致', hint: null });
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (skItem.category && skItem.category !== expectedCategory) {
|
|
669
|
+
errors.push({
|
|
670
|
+
code: 'FLESH_GROUP_CATEGORY_MISMATCH',
|
|
671
|
+
path: key + '[' + i + '].id',
|
|
672
|
+
message: '骨架中 ' + item.id + ' 的 category 是 "' + skItem.category + '",不应放在血肉的 ' + key,
|
|
673
|
+
hint: '按骨架的 category 把 ' + item.id + ' 移到对应数组'
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const detailIds = new Set();
|
|
680
|
+
for (const [i, d] of (data.details || []).entries()) {
|
|
681
|
+
if (!d.id) continue;
|
|
682
|
+
detailIds.add(d.id);
|
|
683
|
+
if (!fleshItemIds.has(d.id)) {
|
|
684
|
+
errors.push({ code: 'FLESH_DETAILS_ORPHAN', path: 'details[' + i + '].id', message: 'details id ' + d.id + ' 没有对应的核心/支线/象征道具', hint: null });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
for (const iid of fleshItemIds) {
|
|
688
|
+
if (!detailIds.has(iid)) {
|
|
689
|
+
// path 要指向"少 details 的那个道具条目"所在的**分组数组**:
|
|
690
|
+
// 之前写的是 'items.' + iid,而血肉的布局里根本没有 items 这个键
|
|
691
|
+
// (分组数组是 core_items / branch_items / symbolic_items)→
|
|
692
|
+
// 归因层匹配不上 → 归不到批次 → 整段重跑。
|
|
693
|
+
errors.push({
|
|
694
|
+
code: 'FLESH_DETAILS_ORPHAN',
|
|
695
|
+
path: (itemGroup.get(iid) || 'details') + '.' + iid,
|
|
696
|
+
message: '道具 ' + iid + ' 缺少对应 details',
|
|
697
|
+
hint: null
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ============================================================
|
|
704
|
+
// 跨文件名称引用
|
|
705
|
+
// ============================================================
|
|
706
|
+
function xrefCheck(data, fileType, skeletonEntity, errors) {
|
|
707
|
+
// 血同时收集 id 和 name 两种引用源——LLM 可能写 id (C001) 或 name (陆子仲)
|
|
708
|
+
const charNames = new Set();
|
|
709
|
+
for (const c of (skeletonEntity.characters || [])) {
|
|
710
|
+
if (c.name) charNames.add(c.name);
|
|
711
|
+
if (c.id) charNames.add(c.id);
|
|
712
|
+
}
|
|
713
|
+
const factionNames = new Set();
|
|
714
|
+
for (const f of (skeletonEntity.factions || [])) {
|
|
715
|
+
if (f.name) factionNames.add(f.name);
|
|
716
|
+
if (f.id) factionNames.add(f.id);
|
|
717
|
+
}
|
|
718
|
+
const conflictNames = new Set();
|
|
719
|
+
for (const x of (skeletonEntity.conflicts || [])) {
|
|
720
|
+
if (x.name) conflictNames.add(x.name);
|
|
721
|
+
if (x.id) conflictNames.add(x.id);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const checkName = (name, set, label, p) => {
|
|
725
|
+
if (!name) return;
|
|
726
|
+
if (!set.has(name)) {
|
|
727
|
+
// 拼错要给正确名字:只说"不存在"的话,LLM 只能靠猜;
|
|
728
|
+
// 给出最接近的那个,它能直接改对(算法见 graph-utils.js suggestClosestName)
|
|
729
|
+
const guess = suggestClosestName(name, set);
|
|
730
|
+
errors.push({
|
|
731
|
+
code: 'FLESH_XREF_MISSING',
|
|
732
|
+
path: p,
|
|
733
|
+
message: '引用' + label + ' "' + name + '" 不存在于骨架',
|
|
734
|
+
hint: guess
|
|
735
|
+
? '疑似拼错:骨架里有 "' + guess.name + '",请改成它'
|
|
736
|
+
: '引用必须用骨架里已有的' + label + '名字;可用 upstream-query.js --stage=骨架 --file=实体 查全量名单'
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
if (fileType === '角色') {
|
|
742
|
+
for (const [i, c] of (data.characters || []).entries()) {
|
|
743
|
+
for (const [j, r] of (c.relationships || []).entries()) {
|
|
744
|
+
checkName(r.target_name, charNames, '角色', 'characters[' + i + '].relationships[' + j + '].target_name');
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
} else if (fileType === '冲突') {
|
|
748
|
+
for (const [i, x] of (data.conflicts || []).entries()) {
|
|
749
|
+
for (const [j, g] of (x.information_gaps || []).entries()) {
|
|
750
|
+
for (const [k, n] of (g.who_knows || []).entries()) {
|
|
751
|
+
checkName(n, charNames, '角色', 'conflicts[' + i + '].information_gaps[' + j + '].who_knows[' + k + ']');
|
|
752
|
+
}
|
|
753
|
+
for (const [k, n] of (g.who_does_not_know || []).entries()) {
|
|
754
|
+
checkName(n, charNames, '角色', 'conflicts[' + i + '].information_gaps[' + j + '].who_does_not_know[' + k + ']');
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
} else if (fileType === '势力') {
|
|
759
|
+
for (const [i, f] of (data.factions || []).entries()) {
|
|
760
|
+
for (const [j, m] of (f.members || []).entries()) {
|
|
761
|
+
checkName(m.name, charNames, '角色', 'factions[' + i + '].members[' + j + '].name');
|
|
762
|
+
}
|
|
763
|
+
for (const [j, r] of (f.relations || []).entries()) {
|
|
764
|
+
checkName(r.target_name, factionNames, '势力', 'factions[' + i + '].relations[' + j + '].target_name');
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
} else if (fileType === '关键道具') {
|
|
768
|
+
for (const [i, d] of (data.details || []).entries()) {
|
|
769
|
+
for (const [j, n] of (d.related_characters || []).entries()) {
|
|
770
|
+
checkName(n, charNames, '角色', 'details[' + i + '].related_characters[' + j + ']');
|
|
771
|
+
}
|
|
772
|
+
for (const [j, n] of (d.related_conflicts || []).entries()) {
|
|
773
|
+
checkName(n, conflictNames, '冲突', 'details[' + i + '].related_conflicts[' + j + ']');
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ============================================================
|
|
780
|
+
// arc_type 与 arc_stages 兼容性 + expected_volume 校验
|
|
781
|
+
// ============================================================
|
|
782
|
+
function arcConsistencyCheck(data, warnings, hierarchy) {
|
|
783
|
+
const validVolumes = new Set();
|
|
784
|
+
if (hierarchy && Array.isArray(hierarchy.volumes)) {
|
|
785
|
+
for (const v of hierarchy.volumes) {
|
|
786
|
+
if (v && v.id) validVolumes.add(v.id);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
for (const [i, c] of (data.characters || []).entries()) {
|
|
790
|
+
const t = c.arc_type;
|
|
791
|
+
const s = c.arc_stages;
|
|
792
|
+
if (typeof s === 'number') {
|
|
793
|
+
let minRequired = 0;
|
|
794
|
+
if (t === '成长型') minRequired = 3;
|
|
795
|
+
else if (t === '堕落型') minRequired = 2;
|
|
796
|
+
else if (t === '发现型') minRequired = 2;
|
|
797
|
+
else if (t === '不变型') minRequired = 1;
|
|
798
|
+
if (s <= 0) {
|
|
799
|
+
warnings.push({
|
|
800
|
+
code: 'FLESH_ARC_STAGES_INVALID',
|
|
801
|
+
path: 'characters[' + i + '].arc_stages',
|
|
802
|
+
message: 'arc_stages=' + s + ' 必须 > 0(弧光阶段数)',
|
|
803
|
+
hint: 'arc_stages 应为正整数'
|
|
804
|
+
});
|
|
805
|
+
} else if (minRequired > 0 && s < minRequired) {
|
|
806
|
+
warnings.push({
|
|
807
|
+
code: 'FLESH_ARC_INCONSISTENT',
|
|
808
|
+
path: 'characters[' + i + '].arc_stages',
|
|
809
|
+
message: 'arc_type="' + t + '" 通常需要至少 ' + minRequired + ' 个阶段,实际 arc_stages=' + s,
|
|
810
|
+
hint: 'arc_stages 表示弧光阶段数'
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// expected_volume 必须存在于骨架层级 volumes
|
|
815
|
+
const ev = c.exclusive_needs && c.exclusive_needs.expected_volume;
|
|
816
|
+
if (ev && validVolumes.size > 0 && !validVolumes.has(ev)) {
|
|
817
|
+
warnings.push({
|
|
818
|
+
code: 'FLESH_EXPECTED_VOLUME_MISSING',
|
|
819
|
+
path: 'characters[' + i + '].exclusive_needs.expected_volume',
|
|
820
|
+
message: 'expected_volume "' + ev + '" 不存在于骨架层级 volumes',
|
|
821
|
+
hint: '改为骨架层级中已存在的卷 id'
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ============================================================
|
|
828
|
+
// 全量:覆盖完整性检查(P0-3:用文件类型 key)
|
|
829
|
+
// ============================================================
|
|
830
|
+
function checkEntityCoverage(skeletonIds, fleshIds, label, errors, warnings) {
|
|
831
|
+
for (const id of skeletonIds) {
|
|
832
|
+
if (!fleshIds.has(id)) {
|
|
833
|
+
warnings.push({ code: 'FLESH_INCOMPLETE', path: label + '.' + id, message: '骨架 ' + label + ' ' + id + ' 未在血肉中出现', hint: '全量写完时应覆盖所有骨架实体' });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
for (const id of fleshIds) {
|
|
837
|
+
if (!skeletonIds.has(id)) {
|
|
838
|
+
errors.push({ code: 'FLESH_EXTRA_ENTITY', path: label + '.' + id, message: '血肉 ' + label + ' ' + id + ' 不在骨架中', hint: '本阶段不能自创实体:删掉这条,或把它列进交付说明的「骨架缺口」小节,由用户重新运行骨架阶段补全后再重跑血肉' });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// P0-3:数组名 → 文件类型 的映射
|
|
844
|
+
const DIRECT_FILE_TYPE_MAP = {
|
|
845
|
+
characters: '角色',
|
|
846
|
+
conflicts: '冲突',
|
|
847
|
+
factions: '势力',
|
|
848
|
+
locations: '地点',
|
|
849
|
+
endings: '结局'
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
function completenessCheck(fleshData, skeletonEntity, errors, warnings) {
|
|
853
|
+
const directChecks = [
|
|
854
|
+
{ fleshKey: 'characters', skKey: 'characters', label: 'character' },
|
|
855
|
+
{ fleshKey: 'conflicts', skKey: 'conflicts', label: 'conflict' },
|
|
856
|
+
{ fleshKey: 'factions', skKey: 'factions', label: 'faction' },
|
|
857
|
+
{ fleshKey: 'locations', skKey: 'locations', label: 'location' },
|
|
858
|
+
{ fleshKey: 'endings', skKey: 'endings', label: 'ending' }
|
|
859
|
+
];
|
|
860
|
+
|
|
861
|
+
for (const { fleshKey, skKey, label } of directChecks) {
|
|
862
|
+
const fileType = DIRECT_FILE_TYPE_MAP[fleshKey];
|
|
863
|
+
const data = fleshData[fileType] ? fleshData[fileType].data : null;
|
|
864
|
+
const fleshIds = new Set(((data && data[fleshKey]) || []).map(e => e.id).filter(Boolean));
|
|
865
|
+
const skIds = new Set((skeletonEntity[skKey] || []).map(e => e.id));
|
|
866
|
+
checkEntityCoverage(skIds, fleshIds, label, errors, warnings);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// items
|
|
870
|
+
{
|
|
871
|
+
const data = fleshData['关键道具'] ? fleshData['关键道具'].data : null;
|
|
872
|
+
const fleshIds = new Set();
|
|
873
|
+
if (data) {
|
|
874
|
+
for (const key of ['core_items', 'branch_items', 'symbolic_items']) {
|
|
875
|
+
for (const x of (data[key] || [])) if (x.id) fleshIds.add(x.id);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
const skIds = new Set((skeletonEntity.items || []).map(e => e.id));
|
|
879
|
+
checkEntityCoverage(skIds, fleshIds, 'item', errors, warnings);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// world_rules
|
|
883
|
+
{
|
|
884
|
+
const data = fleshData['世界观规则'] ? fleshData['世界观规则'].data : null;
|
|
885
|
+
const fleshIds = new Set();
|
|
886
|
+
if (data) {
|
|
887
|
+
for (const key of ['core_rules', 'information_rules', 'social_rules']) {
|
|
888
|
+
for (const x of (data[key] || [])) if (x.id) fleshIds.add(x.id);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const skIds = new Set((skeletonEntity.world_rules || []).map(e => e.id));
|
|
892
|
+
checkEntityCoverage(skIds, fleshIds, 'world_rule', errors, warnings);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// main_lines / branches
|
|
896
|
+
{
|
|
897
|
+
const data = fleshData['剧情'] ? fleshData['剧情'].data : null;
|
|
898
|
+
const fleshMlIds = new Set(((data && data.main_lines) || []).map(e => e.id).filter(Boolean));
|
|
899
|
+
const fleshBrIds = new Set(((data && data.branches) || []).map(e => e.id).filter(Boolean));
|
|
900
|
+
const skMlIds = new Set((skeletonEntity.main_lines || []).map(e => e.id));
|
|
901
|
+
const skBrIds = new Set((skeletonEntity.branches || []).map(e => e.id));
|
|
902
|
+
checkEntityCoverage(skMlIds, fleshMlIds, 'main_line', errors, warnings);
|
|
903
|
+
checkEntityCoverage(skBrIds, fleshBrIds, 'branch', errors, warnings);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// 说明:foreshadows / climaxes / pleasures 在血肉阶段无独立档案,不检查。
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ============================================================
|
|
910
|
+
// md 生成 + stage-status(结构化 markdown,零反引号)
|
|
911
|
+
// ============================================================
|
|
912
|
+
const BT = String.fromCharCode(96);
|
|
913
|
+
const BT3 = BT + BT + BT;
|
|
914
|
+
|
|
915
|
+
function jsonToMd(data, fileType) {
|
|
916
|
+
// flesh 阶段:按 FLESH_DISPATCH 把顶层数组分别渲染为 H2 分组(renderFleshByFileType)
|
|
917
|
+
const body = renderFleshByFileType(data, fileType);
|
|
918
|
+
return '# ' + fileType + '\n\n> 自动生成于 ' + new Date().toISOString() + '\n\n' +
|
|
919
|
+
body + '\n---\n\n## 源数据\n\n' + BT3 + 'json\n' + JSON.stringify(data, null, 2) + '\n' + BT3 + '\n';
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function regenerateMd(stageDir) {
|
|
923
|
+
if (!fs.existsSync(stageDir)) return;
|
|
924
|
+
for (const f of fs.readdirSync(stageDir)) {
|
|
925
|
+
// 排除 .stage-status.json:它是校验状态记账文件,不是业务数据
|
|
926
|
+
if (!f.endsWith('.json') || f.startsWith('.')) continue;
|
|
927
|
+
const jsonPath = path.join(stageDir, f);
|
|
928
|
+
const mdPath = jsonPath.replace(/\.json$/, '.md');
|
|
929
|
+
try {
|
|
930
|
+
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
|
931
|
+
const fileType = f.replace(/\.json$/, '');
|
|
932
|
+
fs.writeFileSync(mdPath, jsonToMd(data, fileType), 'utf-8');
|
|
933
|
+
} catch (e) {}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function updateStageStatus(stageDir, fileType, status) {
|
|
938
|
+
const statusPath = path.join(stageDir, '.stage-status.json');
|
|
939
|
+
let s = { stage: '血肉', last_check: null, files: {}, all_check: { status: 'PENDING', rounds: 0 } };
|
|
940
|
+
if (fs.existsSync(statusPath)) {
|
|
941
|
+
try { s = JSON.parse(fs.readFileSync(statusPath, 'utf-8')); } catch (e) {}
|
|
942
|
+
}
|
|
943
|
+
// 兜底:旧 status 文件可能缺 files / all_check 字段
|
|
944
|
+
s.stage = s.stage || '血肉';
|
|
945
|
+
s.files = s.files || {};
|
|
946
|
+
s.all_check = s.all_check || { status: 'PENDING', rounds: 0 };
|
|
947
|
+
s.last_check = new Date().toISOString();
|
|
948
|
+
if (fileType === 'ALL') {
|
|
949
|
+
s.all_check = { status: status === 0 ? 'PASSED' : 'FAILED', rounds: (s.all_check.rounds || 0) + 1 };
|
|
950
|
+
} else {
|
|
951
|
+
const prev = s.files[fileType] || { retries: 0 };
|
|
952
|
+
s.files[fileType] = { status: status === 0 ? 'PASSED' : 'FAILED', retries: (prev.retries || 0) + (status === 1 ? 1 : 0), last_check: new Date().toISOString() };
|
|
953
|
+
// 单文件一旦被改动/失败,之前的全量结论就不再可信(全量含跨文件检查:
|
|
954
|
+
// 配额一致性、图连通性、引用完整性等),必须作废,等重新跑 --all。
|
|
955
|
+
if (s.all_check && s.all_check.status === 'PASSED') {
|
|
956
|
+
s.all_check = {
|
|
957
|
+
status: 'STALE',
|
|
958
|
+
rounds: s.all_check.rounds || 0,
|
|
959
|
+
stale_reason: '文件 ' + fileType + ' 在 ' + s.last_check + ' 被单文件校验改动,全量结论已作废,请重新执行全量校验',
|
|
960
|
+
stale_at: s.last_check
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
try { fs.writeFileSync(statusPath, JSON.stringify(s, null, 2), 'utf-8'); } catch (e) {}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function emit(status, file, errors, warnings) {
|
|
968
|
+
const payload = { status: status, file: file, errors: errors, warnings: warnings };
|
|
969
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
970
|
+
process.exitCode = status;
|
|
971
|
+
// 返回载荷对象:调用方可能用返回值做守卫(如 `if (err) return err;`),
|
|
972
|
+
// 返回 undefined 会让守卫失效导致继续执行并二次输出。
|
|
973
|
+
return payload;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ============================================================
|
|
977
|
+
// 单文件模式
|
|
978
|
+
// ============================================================
|
|
979
|
+
function validateSingle(args) {
|
|
980
|
+
const input = args.input;
|
|
981
|
+
const template = args.template;
|
|
982
|
+
const file = args.file;
|
|
983
|
+
const errors = [];
|
|
984
|
+
const warnings = [];
|
|
985
|
+
|
|
986
|
+
if (!args.upstream || args.upstream.length === 0) {
|
|
987
|
+
return emit(2, file, [{ code: 'UPSTREAM_MISSING', path: '', message: '血肉阶段必须提供 --upstream=<骨架目录>', hint: null }], []);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const gl = loadGlossary();
|
|
991
|
+
if (gl.error) return emit(2, file, [{ code: 'GLOSSARY_LOAD', path: '', message: gl.error, hint: null }], []);
|
|
992
|
+
|
|
993
|
+
const inputRes = readJson(input);
|
|
994
|
+
if (inputRes.error) return emit(2, file, [{ code: 'READ', path: input, message: inputRes.error, hint: null }], []);
|
|
995
|
+
const data = inputRes.data;
|
|
996
|
+
|
|
997
|
+
const tplRes = readJson(template);
|
|
998
|
+
if (tplRes.error) return emit(2, file, [{ code: 'TEMPLATE', path: template, message: tplRes.error, hint: null }], []);
|
|
999
|
+
|
|
1000
|
+
const sk = loadSkeleton(args.upstream);
|
|
1001
|
+
if (!sk.entity) {
|
|
1002
|
+
return emit(2, file, [{ code: 'UPSTREAM_NOT_READY', path: '', message: '骨架 实体.json 读不到', hint: '请先完成骨架阶段' }], []);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// 阶段门禁:上游必须已通过全量校验(此前只在提示词里约定,脚本不校验)
|
|
1006
|
+
const gate = readStageGate(args.upstream[0]);
|
|
1007
|
+
if (!gate.ok) {
|
|
1008
|
+
return emit(2, file, [{ code: 'UPSTREAM_NOT_PASSED', path: '', message: gate.reason, hint: '请先对骨架阶段执行全量校验并通过' }], []);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const scalarSet = new Set(gl.chapterScalarFields);
|
|
1012
|
+
const containerSet = new Set(gl.chapterContainerFields);
|
|
1013
|
+
|
|
1014
|
+
safeCheck('checkChapterForbidden', () => checkChapterForbidden(data, scalarSet, containerSet, errors), errors);
|
|
1015
|
+
safeCheck('validateNode', () => validateNode(data, tplRes.data.structure || {}, '', gl.enums, errors), errors);
|
|
1016
|
+
safeCheck('charsetAndForbiddenCheck', () => charsetAndForbiddenCheck(data, errors), errors);
|
|
1017
|
+
safeCheck('freeTextChapterCheck', () => freeTextChapterCheck(data, warnings), errors);
|
|
1018
|
+
safeCheck('idUniquenessCheck', () => idUniquenessCheck(data, file, errors), errors);
|
|
1019
|
+
|
|
1020
|
+
if (file === '角色' || file === '冲突' || file === '势力' || file === '地点' || file === '结局') {
|
|
1021
|
+
safeCheck('entityRefCheck', () => entityRefCheck(data, file, sk.entity, errors), errors);
|
|
1022
|
+
} else if (file === '剧情') {
|
|
1023
|
+
safeCheck('storyRefCheck', () => storyRefCheck(data, sk.entity, errors), errors);
|
|
1024
|
+
} else if (file === '世界观规则') {
|
|
1025
|
+
safeCheck('worldRuleRefCheck', () => worldRuleRefCheck(data, sk.entity, errors), errors);
|
|
1026
|
+
} else if (file === '关键道具') {
|
|
1027
|
+
safeCheck('itemRefCheck', () => itemRefCheck(data, sk.entity, errors), errors);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// 结局的伏笔回收计划:foreshadow_id 要真的在骨架里
|
|
1031
|
+
if (file === '结局') {
|
|
1032
|
+
safeCheck('foreshadowRefCheck', () => foreshadowRefCheck(data, sk.entity, errors), errors);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
safeCheck('xrefCheck', () => xrefCheck(data, file, sk.entity, errors), errors);
|
|
1036
|
+
safeCheck('fleshSelfRelationCheck', () => fleshSelfRelationCheck(data, file, errors), errors);
|
|
1037
|
+
safeCheck('contentQualityCheck', () => runContentQualityChecks(data, errors, { enumLeaves: collectEnumLeaves(tplRes.data.structure) }), errors);
|
|
1038
|
+
|
|
1039
|
+
if (file === '角色') {
|
|
1040
|
+
safeCheck('arcConsistencyCheck', () => arcConsistencyCheck(data, warnings, sk.hierarchy), errors);
|
|
1041
|
+
// 单文件模式看不到血肉全貌,只做语义比对,不做覆盖度比对
|
|
1042
|
+
safeCheck('skeletonRelationConsistencyCheck',
|
|
1043
|
+
() => skeletonRelationConsistencyCheck(data, sk, errors, warnings, { checkCoverage: false }), errors);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const stageDir = path.dirname(input);
|
|
1047
|
+
updateStageStatus(stageDir, file, errors.length === 0 ? 0 : 1);
|
|
1048
|
+
regenerateMd(stageDir);
|
|
1049
|
+
|
|
1050
|
+
// 单文件模式不做跨文件检查,必须在全部文件单检完成后跑一次 --all 才算通过
|
|
1051
|
+
if (errors.length === 0) {
|
|
1052
|
+
warnings.push({
|
|
1053
|
+
code: 'NEED_ALL_CHECK',
|
|
1054
|
+
path: '',
|
|
1055
|
+
message: '本文件单检通过;跨文件一致性检查在 --all 模式下进行,全部文件单检完成后仍需执行一次全量校验(--all)',
|
|
1056
|
+
hint: null
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
emit(errors.length === 0 ? 0 : 1, file, errors, warnings);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ============================================================
|
|
1064
|
+
// 全量模式
|
|
1065
|
+
// ============================================================
|
|
1066
|
+
function validateAll(args) {
|
|
1067
|
+
const stageDir = args['stage-dir'];
|
|
1068
|
+
const templateDir = args['template-dir'];
|
|
1069
|
+
const errors = [];
|
|
1070
|
+
const warnings = [];
|
|
1071
|
+
|
|
1072
|
+
if (!args.upstream || args.upstream.length === 0) {
|
|
1073
|
+
return emit(2, 'ALL', [{ code: 'UPSTREAM_MISSING', path: '', message: '血肉阶段必须提供 --upstream=<骨架目录>', hint: null }], []);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const gl = loadGlossary();
|
|
1077
|
+
if (gl.error) return emit(2, 'ALL', [{ code: 'GLOSSARY_LOAD', path: '', message: gl.error, hint: null }], []);
|
|
1078
|
+
|
|
1079
|
+
if (!fs.existsSync(stageDir)) {
|
|
1080
|
+
return emit(2, 'ALL', [{ code: 'ARG', path: stageDir, message: '阶段目录不存在', hint: null }], []);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const sk = loadSkeleton(args.upstream);
|
|
1084
|
+
if (!sk.entity) {
|
|
1085
|
+
return emit(2, 'ALL', [{ code: 'UPSTREAM_NOT_READY', path: '', message: '骨架 实体.json 读不到', hint: null }], []);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// 阶段门禁:上游必须已通过全量校验(此前只在提示词里约定,脚本不校验)
|
|
1089
|
+
const gate = readStageGate(args.upstream[0]);
|
|
1090
|
+
if (!gate.ok) {
|
|
1091
|
+
return emit(2, 'ALL', [{ code: 'UPSTREAM_NOT_PASSED', path: '', message: gate.reason, hint: '请先对骨架阶段执行全量校验并通过' }], []);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const FILES = [
|
|
1095
|
+
{ type: '角色', name: '角色.json', tpl: '角色模板.json' },
|
|
1096
|
+
{ type: '冲突', name: '冲突.json', tpl: '冲突模板.json' },
|
|
1097
|
+
{ type: '势力', name: '势力.json', tpl: '势力模板.json' },
|
|
1098
|
+
{ type: '剧情', name: '剧情.json', tpl: '剧情模板.json' },
|
|
1099
|
+
{ type: '地点', name: '地点.json', tpl: '地点模板.json' },
|
|
1100
|
+
{ type: '结局', name: '结局.json', tpl: '结局模板.json' },
|
|
1101
|
+
{ type: '世界观规则', name: '世界观规则.json', tpl: '世界观规则模板.json' },
|
|
1102
|
+
{ type: '关键道具', name: '关键道具.json', tpl: '关键道具模板.json' }
|
|
1103
|
+
];
|
|
1104
|
+
|
|
1105
|
+
const loaded = {};
|
|
1106
|
+
const scalarSet = new Set(gl.chapterScalarFields);
|
|
1107
|
+
const containerSet = new Set(gl.chapterContainerFields);
|
|
1108
|
+
|
|
1109
|
+
for (const f of FILES) {
|
|
1110
|
+
const dataPath = path.join(stageDir, f.name);
|
|
1111
|
+
const tplPath = path.join(templateDir, f.tpl);
|
|
1112
|
+
const inputRes = readJson(dataPath);
|
|
1113
|
+
if (inputRes.error) {
|
|
1114
|
+
errors.push({ code: 'READ', path: dataPath, message: inputRes.error, hint: null });
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
const tplRes = readJson(tplPath);
|
|
1118
|
+
if (tplRes.error) {
|
|
1119
|
+
errors.push({ code: 'TEMPLATE', path: tplPath, message: tplRes.error, hint: null });
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
loaded[f.type] = { data: inputRes.data, tpl: tplRes.data };
|
|
1123
|
+
const data = inputRes.data;
|
|
1124
|
+
|
|
1125
|
+
safeCheck('checkChapterForbidden', () => checkChapterForbidden(data, scalarSet, containerSet, errors), errors);
|
|
1126
|
+
safeCheck('validateNode', () => validateNode(data, tplRes.data.structure || {}, '', gl.enums, errors), errors);
|
|
1127
|
+
safeCheck('charsetAndForbiddenCheck', () => charsetAndForbiddenCheck(data, errors), errors);
|
|
1128
|
+
safeCheck('freeTextChapterCheck', () => freeTextChapterCheck(data, warnings), errors);
|
|
1129
|
+
safeCheck('idUniquenessCheck', () => idUniquenessCheck(data, f.type, errors), errors);
|
|
1130
|
+
|
|
1131
|
+
if (f.type === '角色' || f.type === '冲突' || f.type === '势力' || f.type === '地点' || f.type === '结局') {
|
|
1132
|
+
safeCheck('entityRefCheck', () => entityRefCheck(data, f.type, sk.entity, errors), errors);
|
|
1133
|
+
} else if (f.type === '剧情') {
|
|
1134
|
+
safeCheck('storyRefCheck', () => storyRefCheck(data, sk.entity, errors), errors);
|
|
1135
|
+
} else if (f.type === '世界观规则') {
|
|
1136
|
+
safeCheck('worldRuleRefCheck', () => worldRuleRefCheck(data, sk.entity, errors), errors);
|
|
1137
|
+
} else if (f.type === '关键道具') {
|
|
1138
|
+
safeCheck('itemRefCheck', () => itemRefCheck(data, sk.entity, errors), errors);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// 结局的伏笔回收计划:foreshadow_id 要真的在骨架里
|
|
1142
|
+
if (f.type === '结局') {
|
|
1143
|
+
safeCheck('foreshadowRefCheck', () => foreshadowRefCheck(data, sk.entity, errors), errors);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
safeCheck('xrefCheck', () => xrefCheck(data, f.type, sk.entity, errors), errors);
|
|
1147
|
+
safeCheck('fleshSelfRelationCheck', () => fleshSelfRelationCheck(data, f.type, errors), errors);
|
|
1148
|
+
safeCheck('contentQualityCheck', () => runContentQualityChecks(data, errors, { enumLeaves: collectEnumLeaves(tplRes.data.structure) }), errors);
|
|
1149
|
+
|
|
1150
|
+
if (f.type === '角色') safeCheck('arcConsistencyCheck', () => arcConsistencyCheck(data, warnings, sk.hierarchy), errors);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
safeCheck('completenessCheck', () => completenessCheck(loaded, sk.entity, errors, warnings), errors);
|
|
1154
|
+
|
|
1155
|
+
if (loaded['角色']) {
|
|
1156
|
+
safeCheck('skeletonRelationConsistencyCheck',
|
|
1157
|
+
() => skeletonRelationConsistencyCheck(loaded['角色'].data, sk, errors, warnings, { checkCoverage: true }), errors);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
updateStageStatus(stageDir, 'ALL', errors.length === 0 ? 0 : 1);
|
|
1161
|
+
regenerateMd(stageDir);
|
|
1162
|
+
|
|
1163
|
+
emit(errors.length === 0 ? 0 : 1, 'ALL', errors, warnings);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// ============================================================
|
|
1167
|
+
// 入口
|
|
1168
|
+
// ============================================================
|
|
1169
|
+
function main() {
|
|
1170
|
+
const args = parseArgs();
|
|
1171
|
+
|
|
1172
|
+
if (args.all) {
|
|
1173
|
+
if (!args['stage-dir'] || !args['template-dir']) {
|
|
1174
|
+
return emit(2, 'ALL', [{ code: 'ARG', path: '', message: '全量模式需要 --stage-dir 和 --template-dir', hint: null }], []);
|
|
1175
|
+
}
|
|
1176
|
+
return validateAll(args);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (args.input) {
|
|
1180
|
+
if (!args.template || !args.file) {
|
|
1181
|
+
return emit(2, args.file || 'UNKNOWN', [{ code: 'ARG', path: '', message: '单文件模式需要 --template 和 --file', hint: null }], []);
|
|
1182
|
+
}
|
|
1183
|
+
return validateSingle(args);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
return emit(2, 'UNKNOWN', [{ code: 'ARG', path: '', message: '需要 --input 或 --all', hint: null }], []);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
main();
|