@cloud411716/fancy-webnovel 2.2.0 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +65 -0
  2. package/client.js +201 -15
  3. package/cordis.patch.yml +5 -1
  4. package/docs/douyin-plugin.md +118 -0
  5. package/docs/material-library/README.md +404 -0
  6. package/docs/material-library/import.js +437 -0
  7. package/docs/material-library/prompt.md +370 -0
  8. package/docs/material-library/queries.surql +94 -0
  9. package/docs/material-library/sample.jsonl +14 -0
  10. package/docs/material-library/schema.surql +46 -0
  11. package/docs/mcp-profile.example.yml +24 -0
  12. package/docs/story-graph-design.md +602 -0
  13. package/docs/story-graph-validation.md +81 -0
  14. package/docs/workspace-mcp.md +284 -0
  15. package/index.js +12 -7
  16. package/lib/atomic-write.js +109 -0
  17. package/lib/chapter-order.js +100 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +11 -11
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +31 -31
  23. package/lib/infra.js +29 -4
  24. package/lib/material-db.js +149 -0
  25. package/lib/path-guard.js +150 -0
  26. package/lib/projects.js +400 -0
  27. package/lib/scraper-utils.js +140 -0
  28. package/lib/surreal-server.js +338 -0
  29. package/lib/topic-decision.js +860 -0
  30. package/lib/topic-fields.js +251 -0
  31. package/lib/topic-schema.js +225 -0
  32. package/lib/workspace.js +501 -0
  33. package/package.json +31 -7
  34. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  35. package/plugins/fancy-analyze/index.js +1128 -77
  36. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  37. package/plugins/fancy-analyze/schema-analyze.md +355 -107
  38. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  39. package/plugins/fancy-analyze/summary-progress.js +71 -7
  40. package/plugins/fancy-analyze/templates.js +437 -139
  41. package/plugins/fancy-bootstrap/index.js +276 -131
  42. package/plugins/fancy-bootstrap/scripts/init.js +59 -25
  43. package/plugins/fancy-bootstrap/templates.js +35 -18
  44. package/plugins/fancy-db/index.js +172 -0
  45. package/plugins/fancy-douyin/deploy.js +134 -0
  46. package/plugins/fancy-douyin/index.js +551 -0
  47. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  48. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  49. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  50. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  51. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  52. package/plugins/fancy-douyin/state.js +197 -0
  53. package/plugins/fancy-douyin/templates.js +127 -0
  54. package/plugins/fancy-export/exporter.js +805 -0
  55. package/plugins/fancy-export/index.js +63 -0
  56. package/plugins/fancy-import/import-driver.js +29 -6
  57. package/plugins/fancy-import/index.js +13 -5
  58. package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
  59. package/plugins/fancy-import/templates.js +17 -8
  60. package/plugins/fancy-material/exporter.js +153 -0
  61. package/plugins/fancy-material/genres.js +256 -0
  62. package/plugins/fancy-material/index.js +835 -0
  63. package/plugins/fancy-material/inventory.js +145 -0
  64. package/plugins/fancy-material/kinds.js +313 -0
  65. package/plugins/fancy-material/queue.js +55 -0
  66. package/plugins/fancy-material/scripts/query.js +254 -0
  67. package/plugins/fancy-material/templates.js +258 -0
  68. package/plugins/fancy-material/tool-deploy.js +138 -0
  69. package/plugins/fancy-material/validate.js +149 -0
  70. package/plugins/fancy-project/index.js +133 -0
  71. package/plugins/fancy-scan/existing-block.js +100 -0
  72. package/plugins/fancy-scan/index.js +410 -21
  73. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  74. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  75. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  76. package/plugins/fancy-scan/scripts/run-scan.js +169 -22
  77. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
  78. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
  79. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
  80. package/plugins/fancy-scan/templates/blocks.js +155 -0
  81. package/plugins/fancy-scan/templates/platforms.js +372 -0
  82. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  83. package/plugins/fancy-scan/templates.js +545 -146
  84. package/plugins/fancy-scan/tool-deploy.js +335 -0
  85. package/plugins/fancy-scan/wait-turn.js +46 -0
  86. package/plugins/fancy-story-builder/db/README.md +106 -0
  87. package/plugins/fancy-story-builder/db/client.js +228 -0
  88. package/plugins/fancy-story-builder/db/commands.js +145 -0
  89. package/plugins/fancy-story-builder/db/ids.js +39 -0
  90. package/plugins/fancy-story-builder/db/import.js +798 -0
  91. package/plugins/fancy-story-builder/db/project.js +299 -0
  92. package/plugins/fancy-story-builder/db/query.js +361 -0
  93. package/plugins/fancy-story-builder/db/schema.js +417 -0
  94. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  95. package/plugins/fancy-story-builder/flesh-output.js +19 -6
  96. package/plugins/fancy-story-builder/index.js +4105 -396
  97. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  98. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
  99. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
  100. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
  101. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
  102. package/plugins/fancy-story-builder/purpose-template.md +42 -66
  103. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
  104. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  105. package/plugins/fancy-story-builder/references/glossary.md +15 -6
  106. package/plugins/fancy-story-builder/references/index.md +97 -6
  107. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
  108. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
  109. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
  110. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
  111. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
  112. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
  113. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
  114. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
  115. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
  116. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  117. package/plugins/fancy-story-builder/schema-setting.md +201 -293
  118. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  119. package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
  120. package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
  121. package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
  122. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  123. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
  124. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  125. package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
  126. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
  127. package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
  128. package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
  129. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  130. package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
  131. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  132. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  133. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  134. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  135. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  136. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  137. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  138. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  139. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  140. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  141. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  142. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  143. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  144. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  145. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  146. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  147. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  148. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  149. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  150. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  151. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  152. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  153. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  154. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  155. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  156. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  157. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  158. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  159. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  160. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  161. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  162. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  163. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  164. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  165. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  166. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  167. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  168. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  169. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  170. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  171. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  172. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  173. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  174. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  175. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  176. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  177. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  178. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  179. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  180. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  181. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  182. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  183. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  184. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  185. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  186. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  187. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  188. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  189. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  190. 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
  191. 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
  192. 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
  193. 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
  194. 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
  195. 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
  196. 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
  197. 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
  198. 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
  199. 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
  200. 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
  201. 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
  202. 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
  203. package/plugins/fancy-story-builder/templates.js +272 -74
  204. package/plugins/fancy-story-builder/wiki-references.js +429 -93
  205. package/plugins/fancy-story-builder/writing-driver.js +336 -9
  206. package/plugins/fancy-topic/index.js +414 -61
  207. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  208. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  209. package/plugins/fancy-topic/scripts/select-topic.js +106 -63
  210. package/plugins/fancy-topic/templates.js +225 -122
  211. package/plugins/fancy-topic/tool-deploy.js +200 -0
  212. package/lib/safe-rm.js +0 -94
