fixture-book.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. /**
  2. * 规模 fixture 造仓器(供 assembly-scale 等测试复用):
  3. * N 章已定稿书——每章确认细纲/定稿章/章摘要,每章 6 条账本(故事线/人物弧线/承诺各 1、线索 3)、
  4. * 3 条时间线;近期章条目保持活跃态,远期条目已兑现/已收,保证切片有界。
  5. */
  6. import * as fs from 'node:fs'
  7. import * as path from 'node:path'
  8. import { paths, seedMinDesign, serializeDocument } from '../../src/index'
  9. function pad4(n: number): string {
  10. return String(n).padStart(4, '0')
  11. }
  12. function outlineBody(chapter: number): string {
  13. return [
  14. '# 章细纲', '', '## 定位段', '', '### 来源窗口项及拆并关系', `章${chapter},单章承接上一章的城门盘问,不并其他窗口项。`, '',
  15. '### 章节功能', '', '- 〔硬〕开场点名主角现身', '- 〔硬〕守门老兵的盘问必须完成', '- 〔软〕铺出城门异响的余韵', '- 〔自由〕可带一两句市集白描', '',
  16. '### 视角与焦点', '主角限知视角,焦点落在盘问的攻防与城门的异样上,不越权写守军内部。', '',
  17. '### 时空锚定', '城门口,清晨', '',
  18. '### 起止边界', '从主角抵达城门开始,到发现异状收束;不写进城之后的事。', '',
  19. '### 故事线与承诺分配', '推进主线承诺:查明异响的来源;同时维持商队支线的既有节奏,不新增支线。', '',
  20. '### 信息边界', '只披露主角所见所闻;异响的真实来源本章不揭示,守军部署只写表象。', '',
  21. '### 情绪与节奏目标', '前段平缓铺陈市集,中段盘问渐紧,末段以异响收束留钩。', '',
  22. '### 前置条件核对结果', '已核对来源与窗口,前置设定均非留白;守门老兵人设已就绪。', '',
  23. '## 细纲段', '', '### 单元 1', '',
  24. '- 目标: 开场', '- 人物: 主角', '- 时空: 城门口', '- 行动/冲突: 盘问与发现', '- 信息披露: 异常线索', '- 状态变化: 从平静到警觉', '',
  25. '### 单元 2', '',
  26. '- 目标: 盘问', '- 人物: 主角、守门老兵', '- 时空: 城门内侧', '- 行动/冲突: 老兵盘查、主角应对', '- 信息披露: 通行文书', '- 状态变化: 由阻到放', '',
  27. '### 单元 3', '',
  28. '- 目标: 收束', '- 人物: 主角', '- 时空: 城门洞', '- 行动/冲突: 察觉异响', '- 信息披露: 异响细节', '- 状态变化: 警觉到行动', '',
  29. ].join('\n')
  30. }
  31. export function makeFixtureBook(root: string, chapters: number, opts?: { readonly 每卷章数?: number }): void {
  32. const perVolume = opts?.每卷章数 ?? 50
  33. seedMinDesign(root)
  34. // 主角条目写实(细纲来源引用点名 → 世界书切片为公共段)
  35. fs.mkdirSync(path.join(root, '世界书', '人物档案'), { recursive: true })
  36. fs.writeFileSync(path.join(root, '世界书', '人物档案', '主角.md'), serializeDocument({
  37. 名称: '主角', 类型: '人物档案', 性质: '设定', 状态: '已确认',
  38. }, [
  39. '主角,随身带着半枚来历不明的铜符。', '性格:话少而准,先看后动;遇险时优先脱身而非硬拼。',
  40. '身手:市井摔扑与短刀,不擅长途奔袭。', '眼下目标:凭通行文书入城,查明城门异响。',
  41. ].join('\n')), 'utf-8')
  42. // 现实尺寸的契约(十段中的「暂定与警告」以契约两部与卷纲段为公共底)
  43. fs.writeFileSync(path.join(root, paths.契约()), serializeDocument({ 状态: '已确认' }, [
  44. '# 契约', '',
  45. '## 叙事方式与文风基调', '',
  46. '全篇以主角限知视角推进,句子短促,画面先行,不解释情绪。', '对话承担性格:主角话少而准,老兵话多而绕;盘问场面以短句往返为主,不做大段说明。', '场面描写控制在三句以内,把笔墨留给交锋与转折;市集白描只取声音与气味两个通道。', '章节收束一律落在未决动作上,不写总结性旁白。', '数字与名物保持前文一致:通行文书、铜符、守军番号都不得凭空更换。', '',
  47. '## 创作禁区与不可妥协项', '',
  48. '- 〔硬〕主角不得死亡', '- 〔硬〕异响的真实来源在卷末前不得揭示', '- 〔硬〕不使用现代词汇', '- 〔软〕守军编制细节不展开', '- 〔软〕不过度堆叠身份谜团', '',
  49. ].join('\n')), 'utf-8')
  50. const ledger: Record<string, string[]> = {
  51. 故事线: ['# 故事线'],
  52. 人物弧线: ['# 人物弧线'],
  53. 承诺: ['# 承诺'],
  54. 线索: ['# 线索'],
  55. }
  56. const timeline: string[] = ['# 时间线']
  57. const volumes = new Set<number>()
  58. for (let i = 1; i <= chapters; i += 1) {
  59. const 卷 = Math.floor((i - 1) / perVolume) + 1
  60. volumes.add(卷)
  61. const 章名 = `章${i}`
  62. const recent = i > chapters - 10
  63. const ensure = (rel: string): void => fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true })
  64. ensure(paths.确认细纲(卷, i, 章名))
  65. ensure(paths.定稿章(卷, i, 章名))
  66. ensure(paths.章摘要(卷, i, 章名))
  67. fs.writeFileSync(
  68. path.join(root, paths.确认细纲(卷, i, 章名)),
  69. serializeDocument({ 状态: '已确认', 版本: 1, 来源引用: ['作品契约/契约.md@1', '世界书/人物档案/主角.md@1'] }, outlineBody(i)),
  70. 'utf-8',
  71. )
  72. const finalBody = `第${i}章正文开头。\n\n${'剧情依细纲推进。'.repeat(60)}\n\n本章收束于城门,主角正要进城。\n`
  73. fs.writeFileSync(path.join(root, paths.定稿章(卷, i, 章名)), serializeDocument({ 状态: '已定稿', 版本: 1 }, finalBody), 'utf-8')
  74. fs.writeFileSync(
  75. path.join(root, paths.章摘要(卷, i, 章名)),
  76. `# 章摘要\n\n第${i}章摘要:主角在城门盘问中发现异状,线索推进。章末状态:主角在城门,正要进城,异响未解。\n`,
  77. 'utf-8',
  78. )
  79. const activeMark = recent ? '进行中' : '已兑现'
  80. ledger['故事线'].push(`\n## 主线-${i}\n状态:${activeMark}\n计划来源:卷纲#${i}\n### 正文\n第${i}章主线推进。\n`)
  81. ledger['人物弧线'].push(`\n## 弧光-${i}\n状态:${activeMark}\n计划来源:卷纲#${i}\n### 正文\n第${i}章人物弧线推进。\n`)
  82. ledger['承诺'].push(`\n## 承诺-${i}\n状态:${activeMark}\n计划来源:卷纲#${i}\n### 正文\n第${i}章承诺推进。\n`)
  83. for (const n of [1, 2, 3]) {
  84. ledger['线索'].push(`\n## 线索-${i}-${n}\n状态:${recent ? '已埋' : '已收'}\n计划来源:卷纲#${i}\n埋设点:第${i}章\n预期兑现区间:第${i}-${i + 2}章\n### 正文\n第${i}章线索${n}埋设。\n`)
  85. }
  86. for (const n of [1, 2, 3]) {
  87. const hit = n === 1 && i % 50 === 0
  88. timeline.push(`\n## ${hit ? '城门异响' : `事件-${i}-${n}`}\n事件:${hit ? '城门口的异响' : `第${i}章事件${n}`}\n章号:第${pad4(i)}章\n### 正文\n${hit ? '城门口的异响被主角察觉。' : `第${i}章事件${n}的记录。`}\n`)
  89. }
  90. }
  91. for (const [kind, lines] of Object.entries(ledger)) {
  92. fs.mkdirSync(path.dirname(path.join(root, paths.账本(kind as '故事线'))), { recursive: true })
  93. fs.writeFileSync(path.join(root, paths.账本(kind as '故事线')), `${lines.join('\n')}\n`, 'utf-8')
  94. }
  95. fs.writeFileSync(path.join(root, paths.账本('时间线')), `${timeline.join('\n')}\n`, 'utf-8')
  96. for (const 卷 of volumes) {
  97. fs.writeFileSync(
  98. path.join(root, paths.卷摘要(卷)),
  99. `# 卷摘要\n\n第${String(卷).padStart(2, '0')}卷至今:主角以盘查立足,城门异响的线索逐步累积,商队支线与守军暗流并行。`,
  100. 'utf-8',
  101. )
  102. fs.writeFileSync(
  103. path.join(root, paths.卷纲(卷)),
  104. [
  105. '# 卷纲', '',
  106. '## 叙事结构 〔已确认〕', '',
  107. `- 开卷三章立住城门盘查的日常与暗流(章${(卷 - 1) * perVolume + 1}起;本卷起始章节:${Array.from({ length: Math.min(2, chapters - (卷 - 1) * perVolume) }, (_, i) => `章${(卷 - 1) * perVolume + i + 1}`).join('、')})`, '',
  108. '## 弧线 〔留白〕', '',
  109. '- 卷中以商队支线调剂节奏', '',
  110. '## 线索推进 〔留白〕', '',
  111. '- 卷末三章收束异响线索', '- 窗口项之间保持一天以内的连续时间', '- 商队接应点的具体位置〔暂定〕', '- 守军换岗的空档时刻〔暂定〕', '- 老兵的旧属番号〔留白〕', '',
  112. '## 卷末兑现 〔留白〕', '',
  113. `第${String(卷).padStart(2, '0')}卷推进主线至卷末高潮:城门异响牵出守军内情,主角在盘查与反盘查之间立足,兑现开卷立下的查明承诺。卷末留出跨卷钩子。`, '',
  114. ].join('\n'),
  115. 'utf-8',
  116. )
  117. }
  118. }