@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,2668 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema-pages.js —— 把 story-builder 的阶段 JSON 转成 llm_wiki 的 schemaingest 页面
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:
|
|
5
|
+
* - JSON 仍是事实源;wiki 页面是确定性派生视图。
|
|
6
|
+
* - 一个对象一个 page;关系表能落到实体字段的就落到实体字段,不能落地的才建关系页。
|
|
7
|
+
* - 页面 body 用人类可读的字段清单,frontmatter 只放 schema 声明的结构化字段。
|
|
8
|
+
* - 所有转换都是纯函数;网络写入集中在 materializeStage。
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { FIELD_CN } from './graph-utils.js';
|
|
13
|
+
|
|
14
|
+
const SOURCES = ['设定', 'raw', 'sources'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 机器内部仍用英文键组织代码,写进 wiki frontmatter 的 type 用中文。
|
|
18
|
+
* 这样 schema.json 的 type 契约、llm_wiki 的目录路由和用户看到的页面
|
|
19
|
+
* 都使用同一套中文类型名。
|
|
20
|
+
*/
|
|
21
|
+
export const TYPE = {
|
|
22
|
+
book: '书',
|
|
23
|
+
volume: '卷',
|
|
24
|
+
act: '幕',
|
|
25
|
+
chapter: '章',
|
|
26
|
+
requirement: '细纲要求',
|
|
27
|
+
scene: '场景',
|
|
28
|
+
character: '人物',
|
|
29
|
+
other_entity: '其他实体',
|
|
30
|
+
faction: '势力',
|
|
31
|
+
location: '地点',
|
|
32
|
+
item: '道具',
|
|
33
|
+
conflict: '冲突',
|
|
34
|
+
foreshadow: '伏笔',
|
|
35
|
+
climax: '高潮',
|
|
36
|
+
pleasure: '爽点',
|
|
37
|
+
ending: '结局',
|
|
38
|
+
world_rule: '世界观',
|
|
39
|
+
main_line: '主线',
|
|
40
|
+
branch: '支线',
|
|
41
|
+
relation: '关系',
|
|
42
|
+
faction_relation: '势力关系',
|
|
43
|
+
membership: '势力成员',
|
|
44
|
+
theme: '主题',
|
|
45
|
+
arc: '角色弧光',
|
|
46
|
+
character_state: '角色状态',
|
|
47
|
+
timeline_event: '时间线',
|
|
48
|
+
character_brief: '章节细纲',
|
|
49
|
+
info_gap: '信息差',
|
|
50
|
+
manuscript: '正文',
|
|
51
|
+
source: '来源',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function pageType(key) {
|
|
55
|
+
return TYPE[key] || key;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readJson(file) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pad3(n) {
|
|
67
|
+
return String(n).padStart(3, '0');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function label(key) {
|
|
71
|
+
return (FIELD_CN && FIELD_CN[key]) || key;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const LONG_TEXT_KEYS = new Set([
|
|
75
|
+
'description', 'note', 'core_flaw', 'golden_finger', 'resolution_method',
|
|
76
|
+
'narrative_role_description', 'suppression_method', 'resource_seizure_path',
|
|
77
|
+
'main_line_connection', 'function', 'relation', 'symbolic_meaning',
|
|
78
|
+
'usage_scenario', 'appearance', 'origin', 'function_detail', 'limit',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
export function objectBody(title, obj) {
|
|
82
|
+
const lines = ['# ' + title, ''];
|
|
83
|
+
const entries = Object.entries(obj || {}).filter(([, value]) =>
|
|
84
|
+
value !== undefined && value !== null && value !== '');
|
|
85
|
+
if (!entries.length) {
|
|
86
|
+
lines.push('- (待补充)');
|
|
87
|
+
return lines.join('\n') + '\n';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 顶层字段分两类:
|
|
91
|
+
// · 短标量 → “基本信息”列表;
|
|
92
|
+
// · 长文本 / 对象 / 多元素数组 → 独立小节。
|
|
93
|
+
// 这样人物页不会变成一长串“ID:/名称:/描述:”平铺列表,渲染后
|
|
94
|
+
// 目标、信息状态、语言档案、关系各自有标题,扫读成本低很多。
|
|
95
|
+
const basic = [];
|
|
96
|
+
const sections = [];
|
|
97
|
+
for (const [key, value] of entries) {
|
|
98
|
+
const isLongText = typeof value === 'string'
|
|
99
|
+
&& (LONG_TEXT_KEYS.has(key) || value.length > 80 || value.includes('\n'));
|
|
100
|
+
const isArray = Array.isArray(value) && value.length > 2;
|
|
101
|
+
const isObject = value && typeof value === 'object';
|
|
102
|
+
if (isLongText || isArray || isObject) sections.push([key, value]);
|
|
103
|
+
else basic.push([key, value]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (basic.length) {
|
|
107
|
+
lines.push('## 基本信息');
|
|
108
|
+
for (const [key, value] of basic) lines.push('- ' + label(key) + ':' + inlineValue(value));
|
|
109
|
+
lines.push('');
|
|
110
|
+
}
|
|
111
|
+
for (const [key, value] of sections) {
|
|
112
|
+
lines.push('## ' + label(key));
|
|
113
|
+
if (typeof value === 'string') {
|
|
114
|
+
lines.push(value);
|
|
115
|
+
} else {
|
|
116
|
+
renderValueLines(value, 0, lines);
|
|
117
|
+
}
|
|
118
|
+
lines.push('');
|
|
119
|
+
}
|
|
120
|
+
return lines.join('\n').trimEnd() + '\n';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 渲染一个独立小节的值;对象/对象数组会继续展开成子标题。 */
|
|
124
|
+
function renderValueLines(value, indent, lines) {
|
|
125
|
+
const pad = ' '.repeat(indent);
|
|
126
|
+
if (Array.isArray(value)) {
|
|
127
|
+
const allPrimitive = value.every((item) => item === null || typeof item !== 'object');
|
|
128
|
+
if (allPrimitive) {
|
|
129
|
+
for (const item of value) lines.push(pad + '- ' + inlineValue(item));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const item of value) {
|
|
133
|
+
if (item === null || item === undefined) continue;
|
|
134
|
+
if (typeof item !== 'object') {
|
|
135
|
+
lines.push(pad + '- ' + inlineValue(item));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const heading = item.name || item.title || item.target_name || item.stage || item.id;
|
|
139
|
+
if (heading) lines.push(pad + '### ' + heading);
|
|
140
|
+
renderObjectLines(item, indent + (heading ? 2 : 0), lines);
|
|
141
|
+
lines.push('');
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (value && typeof value === 'object') {
|
|
146
|
+
renderObjectLines(value, indent, lines);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
lines.push(pad + '- ' + inlineValue(value));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function inlineValue(value) {
|
|
153
|
+
if (value === undefined || value === null) return '';
|
|
154
|
+
if (Array.isArray(value)) return value.map((item) => inlineValue(item)).filter(Boolean).join('、');
|
|
155
|
+
if (typeof value === 'object') {
|
|
156
|
+
return Object.entries(value)
|
|
157
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
158
|
+
.map(([k, v]) => label(k) + ':' + inlineValue(v))
|
|
159
|
+
.join(';');
|
|
160
|
+
}
|
|
161
|
+
return String(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderObjectLines(obj, indent, lines) {
|
|
165
|
+
const pad = ' '.repeat(indent);
|
|
166
|
+
for (const [key, value] of Object.entries(obj || {})) {
|
|
167
|
+
if (value === undefined || value === null || value === '') continue;
|
|
168
|
+
const prefix = pad + '- ' + label(key) + ':';
|
|
169
|
+
if (Array.isArray(value)) {
|
|
170
|
+
if (value.length === 0) continue;
|
|
171
|
+
const allPrimitive = value.every((item) => item === null || typeof item !== 'object');
|
|
172
|
+
if (allPrimitive) {
|
|
173
|
+
lines.push(prefix + value.map((item) => inlineValue(item)).join('、'));
|
|
174
|
+
} else {
|
|
175
|
+
lines.push(prefix);
|
|
176
|
+
for (const item of value) {
|
|
177
|
+
if (item === null || item === undefined) continue;
|
|
178
|
+
if (typeof item === 'object') {
|
|
179
|
+
lines.push(' '.repeat(indent + 2) + '- ' + inlineValue(item));
|
|
180
|
+
} else {
|
|
181
|
+
lines.push(' '.repeat(indent + 2) + '- ' + inlineValue(item));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (typeof value === 'object') {
|
|
188
|
+
lines.push(prefix);
|
|
189
|
+
renderObjectLines(value, indent + 2, lines);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
lines.push(prefix + inlineValue(value));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function textBody(title, text) {
|
|
197
|
+
const value = String(text || '').trim();
|
|
198
|
+
return '# ' + title + '\n\n' + (value || '(待补充)') + '\n';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 在实体页末尾追加“按章生效”的时间线;已有同名小节时跳过。 */
|
|
202
|
+
function appendTimelineSection(body, entries) {
|
|
203
|
+
const base = String(body || '').trimEnd();
|
|
204
|
+
if (!entries.length) return base;
|
|
205
|
+
if (base.includes('\n## 时间线(按章生效)')) return base;
|
|
206
|
+
return base + '\n\n## 时间线(按章生效)\n' + entries.join('\n') + '\n';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function addPage(pages, byId, page) {
|
|
210
|
+
if (!page || !page.id) return;
|
|
211
|
+
const existing = byId.get(page.id);
|
|
212
|
+
if (existing) {
|
|
213
|
+
existing.frontmatter = Object.assign({}, existing.frontmatter || {}, page.frontmatter || {});
|
|
214
|
+
if (page.body) existing.body = page.body;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
byId.set(page.id, page);
|
|
218
|
+
pages.push(page);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function entityPage(type, row, frontmatter) {
|
|
222
|
+
return {
|
|
223
|
+
type: pageType(type),
|
|
224
|
+
id: row.id,
|
|
225
|
+
title: row.name || row.id,
|
|
226
|
+
frontmatter: frontmatter || {},
|
|
227
|
+
body: type === 'conflict' ? conflictBody(row) : objectBody(row.name || row.id, row),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function otherEntityBody(row, relations) {
|
|
232
|
+
const b = row || {};
|
|
233
|
+
const lines = ['# ' + (b.name || b.id || '其他实体'), ''];
|
|
234
|
+
lines.push('## 基本信息');
|
|
235
|
+
lines.push('- ID:' + (b.id || ''));
|
|
236
|
+
lines.push('- 类别:' + (b.kind || '题材特有存在'));
|
|
237
|
+
if (b.note) lines.push('- 备注:' + b.note);
|
|
238
|
+
lines.push('');
|
|
239
|
+
if (Array.isArray(b.traits) && b.traits.length) {
|
|
240
|
+
lines.push('## 独有特征');
|
|
241
|
+
for (const x of b.traits) lines.push('- ' + x);
|
|
242
|
+
lines.push('');
|
|
243
|
+
}
|
|
244
|
+
if (Array.isArray(b.abilities) && b.abilities.length) {
|
|
245
|
+
lines.push('## 能力');
|
|
246
|
+
for (const x of b.abilities) lines.push('- ' + x);
|
|
247
|
+
lines.push('');
|
|
248
|
+
}
|
|
249
|
+
if (Array.isArray(b.limits) && b.limits.length) {
|
|
250
|
+
lines.push('## 限制');
|
|
251
|
+
for (const x of b.limits) lines.push('- ' + x);
|
|
252
|
+
lines.push('');
|
|
253
|
+
}
|
|
254
|
+
if (b.description) {
|
|
255
|
+
lines.push('## 描述');
|
|
256
|
+
lines.push(b.description);
|
|
257
|
+
lines.push('');
|
|
258
|
+
}
|
|
259
|
+
if (b.extensions && typeof b.extensions === 'object') {
|
|
260
|
+
lines.push('## 题材扩展');
|
|
261
|
+
for (const [k, v] of Object.entries(b.extensions)) {
|
|
262
|
+
if (v === undefined || v === null || v === '') continue;
|
|
263
|
+
lines.push('- ' + label(k) + ':' + inlineValue(v));
|
|
264
|
+
}
|
|
265
|
+
lines.push('');
|
|
266
|
+
}
|
|
267
|
+
const rows = (relations || []).filter((r) => r && (r.from === b.id || r.to === b.id));
|
|
268
|
+
if (rows.length) {
|
|
269
|
+
lines.push('## 与其他存在的关系');
|
|
270
|
+
for (const r of rows) {
|
|
271
|
+
const other = r.from === b.id ? r.to : r.from;
|
|
272
|
+
const direction = r.from === b.id ? '→' : '←';
|
|
273
|
+
lines.push('- ' + direction + ' [[' + other + ']]:' + (r.relation_label || r.description || '相关'));
|
|
274
|
+
}
|
|
275
|
+
lines.push('');
|
|
276
|
+
}
|
|
277
|
+
return lines.join('\n');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function characterBody(row, otherRelations) {
|
|
281
|
+
let body = objectBody(row.name || row.id, row);
|
|
282
|
+
const rows = (otherRelations || []).filter((r) => r && (r.from === row.id || r.to === row.id));
|
|
283
|
+
if (!rows.length) return body;
|
|
284
|
+
const lines = ['', '## 特殊关系'];
|
|
285
|
+
for (const r of rows) {
|
|
286
|
+
const other = r.from === row.id ? r.to : r.from;
|
|
287
|
+
const direction = r.from === row.id ? '→' : '←';
|
|
288
|
+
lines.push('- ' + direction + ' [[' + other + ']]:' + (r.relation_label || r.description || '相关'));
|
|
289
|
+
}
|
|
290
|
+
return body.trimEnd() + '\n' + lines.join('\n') + '\n';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function relationBody(row, timelineRows, fleshChanges) {
|
|
294
|
+
const b = row || {};
|
|
295
|
+
const lines = ['# ' + (b.from || '?') + ' → ' + (b.to || '?'), ''];
|
|
296
|
+
lines.push('## 基本信息');
|
|
297
|
+
lines.push('- 起点:[[' + (b.from || '') + ']]');
|
|
298
|
+
lines.push('- 终点:[[' + (b.to || '') + ']]');
|
|
299
|
+
lines.push('- 关系类型:' + (b.relation_kind || ''));
|
|
300
|
+
if (b.relation_label) lines.push('- 关系标签:' + b.relation_label);
|
|
301
|
+
lines.push('');
|
|
302
|
+
|
|
303
|
+
const rows = timelineRows || [];
|
|
304
|
+
lines.push('## 时间线(按章生效)');
|
|
305
|
+
if (rows.length) {
|
|
306
|
+
for (const r of rows) {
|
|
307
|
+
const end = typeof r.end_chapter === 'number' ? r.end_chapter : null;
|
|
308
|
+
const range = '第 ' + r.start_chapter + (end !== null ? '–' + end : '') + ' 章';
|
|
309
|
+
lines.push('### ' + range + (r.stage ? ' · ' + r.stage : ''));
|
|
310
|
+
if (r.state) lines.push('- 阶段状态:' + r.state);
|
|
311
|
+
if (r.trigger_event) lines.push('- 触发事件:' + r.trigger_event);
|
|
312
|
+
if (r.description) lines.push('- 关系表现:' + r.description);
|
|
313
|
+
lines.push('- 写作提示:本章按“' + (r.state || r.stage || '当前阶段') + '”呈现;不要提前使用下一阶段的称谓、态度或亲密程度。');
|
|
314
|
+
lines.push('');
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
lines.push('- ⚠️ 尚未由节奏阶段定时:当前只有关系类型,无法给出每个阶段从哪一章开始。');
|
|
318
|
+
lines.push('- 请在节奏阶段补充 `relationship_timeline`,再同步本页。');
|
|
319
|
+
lines.push('');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const changes = fleshChanges || [];
|
|
323
|
+
if (changes.length) {
|
|
324
|
+
lines.push('## 血肉阶段记录的变化');
|
|
325
|
+
for (const c of changes) {
|
|
326
|
+
lines.push('- ' + (c.stage || '阶段') + ':' + (c.from || '') + ' → ' + (c.to || '') +
|
|
327
|
+
(c.trigger ? ';触发:' + c.trigger : '') +
|
|
328
|
+
(c.description ? ';' + c.description : ''));
|
|
329
|
+
}
|
|
330
|
+
lines.push('');
|
|
331
|
+
}
|
|
332
|
+
return lines.join('\n');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 势力关系的时间线。数据结构与人物关系一致,但主体是势力。
|
|
337
|
+
* 没有节奏时间线时明确写出“尚未定时”,避免把静态关系误当成全时段关系。
|
|
338
|
+
*/
|
|
339
|
+
function factionRelationBody(row, timelineRows) {
|
|
340
|
+
const b = row || {};
|
|
341
|
+
const lines = ['# ' + (b.from || '?') + ' ↔ ' + (b.to || '?'), ''];
|
|
342
|
+
lines.push('## 基本信息');
|
|
343
|
+
lines.push('- 势力 A:[[' + (b.from || '') + ']]');
|
|
344
|
+
lines.push('- 势力 B:[[' + (b.to || '') + ']]');
|
|
345
|
+
lines.push('- 关系类型:' + (b.relation_type || b.kind || ''));
|
|
346
|
+
if (b.description) lines.push('- 描述:' + b.description);
|
|
347
|
+
lines.push('');
|
|
348
|
+
|
|
349
|
+
lines.push('## 时间线(按章生效)');
|
|
350
|
+
const rows = timelineRows || [];
|
|
351
|
+
if (rows.length) {
|
|
352
|
+
for (const r of rows) {
|
|
353
|
+
const end = typeof r.end_chapter === 'number' ? r.end_chapter : null;
|
|
354
|
+
const range = '第 ' + r.start_chapter + (end !== null ? '–' + end : '') + ' 章';
|
|
355
|
+
lines.push('### ' + range + (r.stage ? ' · ' + r.stage : ''));
|
|
356
|
+
if (r.state) lines.push('- 阶段状态:' + r.state);
|
|
357
|
+
if (r.trigger_event) lines.push('- 触发事件:' + r.trigger_event);
|
|
358
|
+
if (r.description) lines.push('- 关系表现:' + r.description);
|
|
359
|
+
lines.push('- 写作提示:本章按“' + (r.state || r.stage || '当前阶段') + '”呈现;不要提前写后续阶段的合作/敌对程度。');
|
|
360
|
+
lines.push('');
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
lines.push('- ⚠️ 尚未由节奏阶段定时:当前只有关系类型,无法给出每个阶段从哪一章开始。');
|
|
364
|
+
lines.push('- 请在节奏阶段补充 `faction_relationship_timeline`,再同步本页。');
|
|
365
|
+
lines.push('');
|
|
366
|
+
}
|
|
367
|
+
return lines.join('\n');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 骨架阶段:卷/幕/实体/关系/层级。
|
|
372
|
+
*/
|
|
373
|
+
export function pagesFromSkeleton(projectRoot) {
|
|
374
|
+
const dir = join(projectRoot, ...SOURCES, '骨架');
|
|
375
|
+
const frameworkRaw = readJson(join(dir, '框架.json'));
|
|
376
|
+
const entitiesRaw = readJson(join(dir, '实体.json'));
|
|
377
|
+
const relationsRaw = readJson(join(dir, '关系.json'));
|
|
378
|
+
const hierarchyRaw = readJson(join(dir, '层级.json'));
|
|
379
|
+
if (!frameworkRaw && !entitiesRaw && !relationsRaw && !hierarchyRaw) return [];
|
|
380
|
+
const framework = frameworkRaw || {};
|
|
381
|
+
const entities = entitiesRaw || {};
|
|
382
|
+
const relations = relationsRaw || {};
|
|
383
|
+
const hierarchy = hierarchyRaw || {};
|
|
384
|
+
const pages = [];
|
|
385
|
+
const byId = new Map();
|
|
386
|
+
|
|
387
|
+
const volumes = Array.isArray(hierarchy.volumes) ? hierarchy.volumes : [];
|
|
388
|
+
const volumeIds = volumes.map((v) => v.id).filter(Boolean);
|
|
389
|
+
const bookTitle = (framework.input_snapshot && framework.input_snapshot.book_name) || '未命名';
|
|
390
|
+
addPage(pages, byId, {
|
|
391
|
+
type: TYPE.book,
|
|
392
|
+
id: 'BOOK',
|
|
393
|
+
title: bookTitle,
|
|
394
|
+
frontmatter: { volumes: volumeIds },
|
|
395
|
+
body: objectBody(bookTitle, framework.input_snapshot || {}),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// 幕:骨架里没有 id,按全局顺序生成 A001…
|
|
399
|
+
const actIdByVolumeIndex = new Map();
|
|
400
|
+
const actsByVolume = new Map();
|
|
401
|
+
let actSeq = 0;
|
|
402
|
+
for (const act of (Array.isArray(hierarchy.acts) ? hierarchy.acts : [])) {
|
|
403
|
+
if (!act || !act.volume) continue;
|
|
404
|
+
const id = 'A' + pad3(++actSeq);
|
|
405
|
+
actIdByVolumeIndex.set(act.volume + ':' + act.act_index, id);
|
|
406
|
+
if (!actsByVolume.has(act.volume)) actsByVolume.set(act.volume, []);
|
|
407
|
+
actsByVolume.get(act.volume).push(id);
|
|
408
|
+
addPage(pages, byId, {
|
|
409
|
+
type: TYPE.act,
|
|
410
|
+
id,
|
|
411
|
+
title: act.theme_short || ('第 ' + act.act_index + ' 幕'),
|
|
412
|
+
frontmatter: { volume: act.volume, theme: act.theme_short || '' },
|
|
413
|
+
body: textBody(act.theme_short || ('第 ' + act.act_index + ' 幕'), act.theme_short),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const ratioByVolume = new Map();
|
|
418
|
+
for (const ratio of (Array.isArray(framework.volume_ratio) ? framework.volume_ratio : [])) {
|
|
419
|
+
if (ratio && ratio.volume_id) ratioByVolume.set(ratio.volume_id, ratio.chapter_ratio);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const volume of volumes) {
|
|
423
|
+
if (!volume || !volume.id) continue;
|
|
424
|
+
addPage(pages, byId, {
|
|
425
|
+
type: TYPE.volume,
|
|
426
|
+
id: volume.id,
|
|
427
|
+
title: volume.name || volume.id,
|
|
428
|
+
frontmatter: {
|
|
429
|
+
theme: volume.theme_short || '',
|
|
430
|
+
chapter_ratio: ratioByVolume.get(volume.id),
|
|
431
|
+
acts: actsByVolume.get(volume.id) || [],
|
|
432
|
+
},
|
|
433
|
+
body: textBody(volume.name || volume.id, volume.theme_short),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const entitySpecs = [
|
|
438
|
+
['characters', 'character', (row) => ({ role: row.type, gender: row.gender, age: row.age_group })],
|
|
439
|
+
['other_entities', 'other_entity', (row) => ({ kind: row.kind })],
|
|
440
|
+
['conflicts', 'conflict', (row) => ({ kind: row.type, scale: row.scale })],
|
|
441
|
+
['factions', 'faction', (row) => ({ kind: row.type })],
|
|
442
|
+
['locations', 'location', (row) => ({ kind: row.type, narrative_function: row.narrative_function })],
|
|
443
|
+
['items', 'item', (row) => ({ category: row.category })],
|
|
444
|
+
['endings', 'ending', (row) => ({ level: row.level, kind: row.type })],
|
|
445
|
+
['foreshadows', 'foreshadow', (row) => ({ kind: row.type })],
|
|
446
|
+
['climaxes', 'climax', (row) => ({ kind: row.type })],
|
|
447
|
+
['pleasures', 'pleasure', (row) => ({ scale: row.scale, kind: row.kind })],
|
|
448
|
+
['world_rules', 'world_rule', (row) => ({ category: row.category })],
|
|
449
|
+
['main_lines', 'main_line', () => ({})],
|
|
450
|
+
['branches', 'branch', (row) => ({ kind: row.type })],
|
|
451
|
+
];
|
|
452
|
+
|
|
453
|
+
for (const [arrayKey, type, fields] of entitySpecs) {
|
|
454
|
+
for (const row of (Array.isArray(entities[arrayKey]) ? entities[arrayKey] : [])) {
|
|
455
|
+
if (!row || !row.id) continue;
|
|
456
|
+
addPage(pages, byId, entityPage(type, row, fields(row)));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
for (const row of (Array.isArray(entities.other_entities) ? entities.other_entities : [])) {
|
|
460
|
+
if (!row || !row.id) continue;
|
|
461
|
+
const page = byId.get(row.id);
|
|
462
|
+
if (page) page.body = otherEntityBody(row, relations.other_relations || []);
|
|
463
|
+
}
|
|
464
|
+
for (const row of (Array.isArray(entities.characters) ? entities.characters : [])) {
|
|
465
|
+
if (!row || !row.id) continue;
|
|
466
|
+
const page = byId.get(row.id);
|
|
467
|
+
if (page) page.body = characterBody(row, relations.other_relations || []);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 主题:每卷一个 theme page(theme_short 是卷级主题)。
|
|
471
|
+
// ID 跟卷号绑定(V001 → TH001),卷顺序变化时不会漂移。
|
|
472
|
+
volumes.forEach((volume, index) => {
|
|
473
|
+
if (!volume || !volume.id) return;
|
|
474
|
+
const volumeSeq = /^V(\d{3})$/.exec(volume.id);
|
|
475
|
+
addPage(pages, byId, {
|
|
476
|
+
type: TYPE.theme,
|
|
477
|
+
id: 'TH' + (volumeSeq ? volumeSeq[1] : pad3(index + 1)),
|
|
478
|
+
title: volume.theme_short || volume.name || volume.id,
|
|
479
|
+
frontmatter: { volumes: [volume.id], statement: volume.theme_short || '' },
|
|
480
|
+
body: textBody(volume.theme_short || volume.name || volume.id, volume.theme_short),
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// 角色关系 -> relation page
|
|
485
|
+
let relationSeq = 0;
|
|
486
|
+
for (const row of (Array.isArray(relations.character_relations) ? relations.character_relations : [])) {
|
|
487
|
+
if (!row || !row.from || !row.to) continue;
|
|
488
|
+
const id = 'REL' + pad3(++relationSeq);
|
|
489
|
+
addPage(pages, byId, {
|
|
490
|
+
type: TYPE.relation,
|
|
491
|
+
id,
|
|
492
|
+
title: row.from + '-' + row.to,
|
|
493
|
+
frontmatter: { from: row.from, to: row.to, kind: row.relation_kind, label: row.relation_label || undefined },
|
|
494
|
+
body: relationBody(row),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 势力关系 -> faction_relation page
|
|
499
|
+
let factionRelationSeq = 0;
|
|
500
|
+
for (const row of (Array.isArray(relations.faction_relations) ? relations.faction_relations : [])) {
|
|
501
|
+
if (!row || !row.from || !row.to) continue;
|
|
502
|
+
const id = 'FREL' + pad3(++factionRelationSeq);
|
|
503
|
+
addPage(pages, byId, {
|
|
504
|
+
type: TYPE.faction_relation,
|
|
505
|
+
id,
|
|
506
|
+
title: row.from + '-' + row.to,
|
|
507
|
+
frontmatter: { from: row.from, to: row.to, kind: row.relation_type },
|
|
508
|
+
body: factionRelationBody(row),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// 势力成员 -> membership page + 角色 faction 字段
|
|
513
|
+
let membershipSeq = 0;
|
|
514
|
+
for (const row of (Array.isArray(relations.faction_membership) ? relations.faction_membership : [])) {
|
|
515
|
+
if (!row || !row.character || !row.faction) continue;
|
|
516
|
+
const id = 'MEM' + pad3(++membershipSeq);
|
|
517
|
+
addPage(pages, byId, {
|
|
518
|
+
type: TYPE.membership,
|
|
519
|
+
id,
|
|
520
|
+
title: row.character + '@' + row.faction,
|
|
521
|
+
frontmatter: {
|
|
522
|
+
character: row.character,
|
|
523
|
+
faction: row.faction,
|
|
524
|
+
kind: row.member_relation,
|
|
525
|
+
position: row.position_level,
|
|
526
|
+
},
|
|
527
|
+
body: textBody(row.character + '@' + row.faction, '成员关系:' + row.member_relation),
|
|
528
|
+
});
|
|
529
|
+
const character = byId.get(row.character);
|
|
530
|
+
if (character && !character.frontmatter.faction) {
|
|
531
|
+
character.frontmatter.faction = row.faction;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 冲突参与方 -> conflict.characters
|
|
536
|
+
for (const row of (Array.isArray(relations.conflict_participants) ? relations.conflict_participants : [])) {
|
|
537
|
+
if (!row || !row.conflict) continue;
|
|
538
|
+
const ids = [];
|
|
539
|
+
for (const value of [row.protagonist_side, row.main_antagonist, row.secondary_antagonist, row.third_party]) {
|
|
540
|
+
if (Array.isArray(value)) ids.push(...value);
|
|
541
|
+
else if (value) ids.push(value);
|
|
542
|
+
}
|
|
543
|
+
const conflict = byId.get(row.conflict);
|
|
544
|
+
if (conflict) conflict.frontmatter.characters = [...new Set(ids)];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// 支线主角
|
|
548
|
+
for (const row of (Array.isArray(relations.branch_protagonist) ? relations.branch_protagonist : [])) {
|
|
549
|
+
const branch = row && byId.get(row.branch);
|
|
550
|
+
if (branch && row.character) branch.frontmatter.protagonist = row.character;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// 道具归属
|
|
554
|
+
for (const row of (Array.isArray(relations.item_owner) ? relations.item_owner : [])) {
|
|
555
|
+
const item = row && byId.get(row.item);
|
|
556
|
+
if (!item || !row.owner_id) continue;
|
|
557
|
+
if (String(row.owner_id).startsWith('C')) item.frontmatter.owner_character = row.owner_id;
|
|
558
|
+
else if (String(row.owner_id).startsWith('F')) item.frontmatter.owner_faction = row.owner_id;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// 地点冲突
|
|
562
|
+
for (const row of (Array.isArray(relations.location_conflict) ? relations.location_conflict : [])) {
|
|
563
|
+
const location = row && byId.get(row.location);
|
|
564
|
+
if (location) location.frontmatter.conflicts = Array.isArray(row.conflicts) ? row.conflicts : [];
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 结局角色
|
|
568
|
+
for (const row of (Array.isArray(relations.ending_character) ? relations.ending_character : [])) {
|
|
569
|
+
const ending = row && byId.get(row.ending);
|
|
570
|
+
if (ending) ending.frontmatter.characters = row.character ? [row.character] : [];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// 支线挂主线
|
|
574
|
+
for (const row of (Array.isArray(relations.main_line_branch_relation) ? relations.main_line_branch_relation : [])) {
|
|
575
|
+
const branch = row && byId.get(row.branch);
|
|
576
|
+
if (branch) branch.frontmatter.main_line = row.main_line;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// 主线/支线跨卷
|
|
580
|
+
for (const span of (Array.isArray(hierarchy.main_line_span) ? hierarchy.main_line_span : [])) {
|
|
581
|
+
const mainLine = span && byId.get(span.main_line);
|
|
582
|
+
if (!mainLine) continue;
|
|
583
|
+
mainLine.frontmatter.volumes = (span.volume_ratio || [])
|
|
584
|
+
.map((ratio, index) => (ratio > 0 ? volumeIds[index] : null))
|
|
585
|
+
.filter(Boolean);
|
|
586
|
+
}
|
|
587
|
+
for (const span of (Array.isArray(hierarchy.branch_span) ? hierarchy.branch_span : [])) {
|
|
588
|
+
const branch = span && byId.get(span.branch);
|
|
589
|
+
if (!branch) continue;
|
|
590
|
+
branch.frontmatter.volumes = (span.volume_ratio || [])
|
|
591
|
+
.map((ratio, index) => (ratio > 0 ? volumeIds[index] : null))
|
|
592
|
+
.filter(Boolean);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return pages;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const FLESH_SPECS = [
|
|
599
|
+
['角色.json', 'characters', 'character'],
|
|
600
|
+
['其他实体.json', 'other_entities', 'other_entity'],
|
|
601
|
+
['冲突.json', 'conflicts', 'conflict'],
|
|
602
|
+
['势力.json', 'factions', 'faction'],
|
|
603
|
+
['剧情.json', 'main_lines', 'main_line'],
|
|
604
|
+
['剧情.json', 'branches', 'branch'],
|
|
605
|
+
['地点.json', 'locations', 'location'],
|
|
606
|
+
['结局.json', 'endings', 'ending'],
|
|
607
|
+
['世界观规则.json', 'core_rules', 'world_rule'],
|
|
608
|
+
['世界观规则.json', 'information_rules', 'world_rule'],
|
|
609
|
+
['世界观规则.json', 'social_rules', 'world_rule'],
|
|
610
|
+
['关键道具.json', 'core_items', 'item'],
|
|
611
|
+
['关键道具.json', 'branch_items', 'item'],
|
|
612
|
+
['关键道具.json', 'symbolic_items', 'item'],
|
|
613
|
+
];
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* 血肉阶段:把详情写回骨架已创建的实体页;preserveFrontmatter=true。
|
|
617
|
+
*/
|
|
618
|
+
export function pagesFromFlesh(projectRoot) {
|
|
619
|
+
const dir = join(projectRoot, ...SOURCES, '血肉');
|
|
620
|
+
const skeletonRelations = readJson(join(projectRoot, ...SOURCES, '骨架', '关系.json')) || {};
|
|
621
|
+
const pages = [];
|
|
622
|
+
const byId = new Map();
|
|
623
|
+
let found = false;
|
|
624
|
+
for (const [file, arrayKey, type] of FLESH_SPECS) {
|
|
625
|
+
const data = readJson(join(dir, file));
|
|
626
|
+
if (data) found = true;
|
|
627
|
+
const rows = data && Array.isArray(data[arrayKey]) ? data[arrayKey] : [];
|
|
628
|
+
for (const row of rows) {
|
|
629
|
+
if (!row || !row.id) continue;
|
|
630
|
+
addPage(pages, byId, {
|
|
631
|
+
type: pageType(type),
|
|
632
|
+
id: row.id,
|
|
633
|
+
title: row.name || row.id,
|
|
634
|
+
frontmatter: {},
|
|
635
|
+
body: type === 'conflict' ? conflictBody(row)
|
|
636
|
+
: (type === 'other_entity' ? otherEntityBody(row, skeletonRelations.other_relations || [])
|
|
637
|
+
: (type === 'character' ? characterBody(row, skeletonRelations.other_relations || []) : objectBody(row.name || row.id, row))),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return found ? pages : [];
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function actMap(projectRoot) {
|
|
645
|
+
const hierarchy = readJson(join(projectRoot, ...SOURCES, '骨架', '层级.json')) || {};
|
|
646
|
+
const map = new Map();
|
|
647
|
+
const byVolume = new Map();
|
|
648
|
+
let seq = 0;
|
|
649
|
+
for (const act of (Array.isArray(hierarchy.acts) ? hierarchy.acts : [])) {
|
|
650
|
+
if (!act || !act.volume) continue;
|
|
651
|
+
const id = 'A' + pad3(++seq);
|
|
652
|
+
map.set(act.volume + ':' + act.act_index, id);
|
|
653
|
+
if (!byVolume.has(act.volume)) byVolume.set(act.volume, []);
|
|
654
|
+
byVolume.get(act.volume).push(id);
|
|
655
|
+
}
|
|
656
|
+
return { map, byVolume };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function outlineRequirementSpecs(outline) {
|
|
660
|
+
if (!outline || typeof outline !== 'object') return [];
|
|
661
|
+
const fields = [
|
|
662
|
+
['function_main', 'function', '主功能'],
|
|
663
|
+
['function_aux', 'function', '辅功能'],
|
|
664
|
+
['emotion_phase', 'emotion', '情绪阶段'],
|
|
665
|
+
['foreshadow_action', 'foreshadow', '伏笔动作'],
|
|
666
|
+
['faction_appearance', 'faction', '势力出场'],
|
|
667
|
+
['hook_head', 'hook_head', '章首钩子'],
|
|
668
|
+
['hook_tail', 'hook_tail', '章尾钩子'],
|
|
669
|
+
['satisfaction_type', 'pleasure', '爽点'],
|
|
670
|
+
];
|
|
671
|
+
return fields
|
|
672
|
+
.filter(([key]) => typeof outline[key] === 'string' && outline[key].trim())
|
|
673
|
+
.map(([key, kind, title]) => ({ kind, title, text: outline[key].trim() }));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* 节奏阶段:章节 / 细纲要求 / 场景。
|
|
678
|
+
*/
|
|
679
|
+
export function pagesFromRhythm(projectRoot) {
|
|
680
|
+
const dir = join(projectRoot, ...SOURCES, '节奏');
|
|
681
|
+
const plan = readJson(join(dir, '章节规划.json')) || {};
|
|
682
|
+
const timeline = readJson(join(dir, '时序映射.json')) || {};
|
|
683
|
+
const outlines = readJson(join(dir, '章节细纲.json')) || {};
|
|
684
|
+
const { map: actByVolumeIndex, byVolume } = actMap(projectRoot);
|
|
685
|
+
const fallbackAct = actByVolumeIndex.values().next().value || '';
|
|
686
|
+
if (!plan || !Array.isArray(plan.chapters) || plan.chapters.length === 0) {
|
|
687
|
+
return { pages: [], chapterIds: [], outlineByChapter: new Map() };
|
|
688
|
+
}
|
|
689
|
+
const pages = [];
|
|
690
|
+
const byId = new Map();
|
|
691
|
+
const chapterIds = [];
|
|
692
|
+
const chaptersByAct = new Map();
|
|
693
|
+
const outlineByChapter = new Map();
|
|
694
|
+
for (const outline of (Array.isArray(outlines.outlines) ? outlines.outlines : [])) {
|
|
695
|
+
if (outline && typeof outline.ch === 'number') outlineByChapter.set(outline.ch, outline);
|
|
696
|
+
}
|
|
697
|
+
const hooksByChapter = new Map();
|
|
698
|
+
for (const hook of (Array.isArray(timeline.hook_timeline) ? timeline.hook_timeline : [])) {
|
|
699
|
+
if (hook && typeof hook.ch === 'number') hooksByChapter.set(hook.ch, hook);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
for (const row of (Array.isArray(plan.chapters) ? plan.chapters : [])) {
|
|
703
|
+
if (!row || typeof row.ch !== 'number') continue;
|
|
704
|
+
const id = 'CH' + pad3(row.ch);
|
|
705
|
+
chapterIds.push(id);
|
|
706
|
+
const actId = actByVolumeIndex.get(row.volume + ':' + row.act) || (byVolume.get(row.volume) || [])[0] || fallbackAct;
|
|
707
|
+
if (actId) {
|
|
708
|
+
if (!chaptersByAct.has(actId)) chaptersByAct.set(actId, []);
|
|
709
|
+
chaptersByAct.get(actId).push(id);
|
|
710
|
+
}
|
|
711
|
+
const outline = outlineByChapter.get(row.ch);
|
|
712
|
+
const hook = hooksByChapter.get(row.ch);
|
|
713
|
+
const requirements = [];
|
|
714
|
+
const scenes = [];
|
|
715
|
+
// 新版每章有独立细纲文件时,不再为每个字段生成一条“细纲要求”页:
|
|
716
|
+
// 那些页面是旧版按字段拆分的视图,会被章节细纲页取代,数量还很多。
|
|
717
|
+
const hasBrief = existsSync(join(dir, '细纲', '第' + pad3(row.ch) + '章.json'));
|
|
718
|
+
const specs = hasBrief ? [] : outlineRequirementSpecs(outline);
|
|
719
|
+
if (!hasBrief && specs.length === 0 && hook) {
|
|
720
|
+
if (hook.hook_head) specs.push({ kind: 'hook_head', title: '章首钩子', text: hook.hook_head });
|
|
721
|
+
if (hook.hook_tail) specs.push({ kind: 'hook_tail', title: '章尾钩子', text: hook.hook_tail });
|
|
722
|
+
}
|
|
723
|
+
specs.forEach((spec, index) => {
|
|
724
|
+
const reqId = 'RQ-' + id + '-' + String(index + 1).padStart(2, '0');
|
|
725
|
+
requirements.push(reqId);
|
|
726
|
+
addPage(pages, byId, {
|
|
727
|
+
type: TYPE.requirement,
|
|
728
|
+
id: reqId,
|
|
729
|
+
title: id + ' ' + spec.title,
|
|
730
|
+
frontmatter: { chapter: id, kind: spec.kind, text: spec.text, status: 'planned' },
|
|
731
|
+
body: textBody(id + ' ' + spec.title, spec.text),
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
if (row.scene_focus) {
|
|
735
|
+
const sceneId = 'SCN-' + id + '-01';
|
|
736
|
+
scenes.push(sceneId);
|
|
737
|
+
addPage(pages, byId, {
|
|
738
|
+
type: TYPE.scene,
|
|
739
|
+
id: sceneId,
|
|
740
|
+
title: id + ' 场景',
|
|
741
|
+
frontmatter: { chapter: id, focus: row.scene_focus },
|
|
742
|
+
body: textBody(id + ' 场景', row.scene_focus),
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
addPage(pages, byId, {
|
|
746
|
+
type: TYPE.chapter,
|
|
747
|
+
id,
|
|
748
|
+
title: '第' + row.ch + '章',
|
|
749
|
+
frontmatter: {
|
|
750
|
+
act: actId,
|
|
751
|
+
volume: row.volume,
|
|
752
|
+
requirements,
|
|
753
|
+
scenes,
|
|
754
|
+
brief: existsSync(join(dir, '细纲', '第' + pad3(row.ch) + '章.json')) ? 'OB-' + id : undefined,
|
|
755
|
+
word_target: outline && typeof outline.word_target === 'number' ? outline.word_target : 0,
|
|
756
|
+
},
|
|
757
|
+
body: textBody('第' + row.ch + '章', row.scene_focus || ''),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const chapterVolume = new Map();
|
|
762
|
+
for (const row of (Array.isArray(plan.chapters) ? plan.chapters : [])) {
|
|
763
|
+
if (row && typeof row.ch === 'number') chapterVolume.set('CH' + pad3(row.ch), row.volume);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// 角色状态 / 弧光:把 character_timeline 的出场区间和 arc_stages 落成对象页。
|
|
767
|
+
for (const row of (Array.isArray(timeline.character_timeline) ? timeline.character_timeline : [])) {
|
|
768
|
+
if (!row || !row.char_id || typeof row.appear_start !== 'number') continue;
|
|
769
|
+
const startChapter = 'CH' + pad3(row.appear_start);
|
|
770
|
+
const startState = 'CS-' + row.char_id + '-' + startChapter;
|
|
771
|
+
addPage(pages, byId, {
|
|
772
|
+
type: TYPE.character_state,
|
|
773
|
+
id: startState,
|
|
774
|
+
title: row.char_id + '@' + startChapter,
|
|
775
|
+
frontmatter: { character: row.char_id, chapter: startChapter },
|
|
776
|
+
body: textBody(row.char_id + '@' + startChapter, '出场区间:第 ' + row.appear_start + '–' + row.appear_end + ' 章'),
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
for (const stage of (Array.isArray(row.arc_stages) ? row.arc_stages : [])) {
|
|
780
|
+
if (!stage || typeof stage.chapter !== 'number') continue;
|
|
781
|
+
const stageChapter = 'CH' + pad3(stage.chapter);
|
|
782
|
+
const stageState = 'CS-' + row.char_id + '-' + stageChapter;
|
|
783
|
+
addPage(pages, byId, {
|
|
784
|
+
type: TYPE.character_state,
|
|
785
|
+
id: stageState,
|
|
786
|
+
title: row.char_id + '@' + stageChapter,
|
|
787
|
+
frontmatter: { character: row.char_id, chapter: stageChapter },
|
|
788
|
+
body: textBody(row.char_id + '@' + stageChapter, stage.stage_name || ''),
|
|
789
|
+
});
|
|
790
|
+
const volume = chapterVolume.get(stageChapter) || chapterVolume.get(startChapter) || '';
|
|
791
|
+
if (!volume) continue;
|
|
792
|
+
const arcId = 'ARC-' + row.char_id + '-' + volume;
|
|
793
|
+
let arc = byId.get(arcId);
|
|
794
|
+
if (!arc) {
|
|
795
|
+
arc = {
|
|
796
|
+
type: TYPE.arc,
|
|
797
|
+
id: arcId,
|
|
798
|
+
title: row.char_id + '@' + volume,
|
|
799
|
+
frontmatter: { character: row.char_id, volume, stages: [] },
|
|
800
|
+
body: textBody(row.char_id + '@' + volume, ''),
|
|
801
|
+
};
|
|
802
|
+
addPage(pages, byId, arc);
|
|
803
|
+
}
|
|
804
|
+
if (!arc.frontmatter.stages.includes(stageState)) arc.frontmatter.stages.push(stageState);
|
|
805
|
+
arc.body = textBody(row.char_id + '@' + volume, (arc.frontmatter.stages || []).join(' → '));
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// 伏笔/高潮/爽点/结局的章节位置在骨架里没有,只在节奏的时序映射里出现。
|
|
810
|
+
// 用骨架实体页做底稿,补上 chapter 链接后重新 upsert,impact 才能从这些
|
|
811
|
+
// 对象走到章节。
|
|
812
|
+
const chapterLink = (value) =>
|
|
813
|
+
typeof value === 'number' ? 'CH' + pad3(value) : undefined;
|
|
814
|
+
const baseEntities = new Map();
|
|
815
|
+
for (const page of pagesFromSkeleton(projectRoot)) {
|
|
816
|
+
baseEntities.set(page.id, page);
|
|
817
|
+
}
|
|
818
|
+
// 节奏阶段重新物化页面时,实体页要以血肉详情为底稿;
|
|
819
|
+
// 只用骨架页会把 traits / description / abilities 覆盖回空壳。
|
|
820
|
+
for (const page of pagesFromFlesh(projectRoot)) {
|
|
821
|
+
baseEntities.set(page.id, page);
|
|
822
|
+
}
|
|
823
|
+
const updateEntity = (id, updates) => {
|
|
824
|
+
const base = baseEntities.get(id);
|
|
825
|
+
if (!base) return;
|
|
826
|
+
const frontmatter = { ...base.frontmatter };
|
|
827
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
828
|
+
if (value !== undefined) frontmatter[key] = value;
|
|
829
|
+
}
|
|
830
|
+
addPage(pages, byId, {
|
|
831
|
+
type: base.type,
|
|
832
|
+
id: base.id,
|
|
833
|
+
title: base.title,
|
|
834
|
+
frontmatter,
|
|
835
|
+
body: base.body,
|
|
836
|
+
});
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
for (const row of (Array.isArray(timeline.foreshadow_timeline) ? timeline.foreshadow_timeline : [])) {
|
|
840
|
+
if (!row || !row.fs_id) continue;
|
|
841
|
+
updateEntity(row.fs_id, {
|
|
842
|
+
buried_chapter: chapterLink(row.buried_chapter),
|
|
843
|
+
recovered_chapter: chapterLink(row.recovered_chapter),
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
for (const row of (Array.isArray(timeline.climax_timeline) ? timeline.climax_timeline : [])) {
|
|
847
|
+
if (!row || !row.climax_id) continue;
|
|
848
|
+
updateEntity(row.climax_id, { chapter: chapterLink(row.chapter) });
|
|
849
|
+
}
|
|
850
|
+
for (const row of (Array.isArray(timeline.pleasure_timeline) ? timeline.pleasure_timeline : [])) {
|
|
851
|
+
if (!row || !row.pleasure_id) continue;
|
|
852
|
+
updateEntity(row.pleasure_id, { chapter: chapterLink(row.chapter) });
|
|
853
|
+
}
|
|
854
|
+
for (const row of (Array.isArray(timeline.ending_timeline) ? timeline.ending_timeline : [])) {
|
|
855
|
+
if (!row || !row.ending_id) continue;
|
|
856
|
+
updateEntity(row.ending_id, { chapter: chapterLink(row.chapter) });
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// 时间线事件:主线关键节点、冲突升级节点、支线交织点。
|
|
860
|
+
let timelineSeq = 0;
|
|
861
|
+
const nextTimelineId = () => 'T' + pad3(++timelineSeq);
|
|
862
|
+
for (const row of (Array.isArray(timeline.main_line_timeline) ? timeline.main_line_timeline : [])) {
|
|
863
|
+
if (!row || !row.main_id) continue;
|
|
864
|
+
for (const node of (Array.isArray(row.key_nodes) ? row.key_nodes : [])) {
|
|
865
|
+
if (!node || typeof node.chapter !== 'number') continue;
|
|
866
|
+
const id = nextTimelineId();
|
|
867
|
+
addPage(pages, byId, {
|
|
868
|
+
type: TYPE.timeline_event,
|
|
869
|
+
id,
|
|
870
|
+
title: '主线 ' + row.main_id + ' 第' + node.chapter + '章',
|
|
871
|
+
frontmatter: {
|
|
872
|
+
chapter: 'CH' + pad3(node.chapter),
|
|
873
|
+
main_line: row.main_id,
|
|
874
|
+
description: node.event_ref || '',
|
|
875
|
+
},
|
|
876
|
+
body: textBody('主线 ' + row.main_id + ' 第' + node.chapter + '章', node.event_ref || ''),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
for (const row of (Array.isArray(timeline.conflict_timeline) ? timeline.conflict_timeline : [])) {
|
|
881
|
+
if (!row || !row.conflict_id) continue;
|
|
882
|
+
for (const node of (Array.isArray(row.escalation_nodes) ? row.escalation_nodes : [])) {
|
|
883
|
+
if (!node || typeof node.chapter !== 'number') continue;
|
|
884
|
+
const id = nextTimelineId();
|
|
885
|
+
addPage(pages, byId, {
|
|
886
|
+
type: TYPE.timeline_event,
|
|
887
|
+
id,
|
|
888
|
+
title: row.conflict_id + ' 第' + node.chapter + '章',
|
|
889
|
+
frontmatter: {
|
|
890
|
+
chapter: 'CH' + pad3(node.chapter),
|
|
891
|
+
conflict: row.conflict_id,
|
|
892
|
+
description: node.node_type || '',
|
|
893
|
+
},
|
|
894
|
+
body: textBody(row.conflict_id + ' 第' + node.chapter + '章', node.node_type || ''),
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
for (const row of (Array.isArray(timeline.branch_timeline) ? timeline.branch_timeline : [])) {
|
|
899
|
+
if (!row || !row.branch_id) continue;
|
|
900
|
+
for (const node of (Array.isArray(row.interweaves) ? row.interweaves : [])) {
|
|
901
|
+
if (!node || typeof node.chapter !== 'number') continue;
|
|
902
|
+
const id = nextTimelineId();
|
|
903
|
+
addPage(pages, byId, {
|
|
904
|
+
type: TYPE.timeline_event,
|
|
905
|
+
id,
|
|
906
|
+
title: row.branch_id + ' 第' + node.chapter + '章',
|
|
907
|
+
frontmatter: {
|
|
908
|
+
chapter: 'CH' + pad3(node.chapter),
|
|
909
|
+
branch: row.branch_id,
|
|
910
|
+
description: node.description || '',
|
|
911
|
+
},
|
|
912
|
+
body: textBody(row.branch_id + ' 第' + node.chapter + '章', node.description || ''),
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// 关系页补上阶段变化:骨架只给初始关系,节奏阶段才知道从第几章开始变。
|
|
918
|
+
const fleshCharsRaw = readJson(join(projectRoot, ...SOURCES, '血肉', '角色.json')) || {};
|
|
919
|
+
const fleshChangesByPair = new Map();
|
|
920
|
+
for (const c of (Array.isArray(fleshCharsRaw.characters) ? fleshCharsRaw.characters : [])) {
|
|
921
|
+
if (!c || !c.id) continue;
|
|
922
|
+
for (const rel of (Array.isArray(c.relationships) ? c.relationships : [])) {
|
|
923
|
+
if (!rel || !Array.isArray(rel.changes) || rel.changes.length === 0) continue;
|
|
924
|
+
const target = (Array.isArray(fleshCharsRaw.characters) ? fleshCharsRaw.characters : [])
|
|
925
|
+
.find((x) => x && x.name === rel.target_name);
|
|
926
|
+
if (!target || !target.id) continue;
|
|
927
|
+
const key = [c.id, target.id].sort().join('↔');
|
|
928
|
+
if (!fleshChangesByPair.has(key)) fleshChangesByPair.set(key, []);
|
|
929
|
+
fleshChangesByPair.get(key).push(...rel.changes);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
for (const base of baseEntities.values()) {
|
|
933
|
+
if (!base || base.type !== TYPE.relation) continue;
|
|
934
|
+
const from = base.frontmatter && base.frontmatter.from;
|
|
935
|
+
const to = base.frontmatter && base.frontmatter.to;
|
|
936
|
+
if (!from || !to) continue;
|
|
937
|
+
const key = [from, to].sort().join('↔');
|
|
938
|
+
const rows = (timeline.relationship_timeline || []).filter((r) =>
|
|
939
|
+
r && [r.from, r.to].sort().join('↔') === key);
|
|
940
|
+
addPage(pages, byId, {
|
|
941
|
+
type: TYPE.relation,
|
|
942
|
+
id: base.id,
|
|
943
|
+
title: base.title,
|
|
944
|
+
frontmatter: { ...base.frontmatter },
|
|
945
|
+
body: relationBody({ from, to, relation_kind: base.frontmatter.kind, relation_label: base.frontmatter.label }, rows, fleshChangesByPair.get(key) || []),
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// 势力关系同样按节奏时间线补阶段;骨架只给初始关系类型。
|
|
950
|
+
for (const base of baseEntities.values()) {
|
|
951
|
+
if (!base || base.type !== TYPE.faction_relation) continue;
|
|
952
|
+
const from = base.frontmatter && base.frontmatter.from;
|
|
953
|
+
const to = base.frontmatter && base.frontmatter.to;
|
|
954
|
+
if (!from || !to) continue;
|
|
955
|
+
const key = [from, to].sort().join('↔');
|
|
956
|
+
const rows = (timeline.faction_relationship_timeline || []).filter((r) =>
|
|
957
|
+
r && [r.from, r.to].sort().join('↔') === key);
|
|
958
|
+
addPage(pages, byId, {
|
|
959
|
+
type: TYPE.faction_relation,
|
|
960
|
+
id: base.id,
|
|
961
|
+
title: base.title,
|
|
962
|
+
frontmatter: { ...base.frontmatter },
|
|
963
|
+
body: factionRelationBody({ from, to, relation_type: base.frontmatter.kind }, rows),
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// 角色 / 地点 / 道具的出场区间也写到实体页上。
|
|
968
|
+
// 这样写作阶段不只知道“关系怎么变”,还知道“这个对象哪几章能用”。
|
|
969
|
+
const appendEntityTimeline = (base, entries) => {
|
|
970
|
+
if (!base) return;
|
|
971
|
+
addPage(pages, byId, {
|
|
972
|
+
type: base.type,
|
|
973
|
+
id: base.id,
|
|
974
|
+
title: base.title,
|
|
975
|
+
frontmatter: { ...base.frontmatter },
|
|
976
|
+
body: appendTimelineSection(base.body, entries),
|
|
977
|
+
});
|
|
978
|
+
};
|
|
979
|
+
for (const row of (timeline.character_timeline || [])) {
|
|
980
|
+
if (!row || !row.char_id) continue;
|
|
981
|
+
const base = baseEntities.get(row.char_id);
|
|
982
|
+
if (!base) continue;
|
|
983
|
+
const entries = [];
|
|
984
|
+
if (typeof row.appear_start === 'number' && typeof row.appear_end === 'number') {
|
|
985
|
+
entries.push('- 出场区间:第 ' + row.appear_start + '–' + row.appear_end + ' 章。');
|
|
986
|
+
}
|
|
987
|
+
if (typeof row.exit_chapter === 'number') {
|
|
988
|
+
entries.push('- 退场章:第 ' + row.exit_chapter + ' 章;退场后不得再出场。');
|
|
989
|
+
}
|
|
990
|
+
for (const stage of (row.arc_stages || [])) {
|
|
991
|
+
if (stage && stage.stage_name && typeof stage.chapter === 'number') {
|
|
992
|
+
entries.push('- 第 ' + stage.chapter + ' 章起:弧光进入「' + stage.stage_name + '」。');
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (entries.length) {
|
|
996
|
+
entries.push('- 写作提示:出场区间外不要无故让该角色出现;阶段未到之前不要提前写下一阶段的心态。');
|
|
997
|
+
appendEntityTimeline(base, entries);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
for (const [key, idField, label] of [
|
|
1001
|
+
['location_timeline', 'loc_id', '地点'],
|
|
1002
|
+
['item_timeline', 'item_id', '道具'],
|
|
1003
|
+
]) {
|
|
1004
|
+
for (const row of (timeline[key] || [])) {
|
|
1005
|
+
if (!row || !row[idField]) continue;
|
|
1006
|
+
const base = baseEntities.get(row[idField]);
|
|
1007
|
+
if (!base) continue;
|
|
1008
|
+
if (typeof row.appear_start !== 'number' || typeof row.appear_end !== 'number') continue;
|
|
1009
|
+
appendEntityTimeline(base, [
|
|
1010
|
+
'- 可用区间:第 ' + row.appear_start + '–' + row.appear_end + ' 章。',
|
|
1011
|
+
'- 写作提示:区间外不要把' + label + '当作当前场景/在手道具;需要提前出现时必须先补节奏时间线。',
|
|
1012
|
+
]);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// 幕页补上章节列表:impact 从幕可以直接走到章节,而不是绕回卷。
|
|
1017
|
+
for (const [actId, ids] of chaptersByAct) {
|
|
1018
|
+
updateEntity(actId, { chapters: ids });
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return { pages, chapterIds, outlineByChapter };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* 独立档案 / 每章细纲:把 设定/raw/sources/档案 与 节奏/细纲 变成页面。
|
|
1027
|
+
*
|
|
1028
|
+
* 这些页面和骨架/血肉/节奏页共用 ID:
|
|
1029
|
+
* - 伏笔/爽点/冲突页会被后写入的档案 body 补全;
|
|
1030
|
+
* - 信息差、章节细纲是新页面类型。
|
|
1031
|
+
*/
|
|
1032
|
+
|
|
1033
|
+
function statusCn(value) {
|
|
1034
|
+
const map = {
|
|
1035
|
+
planned: '计划中', open: '未解决', created: '已创建', resolved: '已解决',
|
|
1036
|
+
invalid: '异常', draft: '草稿', done: '完成', revised: '已修订', stub: '占位', archived: '归档'
|
|
1037
|
+
};
|
|
1038
|
+
return map[String(value || '')] || String(value || '');
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function briefBody(row) {
|
|
1042
|
+
const b = row || {};
|
|
1043
|
+
const lines = ['# 第' + (b.ch || '?') + '章 细纲', ''];
|
|
1044
|
+
lines.push('## 基本信息');
|
|
1045
|
+
lines.push('- 章节:第 ' + (b.ch || '?') + ' 章');
|
|
1046
|
+
lines.push('- 卷 / 幕:' + (b.volume || '未指定') + ' / ' + (b.act || '未指定'));
|
|
1047
|
+
lines.push('- 视角:' + (b.pov_name || b.pov || '未指定'));
|
|
1048
|
+
if (b.scene_focus) lines.push('- 场景焦点:' + b.scene_focus);
|
|
1049
|
+
if (b.function_main) lines.push('- 主功能:' + b.function_main);
|
|
1050
|
+
if (b.function_aux) lines.push('- 辅助功能:' + b.function_aux);
|
|
1051
|
+
if (b.emotion) lines.push('- 情绪:' + (b.emotion.phase || '') + (b.emotion.value !== null && b.emotion.value !== undefined ? '(强度 ' + b.emotion.value + ')' : ''));
|
|
1052
|
+
if (b.word_target) lines.push('- 目标字数:' + b.word_target);
|
|
1053
|
+
if (b.hooks) lines.push('- 钩子:章首 ' + (b.hooks.head || '未指定') + ' / 章尾 ' + (b.hooks.tail || '未指定') + (b.hooks.strength ? '(强度 ' + b.hooks.strength + ')' : ''));
|
|
1054
|
+
lines.push('');
|
|
1055
|
+
|
|
1056
|
+
const cast = Array.isArray(b.cast) ? b.cast : [];
|
|
1057
|
+
if (cast.length) {
|
|
1058
|
+
lines.push('## 出场角色');
|
|
1059
|
+
for (const c of cast) lines.push('- ' + (c.name || c.id) + '(' + c.id + '):' + (c.role || '') + (c.reason ? ',' + c.reason : ''));
|
|
1060
|
+
lines.push('');
|
|
1061
|
+
}
|
|
1062
|
+
if (b.location) lines.push('## 场景\n- ' + (b.location.name || b.location.id) + '(' + b.location.id + ')\n');
|
|
1063
|
+
if (Array.isArray(b.items) && b.items.length) {
|
|
1064
|
+
lines.push('## 关键道具');
|
|
1065
|
+
for (const x of b.items) lines.push('- ' + (x.name || x.id) + '(' + x.id + ')');
|
|
1066
|
+
lines.push('');
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const events = Array.isArray(b.timeline_events) ? b.timeline_events : [];
|
|
1070
|
+
if (events.length) {
|
|
1071
|
+
lines.push('## 本章时间线');
|
|
1072
|
+
for (const e of events) lines.push('- ' + (e.type || '事件') + ':' + (e.description || e.ref || ''));
|
|
1073
|
+
lines.push('');
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const relChanges = Array.isArray(b.relationship_changes) ? b.relationship_changes : [];
|
|
1077
|
+
if (relChanges.length) {
|
|
1078
|
+
lines.push('## 本章关系变化');
|
|
1079
|
+
for (const r of relChanges) {
|
|
1080
|
+
lines.push('- ' + (r.from_name || r.from) + ' → ' + (r.to_name || r.to) + ':' +
|
|
1081
|
+
(r.stage ? '【' + r.stage + '】' : '') + (r.state || '') +
|
|
1082
|
+
(r.trigger_event ? ';触发:' + r.trigger_event : ''));
|
|
1083
|
+
}
|
|
1084
|
+
lines.push('');
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const conflicts = Array.isArray(b.conflicts) ? b.conflicts : [];
|
|
1088
|
+
if (conflicts.length) {
|
|
1089
|
+
lines.push('## 本章冲突');
|
|
1090
|
+
for (const x of conflicts) lines.push('- ' + (x.name || x.id) + '(' + x.id + '):' + (x.node_type || '推进'));
|
|
1091
|
+
lines.push('');
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const fsActions = Array.isArray(b.foreshadow_actions) ? b.foreshadow_actions : [];
|
|
1095
|
+
if (fsActions.length) {
|
|
1096
|
+
lines.push('## 本章伏笔动作');
|
|
1097
|
+
for (const x of fsActions) lines.push('- ' + (x.action || '处理') + ' ' + (x.id || '') + '(' + (x.name || '') + ')');
|
|
1098
|
+
lines.push('');
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const big = [];
|
|
1102
|
+
if (b.climax) big.push('高潮:' + (b.climax.name || b.climax.id) + '(' + b.climax.id + ')');
|
|
1103
|
+
if (b.pleasure) big.push('爽点:' + (b.pleasure.name || b.pleasure.id) + '(' + b.pleasure.id + ')');
|
|
1104
|
+
if (b.ending) big.push('结局节点:' + (b.ending.name || b.ending.id) + '(' + b.ending.id + ')');
|
|
1105
|
+
if (big.length) lines.push('## 本章必须兑现\n' + big.map((x) => '- ' + x).join('\n') + '\n');
|
|
1106
|
+
|
|
1107
|
+
const gaps = Array.isArray(b.information_gaps) ? b.information_gaps : [];
|
|
1108
|
+
if (gaps.length) {
|
|
1109
|
+
lines.push('## 本章信息差');
|
|
1110
|
+
for (const g of gaps) {
|
|
1111
|
+
lines.push('- ' + (g.info_name || g.id) + '(' + g.id + ',' + (g.status === 'created' ? '本章创建' : g.status === 'resolved' ? '本章解决' : '仍未解决') + ')');
|
|
1112
|
+
if (g.who_knows && g.who_knows.length) lines.push(' - 已知:' + g.who_knows.join('、'));
|
|
1113
|
+
if (g.who_does_not_know && g.who_does_not_know.length) lines.push(' - 尚不知道:' + g.who_does_not_know.join('、'));
|
|
1114
|
+
if (g.resolution_method) lines.push(' - 解决方式:' + g.resolution_method);
|
|
1115
|
+
for (const line of (g.forbidden_mentions || [])) lines.push(' - 禁止:' + line);
|
|
1116
|
+
}
|
|
1117
|
+
lines.push('');
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const boundaries = Array.isArray(b.information_boundaries) ? b.information_boundaries : [];
|
|
1121
|
+
if (boundaries.length) {
|
|
1122
|
+
lines.push('## 角色信息边界(不能写漏)');
|
|
1123
|
+
for (const x of boundaries) {
|
|
1124
|
+
if (x.unknown_secrets) lines.push('- ' + (x.name || x.id) + '(' + x.id + ')不知道:' + x.unknown_secrets);
|
|
1125
|
+
if (x.false_beliefs) lines.push('- ' + (x.name || x.id) + '(' + x.id + ')误以为:' + x.false_beliefs);
|
|
1126
|
+
}
|
|
1127
|
+
lines.push('');
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const how = b.how_to_write || {};
|
|
1131
|
+
if (how && Object.keys(how).length) {
|
|
1132
|
+
lines.push('## 怎么写');
|
|
1133
|
+
if (how.pov_rule) lines.push('- 视角规则:' + how.pov_rule);
|
|
1134
|
+
if (how.opening && how.opening.technique) lines.push('- 开场技法(' + (how.opening.hook_type || '') + '):' + how.opening.technique);
|
|
1135
|
+
if (how.ending && how.ending.technique) lines.push('- 收束技法(' + (how.ending.hook_type || '') + '):' + how.ending.technique);
|
|
1136
|
+
for (const beat of (how.scene_beats || [])) {
|
|
1137
|
+
lines.push('- ' + (beat.name || '节拍') + ':' + (beat.goal || ''));
|
|
1138
|
+
for (const x of (beat.must || [])) lines.push(' - 必须:' + x);
|
|
1139
|
+
for (const x of (beat.avoid || [])) lines.push(' - 避免:' + x);
|
|
1140
|
+
}
|
|
1141
|
+
lines.push('');
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const must = Array.isArray(b.must_write) ? b.must_write : [];
|
|
1145
|
+
if (must.length) {
|
|
1146
|
+
lines.push('## 必须写');
|
|
1147
|
+
for (const x of must) lines.push('- ' + x);
|
|
1148
|
+
lines.push('');
|
|
1149
|
+
}
|
|
1150
|
+
const forbidden = Array.isArray(b.must_not_write) ? b.must_not_write : [];
|
|
1151
|
+
if (forbidden.length) {
|
|
1152
|
+
lines.push('## 绝对不能写');
|
|
1153
|
+
for (const x of forbidden) lines.push('- ' + x);
|
|
1154
|
+
lines.push('');
|
|
1155
|
+
}
|
|
1156
|
+
return lines.join('\n');
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function conflictBody(row) {
|
|
1160
|
+
const b = row || {};
|
|
1161
|
+
const lines = ['# ' + (b.name || b.id || '冲突'), ''];
|
|
1162
|
+
lines.push('## 基本信息');
|
|
1163
|
+
lines.push('- ID:' + (b.id || ''));
|
|
1164
|
+
if (b.type) lines.push('- 类型:' + b.type);
|
|
1165
|
+
if (b.scale) lines.push('- 范围:' + b.scale);
|
|
1166
|
+
if (b.participant_names && b.participant_names.length) lines.push('- 参与方:' + b.participant_names.join('、'));
|
|
1167
|
+
else if (b.participants && b.participants.length) lines.push('- 参与方:' + b.participants.join('、'));
|
|
1168
|
+
if (b.description) lines.push('- 描述:' + b.description);
|
|
1169
|
+
if (b.core_goal) lines.push('- 核心目标:' + b.core_goal);
|
|
1170
|
+
if (b.influence_scope) lines.push('- 影响范围:' + b.influence_scope);
|
|
1171
|
+
if (b.focus_points && b.focus_points.length) lines.push('- 焦点:' + b.focus_points.join('、'));
|
|
1172
|
+
lines.push('');
|
|
1173
|
+
|
|
1174
|
+
lines.push('## 矛盾结构');
|
|
1175
|
+
if (b.surface_contradiction) lines.push('- 表层矛盾:' + b.surface_contradiction);
|
|
1176
|
+
if (b.deep_contradiction) lines.push('- 深层矛盾:' + b.deep_contradiction);
|
|
1177
|
+
if (b.ultimate_contradiction) lines.push('- 终极矛盾:' + b.ultimate_contradiction);
|
|
1178
|
+
lines.push('');
|
|
1179
|
+
|
|
1180
|
+
const path = Array.isArray(b.escalation_path) ? b.escalation_path : [];
|
|
1181
|
+
if (path.length) {
|
|
1182
|
+
lines.push('## 升级路径(血肉内容)');
|
|
1183
|
+
for (const node of path) lines.push('- ' + (node.node_type || '节点') + ':' + (node.description || ''));
|
|
1184
|
+
lines.push('');
|
|
1185
|
+
}
|
|
1186
|
+
const nodes = Array.isArray(b.escalation_nodes) ? b.escalation_nodes : [];
|
|
1187
|
+
if (nodes.length) {
|
|
1188
|
+
lines.push('## 章节落点(节奏时间线)');
|
|
1189
|
+
for (const node of nodes) lines.push('- ' + (node.node_type || '节点') + ':第 ' + node.chapter + ' 章');
|
|
1190
|
+
if (typeof b.resolution_chapter === 'number') lines.push('- 解决章:第 ' + b.resolution_chapter + ' 章');
|
|
1191
|
+
lines.push('');
|
|
1192
|
+
}
|
|
1193
|
+
if (b.resolution_plan) {
|
|
1194
|
+
lines.push('## 收束方案');
|
|
1195
|
+
if (b.resolution_plan.method) lines.push('- 方式:' + b.resolution_plan.method);
|
|
1196
|
+
if (b.resolution_plan.residual_impact) lines.push('- 余波影响:' + b.resolution_plan.residual_impact);
|
|
1197
|
+
lines.push('');
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const gaps = Array.isArray(b.information_gaps) ? b.information_gaps : [];
|
|
1201
|
+
if (gaps.length) {
|
|
1202
|
+
lines.push('## 信息差');
|
|
1203
|
+
for (const g of gaps) {
|
|
1204
|
+
lines.push('- ' + (g.info_name || g.id) + '(' + (g.id || '') + ')');
|
|
1205
|
+
if (g.who_knows && g.who_knows.length) lines.push(' - 已知:' + g.who_knows.join('、'));
|
|
1206
|
+
if (g.who_does_not_know && g.who_does_not_know.length) lines.push(' - 尚不知道:' + g.who_does_not_know.join('、'));
|
|
1207
|
+
if (g.resolution_method) lines.push(' - 解决方式:' + g.resolution_method);
|
|
1208
|
+
if (typeof g.created === 'number') lines.push(' - 产生章:第 ' + g.created + ' 章');
|
|
1209
|
+
if (typeof g.resolved === 'number') lines.push(' - 解决章:第 ' + g.resolved + ' 章');
|
|
1210
|
+
for (const line of (g.forbidden_mentions || [])) lines.push(' - 禁止:' + line);
|
|
1211
|
+
}
|
|
1212
|
+
lines.push('');
|
|
1213
|
+
}
|
|
1214
|
+
return lines.join('\n');
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function infoGapBody(row) {
|
|
1218
|
+
const b = row || {};
|
|
1219
|
+
const lines = ['# ' + (b.info_name || b.id || '信息差'), ''];
|
|
1220
|
+
const hasCreated = typeof b.created === 'number';
|
|
1221
|
+
const hasResolved = typeof b.resolved === 'number';
|
|
1222
|
+
const created = hasCreated ? b.created : null;
|
|
1223
|
+
const resolved = hasResolved ? b.resolved : null;
|
|
1224
|
+
const activeEnd = created !== null
|
|
1225
|
+
? Math.max(created, (resolved !== null ? resolved : created + 1) - 1)
|
|
1226
|
+
: null;
|
|
1227
|
+
const activeRange = created === null
|
|
1228
|
+
? null
|
|
1229
|
+
: (resolved === null
|
|
1230
|
+
? '第 ' + created + ' 章起'
|
|
1231
|
+
: (activeEnd > created ? '第 ' + created + '–' + activeEnd + ' 章' : '第 ' + created + ' 章'));
|
|
1232
|
+
|
|
1233
|
+
lines.push('## 时间窗');
|
|
1234
|
+
if (created !== null && resolved !== null) {
|
|
1235
|
+
lines.push('- 产生章:第 ' + created + ' 章');
|
|
1236
|
+
lines.push('- 解决章:第 ' + resolved + ' 章');
|
|
1237
|
+
if (created > 1) {
|
|
1238
|
+
lines.push('- 第 1–' + (created - 1) + ' 章:信息尚未出现,不得提前埋线、暗示或让人物知情。');
|
|
1239
|
+
}
|
|
1240
|
+
lines.push('- ' + activeRange + ':信息存在但未解决,按下方知情范围和写作禁令执行。');
|
|
1241
|
+
lines.push('- 第 ' + resolved + ' 章起:信息解密,可以写相关人物知情、提及并据此行动。');
|
|
1242
|
+
} else if (created !== null) {
|
|
1243
|
+
lines.push('- 产生章:第 ' + created + ' 章');
|
|
1244
|
+
lines.push('- 解决章:尚未定时。');
|
|
1245
|
+
if (created > 1) {
|
|
1246
|
+
lines.push('- 第 1–' + (created - 1) + ' 章:信息尚未出现,不得提前埋线、暗示或让人物知情。');
|
|
1247
|
+
}
|
|
1248
|
+
lines.push('- 第 ' + created + ' 章起:信息存在但未解决,按下方知情范围和写作禁令执行。');
|
|
1249
|
+
} else {
|
|
1250
|
+
lines.push('- ⚠️ 尚未由节奏阶段定时:当前只有知情范围,无法给出“哪一章之前/之后”的精确写作约束。');
|
|
1251
|
+
lines.push('- 请在节奏阶段为这个信息差分配 `created` / `resolved`,再同步本页。');
|
|
1252
|
+
}
|
|
1253
|
+
lines.push('');
|
|
1254
|
+
|
|
1255
|
+
lines.push('## 基本信息');
|
|
1256
|
+
lines.push('- ID:' + (b.id || ''));
|
|
1257
|
+
lines.push('- 所属冲突:' + (b.conflict_id || ''));
|
|
1258
|
+
lines.push('- 信息名称:' + (b.info_name || ''));
|
|
1259
|
+
if (b.status && b.status === 'invalid') lines.push('- ⚠️ 状态:时间窗非法(resolved 不晚于 created)');
|
|
1260
|
+
lines.push('');
|
|
1261
|
+
|
|
1262
|
+
const rangeLabel = activeRange !== null ? '(' + activeRange + ')' : '(定时后生效)';
|
|
1263
|
+
lines.push('## 知情范围' + rangeLabel);
|
|
1264
|
+
if (b.who_knows && b.who_knows.length) lines.push('- 已知:' + b.who_knows.join('、'));
|
|
1265
|
+
if (b.who_does_not_know && b.who_does_not_know.length) lines.push('- 尚不知道:' + b.who_does_not_know.join('、'));
|
|
1266
|
+
lines.push('');
|
|
1267
|
+
|
|
1268
|
+
if (Array.isArray(b.forbidden_mentions) && b.forbidden_mentions.length) {
|
|
1269
|
+
lines.push('## 写作禁令' + rangeLabel);
|
|
1270
|
+
for (const line of b.forbidden_mentions) lines.push('- ' + line);
|
|
1271
|
+
lines.push('');
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (b.resolution_method) {
|
|
1275
|
+
lines.push('## 解禁方式');
|
|
1276
|
+
lines.push('- ' + b.resolution_method);
|
|
1277
|
+
if (resolved !== null) {
|
|
1278
|
+
lines.push('- 从第 ' + resolved + ' 章起,上述禁令解除;可以写知情者公开说出这条信息,或据此行动。');
|
|
1279
|
+
}
|
|
1280
|
+
lines.push('');
|
|
1281
|
+
}
|
|
1282
|
+
return lines.join('\n');
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function foreshadowBody(row) {
|
|
1286
|
+
const b = row || {};
|
|
1287
|
+
const lines = ['# ' + (b.name || b.id || '伏笔'), ''];
|
|
1288
|
+
lines.push('- ID:' + (b.id || ''));
|
|
1289
|
+
lines.push('- 类型:' + (b.type || ''));
|
|
1290
|
+
if (typeof b.buried_chapter === 'number') lines.push('- 埋设章:第 ' + b.buried_chapter + ' 章');
|
|
1291
|
+
if (typeof b.recovered_chapter === 'number') lines.push('- 回收章:第 ' + b.recovered_chapter + ' 章');
|
|
1292
|
+
if (b.status) lines.push('- 状态:' + statusCn(b.status));
|
|
1293
|
+
lines.push('');
|
|
1294
|
+
return lines.join('\n');
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function pleasureBody(row) {
|
|
1298
|
+
const b = row || {};
|
|
1299
|
+
const lines = ['# ' + (b.name || b.id || '爽点'), ''];
|
|
1300
|
+
lines.push('- ID:' + (b.id || ''));
|
|
1301
|
+
lines.push('- 规模:' + (b.scale || ''));
|
|
1302
|
+
lines.push('- 类型:' + (b.kind || ''));
|
|
1303
|
+
if (typeof b.chapter === 'number') lines.push('- 兑现章:第 ' + b.chapter + ' 章');
|
|
1304
|
+
lines.push('');
|
|
1305
|
+
return lines.join('\n');
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
export function pagesFromArtifacts(projectRoot, opts = {}) {
|
|
1309
|
+
const includeObjects = opts.objects !== false;
|
|
1310
|
+
const includeInfoGaps = opts.infoGaps !== false;
|
|
1311
|
+
const includeBriefs = opts.briefs !== false;
|
|
1312
|
+
const artifactRoot = join(projectRoot, ...SOURCES, '档案');
|
|
1313
|
+
const briefRoot = join(projectRoot, ...SOURCES, '节奏', '细纲');
|
|
1314
|
+
const pages = [];
|
|
1315
|
+
const byId = new Map();
|
|
1316
|
+
const readDir = (dir) => {
|
|
1317
|
+
try {
|
|
1318
|
+
return readdirSync(dir).filter((name) => name.endsWith('.json') && !name.startsWith('.')).sort();
|
|
1319
|
+
} catch {
|
|
1320
|
+
return [];
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
const linkCh = (ch) => (typeof ch === 'number' ? 'CH' + pad3(ch) : undefined);
|
|
1324
|
+
const branchAct = actMap(projectRoot).map;
|
|
1325
|
+
|
|
1326
|
+
if (includeObjects) {
|
|
1327
|
+
for (const name of readDir(join(artifactRoot, '伏笔'))) {
|
|
1328
|
+
const row = readJson(join(artifactRoot, '伏笔', name));
|
|
1329
|
+
if (!row || !row.id) continue;
|
|
1330
|
+
addPage(pages, byId, {
|
|
1331
|
+
type: TYPE.foreshadow,
|
|
1332
|
+
id: row.id,
|
|
1333
|
+
title: row.name || row.id,
|
|
1334
|
+
frontmatter: {
|
|
1335
|
+
kind: row.type,
|
|
1336
|
+
buried_chapter: linkCh(row.buried_chapter),
|
|
1337
|
+
recovered_chapter: linkCh(row.recovered_chapter),
|
|
1338
|
+
description: row.description,
|
|
1339
|
+
},
|
|
1340
|
+
body: foreshadowBody(row),
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
for (const name of readDir(join(artifactRoot, '爽点'))) {
|
|
1345
|
+
const row = readJson(join(artifactRoot, '爽点', name));
|
|
1346
|
+
if (!row || !row.id) continue;
|
|
1347
|
+
addPage(pages, byId, {
|
|
1348
|
+
type: TYPE.pleasure,
|
|
1349
|
+
id: row.id,
|
|
1350
|
+
title: row.name || row.id,
|
|
1351
|
+
frontmatter: {
|
|
1352
|
+
scale: row.scale,
|
|
1353
|
+
kind: row.kind,
|
|
1354
|
+
chapter: linkCh(row.chapter),
|
|
1355
|
+
description: row.description,
|
|
1356
|
+
},
|
|
1357
|
+
body: pleasureBody(row),
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
for (const name of readDir(join(artifactRoot, '冲突'))) {
|
|
1362
|
+
const row = readJson(join(artifactRoot, '冲突', name));
|
|
1363
|
+
if (!row || !row.id) continue;
|
|
1364
|
+
addPage(pages, byId, {
|
|
1365
|
+
type: TYPE.conflict,
|
|
1366
|
+
id: row.id,
|
|
1367
|
+
title: row.name || row.id,
|
|
1368
|
+
frontmatter: {
|
|
1369
|
+
kind: row.type,
|
|
1370
|
+
scale: row.scale,
|
|
1371
|
+
characters: row.participants,
|
|
1372
|
+
description: row.description,
|
|
1373
|
+
},
|
|
1374
|
+
body: conflictBody(row),
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
if (includeInfoGaps) {
|
|
1381
|
+
for (const name of readDir(join(artifactRoot, '信息差'))) {
|
|
1382
|
+
const row = readJson(join(artifactRoot, '信息差', name));
|
|
1383
|
+
if (!row || !row.id) continue;
|
|
1384
|
+
addPage(pages, byId, {
|
|
1385
|
+
type: TYPE.info_gap,
|
|
1386
|
+
id: row.id,
|
|
1387
|
+
title: row.info_name || row.id,
|
|
1388
|
+
frontmatter: {
|
|
1389
|
+
conflict: row.conflict_id,
|
|
1390
|
+
who_knows: row.who_knows_ids,
|
|
1391
|
+
who_does_not_know: row.who_does_not_know_ids,
|
|
1392
|
+
created_chapter: linkCh(row.created),
|
|
1393
|
+
resolved_chapter: linkCh(row.resolved),
|
|
1394
|
+
resolution_method: row.resolution_method,
|
|
1395
|
+
forbidden: (row.forbidden_mentions || []).join('\n'),
|
|
1396
|
+
},
|
|
1397
|
+
body: infoGapBody(row),
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
if (includeBriefs) {
|
|
1404
|
+
for (const name of readDir(briefRoot)) {
|
|
1405
|
+
if (!/^第\d+章\.json$/.test(name)) continue;
|
|
1406
|
+
const row = readJson(join(briefRoot, name));
|
|
1407
|
+
if (!row || typeof row.ch !== 'number') continue;
|
|
1408
|
+
const firstConflict = row.conflicts && row.conflicts[0] ? row.conflicts[0].id : undefined;
|
|
1409
|
+
addPage(pages, byId, {
|
|
1410
|
+
type: TYPE.character_brief,
|
|
1411
|
+
id: row.id || ('OB-CH' + pad3(row.ch)),
|
|
1412
|
+
title: row.title || ('第' + row.ch + '章 细纲'),
|
|
1413
|
+
frontmatter: {
|
|
1414
|
+
chapter: linkCh(row.ch),
|
|
1415
|
+
volume: row.volume,
|
|
1416
|
+
act: branchAct.get(row.volume + ':' + row.act),
|
|
1417
|
+
pov: row.pov,
|
|
1418
|
+
cast: (row.cast || []).map((x) => x.id),
|
|
1419
|
+
location: row.location ? row.location.id : undefined,
|
|
1420
|
+
conflict: firstConflict,
|
|
1421
|
+
foreshadows: (row.foreshadow_actions || []).map((x) => x.id),
|
|
1422
|
+
climax: row.climax ? row.climax.id : undefined,
|
|
1423
|
+
pleasure: row.pleasure ? row.pleasure.id : undefined,
|
|
1424
|
+
info_gaps: (row.information_gaps || []).map((x) => x.id),
|
|
1425
|
+
timeline: (row.timeline_events || []).map((x) => x.type + ':' + x.description).join('\n'),
|
|
1426
|
+
mandatory: (row.must_write || []).join('\n'),
|
|
1427
|
+
forbidden: (row.must_not_write || []).join('\n'),
|
|
1428
|
+
word_target: row.word_target,
|
|
1429
|
+
status: 'planned',
|
|
1430
|
+
},
|
|
1431
|
+
body: briefBody(row),
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
return pages;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* 写作阶段:正文 page。正文里没有 [[RQ-...]] 时,在末尾补“本章要求覆盖”清单,
|
|
1442
|
+
* 作为 page-level 的覆盖锚点(不是真正的 block id)。
|
|
1443
|
+
*/
|
|
1444
|
+
export function pagesFromWriting(projectRoot) {
|
|
1445
|
+
const dir = join(projectRoot, ...SOURCES, '写作');
|
|
1446
|
+
if (!existsSync(dir)) return [];
|
|
1447
|
+
const rhythm = pagesFromRhythm(projectRoot);
|
|
1448
|
+
const requirementsByChapter = new Map();
|
|
1449
|
+
for (const page of rhythm.pages) {
|
|
1450
|
+
if (page.type === TYPE.chapter) requirementsByChapter.set(page.id, page.frontmatter.requirements || []);
|
|
1451
|
+
}
|
|
1452
|
+
const pages = [];
|
|
1453
|
+
for (const name of readdirSync(dir)) {
|
|
1454
|
+
const match = /^第(\d+)章\.md$/.exec(name);
|
|
1455
|
+
if (!match) continue;
|
|
1456
|
+
const ch = Number(match[1]);
|
|
1457
|
+
const chapterId = 'CH' + pad3(ch);
|
|
1458
|
+
const id = 'TXT-' + chapterId;
|
|
1459
|
+
let content = readFileSync(join(dir, name), 'utf-8').trim();
|
|
1460
|
+
if (!/\[\[RQ-CH\d{3}-\d{2}/.test(content)) {
|
|
1461
|
+
const requirements = requirementsByChapter.get(chapterId) || [];
|
|
1462
|
+
if (requirements.length) {
|
|
1463
|
+
content += '\n\n## 本章要求覆盖\n\n' + requirements.map((req) => '- [[' + req + ']]').join('\n') + '\n';
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
pages.push({
|
|
1467
|
+
type: TYPE.manuscript,
|
|
1468
|
+
id,
|
|
1469
|
+
title: '第' + ch + '章正文',
|
|
1470
|
+
frontmatter: { chapter: chapterId },
|
|
1471
|
+
body: content,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
return pages;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* 读取 llm_wiki 已经生成的来源摘要页。
|
|
1479
|
+
*
|
|
1480
|
+
* 来源页不是阶段 JSON 的产物,但它们也是 wiki 页面,必须通过 related 接入关系网:
|
|
1481
|
+
* BOOK → SRC-INDEX → 每个 SRC-xxx
|
|
1482
|
+
* 这里只做最小 frontmatter 解析;related 会在 enrichLinks 里重算。
|
|
1483
|
+
*/
|
|
1484
|
+
export function pagesFromSources(projectRoot) {
|
|
1485
|
+
const dir = join(projectRoot, '设定', 'wiki', '来源');
|
|
1486
|
+
if (!existsSync(dir)) return [];
|
|
1487
|
+
const pages = [];
|
|
1488
|
+
for (const name of readdirSync(dir)) {
|
|
1489
|
+
if (!name.endsWith('.md') || name.startsWith('.')) continue;
|
|
1490
|
+
let content = '';
|
|
1491
|
+
try {
|
|
1492
|
+
content = readFileSync(join(dir, name), 'utf-8');
|
|
1493
|
+
} catch {
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
|
|
1497
|
+
if (!fmMatch) continue;
|
|
1498
|
+
const frontmatter = {};
|
|
1499
|
+
for (const line of fmMatch[1].split(/\r?\n/)) {
|
|
1500
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line.trim());
|
|
1501
|
+
if (!match) continue;
|
|
1502
|
+
let value = match[2].trim();
|
|
1503
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
1504
|
+
value = value.slice(1, -1);
|
|
1505
|
+
}
|
|
1506
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
1507
|
+
value = value.slice(1, -1).split(',').map((item) => item.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
1508
|
+
}
|
|
1509
|
+
frontmatter[match[1]] = value;
|
|
1510
|
+
}
|
|
1511
|
+
const type = frontmatter.type;
|
|
1512
|
+
if (type !== TYPE.source && type !== 'source') continue;
|
|
1513
|
+
const id = typeof frontmatter.id === 'string' ? frontmatter.id.trim() : '';
|
|
1514
|
+
if (!id || id === 'SRC-INDEX') continue;
|
|
1515
|
+
pages.push({
|
|
1516
|
+
type: TYPE.source,
|
|
1517
|
+
id,
|
|
1518
|
+
title: typeof frontmatter.title === 'string' && frontmatter.title.trim() ? frontmatter.title.trim() : id,
|
|
1519
|
+
frontmatter: { ...frontmatter, type: TYPE.source, id },
|
|
1520
|
+
body: content.slice(fmMatch[0].length),
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
if (!pages.length) return [];
|
|
1524
|
+
pages.sort((left, right) => left.id.localeCompare(right.id));
|
|
1525
|
+
const index = {
|
|
1526
|
+
type: TYPE.source,
|
|
1527
|
+
id: 'SRC-INDEX',
|
|
1528
|
+
title: '来源索引',
|
|
1529
|
+
frontmatter: { source_file: 'wiki/来源' },
|
|
1530
|
+
body: '# 来源索引\n\n' + pages.map((page) => '- [[' + page.id + ']] ' + page.title).join('\n') + '\n',
|
|
1531
|
+
};
|
|
1532
|
+
return [index, ...pages];
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// ============================================================
|
|
1536
|
+
// 2.0 阶段页面(设定/00-baseline … 设定/50-writing)
|
|
1537
|
+
// ============================================================
|
|
1538
|
+
|
|
1539
|
+
function v2StageDir(projectRoot, stage) {
|
|
1540
|
+
const name = {
|
|
1541
|
+
'00-baseline': '00-baseline',
|
|
1542
|
+
'10-scale': '10-scale',
|
|
1543
|
+
'20-macro': '20-macro',
|
|
1544
|
+
'30-cast': '30-cast',
|
|
1545
|
+
'40-rhythm': '40-rhythm',
|
|
1546
|
+
'50-writing': '50-writing',
|
|
1547
|
+
'60-deslop': '60-deslop',
|
|
1548
|
+
}[stage] || stage;
|
|
1549
|
+
return join(projectRoot, '设定', name);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function v2Read(projectRoot, stage, file) {
|
|
1553
|
+
return readJson(join(v2StageDir(projectRoot, stage), file));
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function v2LinkList(rows, key = 'id') {
|
|
1557
|
+
return rows.map((row) => row && row[key]).filter(Boolean);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function namedLine(labelText, value) {
|
|
1561
|
+
if (value === undefined || value === null || value === '') return '';
|
|
1562
|
+
if (Array.isArray(value)) {
|
|
1563
|
+
if (!value.length) return '';
|
|
1564
|
+
return '- ' + labelText + ':' + value.map((x) => (typeof x === 'object' ? JSON.stringify(x) : String(x))).join(';');
|
|
1565
|
+
}
|
|
1566
|
+
if (typeof value === 'object') return '- ' + labelText + ':' + JSON.stringify(value);
|
|
1567
|
+
return '- ' + labelText + ':' + String(value);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function semanticSection(title, questions) {
|
|
1571
|
+
const lines = ['', '## 语义验证点(交给 llm_wiki 语义检查)', ''];
|
|
1572
|
+
lines.push('> 这一节不是硬约束枚举,而是给语义检查的检查清单;结构校验不替它下结论。');
|
|
1573
|
+
lines.push('');
|
|
1574
|
+
for (const q of questions) lines.push('- ' + q);
|
|
1575
|
+
return lines.join('\n');
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function linkIds(ids) {
|
|
1579
|
+
const list = Array.isArray(ids) ? ids : (ids ? [ids] : []);
|
|
1580
|
+
return list.filter(Boolean).map((id) => '[[' + String(id).replace(/^\[\[|\]\]$/g, '') + ']]').join('、');
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* 20-macro:书、卷、幕、世界观、主线、支线、结局、主题。
|
|
1585
|
+
*/
|
|
1586
|
+
export function pagesFromMacroV2(projectRoot) {
|
|
1587
|
+
const dir = v2StageDir(projectRoot, '20-macro');
|
|
1588
|
+
if (!existsSync(join(dir, 'world.json')) && !existsSync(join(dir, 'volumes.json'))) return [];
|
|
1589
|
+
const baseline = v2Read(projectRoot, '00-baseline', 'input.json') || {};
|
|
1590
|
+
const scale = v2Read(projectRoot, '10-scale', 'scale.json') || {};
|
|
1591
|
+
const world = v2Read(projectRoot, '20-macro', 'world.json') || {};
|
|
1592
|
+
const story = v2Read(projectRoot, '20-macro', 'story.json') || {};
|
|
1593
|
+
const volumesDoc = v2Read(projectRoot, '20-macro', 'volumes.json') || {};
|
|
1594
|
+
const lines = v2Read(projectRoot, '20-macro', 'lines.json') || {};
|
|
1595
|
+
const endings = v2Read(projectRoot, '20-macro', 'endings.json') || {};
|
|
1596
|
+
const pages = [];
|
|
1597
|
+
const byId = new Map();
|
|
1598
|
+
const volumes = Array.isArray(volumesDoc.volumes) ? volumesDoc.volumes : [];
|
|
1599
|
+
const volumeIds = v2LinkList(volumes);
|
|
1600
|
+
const bookTitle = baseline.book_name || '未命名';
|
|
1601
|
+
addPage(pages, byId, {
|
|
1602
|
+
type: TYPE.book,
|
|
1603
|
+
id: 'BOOK',
|
|
1604
|
+
title: bookTitle,
|
|
1605
|
+
frontmatter: { volumes: volumeIds },
|
|
1606
|
+
body: objectBody(bookTitle, {
|
|
1607
|
+
一句话定位: baseline.positioning,
|
|
1608
|
+
题材: baseline.genre,
|
|
1609
|
+
核心冲突: story.core_conflict || baseline.core_conflict,
|
|
1610
|
+
主题: world.theme,
|
|
1611
|
+
基调: world.tone,
|
|
1612
|
+
总章数: scale.capacity && scale.capacity.target_chapters,
|
|
1613
|
+
总字数: scale.capacity && scale.capacity.target_words,
|
|
1614
|
+
卷数: scale.structure && scale.structure.volume_count,
|
|
1615
|
+
}),
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
for (const volume of volumes) {
|
|
1619
|
+
if (!volume || !volume.id) continue;
|
|
1620
|
+
const acts = Array.isArray(volume.acts) ? volume.acts : [];
|
|
1621
|
+
addPage(pages, byId, {
|
|
1622
|
+
type: TYPE.volume,
|
|
1623
|
+
id: volume.id,
|
|
1624
|
+
title: volume.title || volume.id,
|
|
1625
|
+
frontmatter: {
|
|
1626
|
+
theme: volume.emotional_arc || '',
|
|
1627
|
+
acts: v2LinkList(acts, 'act_id'),
|
|
1628
|
+
chapter_ratio: volume.chapter_end && volume.chapter_start
|
|
1629
|
+
? (volume.chapter_end - volume.chapter_start + 1) / Math.max(1, Number(scale.capacity && scale.capacity.target_chapters || 1))
|
|
1630
|
+
: undefined,
|
|
1631
|
+
},
|
|
1632
|
+
body: objectBody(volume.title || volume.id, {
|
|
1633
|
+
章节区间: volume.chapter_start + '-' + volume.chapter_end,
|
|
1634
|
+
卷目标: volume.goal,
|
|
1635
|
+
核心冲突: volume.core_conflict,
|
|
1636
|
+
情绪走向: volume.emotional_arc,
|
|
1637
|
+
卷尾钩子: volume.ending_hook,
|
|
1638
|
+
主线进度: volume.main_line_progress,
|
|
1639
|
+
支线进度: volume.branch_progress,
|
|
1640
|
+
}),
|
|
1641
|
+
});
|
|
1642
|
+
for (const act of acts) {
|
|
1643
|
+
if (!act || !act.act_id) continue;
|
|
1644
|
+
addPage(pages, byId, {
|
|
1645
|
+
type: TYPE.act,
|
|
1646
|
+
id: act.act_id,
|
|
1647
|
+
title: act.title || act.goal || act.act_id,
|
|
1648
|
+
frontmatter: { volume: volume.id, theme: act.goal || '' },
|
|
1649
|
+
body: objectBody(act.title || act.goal || act.act_id, {
|
|
1650
|
+
章节区间: act.chapter_start + '-' + act.chapter_end,
|
|
1651
|
+
幕目标: act.goal,
|
|
1652
|
+
幕高潮: act.climax,
|
|
1653
|
+
转折: act.turn,
|
|
1654
|
+
结束状态: act.end_state,
|
|
1655
|
+
}),
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
for (const rule of (Array.isArray(world.world_rules) ? world.world_rules : [])) {
|
|
1661
|
+
if (!rule || !rule.id) continue;
|
|
1662
|
+
addPage(pages, byId, {
|
|
1663
|
+
type: TYPE.world_rule,
|
|
1664
|
+
id: rule.id,
|
|
1665
|
+
title: rule.name || rule.id,
|
|
1666
|
+
frontmatter: { category: rule.category || rule.kind || '核心', scope: rule.scope || '' },
|
|
1667
|
+
body: objectBody(rule.name || rule.id, {
|
|
1668
|
+
规则内容: rule.content,
|
|
1669
|
+
适用范围: rule.scope,
|
|
1670
|
+
例外: rule.exceptions,
|
|
1671
|
+
代价: rule.cost,
|
|
1672
|
+
影响: rule.impact,
|
|
1673
|
+
揭示阶段: rule.reveal_stage,
|
|
1674
|
+
揭示方式: rule.reveal_method,
|
|
1675
|
+
}) + semanticSection('世界观规则', [
|
|
1676
|
+
'这条规则的代价与例外是否在后续冲突中被人物真实承担?',
|
|
1677
|
+
'是否出现“规则只在需要时生效”的情节?',
|
|
1678
|
+
'规则的揭示阶段是否与章节地图一致,没有提前剧透?',
|
|
1679
|
+
]),
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const main = lines.main_line;
|
|
1684
|
+
if (main && main.id) {
|
|
1685
|
+
addPage(pages, byId, {
|
|
1686
|
+
type: TYPE.main_line,
|
|
1687
|
+
id: main.id,
|
|
1688
|
+
title: main.name || main.id,
|
|
1689
|
+
frontmatter: { volumes: [...new Set((main.nodes || []).map((n) => n && n.volume).filter(Boolean))] },
|
|
1690
|
+
body: objectBody(main.name || main.id, { 主线节点: main.nodes }) + semanticSection('主线', [
|
|
1691
|
+
'每个主线节点的目标与转折是否能推动核心问题?',
|
|
1692
|
+
'主线节点是否与卷纲的章节区间一致,没有跳卷或倒序?',
|
|
1693
|
+
]),
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
for (const branch of (Array.isArray(lines.branches) ? lines.branches : [])) {
|
|
1698
|
+
if (!branch || !branch.id) continue;
|
|
1699
|
+
addPage(pages, byId, {
|
|
1700
|
+
type: TYPE.branch,
|
|
1701
|
+
id: branch.id,
|
|
1702
|
+
title: branch.name || branch.id,
|
|
1703
|
+
frontmatter: { main_line: main && main.id ? main.id : undefined },
|
|
1704
|
+
body: objectBody(branch.name || branch.id, {
|
|
1705
|
+
支线目标: branch.goal,
|
|
1706
|
+
开始: branch.start,
|
|
1707
|
+
转折: branch.turning,
|
|
1708
|
+
收束: branch.resolution,
|
|
1709
|
+
交织主线: branch.interweaves,
|
|
1710
|
+
}) + semanticSection('支线', [
|
|
1711
|
+
'支线是否真正服务主线,而不是可以整段删除?',
|
|
1712
|
+
'支线开始/转折/收束是否落在合法卷幕?',
|
|
1713
|
+
]),
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
for (const ending of (Array.isArray(endings.endings) ? endings.endings : [])) {
|
|
1718
|
+
if (!ending || !ending.id) continue;
|
|
1719
|
+
addPage(pages, byId, {
|
|
1720
|
+
type: TYPE.ending,
|
|
1721
|
+
id: ending.id,
|
|
1722
|
+
title: ending.type || ending.id,
|
|
1723
|
+
frontmatter: { level: ending.level || '', kind: ending.type || '' },
|
|
1724
|
+
body: objectBody(ending.type || ending.id, {
|
|
1725
|
+
结局方向: ending.direction,
|
|
1726
|
+
必须回收的伏笔: ending.required_foreshadow_recovery,
|
|
1727
|
+
人物归宿: ending.character_destinations,
|
|
1728
|
+
}) + semanticSection('结局', [
|
|
1729
|
+
'结局方向是否回答了故事的核心问题?',
|
|
1730
|
+
'必须回收的伏笔是否都能在节奏阶段找到落点?',
|
|
1731
|
+
]),
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// 主题页:每卷一个,沿用 THxxx。
|
|
1736
|
+
volumes.forEach((volume, index) => {
|
|
1737
|
+
if (!volume || !volume.id) return;
|
|
1738
|
+
const m = /^V(\d{3})$/.exec(volume.id);
|
|
1739
|
+
const id = 'TH' + (m ? m[1] : pad3(index + 1));
|
|
1740
|
+
addPage(pages, byId, {
|
|
1741
|
+
type: TYPE.theme,
|
|
1742
|
+
id,
|
|
1743
|
+
title: volume.emotional_arc || volume.title || volume.id,
|
|
1744
|
+
frontmatter: { volumes: [volume.id], statement: volume.emotional_arc || '' },
|
|
1745
|
+
body: textBody(volume.emotional_arc || volume.title || volume.id, volume.emotional_arc || ''),
|
|
1746
|
+
});
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
return pages;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* 30-cast:人物、势力、地点、道具、冲突、伏笔、高潮、爽点、关系、成员、信息差。
|
|
1754
|
+
*
|
|
1755
|
+
* 关系页是独立 page,且带有“语义验证点”;kind 不设枚举,
|
|
1756
|
+
* 结构校验只管两端存在与变化节点,是否自洽交给语义检查。
|
|
1757
|
+
*/
|
|
1758
|
+
export function pagesFromCastV2(projectRoot) {
|
|
1759
|
+
const dir = v2StageDir(projectRoot, '30-cast');
|
|
1760
|
+
if (!existsSync(dir)) return [];
|
|
1761
|
+
const characters = v2Read(projectRoot, '30-cast', 'characters.json') || {};
|
|
1762
|
+
const factions = v2Read(projectRoot, '30-cast', 'factions.json') || {};
|
|
1763
|
+
const locations = v2Read(projectRoot, '30-cast', 'locations.json') || {};
|
|
1764
|
+
const items = v2Read(projectRoot, '30-cast', 'items.json') || {};
|
|
1765
|
+
const conflicts = v2Read(projectRoot, '30-cast', 'conflicts.json') || {};
|
|
1766
|
+
const foreshadows = v2Read(projectRoot, '30-cast', 'foreshadows.json') || {};
|
|
1767
|
+
const climaxes = v2Read(projectRoot, '30-cast', 'climaxes.json') || {};
|
|
1768
|
+
const payoffs = v2Read(projectRoot, '30-cast', 'payoffs.json') || {};
|
|
1769
|
+
const relations = v2Read(projectRoot, '30-cast', 'relations.json') || {};
|
|
1770
|
+
const information = v2Read(projectRoot, '30-cast', 'information.json') || {};
|
|
1771
|
+
const pages = [];
|
|
1772
|
+
const byId = new Map();
|
|
1773
|
+
|
|
1774
|
+
for (const c of (Array.isArray(characters.characters) ? characters.characters : [])) {
|
|
1775
|
+
if (!c || !c.id) continue;
|
|
1776
|
+
addPage(pages, byId, {
|
|
1777
|
+
type: TYPE.character,
|
|
1778
|
+
id: c.id,
|
|
1779
|
+
title: c.name || c.id,
|
|
1780
|
+
frontmatter: { role: c.type || c.role, faction: c.faction, role_slot: c.role_slot },
|
|
1781
|
+
body: objectBody(c.name || c.id, {
|
|
1782
|
+
身份: c.type || c.role,
|
|
1783
|
+
所属势力: c.faction,
|
|
1784
|
+
简介: c.description,
|
|
1785
|
+
目标: c.goal,
|
|
1786
|
+
缺陷: c.flaw,
|
|
1787
|
+
金手指: c.golden_finger,
|
|
1788
|
+
信息状态: c.information_state,
|
|
1789
|
+
弧光: c.arc,
|
|
1790
|
+
语言: c.voice,
|
|
1791
|
+
特质: c.traits,
|
|
1792
|
+
退场: c.exit,
|
|
1793
|
+
}) + semanticSection('人物', [
|
|
1794
|
+
'人物的目标/缺陷/弧光是否在冲突与章节细纲中真实发生变化?',
|
|
1795
|
+
'人物知道与不知道的信息,是否与信息差页面一致?',
|
|
1796
|
+
'人物的语言节奏与行为是否前后一致(没有 OOC)?',
|
|
1797
|
+
]),
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
for (const f of (Array.isArray(factions.factions) ? factions.factions : [])) {
|
|
1802
|
+
if (!f || !f.id) continue;
|
|
1803
|
+
addPage(pages, byId, {
|
|
1804
|
+
type: TYPE.faction,
|
|
1805
|
+
id: f.id,
|
|
1806
|
+
title: f.name || f.id,
|
|
1807
|
+
frontmatter: { kind: f.type || '' },
|
|
1808
|
+
body: objectBody(f.name || f.id, {
|
|
1809
|
+
类型: f.type,
|
|
1810
|
+
目标: f.goal,
|
|
1811
|
+
资源: f.resources,
|
|
1812
|
+
地盘: f.territory,
|
|
1813
|
+
内部矛盾: f.internal_conflict,
|
|
1814
|
+
简介: f.description,
|
|
1815
|
+
}) + semanticSection('势力', [
|
|
1816
|
+
'势力的资源与地盘是否足以支撑它在本卷的行动?',
|
|
1817
|
+
'势力的内部矛盾是否在冲突或关系变化中被使用?',
|
|
1818
|
+
]),
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
for (const l of (Array.isArray(locations.locations) ? locations.locations : [])) {
|
|
1823
|
+
if (!l || !l.id) continue;
|
|
1824
|
+
addPage(pages, byId, {
|
|
1825
|
+
type: TYPE.location,
|
|
1826
|
+
id: l.id,
|
|
1827
|
+
title: l.name || l.id,
|
|
1828
|
+
frontmatter: { kind: l.type, narrative_function: l.narrative_function },
|
|
1829
|
+
body: objectBody(l.name || l.id, {
|
|
1830
|
+
类型: l.type,
|
|
1831
|
+
叙事功能: l.narrative_function,
|
|
1832
|
+
简介: l.description,
|
|
1833
|
+
'规则/限制': l.rules,
|
|
1834
|
+
}),
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
for (const it of (Array.isArray(items.items) ? items.items : [])) {
|
|
1839
|
+
if (!it || !it.id) continue;
|
|
1840
|
+
addPage(pages, byId, {
|
|
1841
|
+
type: TYPE.item,
|
|
1842
|
+
id: it.id,
|
|
1843
|
+
title: it.name || it.id,
|
|
1844
|
+
frontmatter: { category: it.category, owner_character: it.owner_character, owner_faction: it.owner_faction },
|
|
1845
|
+
body: objectBody(it.name || it.id, {
|
|
1846
|
+
类别: it.category,
|
|
1847
|
+
功能: it.function,
|
|
1848
|
+
简介: it.description,
|
|
1849
|
+
归属: it.owner,
|
|
1850
|
+
'代价/限制': it.limit,
|
|
1851
|
+
}),
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
for (const x of (Array.isArray(conflicts.conflicts) ? conflicts.conflicts : [])) {
|
|
1856
|
+
if (!x || !x.id) continue;
|
|
1857
|
+
addPage(pages, byId, {
|
|
1858
|
+
type: TYPE.conflict,
|
|
1859
|
+
id: x.id,
|
|
1860
|
+
title: x.name || x.id,
|
|
1861
|
+
frontmatter: { kind: x.type, characters: (x.participants || []).filter((id) => /^C\d{3}$/.test(String(id))) },
|
|
1862
|
+
body: objectBody(x.name || x.id, {
|
|
1863
|
+
类型: x.type,
|
|
1864
|
+
核心目标: x.core_goal,
|
|
1865
|
+
赌注: x.stakes,
|
|
1866
|
+
简介: x.description,
|
|
1867
|
+
升级路径: x.escalation_path,
|
|
1868
|
+
解决方式: x.resolution_plan,
|
|
1869
|
+
参与方: x.participants,
|
|
1870
|
+
}) + semanticSection('冲突', [
|
|
1871
|
+
'冲突的升级路径是否每一步都提高赌注,而不是原地重复?',
|
|
1872
|
+
'解决方式是否由人物选择推动,而不是机械降神?',
|
|
1873
|
+
]),
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
for (const f of (Array.isArray(foreshadows.foreshadows) ? foreshadows.foreshadows : [])) {
|
|
1878
|
+
if (!f || !f.id) continue;
|
|
1879
|
+
addPage(pages, byId, {
|
|
1880
|
+
type: TYPE.foreshadow,
|
|
1881
|
+
id: f.id,
|
|
1882
|
+
title: f.name || f.id,
|
|
1883
|
+
frontmatter: { kind: f.type, scope: f.scope || undefined },
|
|
1884
|
+
body: objectBody(f.name || f.id, {
|
|
1885
|
+
级别: f.type,
|
|
1886
|
+
埋设: f.setup,
|
|
1887
|
+
线索: f.clues,
|
|
1888
|
+
回收目标: f.payoff_target,
|
|
1889
|
+
回收要求: f.recovery_requirement,
|
|
1890
|
+
红鲱鱼: f.red_herrings,
|
|
1891
|
+
}) + semanticSection('伏笔', [
|
|
1892
|
+
'线索是否足以让读者在回收时觉得合理,而不是作者临时解释?',
|
|
1893
|
+
'伏笔的埋设与回收是否与章节地图顺序一致?',
|
|
1894
|
+
]),
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
for (const cl of (Array.isArray(climaxes.climaxes) ? climaxes.climaxes : [])) {
|
|
1899
|
+
if (!cl || !cl.id) continue;
|
|
1900
|
+
addPage(pages, byId, {
|
|
1901
|
+
type: TYPE.climax,
|
|
1902
|
+
id: cl.id,
|
|
1903
|
+
title: cl.name || cl.id,
|
|
1904
|
+
frontmatter: { kind: cl.type, characters: (cl.participants || []).filter((id) => /^C\d{3}$/.test(String(id))) },
|
|
1905
|
+
body: objectBody(cl.name || cl.id, {
|
|
1906
|
+
级别: cl.type,
|
|
1907
|
+
简介: cl.description,
|
|
1908
|
+
兑现: cl.payoff,
|
|
1909
|
+
参与方: cl.participants,
|
|
1910
|
+
}),
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
for (const pl of (Array.isArray(payoffs.payoffs) ? payoffs.payoffs : [])) {
|
|
1915
|
+
if (!pl || !pl.id) continue;
|
|
1916
|
+
addPage(pages, byId, {
|
|
1917
|
+
type: TYPE.pleasure,
|
|
1918
|
+
id: pl.id,
|
|
1919
|
+
title: pl.name || pl.id,
|
|
1920
|
+
frontmatter: { scale: pl.scale, kind: pl.type },
|
|
1921
|
+
body: objectBody(pl.name || pl.id, {
|
|
1922
|
+
类型: pl.type,
|
|
1923
|
+
级别: pl.scale,
|
|
1924
|
+
铺垫: pl.setup,
|
|
1925
|
+
兑现: pl.payoff,
|
|
1926
|
+
前置依赖: pl.required_setup,
|
|
1927
|
+
情绪效果: pl.emotional_effect,
|
|
1928
|
+
}) + semanticSection('爽点', [
|
|
1929
|
+
'铺垫是否足够,兑现时读者是否能理解为什么爽?',
|
|
1930
|
+
'爽点是否重复同一种套路,缺少变化?',
|
|
1931
|
+
]),
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
for (const rel of (Array.isArray(relations.character_relations) ? relations.character_relations : [])) {
|
|
1936
|
+
if (!rel || !rel.id) continue;
|
|
1937
|
+
addPage(pages, byId, {
|
|
1938
|
+
type: TYPE.relation,
|
|
1939
|
+
id: rel.id,
|
|
1940
|
+
title: (rel.from || '?') + '-' + (rel.to || '?'),
|
|
1941
|
+
frontmatter: { from: rel.from, to: rel.to, kind: rel.kind, label: rel.label || undefined },
|
|
1942
|
+
body: [
|
|
1943
|
+
'# ' + (rel.kind || '人物关系'),
|
|
1944
|
+
'',
|
|
1945
|
+
'- 两端:[[' + rel.from + ']] → [[' + rel.to + ']]',
|
|
1946
|
+
'- 关系类型(自由文本,不设枚举):' + (rel.kind || '未填写'),
|
|
1947
|
+
'- 初始状态:' + (rel.initial_state || '未填写'),
|
|
1948
|
+
'',
|
|
1949
|
+
'## 阶段变化',
|
|
1950
|
+
...(Array.isArray(rel.changes) ? rel.changes.map((c, i) =>
|
|
1951
|
+
'- 第 ' + (i + 1) + ' 阶段:' + (c.stage || '') + ';触发:' + (c.trigger || '') + ';' + (c.description || '')
|
|
1952
|
+
) : ['- 未填写']),
|
|
1953
|
+
semanticSection('人物关系', [
|
|
1954
|
+
'这条关系与 ' + rel.from + ' 的目标/缺陷是否冲突?如果冲突,文本是否解释?',
|
|
1955
|
+
'这条关系的变化是否有触发事件,而不是突然转向?',
|
|
1956
|
+
'这条关系是否与双方各自的信息状态一致(没有让角色知道不该知道的事)?',
|
|
1957
|
+
'同一条关系在不同章节里的称呼、态度、亲密程度是否连续?',
|
|
1958
|
+
]),
|
|
1959
|
+
].join('\n'),
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
for (const rel of (Array.isArray(relations.faction_relations) ? relations.faction_relations : [])) {
|
|
1964
|
+
if (!rel || !rel.id) continue;
|
|
1965
|
+
addPage(pages, byId, {
|
|
1966
|
+
type: TYPE.faction_relation,
|
|
1967
|
+
id: rel.id,
|
|
1968
|
+
title: (rel.from || '?') + '-' + (rel.to || '?'),
|
|
1969
|
+
frontmatter: { from: rel.from, to: rel.to, kind: rel.kind },
|
|
1970
|
+
body: [
|
|
1971
|
+
'# ' + (rel.kind || '势力关系'),
|
|
1972
|
+
'',
|
|
1973
|
+
'- 两端:[[' + rel.from + ']] → [[' + rel.to + ']]',
|
|
1974
|
+
'- 关系类型(自由文本,不设枚举):' + (rel.kind || '未填写'),
|
|
1975
|
+
'- 初始状态:' + (rel.initial_state || '未填写'),
|
|
1976
|
+
'',
|
|
1977
|
+
'## 阶段变化',
|
|
1978
|
+
...(Array.isArray(rel.changes) ? rel.changes.map((c, i) =>
|
|
1979
|
+
'- 第 ' + (i + 1) + ' 阶段:' + (c.stage || '') + ';触发:' + (c.trigger || '') + ';' + (c.description || '')
|
|
1980
|
+
) : ['- 未填写']),
|
|
1981
|
+
semanticSection('势力关系', [
|
|
1982
|
+
'这条势力关系与双方的资源、地盘、目标是否冲突?',
|
|
1983
|
+
'关系变化是否由具体事件触发,并与章节地图一致?',
|
|
1984
|
+
]),
|
|
1985
|
+
].join('\n'),
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
for (const m of (Array.isArray(relations.memberships) ? relations.memberships : [])) {
|
|
1990
|
+
if (!m || !m.id) continue;
|
|
1991
|
+
addPage(pages, byId, {
|
|
1992
|
+
type: TYPE.membership,
|
|
1993
|
+
id: m.id,
|
|
1994
|
+
title: (m.character || '?') + '@' + (m.faction || '?'),
|
|
1995
|
+
frontmatter: { character: m.character, faction: m.faction, kind: m.kind, position: m.position },
|
|
1996
|
+
body: objectBody((m.character || '?') + '@' + (m.faction || '?'), {
|
|
1997
|
+
人物: m.character,
|
|
1998
|
+
势力: m.faction,
|
|
1999
|
+
身份: m.position,
|
|
2000
|
+
成员关系: m.kind,
|
|
2001
|
+
说明: m.description,
|
|
2002
|
+
}),
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
for (const g of (Array.isArray(information.information_gaps) ? information.information_gaps : [])) {
|
|
2007
|
+
if (!g || !g.id) continue;
|
|
2008
|
+
addPage(pages, byId, {
|
|
2009
|
+
type: TYPE.info_gap,
|
|
2010
|
+
id: g.id,
|
|
2011
|
+
title: g.info_name || g.id,
|
|
2012
|
+
frontmatter: { conflict: g.conflict },
|
|
2013
|
+
body: objectBody(g.info_name || g.id, {
|
|
2014
|
+
所属冲突: g.conflict,
|
|
2015
|
+
谁知道: g.who_knows,
|
|
2016
|
+
谁不知道: g.who_does_not_know,
|
|
2017
|
+
解决方式: g.resolution_method,
|
|
2018
|
+
写作禁令: g.writing_ban,
|
|
2019
|
+
}) + semanticSection('信息差', [
|
|
2020
|
+
'谁知道/谁不知道是否与人物信息和章节地图一致?',
|
|
2021
|
+
'在解决前,是否有角色说出了他不该知道的秘密?',
|
|
2022
|
+
]),
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
return pages;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/**
|
|
2030
|
+
* 40-rhythm:章节页 + 每章独立细纲页。
|
|
2031
|
+
*/
|
|
2032
|
+
export function pagesFromRhythmV2(projectRoot) {
|
|
2033
|
+
const dir = v2StageDir(projectRoot, '40-rhythm');
|
|
2034
|
+
const chapterMap = readJson(join(dir, 'chapter_map.json'));
|
|
2035
|
+
if (!chapterMap) return { pages: [], byId: new Map() };
|
|
2036
|
+
const chapters = Array.isArray(chapterMap.chapters) ? chapterMap.chapters : [];
|
|
2037
|
+
const pages = [];
|
|
2038
|
+
const byId = new Map();
|
|
2039
|
+
const outlineDir = join(dir, 'outlines');
|
|
2040
|
+
let outlineFiles = [];
|
|
2041
|
+
try {
|
|
2042
|
+
outlineFiles = readdirSync(outlineDir).filter((n) => n.endsWith('.json') && !n.startsWith('.'));
|
|
2043
|
+
} catch {
|
|
2044
|
+
outlineFiles = [];
|
|
2045
|
+
}
|
|
2046
|
+
const outlineById = new Map();
|
|
2047
|
+
for (const name of outlineFiles) {
|
|
2048
|
+
const data = readJson(join(outlineDir, name));
|
|
2049
|
+
if (!data) continue;
|
|
2050
|
+
const chapter = data.chapter || {};
|
|
2051
|
+
const id = chapter.chapter_id || chapter.id || name.replace(/\.json$/, '');
|
|
2052
|
+
outlineById.set(id, data);
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
for (const c of chapters) {
|
|
2056
|
+
if (!c || !c.chapter_id) continue;
|
|
2057
|
+
const reason = c.why || {};
|
|
2058
|
+
const how = c.how || {};
|
|
2059
|
+
const body = [
|
|
2060
|
+
'# 第 ' + c.display_no + ' 章 ' + (c.title || ''),
|
|
2061
|
+
'',
|
|
2062
|
+
'- 所属卷/幕:[[' + c.volume + ']] / [[' + c.act + ']]',
|
|
2063
|
+
'- 视角:[[' + c.pov + ']]',
|
|
2064
|
+
'- 出场人物:' + linkIds(c.cast),
|
|
2065
|
+
'- 地点:' + linkIds(c.locations),
|
|
2066
|
+
'- 时间:第 ' + (c.time && c.time.story_day) + ' 天 ' + (c.time && c.time.clock || '') + (c.time && c.time.duration_minutes ? '(' + c.time.duration_minutes + ' 分钟)' : ''),
|
|
2067
|
+
'- 主线节点:' + (reason.main_line_nodes || []).join('、'),
|
|
2068
|
+
'- 支线:' + (reason.branch_nodes || []).join('、'),
|
|
2069
|
+
'- 冲突:' + linkIds(reason.conflict),
|
|
2070
|
+
'- 主题:' + (reason.theme || ''),
|
|
2071
|
+
'- 赌注:' + (reason.stakes || ''),
|
|
2072
|
+
'- 本章功能:' + (how.function_main || '') + (how.function_aux ? ';辅助:' + how.function_aux : ''),
|
|
2073
|
+
'- 转折:' + (how.turn || ''),
|
|
2074
|
+
'- 章尾钩子:' + (how.ending_hook || ''),
|
|
2075
|
+
'- 情绪:' + (c.emotion && c.emotion.phase || '') + (c.emotion && c.emotion.value ? '(' + c.emotion.value + '/10)' : ''),
|
|
2076
|
+
'- 钩子强度:' + (c.hook && c.hook.strength || ''),
|
|
2077
|
+
'- 字数目标:' + (c.word_target || ''),
|
|
2078
|
+
'',
|
|
2079
|
+
'## 伏笔动作',
|
|
2080
|
+
...((c.foreshadow_actions || []).length ? c.foreshadow_actions.map((a) => '- ' + a.action + ' [[' + a.id + ']]:' + (a.detail || '')) : ['- 无']),
|
|
2081
|
+
'',
|
|
2082
|
+
'## 爽点动作',
|
|
2083
|
+
...((c.payoff_actions || []).length ? c.payoff_actions.map((a) => '- ' + a.action + ' [[' + a.id + ']]:' + (a.detail || '')) : ['- 无']),
|
|
2084
|
+
'',
|
|
2085
|
+
'## 信息差',
|
|
2086
|
+
...((c.information_gaps || []).length ? c.information_gaps.map((a) => '- ' + a.state + ' [[' + a.id + ']]:' + (a.detail || '')) : ['- 无']),
|
|
2087
|
+
].join('\n');
|
|
2088
|
+
addPage(pages, byId, {
|
|
2089
|
+
type: TYPE.chapter,
|
|
2090
|
+
id: c.chapter_id,
|
|
2091
|
+
title: '第' + c.display_no + '章' + (c.title && !String(c.title).startsWith('第' + c.display_no + '章') ? ' ' + c.title : ''),
|
|
2092
|
+
frontmatter: { act: c.act, volume: c.volume, chapter_no: c.display_no },
|
|
2093
|
+
body,
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
const outline = outlineById.get(c.chapter_id);
|
|
2097
|
+
if (outline) {
|
|
2098
|
+
const ch = outline.chapter || {};
|
|
2099
|
+
const hard = ch.hard_constraints || outline.hard_constraints || {};
|
|
2100
|
+
const howWrite = ch.how || outline.how || {};
|
|
2101
|
+
const scenes = Array.isArray(howWrite.scenes) ? howWrite.scenes : [];
|
|
2102
|
+
const lines = [
|
|
2103
|
+
'# 第 ' + c.display_no + ' 章细纲:' + (ch.title || c.title || ''),
|
|
2104
|
+
'',
|
|
2105
|
+
'- 对应章节:[[' + c.chapter_id + ']]',
|
|
2106
|
+
'- 视角:[[' + (ch.pov && ch.pov.id || c.pov) + ']]',
|
|
2107
|
+
'- 出场人物:' + linkIds((ch.cast || []).map((x) => (typeof x === 'string' ? x : x && x.id))),
|
|
2108
|
+
'- 地点:' + linkIds((ch.locations || []).map((x) => (typeof x === 'string' ? x : x && x.id))),
|
|
2109
|
+
'- 字数目标:' + (ch.word_target || c.word_target || ''),
|
|
2110
|
+
'',
|
|
2111
|
+
'## 为什么写这一章',
|
|
2112
|
+
'- 主线:' + JSON.stringify(ch.why && ch.why.main_line || c.why.main_line_nodes || []),
|
|
2113
|
+
'- 支线:' + JSON.stringify(ch.why && ch.why.branch || c.why.branch_nodes || []),
|
|
2114
|
+
'- 冲突:' + linkIds(ch.why && ch.why.conflict || c.why.conflict || []),
|
|
2115
|
+
'- 赌注:' + (ch.why && ch.why.stakes || c.why.stakes || ''),
|
|
2116
|
+
'- 人物弧光:' + (ch.why && ch.why.character_arc || ''),
|
|
2117
|
+
'',
|
|
2118
|
+
'## 必须写',
|
|
2119
|
+
...(Array.isArray(hard.must_write || ch.must_write) ? (hard.must_write || ch.must_write).map((x) => '- ' + x) : ['- 未填写']),
|
|
2120
|
+
'',
|
|
2121
|
+
'## 绝对不能写',
|
|
2122
|
+
...(Array.isArray(hard.must_not_write || ch.must_not_write) ? (hard.must_not_write || ch.must_not_write).map((x) => '- ⛔ ' + x) : ['- 未填写']),
|
|
2123
|
+
'',
|
|
2124
|
+
'## 场景',
|
|
2125
|
+
...(scenes.length ? scenes.map((s, i) =>
|
|
2126
|
+
'- 场景 ' + (s.no || i + 1) + ':' + (s.goal || '') + ';阻碍:' + (s.obstacle || '') + ';转折:' + (s.turn || '') + ';结果:' + (s.outcome || '') + (s.emotion ? ';情绪:' + s.emotion : '')
|
|
2127
|
+
) : ['- 未填写']),
|
|
2128
|
+
'',
|
|
2129
|
+
'## 结束方式',
|
|
2130
|
+
'- 类型:' + (howWrite.ending && howWrite.ending.type || ''),
|
|
2131
|
+
'- 收束:' + (howWrite.ending && howWrite.ending.text || ''),
|
|
2132
|
+
'- 钩子:' + (howWrite.ending && howWrite.ending.hook || ''),
|
|
2133
|
+
'',
|
|
2134
|
+
'## 衔接',
|
|
2135
|
+
'- 上一章:' + (ch.continuity && ch.continuity.from_prev || ''),
|
|
2136
|
+
'- 下一章:' + (ch.continuity && ch.continuity.to_next || ''),
|
|
2137
|
+
];
|
|
2138
|
+
addPage(pages, byId, {
|
|
2139
|
+
type: TYPE.character_brief,
|
|
2140
|
+
id: 'OB-' + c.chapter_id,
|
|
2141
|
+
title: '第' + c.display_no + '章细纲',
|
|
2142
|
+
frontmatter: { chapter: c.chapter_id, kind: '可落地细纲' },
|
|
2143
|
+
body: lines.join('\n'),
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
return { pages, byId };
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
export function pagesFromWritingV2(projectRoot) {
|
|
2151
|
+
const dir = v2StageDir(projectRoot, '50-writing');
|
|
2152
|
+
const chaptersDir = join(dir, 'chapters');
|
|
2153
|
+
if (!existsSync(chaptersDir)) return [];
|
|
2154
|
+
const chapterMap = v2Read(projectRoot, '40-rhythm', 'chapter_map.json') || {};
|
|
2155
|
+
const byDisplay = new Map((chapterMap.chapters || []).map((c) => [Number(c.display_no), c]));
|
|
2156
|
+
const pages = [];
|
|
2157
|
+
let names = [];
|
|
2158
|
+
try {
|
|
2159
|
+
names = readdirSync(chaptersDir).filter((n) => /^第\d+章\.md$/.test(n));
|
|
2160
|
+
} catch {
|
|
2161
|
+
names = [];
|
|
2162
|
+
}
|
|
2163
|
+
for (const name of names) {
|
|
2164
|
+
const m = /^第(\d+)章\.md$/.exec(name);
|
|
2165
|
+
if (!m) continue;
|
|
2166
|
+
const displayNo = Number(m[1]);
|
|
2167
|
+
const meta = byDisplay.get(displayNo);
|
|
2168
|
+
const content = readFileSync(join(chaptersDir, name), 'utf-8');
|
|
2169
|
+
const id = 'TXT-' + (meta && meta.chapter_id || ('CH' + pad3(displayNo)));
|
|
2170
|
+
pages.push({
|
|
2171
|
+
type: TYPE.manuscript,
|
|
2172
|
+
id,
|
|
2173
|
+
title: '第' + displayNo + '章' + (meta && meta.title && !String(meta.title).startsWith('第' + displayNo + '章') ? ' ' + meta.title : ''),
|
|
2174
|
+
frontmatter: { chapter: meta && meta.chapter_id || ('CH' + pad3(displayNo)) },
|
|
2175
|
+
body: content,
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
return pages;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
/**
|
|
2182
|
+
* 阶段源文件是否存在(用于 sync 默认模式跳过未产出的阶段)。
|
|
2183
|
+
*/
|
|
2184
|
+
export function hasStageSources(projectRoot, stage) {
|
|
2185
|
+
if (stage === 'source') {
|
|
2186
|
+
const dir = join(projectRoot, '设定', 'wiki', '来源');
|
|
2187
|
+
if (!existsSync(dir)) return false;
|
|
2188
|
+
try {
|
|
2189
|
+
return readdirSync(dir).some((name) => !name.startsWith('.') && name.endsWith('.md') && name !== 'SRC-INDEX.md');
|
|
2190
|
+
} catch {
|
|
2191
|
+
return false;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
const dirs = {
|
|
2195
|
+
skeleton: '骨架', flesh: '血肉', rhythm: '节奏', writing: '写作',
|
|
2196
|
+
'20-macro': '20-macro', macro: '20-macro',
|
|
2197
|
+
'30-cast': '30-cast', cast: '30-cast',
|
|
2198
|
+
'40-rhythm': '40-rhythm', 'rhythm-v2': '40-rhythm',
|
|
2199
|
+
'50-writing': '50-writing', 'writing-v2': '50-writing',
|
|
2200
|
+
};
|
|
2201
|
+
const name = dirs[stage];
|
|
2202
|
+
if (!name) return false;
|
|
2203
|
+
const base = name.startsWith('0') || name.startsWith('1') || name.startsWith('2') || name.startsWith('3') || name.startsWith('4') || name.startsWith('5')
|
|
2204
|
+
? join(projectRoot, '设定', name)
|
|
2205
|
+
: join(projectRoot, ...SOURCES, name);
|
|
2206
|
+
if (!existsSync(base)) return false;
|
|
2207
|
+
try {
|
|
2208
|
+
return readdirSync(base, { withFileTypes: true }).some((entry) => {
|
|
2209
|
+
if (entry.name.startsWith('.') || entry.name === 'bundle.json' || entry.name === 'README.md') return false;
|
|
2210
|
+
if (entry.isDirectory()) {
|
|
2211
|
+
if (!['chapters', 'outlines', 'timelines'].includes(entry.name)) return false;
|
|
2212
|
+
try {
|
|
2213
|
+
return readdirSync(join(base, entry.name)).some((n) => n.endsWith('.json') || n.endsWith('.md'));
|
|
2214
|
+
} catch {
|
|
2215
|
+
return false;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
return entry.name.endsWith('.json') || entry.name.endsWith('.md');
|
|
2219
|
+
});
|
|
2220
|
+
} catch {
|
|
2221
|
+
return false;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
export function pagesForStage(projectRoot, stage) {
|
|
2226
|
+
if (stage === 'skeleton') return mergePageList([...pagesFromSkeleton(projectRoot), ...pagesFromArtifacts(projectRoot, { objects: true, infoGaps: false, briefs: false })]);
|
|
2227
|
+
if (stage === 'flesh') return mergePageList([...pagesFromFlesh(projectRoot), ...pagesFromArtifacts(projectRoot, { objects: false, infoGaps: true, briefs: false })]);
|
|
2228
|
+
if (stage === 'rhythm') return mergePageList([...pagesFromRhythm(projectRoot).pages, ...pagesFromArtifacts(projectRoot)]);
|
|
2229
|
+
if (stage === 'writing') return mergePageList([...pagesFromWriting(projectRoot), ...pagesFromArtifacts(projectRoot)]);
|
|
2230
|
+
if (stage === 'macro' || stage === '20-macro') {
|
|
2231
|
+
return pagesFromMacroV2(projectRoot);
|
|
2232
|
+
}
|
|
2233
|
+
if (stage === 'cast' || stage === '30-cast') {
|
|
2234
|
+
// 首次正式同步必须带 macro 闭包:人物/势力/地点出现后,minimums 才满足。
|
|
2235
|
+
return mergePageList([...pagesFromMacroV2(projectRoot), ...pagesFromCastV2(projectRoot)]);
|
|
2236
|
+
}
|
|
2237
|
+
if (stage === 'rhythm-v2' || stage === '40-rhythm') {
|
|
2238
|
+
return mergePageList([
|
|
2239
|
+
...pagesFromMacroV2(projectRoot),
|
|
2240
|
+
...pagesFromCastV2(projectRoot),
|
|
2241
|
+
...pagesFromRhythmV2(projectRoot).pages,
|
|
2242
|
+
]);
|
|
2243
|
+
}
|
|
2244
|
+
if (stage === 'writing-v2' || stage === '50-writing') {
|
|
2245
|
+
return mergePageList([
|
|
2246
|
+
...pagesFromMacroV2(projectRoot),
|
|
2247
|
+
...pagesFromCastV2(projectRoot),
|
|
2248
|
+
...pagesFromRhythmV2(projectRoot).pages,
|
|
2249
|
+
...pagesFromWritingV2(projectRoot),
|
|
2250
|
+
]);
|
|
2251
|
+
}
|
|
2252
|
+
if (stage === 'source') return pagesFromSources(projectRoot);
|
|
2253
|
+
return [];
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
function mergePageList(pages) {
|
|
2257
|
+
const byId = new Map();
|
|
2258
|
+
for (const page of pages) {
|
|
2259
|
+
if (!page || !page.id) continue;
|
|
2260
|
+
const existing = byId.get(page.id);
|
|
2261
|
+
if (!existing) {
|
|
2262
|
+
byId.set(page.id, {
|
|
2263
|
+
...page,
|
|
2264
|
+
frontmatter: { ...(page.frontmatter || {}) },
|
|
2265
|
+
});
|
|
2266
|
+
continue;
|
|
2267
|
+
}
|
|
2268
|
+
existing.type = page.type || existing.type;
|
|
2269
|
+
existing.title = page.title || existing.title;
|
|
2270
|
+
existing.frontmatter = { ...(existing.frontmatter || {}), ...(page.frontmatter || {}) };
|
|
2271
|
+
if (page.body) existing.body = page.body;
|
|
2272
|
+
}
|
|
2273
|
+
return [...byId.values()];
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
/**
|
|
2277
|
+
* 把项目里所有已存在的阶段产物合并成一份“全局页面表”。
|
|
2278
|
+
* 关系网必须跨阶段计算:节奏页要能连到骨架角色,写作页要能连到章节。
|
|
2279
|
+
*/
|
|
2280
|
+
export function collectAllPages(projectRoot) {
|
|
2281
|
+
return mergePageList([
|
|
2282
|
+
...pagesFromSkeleton(projectRoot),
|
|
2283
|
+
...pagesFromFlesh(projectRoot),
|
|
2284
|
+
...pagesFromRhythm(projectRoot).pages,
|
|
2285
|
+
...pagesFromArtifacts(projectRoot),
|
|
2286
|
+
...pagesFromWriting(projectRoot),
|
|
2287
|
+
...pagesFromMacroV2(projectRoot),
|
|
2288
|
+
...pagesFromCastV2(projectRoot),
|
|
2289
|
+
...pagesFromRhythmV2(projectRoot).pages,
|
|
2290
|
+
...pagesFromWritingV2(projectRoot),
|
|
2291
|
+
...pagesFromSources(projectRoot),
|
|
2292
|
+
]);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
function parentIdOf(page) {
|
|
2296
|
+
const fm = page.frontmatter || {};
|
|
2297
|
+
switch (page.type) {
|
|
2298
|
+
case TYPE.book:
|
|
2299
|
+
return null;
|
|
2300
|
+
case TYPE.volume:
|
|
2301
|
+
return 'BOOK';
|
|
2302
|
+
case TYPE.act:
|
|
2303
|
+
return fm.volume || 'BOOK';
|
|
2304
|
+
case TYPE.chapter:
|
|
2305
|
+
return fm.act || fm.volume || 'BOOK';
|
|
2306
|
+
case TYPE.requirement:
|
|
2307
|
+
case TYPE.scene:
|
|
2308
|
+
case TYPE.manuscript:
|
|
2309
|
+
return fm.chapter || 'BOOK';
|
|
2310
|
+
case TYPE.theme:
|
|
2311
|
+
case TYPE.main_line:
|
|
2312
|
+
case TYPE.world_rule:
|
|
2313
|
+
case TYPE.faction:
|
|
2314
|
+
case TYPE.location:
|
|
2315
|
+
case TYPE.conflict:
|
|
2316
|
+
return 'BOOK';
|
|
2317
|
+
case TYPE.source:
|
|
2318
|
+
return page.id === 'SRC-INDEX' ? 'BOOK' : 'SRC-INDEX';
|
|
2319
|
+
case TYPE.branch:
|
|
2320
|
+
return fm.main_line || 'BOOK';
|
|
2321
|
+
case TYPE.character:
|
|
2322
|
+
return fm.faction || 'BOOK';
|
|
2323
|
+
case TYPE.other_entity:
|
|
2324
|
+
return 'BOOK';
|
|
2325
|
+
case TYPE.item:
|
|
2326
|
+
return fm.owner_character || fm.owner_faction || 'BOOK';
|
|
2327
|
+
case TYPE.foreshadow:
|
|
2328
|
+
return fm.buried_chapter || 'BOOK';
|
|
2329
|
+
case TYPE.climax:
|
|
2330
|
+
case TYPE.pleasure:
|
|
2331
|
+
case TYPE.ending:
|
|
2332
|
+
return fm.chapter || 'BOOK';
|
|
2333
|
+
case TYPE.relation:
|
|
2334
|
+
case TYPE.faction_relation:
|
|
2335
|
+
return fm.from || 'BOOK';
|
|
2336
|
+
case TYPE.membership:
|
|
2337
|
+
return fm.faction || 'BOOK';
|
|
2338
|
+
case TYPE.arc:
|
|
2339
|
+
case TYPE.character_state:
|
|
2340
|
+
return fm.character || 'BOOK';
|
|
2341
|
+
case TYPE.timeline_event:
|
|
2342
|
+
return fm.chapter || 'BOOK';
|
|
2343
|
+
case TYPE.character_brief:
|
|
2344
|
+
return fm.chapter || 'BOOK';
|
|
2345
|
+
case TYPE.info_gap:
|
|
2346
|
+
return fm.conflict || fm.created_chapter || 'BOOK';
|
|
2347
|
+
default:
|
|
2348
|
+
return 'BOOK';
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
const ID_VALUE_RE = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
2353
|
+
const NON_LINK_FIELDS = new Set([
|
|
2354
|
+
'type', 'id', 'title', 'status', 'tags', 'sources', 'related', 'created', 'updated', 'aliases',
|
|
2355
|
+
]);
|
|
2356
|
+
|
|
2357
|
+
function semanticLinkIds(page, byId) {
|
|
2358
|
+
const out = new Set();
|
|
2359
|
+
const fm = page.frontmatter || {};
|
|
2360
|
+
for (const [key, value] of Object.entries(fm)) {
|
|
2361
|
+
if (NON_LINK_FIELDS.has(key)) continue;
|
|
2362
|
+
const values = Array.isArray(value) ? value : [value];
|
|
2363
|
+
for (const item of values) {
|
|
2364
|
+
if (typeof item !== 'string') continue;
|
|
2365
|
+
if (item === page.id || !ID_VALUE_RE.test(item)) continue;
|
|
2366
|
+
if (byId.has(item)) out.add(item);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
return out;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
function bodyLinkIds(page, byId) {
|
|
2373
|
+
const out = new Set();
|
|
2374
|
+
const regex = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g;
|
|
2375
|
+
let match;
|
|
2376
|
+
while ((match = regex.exec(String(page.body || ''))) !== null) {
|
|
2377
|
+
const id = match[1].trim();
|
|
2378
|
+
if (id !== page.id && byId.has(id)) out.add(id);
|
|
2379
|
+
}
|
|
2380
|
+
return out;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
function uniqueIds(values) {
|
|
2384
|
+
return [...new Set(values.filter(Boolean))];
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
/**
|
|
2388
|
+
* 去掉旧版本生成的 `## 关系网` 正文块。
|
|
2389
|
+
* 关系只放在 frontmatter.related 里;正文不再重复一张“上下级”表。
|
|
2390
|
+
*/
|
|
2391
|
+
function stripRelationSection(body) {
|
|
2392
|
+
const source = String(body || '');
|
|
2393
|
+
const index = source.indexOf('\n## 关系网');
|
|
2394
|
+
if (index < 0) return source;
|
|
2395
|
+
return source.slice(0, index).trimEnd() + '\n';
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
/** 页面 title 统一带上 ID 前缀:V001_除夕分岁。 */
|
|
2399
|
+
function withIdTitle(page) {
|
|
2400
|
+
const id = String(page.id || '').trim();
|
|
2401
|
+
const title = String(page.title || id).trim();
|
|
2402
|
+
if (!id) return title;
|
|
2403
|
+
return title.startsWith(id + '_') ? title : id + '_' + title;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/**
|
|
2407
|
+
* 给页面补齐层级 / 相邻 / 语义链接。
|
|
2408
|
+
*
|
|
2409
|
+
* 目标不是“有链接就行”,而是让页面组成一张有上下级、叶子相邻、
|
|
2410
|
+
* 跨实体关联的图:
|
|
2411
|
+
* - 上级:卷→书、幕→卷、章→幕、要求/场景/正文→章……
|
|
2412
|
+
* - 下级:反向由父页链接所有子页
|
|
2413
|
+
* - 相邻:同类型同上级的页面按 ID 串成双向链表
|
|
2414
|
+
* - 相关:frontmatter 里已有的实体 link 字段
|
|
2415
|
+
*
|
|
2416
|
+
* 只写入 frontmatter.related(wikilink + 完整标题),不再生成正文关系表。
|
|
2417
|
+
*/
|
|
2418
|
+
export function enrichLinks(pages, contextPages = pages) {
|
|
2419
|
+
const context = mergePageList(contextPages);
|
|
2420
|
+
const byId = new Map(context.map((page) => [page.id, page]));
|
|
2421
|
+
const parentOf = new Map();
|
|
2422
|
+
const childrenOf = new Map();
|
|
2423
|
+
|
|
2424
|
+
for (const page of context) {
|
|
2425
|
+
const parent = parentIdOf(page);
|
|
2426
|
+
if (!parent || parent === page.id || !byId.has(parent)) continue;
|
|
2427
|
+
parentOf.set(page.id, parent);
|
|
2428
|
+
if (!childrenOf.has(parent)) childrenOf.set(parent, []);
|
|
2429
|
+
childrenOf.get(parent).push(page.id);
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
const groups = new Map();
|
|
2433
|
+
for (const page of context) {
|
|
2434
|
+
if (page.type === TYPE.book || page.id === 'SRC-INDEX') continue;
|
|
2435
|
+
let key = page.type;
|
|
2436
|
+
if (page.type === TYPE.requirement || page.type === TYPE.scene) {
|
|
2437
|
+
key += ':' + String((page.frontmatter || {}).chapter || '');
|
|
2438
|
+
}
|
|
2439
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
2440
|
+
groups.get(key).push(page);
|
|
2441
|
+
}
|
|
2442
|
+
const prevNext = new Map();
|
|
2443
|
+
for (const list of groups.values()) {
|
|
2444
|
+
list.sort((left, right) => left.id.localeCompare(right.id));
|
|
2445
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
2446
|
+
prevNext.set(list[i].id, {
|
|
2447
|
+
prev: list[i - 1] ? list[i - 1].id : null,
|
|
2448
|
+
next: list[i + 1] ? list[i + 1].id : null,
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
// 标题统一加 ID 前缀,related 里用 [[ID|ID_标题]]:目标是文件名,别名给人看。
|
|
2454
|
+
for (const page of context) page.title = withIdTitle(page);
|
|
2455
|
+
const targets = mergePageList(pages);
|
|
2456
|
+
for (const page of targets) page.title = withIdTitle(page);
|
|
2457
|
+
const titleById = new Map(context.map((page) => [page.id, page.title]));
|
|
2458
|
+
for (const page of targets) titleById.set(page.id, page.title);
|
|
2459
|
+
|
|
2460
|
+
for (const page of targets) {
|
|
2461
|
+
const parent = parentOf.get(page.id) || null;
|
|
2462
|
+
const children = uniqueIds(childrenOf.get(page.id) || []).filter((id) => id !== page.id);
|
|
2463
|
+
const adjacent = prevNext.get(page.id) || {};
|
|
2464
|
+
page.body = stripRelationSection(page.body);
|
|
2465
|
+
const semantic = [...new Set([
|
|
2466
|
+
...semanticLinkIds(page, byId),
|
|
2467
|
+
...bodyLinkIds(page, byId),
|
|
2468
|
+
])];
|
|
2469
|
+
const relatedIds = uniqueIds([
|
|
2470
|
+
parent,
|
|
2471
|
+
...children,
|
|
2472
|
+
adjacent.prev,
|
|
2473
|
+
adjacent.next,
|
|
2474
|
+
...semantic,
|
|
2475
|
+
]).filter((id) => id !== page.id && byId.has(id));
|
|
2476
|
+
if (!relatedIds.length && page.type !== TYPE.book && byId.has('BOOK')) {
|
|
2477
|
+
relatedIds.push('BOOK');
|
|
2478
|
+
}
|
|
2479
|
+
// related 用「[[ID|完整标题]]」:
|
|
2480
|
+
// - 链接目标必须是 ID(llm_wiki 按文件名解析,页面文件是 C001.md);
|
|
2481
|
+
// - 显示名用 ID_标题,UI 上仍然可读。
|
|
2482
|
+
// 之前写 [[C001_岑野]],解析器会去找 C001_岑野.md,结果全是
|
|
2483
|
+
// “Related page not found”。
|
|
2484
|
+
page.frontmatter = {
|
|
2485
|
+
...(page.frontmatter || {}),
|
|
2486
|
+
related: relatedIds.map((id) => '[[' + id + '|' + (titleById.get(id) || id) + ']]'),
|
|
2487
|
+
};
|
|
2488
|
+
}
|
|
2489
|
+
return targets;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
function chunkPages(pages, maxPages = 80, maxBytes = 4 * 1024 * 1024) {
|
|
2493
|
+
const chunks = [];
|
|
2494
|
+
let current = [];
|
|
2495
|
+
let bytes = 0;
|
|
2496
|
+
for (const page of pages) {
|
|
2497
|
+
const size = Buffer.byteLength(JSON.stringify(page), 'utf-8');
|
|
2498
|
+
if (current.length >= maxPages || (current.length > 0 && bytes + size > maxBytes)) {
|
|
2499
|
+
chunks.push(current);
|
|
2500
|
+
current = [];
|
|
2501
|
+
bytes = 0;
|
|
2502
|
+
}
|
|
2503
|
+
current.push(page);
|
|
2504
|
+
bytes += size;
|
|
2505
|
+
}
|
|
2506
|
+
if (current.length) chunks.push(current);
|
|
2507
|
+
return chunks;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
async function postJson(baseUrl, token, path, body, timeoutMs, signal) {
|
|
2511
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
2512
|
+
const response = await fetch(baseUrl.replace(/\/$/, '') + path, {
|
|
2513
|
+
method: 'POST',
|
|
2514
|
+
headers: {
|
|
2515
|
+
'Content-Type': 'application/json',
|
|
2516
|
+
Authorization: 'Bearer ' + token,
|
|
2517
|
+
},
|
|
2518
|
+
body: JSON.stringify(body),
|
|
2519
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
2520
|
+
});
|
|
2521
|
+
let data = null;
|
|
2522
|
+
try {
|
|
2523
|
+
data = await response.json();
|
|
2524
|
+
} catch {
|
|
2525
|
+
data = null;
|
|
2526
|
+
}
|
|
2527
|
+
return { status: response.status, data };
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/**
|
|
2531
|
+
* 把一个阶段物化到 llm_wiki。
|
|
2532
|
+
*
|
|
2533
|
+
* @param {object} opts
|
|
2534
|
+
* @param {string} opts.projectRoot
|
|
2535
|
+
* @param {'skeleton'|'flesh'|'rhythm'|'writing'} opts.stage
|
|
2536
|
+
* @param {string} opts.setupId
|
|
2537
|
+
* @param {string} opts.llmWikiUrl
|
|
2538
|
+
* @param {string} opts.llmWikiToken
|
|
2539
|
+
* @param {AbortSignal|null} [opts.signal]
|
|
2540
|
+
* @returns {Promise<{ok:boolean, pageCount:number, written:string[], createdStubs:string[], errors:string[], warnings:string[], error:string|null}>}
|
|
2541
|
+
*/
|
|
2542
|
+
export async function materializeStage(opts) {
|
|
2543
|
+
const { projectRoot, stage, setupId, llmWikiUrl, llmWikiToken, signal } = opts || {};
|
|
2544
|
+
const stagePages = pagesForStage(projectRoot, stage);
|
|
2545
|
+
if (!stagePages.length) {
|
|
2546
|
+
return { ok: false, pageCount: 0, written: [], createdStubs: [], errors: [], warnings: [], error: '没有可物化的页面' };
|
|
2547
|
+
}
|
|
2548
|
+
if (!setupId) {
|
|
2549
|
+
return { ok: false, pageCount: stagePages.length, written: [], createdStubs: [], errors: [], warnings: [], error: '缺少 setup_id' };
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
// 关系网必须跨阶段计算:节奏页要连到骨架角色,写作页要连到章节。
|
|
2553
|
+
// 这里先合并全部阶段页面并补齐链接,再选出“本阶段页面 + 它们直接关联的
|
|
2554
|
+
// 页面”。父页/子页一起写,才能保证新页面有入站链接、父页有出站链接。
|
|
2555
|
+
const allPages = collectAllPages(projectRoot);
|
|
2556
|
+
const enrichedAll = enrichLinks(allPages, allPages);
|
|
2557
|
+
const enrichedById = new Map(enrichedAll.map((page) => [page.id, page]));
|
|
2558
|
+
// 递归闭包:本阶段页面 → related 指向的页面 → 这些页面继续指向的页面。
|
|
2559
|
+
// 只同步直接关联会漏掉“人物页 → 势力页 → 书页”这类两跳链接,
|
|
2560
|
+
// link-lint 仍会报 broken-link。
|
|
2561
|
+
const selectedIds = new Set();
|
|
2562
|
+
const queue = stagePages.map((page) => page.id);
|
|
2563
|
+
while (queue.length) {
|
|
2564
|
+
const id = queue.shift();
|
|
2565
|
+
if (!id || selectedIds.has(id)) continue;
|
|
2566
|
+
selectedIds.add(id);
|
|
2567
|
+
const enriched = enrichedById.get(id);
|
|
2568
|
+
if (!enriched) continue;
|
|
2569
|
+
const related = (enriched.frontmatter && enriched.frontmatter.related) || [];
|
|
2570
|
+
for (const link of related) {
|
|
2571
|
+
const target = String(link).replace(/^\[\[|\]\]$/g, '').split('|')[0].split('_')[0];
|
|
2572
|
+
if (target && !selectedIds.has(target)) queue.push(target);
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
// 来源摘要页是 llm_wiki ingest 的产物,不属于任何阶段 JSON。
|
|
2576
|
+
// 写作阶段是最后一道同步,顺手把 BOOK → SRC-INDEX → 来源页 接上。
|
|
2577
|
+
if ((stage === 'writing' || stage === 'writing-v2' || stage === '50-writing') && allPages.some((page) => page.type === TYPE.source)) {
|
|
2578
|
+
selectedIds.add('BOOK');
|
|
2579
|
+
for (const page of allPages) {
|
|
2580
|
+
if (page.type === TYPE.source) selectedIds.add(page.id);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
const pages = enrichedAll.filter((page) => selectedIds.has(page.id));
|
|
2584
|
+
|
|
2585
|
+
const chunks = chunkPages(pages);
|
|
2586
|
+
const written = [];
|
|
2587
|
+
const createdStubs = [];
|
|
2588
|
+
const errors = [];
|
|
2589
|
+
const warnings = [];
|
|
2590
|
+
const preserveFrontmatter = [
|
|
2591
|
+
'flesh', 'rhythm', 'writing', 'source',
|
|
2592
|
+
'macro', '20-macro', 'cast', '30-cast', 'rhythm-v2', '40-rhythm', 'writing-v2', '50-writing',
|
|
2593
|
+
].includes(stage);
|
|
2594
|
+
|
|
2595
|
+
for (const chunk of chunks) {
|
|
2596
|
+
let result;
|
|
2597
|
+
try {
|
|
2598
|
+
result = await postJson(
|
|
2599
|
+
llmWikiUrl,
|
|
2600
|
+
llmWikiToken,
|
|
2601
|
+
'/projects/' + setupId + '/schemaingest',
|
|
2602
|
+
{
|
|
2603
|
+
mode: 'upsert',
|
|
2604
|
+
createMissingLinks: true,
|
|
2605
|
+
preserveFrontmatter,
|
|
2606
|
+
pages: chunk,
|
|
2607
|
+
},
|
|
2608
|
+
5 * 60 * 1000,
|
|
2609
|
+
signal,
|
|
2610
|
+
);
|
|
2611
|
+
} catch (error) {
|
|
2612
|
+
return {
|
|
2613
|
+
ok: false,
|
|
2614
|
+
pageCount: pages.length,
|
|
2615
|
+
written,
|
|
2616
|
+
createdStubs,
|
|
2617
|
+
errors,
|
|
2618
|
+
warnings,
|
|
2619
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
const data = result.data || {};
|
|
2624
|
+
if (result.status < 200 || result.status >= 300 || data.ok === false) {
|
|
2625
|
+
const message = data.error || data.errors?.[0]?.message || ('HTTP ' + result.status);
|
|
2626
|
+
return {
|
|
2627
|
+
ok: false,
|
|
2628
|
+
pageCount: pages.length,
|
|
2629
|
+
written,
|
|
2630
|
+
createdStubs,
|
|
2631
|
+
errors,
|
|
2632
|
+
warnings,
|
|
2633
|
+
error: String(message),
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
if (Array.isArray(data.written)) written.push(...data.written);
|
|
2637
|
+
if (Array.isArray(data.createdStubs)) createdStubs.push(...data.createdStubs);
|
|
2638
|
+
if (Array.isArray(data.warnings)) warnings.push(...data.warnings);
|
|
2639
|
+
if (Array.isArray(data.errors)) {
|
|
2640
|
+
for (const item of data.errors) errors.push(item.message || JSON.stringify(item));
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
return {
|
|
2645
|
+
ok: errors.length === 0,
|
|
2646
|
+
pageCount: pages.length,
|
|
2647
|
+
written,
|
|
2648
|
+
createdStubs,
|
|
2649
|
+
errors,
|
|
2650
|
+
warnings,
|
|
2651
|
+
error: errors.length ? errors[0] : null,
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
export function formatMaterializeNote(result, stageName) {
|
|
2656
|
+
if (!result) return '';
|
|
2657
|
+
if (!result.ok) {
|
|
2658
|
+
return '⚠️ ' + stageName + ' 物化到 llm_wiki 失败:' + (result.error || '未知错误') +
|
|
2659
|
+
(result.errors && result.errors.length ? '\n' + result.errors.slice(0, 5).join('\n') : '');
|
|
2660
|
+
}
|
|
2661
|
+
const parts = ['✅ ' + stageName + ' 已物化到 llm_wiki:' + result.pageCount + ' 个页面'];
|
|
2662
|
+
if (result.createdStubs.length) {
|
|
2663
|
+
parts.push('自动创建 stub ' + result.createdStubs.length + ' 个:' + result.createdStubs.slice(0, 10).join('、') +
|
|
2664
|
+
(result.createdStubs.length > 10 ? ' …' : ''));
|
|
2665
|
+
}
|
|
2666
|
+
if (result.warnings.length) parts.push('警告:' + result.warnings.slice(0, 5).join(';'));
|
|
2667
|
+
return parts.join('\n');
|
|
2668
|
+
}
|