@@ -0,0 +1,195 @@
1
+ /**
2
+ * v2/genre.js —— 题材 profile:结构、倍率、卷级分配权重、质量阈值。
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const PLUGIN_FILE = path.join(__dirname, '..', '..', 'references', 'genre-profiles.json');
10
+
11
+ export const PROFILE_ALIASES = {
12
+ '玄幻': 'upgrade', '仙侠': 'upgrade', '升级流': 'upgrade',
13
+ '都市': 'urban', '都市爽文': 'urban',
14
+ '悬疑': 'suspense', '推理': 'suspense', '规则怪谈': 'suspense',
15
+ '言情': 'romance', '情感': 'romance',
16
+ '群像': 'ensemble', '正剧': 'ensemble',
17
+ '无限流': 'infinite', '副本': 'infinite',
18
+ '历史': 'history', '军事': 'history',
19
+ '末世': 'apocalypse', '科幻': 'apocalypse',
20
+ };
21
+
22
+ const MULTIPLIER_BY_FORMULA = {
23
+ 'character_elements.volume_supporting_count': 'volume_supporting',
24
+ 'character_elements.volume_antagonist_count': 'volume_antagonist',
25
+ 'conflict_elements.phase_conflict_count': 'conflicts',
26
+ 'conflict_elements.branch_conflict_count': 'conflicts',
27
+ 'faction_elements.faction_count': 'factions',
28
+ 'faction_elements.faction_relation_count': 'faction_relations',
29
+ 'location_elements.key_location_count': 'locations',
30
+ 'foreshadow_elements.small_foreshadow_count': 'foreshadows',
31
+ 'foreshadow_elements.medium_foreshadow_count': 'foreshadows',
32
+ 'foreshadow_elements.large_foreshadow_count': 'foreshadows',
33
+ 'climax_elements.small_climax_count': 'climaxes',
34
+ 'climax_elements.medium_climax_count': 'climaxes',
35
+ 'climax_elements.large_climax_count': 'climaxes',
36
+ 'pleasure_elements.small_pleasure_count': 'pleasures',
37
+ 'pleasure_elements.medium_pleasure_count': 'pleasures',
38
+ 'pleasure_elements.large_pleasure_count': 'pleasures',
39
+ 'item_elements.core_item_count': 'items',
40
+ 'item_elements.branch_item_count': 'items',
41
+ 'item_elements.symbolic_item_count': 'items',
42
+ 'world_rule_counts.core': 'world_rules',
43
+ 'world_rule_counts.information': 'world_rules',
44
+ 'world_rule_counts.social': 'world_rules',
45
+ 'fixed.character_elements.protagonist_count': 'protagonist',
46
+ 'fixed.character_elements.core_supporting_count': 'core_supporting',
47
+ 'fixed.character_elements.core_antagonist_count': 'core_antagonist',
48
+ 'fixed.conflict_elements.core_conflict_count': 'conflicts',
49
+ };
50
+
51
+ export function loadGenreProfiles(projectRoot) {
52
+ const candidates = [];
53
+ if (projectRoot) candidates.push(path.join(projectRoot, 'references', 'genre-profiles.json'));
54
+ if (projectRoot) candidates.push(path.join(projectRoot, '设定', '00-baseline', 'genre-profiles.json'));
55
+ candidates.push(PLUGIN_FILE);
56
+ for (const file of candidates) {
57
+ try {
58
+ const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
59
+ if (data && data.profiles) {
60
+ const base = data.base_overrides || {};
61
+ for (const profile of Object.values(data.profiles)) {
62
+ profile.overrides = { ...base, ...(profile.overrides || {}) };
63
+ }
64
+ return data;
65
+ }
66
+ } catch {
67
+ // try next
68
+ }
69
+ }
70
+ return {
71
+ version: '2.1',
72
+ base_words_per_chapter: 2000,
73
+ default_profile: 'default',
74
+ profiles: {
75
+ default: {
76
+ label: '通用',
77
+ structure: { type: 'serial', volume_chapters: [40, 120], preferred_chapters_per_volume: 80, acts: { lt10: 1, '10_25': 2, gt25: 3 } },
78
+ multipliers: {},
79
+ distribution_weights: {},
80
+ quality: {},
81
+ },
82
+ },
83
+ };
84
+ }
85
+
86
+ export function detectProfileKey(topicData, profilesDoc) {
87
+ const profiles = (profilesDoc && profilesDoc.profiles) || {};
88
+ const explicit = topicData && (topicData.genre_profile || topicData.genreProfile);
89
+ if (explicit && profiles[explicit]) return explicit;
90
+ const genre = String(topicData && (topicData.genre || topicData.category) || '');
91
+ if (genre && profiles[genre]) return genre;
92
+ for (const [key, val] of Object.entries(profiles)) {
93
+ const aliases = (val && val.aliases) || [];
94
+ if (aliases.some((alias) => alias && genre.includes(alias))) return key;
95
+ }
96
+ for (const [key, val] of Object.entries(profiles)) {
97
+ if (val && val.label && genre.includes(val.label)) return key;
98
+ }
99
+ for (const [alias, key] of Object.entries(PROFILE_ALIASES)) {
100
+ if (genre.includes(alias) && profiles[key]) return key;
101
+ }
102
+ const keys = Object.keys(profiles);
103
+ return profiles.default ? 'default' : (keys[0] || 'default');
104
+ }
105
+
106
+ export function getProfile(profilesDoc, key) {
107
+ const profiles = (profilesDoc && profilesDoc.profiles) || {};
108
+ const base = (profilesDoc && profilesDoc.base_overrides) || {};
109
+ const profile = profiles[key] || profiles.default || profiles[Object.keys(profiles)[0]] || {
110
+ label: '通用', structure: {}, multipliers: {}, distribution_weights: {}, quality: {},
111
+ };
112
+ return { ...profile, overrides: { ...base, ...(profile.overrides || {}) } };
113
+ }
114
+
115
+ export function multiplierForFormula(key, profile) {
116
+ const cat = MULTIPLIER_BY_FORMULA[key];
117
+ if (!cat) return 1;
118
+ const value = profile && profile.multipliers && profile.multipliers[cat];
119
+ return Number.isFinite(Number(value)) ? Number(value) : 1;
120
+ }
121
+
122
+ export function multiplierForCategory(category, profile) {
123
+ const value = profile && profile.multipliers && profile.multipliers[category];
124
+ return Number.isFinite(Number(value)) ? Number(value) : 1;
125
+ }
126
+
127
+ /**
128
+ * 把倍率作用到区间。低值向上取整,高值向下取整;
129
+ * 区间为空时以低值为准,保证 LLM 始终有合法值可选。
130
+ */
131
+ export function applyMultiplier(low, high, multiplier, opts = {}) {
132
+ const m = Number.isFinite(Number(multiplier)) && Number(multiplier) > 0 ? Number(multiplier) : 1;
133
+ const allowZero = Boolean(opts.allowZero);
134
+ const rawLow = Number(low);
135
+ const rawHigh = Number(high);
136
+ let nextLow = Math.ceil(rawLow * m);
137
+ let nextHigh = Math.floor(rawHigh * m);
138
+ if (!allowZero && rawLow > 0) nextLow = Math.max(1, nextLow);
139
+ if (nextHigh < nextLow) nextHigh = nextLow;
140
+ return { low: nextLow, high: nextHigh, multiplier: m };
141
+ }
142
+
143
+ export function structureVolumeRange(T, profile) {
144
+ const total = Number(T) || 0;
145
+ const cfg = profile && profile.structure && profile.structure.volume_chapters;
146
+ const minCh = cfg && Number(cfg[0]) > 0 ? Number(cfg[0]) : 40;
147
+ const maxCh = cfg && Number(cfg[1]) >= minCh ? Number(cfg[1]) : 120;
148
+ const low = Math.max(1, Math.ceil(total / maxCh));
149
+ const high = Math.max(low, Math.min(Math.max(1, Math.ceil(total / minCh)), 30));
150
+ return { low, high };
151
+ }
152
+
153
+ export function preferredVolumeCount(T, profile, low, high) {
154
+ const pref = Number(profile && profile.structure && profile.structure.preferred_chapters_per_volume);
155
+ const p = Number.isFinite(pref) && pref > 0 ? pref : 80;
156
+ return Math.min(high, Math.max(low, Math.round((Number(T) || 0) / p)));
157
+ }
158
+
159
+ export function actCountForChapters(chapters, profile) {
160
+ const n = Number(chapters) || 0;
161
+ const acts = (profile && profile.structure && profile.structure.acts) || {};
162
+ if (n < 10) return Number(acts.lt10) || 1;
163
+ if (n <= 25) return Number(acts['10_25']) || 2;
164
+ return Number(acts.gt25) || 3;
165
+ }
166
+
167
+ export function formulaOverrides(profile) {
168
+ return (profile && profile.overrides) || {};
169
+ }
170
+
171
+ /**
172
+ * 按卷位置生成权重。V=1 全是 1;V=2 用 first/last;
173
+ * V>=3 用 first + middle... + climax + last。
174
+ */
175
+ export function distributionWeights(profile, type, V) {
176
+ const n = Math.max(1, Number(V) || 1);
177
+ const cfg = (profile && profile.distribution_weights && profile.distribution_weights[type]) || {};
178
+ const first = Number.isFinite(Number(cfg.first)) ? Number(cfg.first) : 1;
179
+ const middle = Number.isFinite(Number(cfg.middle)) ? Number(cfg.middle) : 1;
180
+ const climax = Number.isFinite(Number(cfg.climax)) ? Number(cfg.climax) : middle;
181
+ const last = Number.isFinite(Number(cfg.last)) ? Number(cfg.last) : middle;
182
+ if (n === 1) return [1];
183
+ if (n === 2) return [first, last];
184
+ const out = [first];
185
+ for (let i = 1; i < n - 2; i += 1) out.push(middle);
186
+ out.push(climax);
187
+ out.push(last);
188
+ return out.slice(0, n);
189
+ }
190
+
191
+ export function qualityOf(profile, key, fallback) {
192
+ const q = (profile && profile.quality) || {};
193
+ const value = q[key];
194
+ return value === undefined ? fallback : value;
195
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * v2/knowledge.js —— 角色知识时间线。
3
+ *
4
+ * 设计原则:知识不是“某一章谁在谁不在”的静态名单,而是一条可回放的事件流:
5
+ *
6
+ * subject_type + subject_id + character + chapter
7
+ * + change(获知/部分获知/确认/纠正/遗忘/误信)
8
+ * + coverage(unknown/partial/full/false)
9
+ * + knowledge(具体知道了什么)
10
+ * + how(亲眼看到/他人告知/推断/文书/传闻/亲身经历/其他)
11
+ * + source_character / scene / evidence
12
+ *
13
+ * 只要按章节顺序回放事件,就能知道每个角色在任意章节结束时到底知道什么。
14
+ * 这样“第 10 章已经知道,第 20 章又写不知道”会在结构化数据层直接报错。
15
+ */
16
+ import { asArray } from './core.js';
17
+
18
+ export const KNOWLEDGE_SUBJECT_TYPES = ['information', 'foreshadow'];
19
+ export const KNOWLEDGE_STATES = ['unknown', 'partial', 'full', 'false'];
20
+ export const KNOWLEDGE_CHANGES = ['learns', 'partially_learns', 'confirms', 'corrects', 'forgets', 'misinforms'];
21
+ export const KNOWLEDGE_HOWS = ['witnessed', 'told', 'inferred', 'document', 'rumor', 'experience', 'other', 'none'];
22
+
23
+ export function knowledgeKey(subjectType, subjectId, character) {
24
+ return [subjectType, subjectId, character].map(String).join('|');
25
+ }
26
+
27
+ export function eventKey(event) {
28
+ return [event && event.subject_type, event && event.subject_id, event && event.character, event && event.change, event && event.coverage].map(String).join('|');
29
+ }
30
+
31
+ export function stateAfterChange(change, coverage) {
32
+ if (coverage) return coverage;
33
+ if (change === 'forgets') return 'unknown';
34
+ if (change === 'misinforms') return 'false';
35
+ if (change === 'confirms') return 'full';
36
+ if (change === 'partially_learns') return 'partial';
37
+ return 'full';
38
+ }
39
+
40
+ function infoIds(cast) {
41
+ return new Set(asArray(cast && cast.information && cast.information.information_gaps).map((g) => g && g.id).filter(Boolean));
42
+ }
43
+
44
+ function foreshadowIds(cast) {
45
+ return new Set(asArray(cast && cast.foreshadows && cast.foreshadows.foreshadows).map((g) => g && g.id).filter(Boolean));
46
+ }
47
+
48
+ function charIds(cast) {
49
+ return new Set(asArray(cast && cast.characters && cast.characters.characters).map((c) => c && c.id).filter(Boolean));
50
+ }
51
+
52
+ function knownCharacters(value) {
53
+ return new Set(asArray(value).map(String));
54
+ }
55
+
56
+ /** 从 30-cast 的初始知识/旧版 who_knows 生成时间线起点。 */
57
+ export function initialKnowledgeStates(cast) {
58
+ const states = new Map();
59
+ for (const gap of asArray(cast && cast.information && cast.information.information_gaps)) {
60
+ if (!gap || !gap.id) continue;
61
+ const rows = asArray(gap.initial_knowledge);
62
+ if (rows.length) {
63
+ for (const row of rows) {
64
+ if (!row || !row.character) continue;
65
+ states.set(knowledgeKey('information', gap.id, row.character), {
66
+ state: KNOWLEDGE_STATES.includes(row.state) ? row.state : 'unknown',
67
+ coverage: KNOWLEDGE_STATES.includes(row.coverage) ? row.coverage : (row.state || 'unknown'),
68
+ knowledge: row.knowledge || '',
69
+ how: row.how || 'none',
70
+ source_character: row.source_character || null,
71
+ scene: row.scene || '',
72
+ evidence: row.evidence || '',
73
+ chapter: 0,
74
+ });
75
+ }
76
+ continue;
77
+ }
78
+ // 旧版字段的语义:who_knows = 开篇已完全知道;who_does_not_know = 不知道。
79
+ for (const character of knownCharacters(gap.who_knows)) {
80
+ states.set(knowledgeKey('information', gap.id, character), {
81
+ state: 'full', coverage: 'full', knowledge: gap.info_name || '', how: 'none',
82
+ source_character: null, scene: '', evidence: '', chapter: 0,
83
+ });
84
+ }
85
+ for (const character of knownCharacters(gap.who_does_not_know)) {
86
+ const key = knowledgeKey('information', gap.id, character);
87
+ if (!states.has(key)) {
88
+ states.set(key, {
89
+ state: 'unknown', coverage: 'unknown', knowledge: '不知道这条信息', how: 'none',
90
+ source_character: null, scene: '', evidence: '', chapter: 0,
91
+ });
92
+ }
93
+ }
94
+ }
95
+ for (const fs of asArray(cast && cast.foreshadows && cast.foreshadows.foreshadows)) {
96
+ if (!fs || !fs.id) continue;
97
+ for (const row of asArray(fs.initial_knowledge)) {
98
+ if (!row || !row.character) continue;
99
+ states.set(knowledgeKey('foreshadow', fs.id, row.character), {
100
+ state: KNOWLEDGE_STATES.includes(row.state) ? row.state : 'unknown',
101
+ coverage: KNOWLEDGE_STATES.includes(row.coverage) ? row.coverage : (row.state || 'unknown'),
102
+ knowledge: row.knowledge || '',
103
+ how: row.how || 'none',
104
+ source_character: row.source_character || null,
105
+ scene: row.scene || '',
106
+ evidence: row.evidence || '',
107
+ chapter: 0,
108
+ });
109
+ }
110
+ }
111
+ return states;
112
+ }
113
+
114
+ export function validateInitialKnowledge(rows, ctx = {}) {
115
+ const errors = [];
116
+ const warnings = [];
117
+ const characters = charIds(ctx.cast);
118
+ const seen = new Set();
119
+ asArray(rows).forEach((row, index) => {
120
+ const p = (ctx.path || 'initial_knowledge') + '[' + index + ']';
121
+ if (!row || !row.character) {
122
+ errors.push({ code: 'KNOWLEDGE_INITIAL_CHARACTER', path: p + '.character', message: '初始知识必须写人物' });
123
+ return;
124
+ }
125
+ if (!characters.has(row.character)) {
126
+ errors.push({ code: 'KNOWLEDGE_INITIAL_CHARACTER_REF', path: p + '.character', message: '初始知识引用了不存在的人物:' + row.character });
127
+ }
128
+ const key = String(row.character);
129
+ if (seen.has(key)) errors.push({ code: 'KNOWLEDGE_INITIAL_DUP', path: p + '.character', message: '同一人物重复登记初始知识:' + row.character });
130
+ seen.add(key);
131
+ if (!KNOWLEDGE_STATES.includes(row.state)) {
132
+ errors.push({ code: 'KNOWLEDGE_INITIAL_STATE', path: p + '.state', message: '初始知识状态必须是 unknown/partial/full/false', actual: row.state });
133
+ }
134
+ if (!KNOWLEDGE_HOWS.includes(row.how || 'none')) {
135
+ errors.push({ code: 'KNOWLEDGE_INITIAL_HOW', path: p + '.how', message: '初始知识的来源方式不合法', actual: row.how });
136
+ }
137
+ if ((row.how === 'told') && !row.source_character) {
138
+ errors.push({ code: 'KNOWLEDGE_INITIAL_SOURCE', path: p + '.source_character', message: 'how=told 时必须写 source_character' });
139
+ }
140
+ if (row.source_character && !characters.has(row.source_character)) {
141
+ errors.push({ code: 'KNOWLEDGE_INITIAL_SOURCE_REF', path: p + '.source_character', message: '初始知识来源人物不存在:' + row.source_character });
142
+ }
143
+ if (!row.knowledge || !String(row.knowledge).trim()) {
144
+ warnings.push({ code: 'KNOWLEDGE_INITIAL_EMPTY', path: p + '.knowledge', message: '初始知识没有写具体知道/不知道什么' });
145
+ }
146
+ });
147
+ return { errors, warnings };
148
+ }
149
+
150
+ /** 单条知识事件的结构校验。 */
151
+ export function validateKnowledgeEvent(event, ctx = {}) {
152
+ const errors = [];
153
+ const warnings = [];
154
+ const p = ctx.path || 'knowledge_events';
155
+ if (!event || typeof event !== 'object') {
156
+ errors.push({ code: 'KNOWLEDGE_EVENT_OBJECT', path: p, message: '知识事件必须是对象' });
157
+ return { errors, warnings };
158
+ }
159
+ if (!KNOWLEDGE_SUBJECT_TYPES.includes(event.subject_type)) {
160
+ errors.push({ code: 'KNOWLEDGE_SUBJECT_TYPE', path: p + '.subject_type', message: 'subject_type 必须是 information/foreshadow', actual: event.subject_type });
161
+ }
162
+ const subjectSet = event.subject_type === 'information' ? infoIds(ctx.cast) : foreshadowIds(ctx.cast);
163
+ if (!event.subject_id || !subjectSet.has(event.subject_id)) {
164
+ errors.push({ code: 'KNOWLEDGE_SUBJECT_REF', path: p + '.subject_id', message: '知识事件引用了不存在的对象:' + event.subject_id });
165
+ }
166
+ if (!event.character || !charIds(ctx.cast).has(event.character)) {
167
+ errors.push({ code: 'KNOWLEDGE_CHARACTER_REF', path: p + '.character', message: '知识事件引用了不存在的人物:' + event.character });
168
+ }
169
+ if (!KNOWLEDGE_CHANGES.includes(event.change)) {
170
+ errors.push({ code: 'KNOWLEDGE_CHANGE', path: p + '.change', message: 'change 必须是 learns/partially_learns/confirms/corrects/forgets/misinforms', actual: event.change });
171
+ }
172
+ if (!KNOWLEDGE_STATES.includes(event.coverage)) {
173
+ errors.push({ code: 'KNOWLEDGE_COVERAGE', path: p + '.coverage', message: 'coverage 必须是 unknown/partial/full/false', actual: event.coverage });
174
+ } else {
175
+ const expected = {
176
+ learns: ['partial', 'full'],
177
+ partially_learns: ['partial'],
178
+ confirms: ['full'],
179
+ corrects: ['partial', 'full'],
180
+ forgets: ['unknown'],
181
+ misinforms: ['false'],
182
+ }[event.change] || [];
183
+ if (expected.length && !expected.includes(event.coverage)) {
184
+ errors.push({ code: 'KNOWLEDGE_COVERAGE_CHANGE', path: p + '.coverage', message: 'change 与 coverage 不匹配', expected: expected.join('/'), actual: event.coverage });
185
+ }
186
+ }
187
+ if (!KNOWLEDGE_HOWS.includes(event.how)) {
188
+ errors.push({ code: 'KNOWLEDGE_HOW', path: p + '.how', message: 'how 必须是 witnessed/told/inferred/document/rumor/experience/other', actual: event.how });
189
+ }
190
+ if (event.how === 'told' && !event.source_character) {
191
+ errors.push({ code: 'KNOWLEDGE_SOURCE', path: p + '.source_character', message: 'how=told 时必须写 source_character' });
192
+ }
193
+ if (event.source_character && !charIds(ctx.cast).has(event.source_character)) {
194
+ errors.push({ code: 'KNOWLEDGE_SOURCE_REF', path: p + '.source_character', message: '来源人物不存在:' + event.source_character });
195
+ }
196
+ if (event.source_character && event.source_character === event.character) {
197
+ errors.push({ code: 'KNOWLEDGE_SOURCE_SELF', path: p + '.source_character', message: '来源人物不能是自己' });
198
+ }
199
+ if (!event.knowledge || !String(event.knowledge).trim()) {
200
+ errors.push({ code: 'KNOWLEDGE_WHAT', path: p + '.knowledge', message: '必须写清楚角色具体知道了什么' });
201
+ }
202
+ if (!event.scene || !String(event.scene).trim()) {
203
+ warnings.push({ code: 'KNOWLEDGE_SCENE', path: p + '.scene', message: '建议写清楚在什么场景/地点知道' });
204
+ }
205
+ if (!event.evidence || !String(event.evidence).trim()) {
206
+ warnings.push({ code: 'KNOWLEDGE_EVIDENCE', path: p + '.evidence', message: '建议写清楚依据/看到/听到了什么' });
207
+ }
208
+ if (ctx.chapter && event.character && ctx.chapter.cast && !asArray(ctx.chapter.cast).map(String).includes(String(event.character))
209
+ && !asArray(ctx.chapter.mentioned).map(String).includes(String(event.character))) {
210
+ warnings.push({ code: 'KNOWLEDGE_CHARACTER_NOT_PRESENT', path: p + '.character', message: '人物既不在本章出场也不在提及列表里' });
211
+ }
212
+ return { errors, warnings };
213
+ }
214
+
215
+ /** 回放全书知识事件,返回每个章节结束时的知识快照。 */
216
+ export function replayKnowledge(chapters, cast) {
217
+ const states = initialKnowledgeStates(cast);
218
+ const snapshots = new Map();
219
+ const events = [];
220
+ const errors = [];
221
+ const warnings = [];
222
+ const sorted = [...asArray(chapters)].sort((a, b) => Number(a.display_no) - Number(b.display_no));
223
+ for (const chapter of sorted) {
224
+ const displayNo = Number(chapter.display_no);
225
+ for (const event of asArray(chapter.knowledge_events)) {
226
+ const checked = validateKnowledgeEvent(event, { cast, chapter, path: 'chapter_map.chapters[' + (displayNo - 1) + '].knowledge_events' });
227
+ errors.push(...checked.errors);
228
+ warnings.push(...checked.warnings);
229
+ const key = knowledgeKey(event.subject_type, event.subject_id, event.character);
230
+ const previous = states.get(key) || { state: 'unknown', coverage: 'unknown' };
231
+ const nextState = stateAfterChange(event.change, event.coverage);
232
+ const basePath = 'chapter_map.chapters[' + (displayNo - 1) + '].knowledge_events';
233
+ if (previous.state === 'full' && ['learns', 'partially_learns', 'misinforms'].includes(event.change)) {
234
+ errors.push({
235
+ code: 'KNOWLEDGE_ORDER',
236
+ path: basePath,
237
+ message: '第 ' + displayNo + ' 章:' + event.character + ' 已经知道 ' + event.subject_id + ',不能再次写成“' + event.change + '”',
238
+ expected: '已知道 full',
239
+ actual: previous.state + ' -> ' + nextState,
240
+ });
241
+ }
242
+ if (previous.state === 'false' && ['learns', 'partially_learns', 'confirms'].includes(event.change)) {
243
+ errors.push({
244
+ code: 'KNOWLEDGE_ORDER',
245
+ path: basePath,
246
+ message: '第 ' + displayNo + ' 章:' + event.character + ' 对 ' + event.subject_id + ' 之前是误信,必须先 corrects 才能写成 ' + event.change,
247
+ expected: 'corrects',
248
+ actual: previous.state + ' -> ' + nextState,
249
+ });
250
+ }
251
+ if (previous.state === 'unknown' && event.change === 'confirms') {
252
+ errors.push({
253
+ code: 'KNOWLEDGE_ORDER',
254
+ path: basePath,
255
+ message: '第 ' + displayNo + ' 章:' + event.character + ' 尚未学会 ' + event.subject_id + ',不能直接 confirm',
256
+ expected: 'learns/partially_learns',
257
+ actual: 'unknown -> full',
258
+ });
259
+ }
260
+ if (previous.state === 'unknown' && event.change === 'forgets') {
261
+ warnings.push({
262
+ code: 'KNOWLEDGE_FORGET_UNKNOWN',
263
+ path: basePath,
264
+ message: '第 ' + displayNo + ' 章:' + event.character + ' 本来不知道 ' + event.subject_id + ',forgets 没有实际变化',
265
+ });
266
+ }
267
+ if (previous.state === 'full' && event.change === 'confirms') {
268
+ warnings.push({
269
+ code: 'KNOWLEDGE_REDUNDANT',
270
+ path: basePath,
271
+ message: '第 ' + displayNo + ' 章:' + event.character + ' 已完全知道 ' + event.subject_id + ',重复 confirm',
272
+ });
273
+ }
274
+ const next = {
275
+ state: nextState,
276
+ coverage: event.coverage || nextState,
277
+ knowledge: event.knowledge || '',
278
+ how: event.how || 'other',
279
+ source_character: event.source_character || null,
280
+ scene: event.scene || '',
281
+ evidence: event.evidence || '',
282
+ chapter: displayNo,
283
+ };
284
+ states.set(key, next);
285
+ events.push({ ...event, chapter: displayNo, before: previous.state, after: next.state });
286
+ }
287
+ snapshots.set(displayNo, new Map(states));
288
+ }
289
+ return { states, snapshots, events, errors, warnings };
290
+ }
291
+
292
+ /** 校验细纲/台账里的知识快照是否与章节地图回放一致。 */
293
+ export function validateKnowledgeSnapshot(rows, expectedStates, ctx = {}) {
294
+ const errors = [];
295
+ const warnings = [];
296
+ asArray(rows).forEach((row, index) => {
297
+ if (!row || typeof row !== 'object') return;
298
+ const p = (ctx.path || 'knowledge') + '[' + index + ']';
299
+ if (!row.subject_type || !row.subject_id || !row.character) {
300
+ errors.push({ code: 'KNOWLEDGE_SNAPSHOT_FIELDS', path: p, message: '知识快照必须写 subject_type、subject_id、character' });
301
+ return;
302
+ }
303
+ const key = knowledgeKey(row.subject_type, row.subject_id, row.character);
304
+ const expected = expectedStates.get(key);
305
+ const state = row.state || row.coverage;
306
+ if (!expected) {
307
+ warnings.push({ code: 'KNOWLEDGE_SNAPSHOT_UNKNOWN', path: p, message: '章节地图没有登记这条知识:' + key });
308
+ return;
309
+ }
310
+ if (state && state !== expected.state) {
311
+ errors.push({
312
+ code: 'KNOWLEDGE_CONFLICT',
313
+ path: p + '.state',
314
+ message: '第 ' + (ctx.chapterNo || '?') + ' 章:' + row.character + ' 对 ' + row.subject_id + ' 的状态与章节地图冲突',
315
+ expected: expected.state,
316
+ actual: state,
317
+ });
318
+ }
319
+ });
320
+ return { errors, warnings };
321
+ }