@cloud411716/fancy-webnovel 2.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +201 -15
- package/cordis.patch.yml +5 -0
- 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 +11 -7
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +11 -11
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +31 -31
- package/lib/infra.js +29 -4
- 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 +140 -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 +29 -4
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/index.js +1128 -77
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +355 -107
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/summary-progress.js +71 -7
- package/plugins/fancy-analyze/templates.js +437 -139
- package/plugins/fancy-bootstrap/index.js +276 -131
- package/plugins/fancy-bootstrap/scripts/init.js +59 -25
- package/plugins/fancy-bootstrap/templates.js +35 -18
- 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-driver.js +29 -6
- package/plugins/fancy-import/index.js +13 -5
- package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
- package/plugins/fancy-import/templates.js +17 -8
- 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 +410 -21
- 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 +169 -22
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
- 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 +545 -146
- 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/flesh-output.js +19 -6
- package/plugins/fancy-story-builder/index.js +4105 -396
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
- package/plugins/fancy-story-builder/purpose-template.md +42 -66
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +15 -6
- package/plugins/fancy-story-builder/references/index.md +97 -6
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +201 -293
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
- package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
- package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
- package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
- package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
- 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/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 +267 -44
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +121 -19
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +129 -21
- 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 +76 -17
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +56 -8
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +63 -13
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +34 -6
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +81 -18
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +202 -42
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +160 -27
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +217 -41
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +215 -41
- package/plugins/fancy-story-builder/templates.js +272 -74
- package/plugins/fancy-story-builder/wiki-references.js +429 -93
- package/plugins/fancy-story-builder/writing-driver.js +336 -9
- package/plugins/fancy-topic/index.js +414 -61
- 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 +106 -63
- package/plugins/fancy-topic/templates.js +225 -122
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/lib/safe-rm.js +0 -94
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SurrealDB -> stage JSON projection.
|
|
3
|
+
*
|
|
4
|
+
* The generation prompts still speak in stage JSON. This module writes a
|
|
5
|
+
* read-only projection of the database back into `设定/...` before a stage
|
|
6
|
+
* runs, so the prompt builders and validators keep working unchanged.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here is a source of truth: the projection is overwritten on the
|
|
9
|
+
* next command and can be deleted without losing story data.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { dbErrorMessage, StoryDbError } from './client.js';
|
|
15
|
+
|
|
16
|
+
const STORY = 'story:main';
|
|
17
|
+
const STAGE_DIR = {
|
|
18
|
+
'10-scale': '10-scale',
|
|
19
|
+
'20-macro': '20-macro',
|
|
20
|
+
'30-cast': '30-cast',
|
|
21
|
+
'40-rhythm': '40-rhythm',
|
|
22
|
+
'50-writing': '50-writing',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ORDER = ['10-scale', '20-macro', '30-cast', '40-rhythm', 'outline', '50-writing'];
|
|
26
|
+
|
|
27
|
+
function dirOf(projectRoot, stageId, sub = '') {
|
|
28
|
+
return path.join(projectRoot, '设定', STAGE_DIR[stageId] || stageId, sub);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureDir(dir) {
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeJson(file, value) {
|
|
36
|
+
ensureDir(path.dirname(file));
|
|
37
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeText(file, value) {
|
|
41
|
+
ensureDir(path.dirname(file));
|
|
42
|
+
fs.writeFileSync(file, value, 'utf8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function meta(stage, nodeId) {
|
|
46
|
+
return {
|
|
47
|
+
schema_version: '2.1',
|
|
48
|
+
node_id: nodeId,
|
|
49
|
+
stage,
|
|
50
|
+
generated_at: new Date().toISOString(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function payloadOf(row, fallback = {}) {
|
|
55
|
+
return row && row.payload && typeof row.payload === 'object' ? row.payload : fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mapPayload(rows) {
|
|
59
|
+
return rows.map((row) => payloadOf(row, { id: row.entity_id, name: row.name }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function count(sdb, sql, vars) {
|
|
63
|
+
const rows = await sdb.rows(sql, vars);
|
|
64
|
+
return Number(rows[0] && rows[0].count) || 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function stageHasData(sdb, stageId) {
|
|
68
|
+
switch (stageId) {
|
|
69
|
+
case '10-scale': return (await count(sdb, `SELECT count() FROM scale WHERE story = ${STORY} GROUP ALL;`)) > 0;
|
|
70
|
+
case '20-macro': return (await count(sdb, `SELECT count() FROM stage_doc WHERE story = ${STORY} AND stage = '20-macro' GROUP ALL;`)) > 0;
|
|
71
|
+
case '30-cast': return (await count(sdb, `SELECT count() FROM character WHERE story = ${STORY} GROUP ALL;`)) > 0
|
|
72
|
+
|| (await count(sdb, `SELECT count() FROM faction WHERE story = ${STORY} GROUP ALL;`)) > 0;
|
|
73
|
+
case '40-rhythm': return (await count(sdb, `SELECT count() FROM chapter WHERE story = ${STORY} GROUP ALL;`)) > 0;
|
|
74
|
+
case 'outline': return (await count(sdb, `SELECT count() FROM outline WHERE story = ${STORY} GROUP ALL;`)) > 0;
|
|
75
|
+
case '50-writing': return (await count(sdb, `SELECT count() FROM chapter_text WHERE story = ${STORY} GROUP ALL;`)) > 0;
|
|
76
|
+
default: return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function projectScale(sdb, projectRoot) {
|
|
81
|
+
const scale = await sdb.one(`SELECT * FROM scale:main;`);
|
|
82
|
+
if (!scale || !scale.payload) return false;
|
|
83
|
+
writeJson(dirOf(projectRoot, '10-scale', 'scale.json'), scale.payload);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const MACRO_FILES = ['world', 'story', 'volumes', 'lines', 'endings', 'emotion', 'roles'];
|
|
88
|
+
|
|
89
|
+
async function projectMacro(sdb, projectRoot) {
|
|
90
|
+
const rows = await sdb.rows(
|
|
91
|
+
`SELECT * FROM stage_doc WHERE story = ${STORY} AND stage = '20-macro';`,
|
|
92
|
+
);
|
|
93
|
+
if (!rows.length) return false;
|
|
94
|
+
const byName = new Map(rows.map((row) => [row.name, row.payload]));
|
|
95
|
+
let wrote = false;
|
|
96
|
+
for (const name of MACRO_FILES) {
|
|
97
|
+
if (byName.has(name)) {
|
|
98
|
+
writeJson(dirOf(projectRoot, '20-macro', name + '.json'), byName.get(name));
|
|
99
|
+
wrote = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return wrote;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function projectCast(sdb, projectRoot) {
|
|
106
|
+
const characters = await sdb.rows(`SELECT * FROM character WHERE story = ${STORY} ORDER BY entity_id;`);
|
|
107
|
+
if (!characters.length) return false;
|
|
108
|
+
|
|
109
|
+
const files = [
|
|
110
|
+
['characters', 'character'],
|
|
111
|
+
['factions', 'faction'],
|
|
112
|
+
['locations', 'location'],
|
|
113
|
+
['items', 'item'],
|
|
114
|
+
['conflicts', 'conflict'],
|
|
115
|
+
['foreshadows', 'foreshadow'],
|
|
116
|
+
['climaxes', 'climax'],
|
|
117
|
+
['payoffs', 'payoff'],
|
|
118
|
+
];
|
|
119
|
+
for (const [fileKey, table] of files) {
|
|
120
|
+
const rows = await sdb.rows(`SELECT * FROM ${table} WHERE story = ${STORY} ORDER BY entity_id;`);
|
|
121
|
+
writeJson(dirOf(projectRoot, '30-cast', fileKey + '.json'), {
|
|
122
|
+
[fileKey]: mapPayload(rows),
|
|
123
|
+
_meta: meta('30-cast', '30-cast.' + fileKey),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const gaps = await sdb.rows(`SELECT * FROM information WHERE story = ${STORY} ORDER BY entity_id;`);
|
|
128
|
+
writeJson(dirOf(projectRoot, '30-cast', 'information.json'), {
|
|
129
|
+
information_gaps: mapPayload(gaps),
|
|
130
|
+
_meta: meta('30-cast', '30-cast.information'),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const rels = await sdb.rows(`SELECT * FROM relation WHERE story = ${STORY};`);
|
|
134
|
+
const factionRels = await sdb.rows(`SELECT * FROM faction_relation WHERE story = ${STORY};`);
|
|
135
|
+
const memberships = await sdb.rows(`SELECT * FROM membership WHERE story = ${STORY};`);
|
|
136
|
+
writeJson(dirOf(projectRoot, '30-cast', 'relations.json'), {
|
|
137
|
+
character_relations: rels.map((row) => {
|
|
138
|
+
const p = payloadOf(row);
|
|
139
|
+
return {
|
|
140
|
+
...p,
|
|
141
|
+
id: p.id || row.id,
|
|
142
|
+
from: p.from || row.from_id,
|
|
143
|
+
to: p.to || row.to_id,
|
|
144
|
+
kind: p.kind || row.kind,
|
|
145
|
+
initial_state: p.initial_state !== undefined ? p.initial_state : row.initial_state,
|
|
146
|
+
changes: p.changes !== undefined ? p.changes : (row.changes || []),
|
|
147
|
+
};
|
|
148
|
+
}),
|
|
149
|
+
faction_relations: factionRels.map((row) => {
|
|
150
|
+
const p = payloadOf(row);
|
|
151
|
+
return {
|
|
152
|
+
...p,
|
|
153
|
+
id: p.id || row.id,
|
|
154
|
+
from: p.from || row.from_id,
|
|
155
|
+
to: p.to || row.to_id,
|
|
156
|
+
kind: p.kind || row.kind,
|
|
157
|
+
initial_state: p.initial_state !== undefined ? p.initial_state : row.initial_state,
|
|
158
|
+
changes: p.changes !== undefined ? p.changes : (row.changes || []),
|
|
159
|
+
};
|
|
160
|
+
}),
|
|
161
|
+
memberships: memberships.map((row) => {
|
|
162
|
+
const p = payloadOf(row);
|
|
163
|
+
const fromRecord = p.character || String(row.in || '').split(':').pop().replace(/[⟨⟩]/g, '');
|
|
164
|
+
const toRecord = p.faction || String(row.out || '').split(':').pop().replace(/[⟨⟩]/g, '');
|
|
165
|
+
return {
|
|
166
|
+
...p,
|
|
167
|
+
character: fromRecord,
|
|
168
|
+
faction: toRecord,
|
|
169
|
+
role: p.role || row.role,
|
|
170
|
+
};
|
|
171
|
+
}),
|
|
172
|
+
_meta: meta('30-cast', '30-cast.relations'),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function projectRhythm(sdb, projectRoot, opts = {}) {
|
|
179
|
+
const chapters = await sdb.rows(
|
|
180
|
+
`SELECT * FROM chapter WHERE story = ${STORY} ORDER BY display_no;`,
|
|
181
|
+
);
|
|
182
|
+
if (!chapters.length) return false;
|
|
183
|
+
|
|
184
|
+
if (!opts.outlinesOnly) {
|
|
185
|
+
// Remove stale chunk shards; seedChunksFromChapterMap will split the
|
|
186
|
+
// merged map again for the generation loop.
|
|
187
|
+
const shardDir = dirOf(projectRoot, '40-rhythm', '.chapter-map');
|
|
188
|
+
try { fs.rmSync(shardDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
189
|
+
writeJson(dirOf(projectRoot, '40-rhythm', 'chapter_map.json'), {
|
|
190
|
+
_meta: meta('40-rhythm', '40-rhythm.chapter_map'),
|
|
191
|
+
chapters: chapters.map((row) => payloadOf(row)),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const outlines = await sdb.rows(
|
|
196
|
+
`SELECT * FROM outline WHERE story = ${STORY} ORDER BY chapter_id;`,
|
|
197
|
+
);
|
|
198
|
+
if (outlines.length) {
|
|
199
|
+
const outDir = dirOf(projectRoot, '40-rhythm', 'outlines');
|
|
200
|
+
ensureDir(outDir);
|
|
201
|
+
const existing = new Set(fs.readdirSync(outDir).filter((name) => name.endsWith('.json')));
|
|
202
|
+
for (const row of outlines) {
|
|
203
|
+
const chapterId = row.chapter_id;
|
|
204
|
+
writeJson(path.join(outDir, chapterId + '.json'), payloadOf(row));
|
|
205
|
+
existing.delete(chapterId + '.json');
|
|
206
|
+
}
|
|
207
|
+
for (const stale of existing) {
|
|
208
|
+
try { fs.unlinkSync(path.join(outDir, stale)); } catch { /* ignore */ }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function projectWriting(sdb, projectRoot) {
|
|
215
|
+
const texts = await sdb.rows(
|
|
216
|
+
`SELECT * FROM chapter_text WHERE story = ${STORY} ORDER BY display_no;`,
|
|
217
|
+
);
|
|
218
|
+
if (!texts.length) return false;
|
|
219
|
+
const chapDir = dirOf(projectRoot, '50-writing', 'chapters');
|
|
220
|
+
ensureDir(chapDir);
|
|
221
|
+
const keep = new Set();
|
|
222
|
+
for (const row of texts) {
|
|
223
|
+
const name = '第' + String(row.display_no).padStart(3, '0') + '章.md';
|
|
224
|
+
keep.add(name);
|
|
225
|
+
writeText(path.join(chapDir, name), row.content);
|
|
226
|
+
}
|
|
227
|
+
for (const name of fs.readdirSync(chapDir)) {
|
|
228
|
+
if (name.endsWith('.md') && !keep.has(name)) {
|
|
229
|
+
try { fs.unlinkSync(path.join(chapDir, name)); } catch { /* ignore */ }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const ledger = await sdb.rows(`SELECT * FROM ledger_entry WHERE story = ${STORY};`);
|
|
234
|
+
if (ledger.length) {
|
|
235
|
+
writeJson(dirOf(projectRoot, '50-writing', 'ledger.json'), {
|
|
236
|
+
_meta: meta('50-writing', '50-writing.ledger'),
|
|
237
|
+
chapters: ledger.map((row) => payloadOf(row)),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function writeStageStatus(sdb, projectRoot, stageId) {
|
|
244
|
+
const state = await sdb.one(
|
|
245
|
+
`SELECT * FROM stage_state WHERE story = ${STORY} AND stage = $stage LIMIT 1;`,
|
|
246
|
+
{ stage: stageId },
|
|
247
|
+
);
|
|
248
|
+
if (!state) return;
|
|
249
|
+
const dir = dirOf(projectRoot, stageId);
|
|
250
|
+
if (!fs.existsSync(dir)) return;
|
|
251
|
+
writeJson(path.join(dir, '.stage-status.json'), {
|
|
252
|
+
schema_version: '2.1',
|
|
253
|
+
stage: stageId,
|
|
254
|
+
status: state.status,
|
|
255
|
+
checked_at: state.checked_at,
|
|
256
|
+
files: {},
|
|
257
|
+
errors: state.errors || [],
|
|
258
|
+
warnings: state.warnings || [],
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Project every stage that exists in the DB. Used by `tools export`. */
|
|
263
|
+
export async function projectAll(sdb, projectRoot) {
|
|
264
|
+
const written = {};
|
|
265
|
+
for (const stageId of ORDER) {
|
|
266
|
+
written[stageId] = await projectOne(sdb, stageId, projectRoot);
|
|
267
|
+
}
|
|
268
|
+
return written;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Project the stages needed by `targetStage`, in dependency order. */
|
|
272
|
+
export async function projectForStage(sdb, targetStage, projectRoot) {
|
|
273
|
+
const idx = ORDER.indexOf(targetStage);
|
|
274
|
+
const stages = idx >= 0 ? ORDER.slice(0, idx + 1) : ORDER;
|
|
275
|
+
const written = {};
|
|
276
|
+
for (const stageId of stages) {
|
|
277
|
+
written[stageId] = await projectOne(sdb, stageId, projectRoot);
|
|
278
|
+
}
|
|
279
|
+
return written;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function projectOne(sdb, stageId, projectRoot) {
|
|
283
|
+
let written = false;
|
|
284
|
+
switch (stageId) {
|
|
285
|
+
case '10-scale': written = await projectScale(sdb, projectRoot); break;
|
|
286
|
+
case '20-macro': written = await projectMacro(sdb, projectRoot); break;
|
|
287
|
+
case '30-cast': written = await projectCast(sdb, projectRoot); break;
|
|
288
|
+
case '40-rhythm': written = await projectRhythm(sdb, projectRoot); break;
|
|
289
|
+
case 'outline': written = await projectRhythm(sdb, projectRoot, { outlinesOnly: true }); break;
|
|
290
|
+
case '50-writing': written = await projectWriting(sdb, projectRoot); break;
|
|
291
|
+
default: written = false;
|
|
292
|
+
}
|
|
293
|
+
if (written && stageId !== 'outline') {
|
|
294
|
+
await writeStageStatus(sdb, projectRoot, stageId);
|
|
295
|
+
}
|
|
296
|
+
return written;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export { writeStageStatus };
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only queries used by the `query` and `analyze` commands.
|
|
3
|
+
*
|
|
4
|
+
* Everything here reads only SurrealDB. No stage JSON is consulted, so an
|
|
5
|
+
* agent asking "what does changing this relationship affect?" gets one
|
|
6
|
+
* consistent answer from one store.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { dbErrorMessage, StoryDbError } from './client.js';
|
|
10
|
+
|
|
11
|
+
const STORY = 'story:main';
|
|
12
|
+
|
|
13
|
+
function recordOf(table, id) {
|
|
14
|
+
return `type::record(${JSON.stringify(table)}, ${JSON.stringify(String(id))})`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function resolveCharacter(sdb, value) {
|
|
18
|
+
const q = String(value || '').trim();
|
|
19
|
+
const rows = await sdb.rows(
|
|
20
|
+
`SELECT * FROM character WHERE story = ${STORY} AND (entity_id = $q OR name = $q) LIMIT 2;`,
|
|
21
|
+
{ q },
|
|
22
|
+
);
|
|
23
|
+
if (!rows.length) throw new StoryDbError('QUERY_NOT_FOUND', '未找到人物:' + q);
|
|
24
|
+
return rows[0];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function resolveForeshadow(sdb, value) {
|
|
28
|
+
const q = String(value || '').trim();
|
|
29
|
+
const rows = await sdb.rows(
|
|
30
|
+
`SELECT * FROM foreshadow WHERE story = ${STORY} AND (entity_id = $q OR name = $q) LIMIT 2;`,
|
|
31
|
+
{ q },
|
|
32
|
+
);
|
|
33
|
+
if (!rows.length) throw new StoryDbError('QUERY_NOT_FOUND', '未找到伏笔:' + q);
|
|
34
|
+
return rows[0];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function getStatus(sdb) {
|
|
38
|
+
const tables = [
|
|
39
|
+
'scale', 'volume', 'act', 'character', 'faction', 'location', 'item',
|
|
40
|
+
'conflict', 'foreshadow', 'payoff', 'climax', 'information',
|
|
41
|
+
'relation', 'chapter', 'knowledge_event', 'knowledge_state',
|
|
42
|
+
'chapter_metric', 'outline', 'chapter_text', 'ledger_entry',
|
|
43
|
+
];
|
|
44
|
+
const counts = {};
|
|
45
|
+
for (const table of tables) {
|
|
46
|
+
const rows = await sdb.rows(`SELECT count() FROM ${table} WHERE story = ${STORY} GROUP ALL;`);
|
|
47
|
+
counts[table] = Number(rows[0] && rows[0].count) || 0;
|
|
48
|
+
}
|
|
49
|
+
const stages = await sdb.rows(
|
|
50
|
+
`SELECT stage, status, checked_at, array::len(errors) AS error_count, array::len(warnings) AS warning_count
|
|
51
|
+
FROM stage_state WHERE story = ${STORY} ORDER BY stage;`,
|
|
52
|
+
);
|
|
53
|
+
const story = await sdb.one('SELECT * FROM story:main;');
|
|
54
|
+
return {
|
|
55
|
+
story: story || null,
|
|
56
|
+
stages,
|
|
57
|
+
counts,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function getCharacter(sdb, idOrName) {
|
|
62
|
+
const character = await resolveCharacter(sdb, idOrName);
|
|
63
|
+
const id = character.entity_id;
|
|
64
|
+
const record = recordOf('character', id);
|
|
65
|
+
const [relations, appearances, knowledge, events] = await Promise.all([
|
|
66
|
+
sdb.rows(
|
|
67
|
+
`SELECT from_id, to_id, kind, initial_state, changes
|
|
68
|
+
FROM relation WHERE story = ${STORY} AND (from_id = $id OR to_id = $id);`,
|
|
69
|
+
{ id },
|
|
70
|
+
),
|
|
71
|
+
sdb.rows(
|
|
72
|
+
`SELECT in.display_no AS chapter, in.title AS title, role
|
|
73
|
+
FROM chapter_cast WHERE story = ${STORY} AND out = ${record}
|
|
74
|
+
ORDER BY chapter;`,
|
|
75
|
+
),
|
|
76
|
+
sdb.rows(
|
|
77
|
+
`SELECT subject_type, subject_id, state
|
|
78
|
+
FROM knowledge_state WHERE story = ${STORY} AND character = ${record}
|
|
79
|
+
ORDER BY subject_type, subject_id;`,
|
|
80
|
+
),
|
|
81
|
+
sdb.rows(
|
|
82
|
+
`SELECT chapter_no, subject_type, subject_id, change, coverage, knowledge, how
|
|
83
|
+
FROM knowledge_event WHERE story = ${STORY} AND character_id = $id
|
|
84
|
+
ORDER BY chapter_no;`,
|
|
85
|
+
{ id },
|
|
86
|
+
),
|
|
87
|
+
]);
|
|
88
|
+
return {
|
|
89
|
+
character,
|
|
90
|
+
relations,
|
|
91
|
+
appearances,
|
|
92
|
+
knowledge,
|
|
93
|
+
knowledge_events: events,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function getForeshadow(sdb, idOrName) {
|
|
98
|
+
const foreshadow = await resolveForeshadow(sdb, idOrName);
|
|
99
|
+
const id = foreshadow.entity_id;
|
|
100
|
+
const record = recordOf('foreshadow', id);
|
|
101
|
+
const [actions, payoffs, knowledge, states] = await Promise.all([
|
|
102
|
+
sdb.rows(
|
|
103
|
+
`SELECT in.display_no AS chapter, in.title AS title, action, detail
|
|
104
|
+
FROM foreshadow_action WHERE story = ${STORY} AND out = ${record}
|
|
105
|
+
ORDER BY chapter;`,
|
|
106
|
+
),
|
|
107
|
+
sdb.rows(
|
|
108
|
+
`SELECT out.entity_id AS payoff, out.name AS name FROM foreshadow_payoff
|
|
109
|
+
WHERE story = ${STORY} AND in = ${record};`,
|
|
110
|
+
),
|
|
111
|
+
sdb.rows(
|
|
112
|
+
`SELECT chapter_no, character_id, change, coverage, knowledge, how
|
|
113
|
+
FROM knowledge_event
|
|
114
|
+
WHERE story = ${STORY} AND subject_type = 'foreshadow' AND subject_id = $id
|
|
115
|
+
ORDER BY chapter_no;`,
|
|
116
|
+
{ id },
|
|
117
|
+
),
|
|
118
|
+
sdb.rows(
|
|
119
|
+
`SELECT character.entity_id AS character, state FROM knowledge_state
|
|
120
|
+
WHERE story = ${STORY} AND subject_type = 'foreshadow' AND subject_id = $id
|
|
121
|
+
ORDER BY character;`,
|
|
122
|
+
{ id },
|
|
123
|
+
),
|
|
124
|
+
]);
|
|
125
|
+
const requiredBy = await sdb.rows(
|
|
126
|
+
`SELECT entity_id, name FROM payoff WHERE story = ${STORY} AND $id IN required_setup;`,
|
|
127
|
+
{ id },
|
|
128
|
+
);
|
|
129
|
+
return {
|
|
130
|
+
foreshadow,
|
|
131
|
+
actions,
|
|
132
|
+
linked_payoffs: payoffs,
|
|
133
|
+
required_by_payoffs: requiredBy,
|
|
134
|
+
knowledge_events: knowledge,
|
|
135
|
+
knowledge_states: states,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function getChapters(sdb, from, to) {
|
|
140
|
+
const start = Number(from) || 1;
|
|
141
|
+
const end = Number(to) || start;
|
|
142
|
+
const chapters = await sdb.rows(
|
|
143
|
+
`SELECT chapter_id, display_no, title, volume, act, pov, word_target,
|
|
144
|
+
emotion_value, hook_type, hook_strength
|
|
145
|
+
FROM chapter WHERE story = ${STORY} AND display_no >= $start AND display_no <= $end
|
|
146
|
+
ORDER BY display_no;`,
|
|
147
|
+
{ start, end },
|
|
148
|
+
);
|
|
149
|
+
const metrics = await sdb.rows(
|
|
150
|
+
`SELECT chapter_no, emotion_value, hook_strength, word_target, word_count
|
|
151
|
+
FROM chapter_metric WHERE story = ${STORY} AND chapter_no >= $start AND chapter_no <= $end
|
|
152
|
+
ORDER BY chapter_no;`,
|
|
153
|
+
{ start, end },
|
|
154
|
+
);
|
|
155
|
+
const texts = await sdb.rows(
|
|
156
|
+
`SELECT display_no, word_count FROM chapter_text
|
|
157
|
+
WHERE story = ${STORY} AND display_no >= $start AND display_no <= $end
|
|
158
|
+
ORDER BY display_no;`,
|
|
159
|
+
{ start, end },
|
|
160
|
+
);
|
|
161
|
+
return { range: { from: start, to: end }, chapters, metrics, texts };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function getRelations(sdb, idOrName) {
|
|
165
|
+
const character = await resolveCharacter(sdb, idOrName);
|
|
166
|
+
const id = character.entity_id;
|
|
167
|
+
const rows = await sdb.rows(
|
|
168
|
+
`SELECT from_id, to_id, kind, initial_state, changes
|
|
169
|
+
FROM relation WHERE story = ${STORY} AND (from_id = $id OR to_id = $id);`,
|
|
170
|
+
{ id },
|
|
171
|
+
);
|
|
172
|
+
return { character: { id, name: character.name }, relations: rows };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function getKnowledge(sdb, idOrName, chapterNo) {
|
|
176
|
+
const character = await resolveCharacter(sdb, idOrName);
|
|
177
|
+
const id = character.entity_id;
|
|
178
|
+
if (chapterNo === undefined || chapterNo === null) {
|
|
179
|
+
const rows = await sdb.rows(
|
|
180
|
+
`SELECT subject_type, subject_id, state FROM knowledge_state
|
|
181
|
+
WHERE story = ${STORY} AND character = ${recordOf('character', id)}
|
|
182
|
+
ORDER BY subject_type, subject_id;`,
|
|
183
|
+
);
|
|
184
|
+
return { character: { id, name: character.name }, at: 'current', knowledge: rows };
|
|
185
|
+
}
|
|
186
|
+
const chapter = Number(chapterNo);
|
|
187
|
+
const events = await sdb.rows(
|
|
188
|
+
`SELECT chapter_no, subject_type, subject_id, change, coverage, knowledge, how
|
|
189
|
+
FROM knowledge_event
|
|
190
|
+
WHERE story = ${STORY} AND character_id = $id AND chapter_no <= $chapter
|
|
191
|
+
ORDER BY chapter_no, id;`,
|
|
192
|
+
{ id, chapter },
|
|
193
|
+
);
|
|
194
|
+
const latest = new Map();
|
|
195
|
+
for (const event of events) {
|
|
196
|
+
latest.set(event.subject_type + ':' + event.subject_id, event);
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
character: { id, name: character.name },
|
|
200
|
+
at: chapter,
|
|
201
|
+
knowledge: [...latest.values()].map((event) => ({
|
|
202
|
+
subject_type: event.subject_type,
|
|
203
|
+
subject_id: event.subject_id,
|
|
204
|
+
state: event.coverage,
|
|
205
|
+
since_chapter: event.chapter_no,
|
|
206
|
+
change: event.change,
|
|
207
|
+
})),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function analyzeRelationChange(sdb, aRaw, bRaw, opts = {}) {
|
|
212
|
+
const a = await resolveCharacter(sdb, aRaw);
|
|
213
|
+
const b = await resolveCharacter(sdb, bRaw);
|
|
214
|
+
const aId = a.entity_id;
|
|
215
|
+
const bId = b.entity_id;
|
|
216
|
+
const fromChapter = Number(opts.fromChapter) || 1;
|
|
217
|
+
const newKind = opts.newKind || null;
|
|
218
|
+
|
|
219
|
+
const current = await sdb.rows(
|
|
220
|
+
`SELECT from_id, to_id, kind, initial_state, changes FROM relation
|
|
221
|
+
WHERE story = ${STORY}
|
|
222
|
+
AND ((from_id = $a AND to_id = $b) OR (from_id = $b AND to_id = $a));`,
|
|
223
|
+
{ a: aId, b: bId },
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const chapters = await sdb.rows(
|
|
227
|
+
`SELECT display_no, chapter_id, title FROM chapter
|
|
228
|
+
WHERE story = ${STORY} AND display_no >= $from
|
|
229
|
+
AND $a IN ->chapter_cast->character.entity_id
|
|
230
|
+
AND $b IN ->chapter_cast->character.entity_id
|
|
231
|
+
ORDER BY display_no;`,
|
|
232
|
+
{ from: fromChapter, a: aId, b: bId },
|
|
233
|
+
);
|
|
234
|
+
const chapterNos = chapters.map((row) => Number(row.display_no));
|
|
235
|
+
|
|
236
|
+
const conflicts = await sdb.rows(
|
|
237
|
+
`SELECT entity_id, name, type FROM conflict
|
|
238
|
+
WHERE story = ${STORY} AND $a IN participants AND $b IN participants;`,
|
|
239
|
+
{ a: aId, b: bId },
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const allActions = await sdb.rows(
|
|
243
|
+
`SELECT out.entity_id AS foreshadow, out.name AS name, in.display_no AS chapter, action, detail
|
|
244
|
+
FROM foreshadow_action WHERE story = ${STORY} AND in.display_no >= $from
|
|
245
|
+
ORDER BY chapter;`,
|
|
246
|
+
{ from: fromChapter },
|
|
247
|
+
);
|
|
248
|
+
const foreshadows = allActions.filter((row) => chapterNos.includes(Number(row.chapter)));
|
|
249
|
+
|
|
250
|
+
const knowledge = await sdb.rows(
|
|
251
|
+
`SELECT chapter_no, character_id, subject_type, subject_id, change, coverage
|
|
252
|
+
FROM knowledge_event WHERE story = ${STORY} AND chapter_no >= $from
|
|
253
|
+
AND character_id IN [$a, $b]
|
|
254
|
+
ORDER BY chapter_no;`,
|
|
255
|
+
{ from: fromChapter, a: aId, b: bId },
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const warnings = [];
|
|
259
|
+
if (!current.length) warnings.push('当前没有找到 ' + aId + ' 与 ' + bId + ' 的关系边');
|
|
260
|
+
if (!chapters.length) warnings.push('第 ' + fromChapter + ' 章之后没有两人同时出场的章节');
|
|
261
|
+
if (newKind && current.length && current[0].kind === newKind) warnings.push('新关系与当前关系相同:' + newKind);
|
|
262
|
+
for (const fs of foreshadows) {
|
|
263
|
+
if (fs.action === '回收') warnings.push('伏笔 ' + fs.foreshadow + ' 在第 ' + fs.chapter + ' 章回收,依赖当前关系');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
query: 'relation_change',
|
|
268
|
+
characters: [{ id: aId, name: a.name }, { id: bId, name: b.name }],
|
|
269
|
+
current_relation: current[0] || null,
|
|
270
|
+
change: { from_chapter: fromChapter, to_kind: newKind },
|
|
271
|
+
impact: {
|
|
272
|
+
chapters,
|
|
273
|
+
conflicts,
|
|
274
|
+
foreshadows,
|
|
275
|
+
knowledge_events: knowledge,
|
|
276
|
+
},
|
|
277
|
+
warnings,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function analyzeForeshadowChange(sdb, idOrName, opts = {}) {
|
|
282
|
+
const foreshadow = await resolveForeshadow(sdb, idOrName);
|
|
283
|
+
const id = foreshadow.entity_id;
|
|
284
|
+
const record = recordOf('foreshadow', id);
|
|
285
|
+
const action = opts.action || null;
|
|
286
|
+
const chapter = Number(opts.chapter) || null;
|
|
287
|
+
|
|
288
|
+
const actions = await sdb.rows(
|
|
289
|
+
`SELECT in.display_no AS chapter, in.chapter_id AS chapter_id, action, detail
|
|
290
|
+
FROM foreshadow_action WHERE story = ${STORY} AND out = ${record}
|
|
291
|
+
ORDER BY chapter;`,
|
|
292
|
+
);
|
|
293
|
+
const payoffs = await sdb.rows(
|
|
294
|
+
`SELECT out.entity_id AS payoff, out.name AS name FROM foreshadow_payoff
|
|
295
|
+
WHERE story = ${STORY} AND in = ${record};`,
|
|
296
|
+
);
|
|
297
|
+
const requiredBy = await sdb.rows(
|
|
298
|
+
`SELECT entity_id, name FROM payoff WHERE story = ${STORY} AND $id IN required_setup;`,
|
|
299
|
+
{ id },
|
|
300
|
+
);
|
|
301
|
+
const knowledge = await sdb.rows(
|
|
302
|
+
`SELECT chapter_no, character_id, change, coverage
|
|
303
|
+
FROM knowledge_event
|
|
304
|
+
WHERE story = ${STORY} AND subject_type = 'foreshadow' AND subject_id = $id
|
|
305
|
+
ORDER BY chapter_no;`,
|
|
306
|
+
{ id },
|
|
307
|
+
);
|
|
308
|
+
const chapters = await sdb.rows(
|
|
309
|
+
`SELECT display_no, chapter_id, title FROM chapter
|
|
310
|
+
WHERE story = ${STORY} AND $id IN ->foreshadow_action->foreshadow.entity_id
|
|
311
|
+
ORDER BY display_no;`,
|
|
312
|
+
{ id },
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const plant = actions.find((row) => row.action === '埋设');
|
|
316
|
+
const recover = actions.find((row) => row.action === '回收');
|
|
317
|
+
const warnings = [];
|
|
318
|
+
if (!plant) warnings.push('这条伏笔尚未埋设记录');
|
|
319
|
+
if (action === '回收') {
|
|
320
|
+
if (recover) warnings.push('这条伏笔已经在第 ' + recover.chapter + ' 章回收');
|
|
321
|
+
if (chapter && plant && Number(plant.chapter) >= chapter) {
|
|
322
|
+
warnings.push('回收章(' + chapter + ')必须晚于埋设章(' + plant.chapter + ')');
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (payoffs.length + requiredBy.length > 0 && !recover) {
|
|
326
|
+
warnings.push('有 ' + (payoffs.length + requiredBy.length) + ' 个爽点依赖这条伏笔,但尚未回收记录');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
query: 'foreshadow_change',
|
|
331
|
+
foreshadow,
|
|
332
|
+
change: { action, chapter },
|
|
333
|
+
timeline: actions,
|
|
334
|
+
linked_payoffs: payoffs,
|
|
335
|
+
required_by_payoffs: requiredBy,
|
|
336
|
+
knowledge_events: knowledge,
|
|
337
|
+
affected_chapters: chapters,
|
|
338
|
+
warnings,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const READ_ONLY = /^\s*(SELECT|INFO|RETURN|SHOW|DESCRIBE|EXPLAIN)\b/i;
|
|
343
|
+
|
|
344
|
+
export async function runReadOnlySql(sdb, sql) {
|
|
345
|
+
const text = String(sql || '').trim().replace(/;+\s*$/, '');
|
|
346
|
+
if (!text) throw new StoryDbError('QUERY_EMPTY', '查询语句为空');
|
|
347
|
+
if (!READ_ONLY.test(text)) {
|
|
348
|
+
throw new StoryDbError('QUERY_NOT_READ_ONLY', '只允许 SELECT / INFO / RETURN / SHOW / DESCRIBE / EXPLAIN 查询');
|
|
349
|
+
}
|
|
350
|
+
if (/;\s*(DELETE|CREATE|UPDATE|UPSERT|REMOVE|DEFINE|RELATE|INSERT|ALTER|KILL)\b/i.test(text)) {
|
|
351
|
+
throw new StoryDbError('QUERY_NOT_READ_ONLY', '查询中不能包含写操作');
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
const out = await sdb.query(text + ';');
|
|
355
|
+
return Array.isArray(out) && out.length === 1 ? out[0] : out;
|
|
356
|
+
} catch (err) {
|
|
357
|
+
throw new StoryDbError('QUERY_FAILED', '查询失败:' + dbErrorMessage(err));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export { resolveCharacter, resolveForeshadow };
|