import assert from 'node:assert/strict'; import { CHAPTERS } from '../src/engine/chapters'; import { CONSENT_VERSION, StoryEngine } from '../src/engine/engine'; import type { EventStore, StoryEvent } from '../src/engine/eventStore'; import { deriveFlags } from '../src/engine/flags'; import { MemoryProgressStore } from '../src/engine/progress'; import { buildState } from '../src/engine/scoring'; import { MockJevClient } from '../src/services/jev'; import type { StoryNode } from '../src/engine/types'; import { FOLLOW_UP_DIMENSIONS } from '../src/engine/types'; class MemoryStore implements EventStore { events: StoryEvent[] = []; async append(e: StoryEvent) { this.events.push(e); } async list(session?: string) { return this.events.filter((e) => !session || e.sessionId === session); } async purge() { this.events = []; } } const pointIds = new Set(); { const store = new MemoryStore(); store.events.push({ id: 'old', ts: 1, sessionId: 'old', chapterId: 'chapter1', kind: 'consent', pointId: 'CONSENT', dimensions: [], consentVersion: 'act1-consent-v1' }); const engine = new StoryEngine(CHAPTERS, store, new MockJevClient()); assert.equal(await engine.hasConsent(), false); await engine.grantConsent(); assert.equal(await engine.hasConsent(), true); assert.equal(store.events.at(-1)?.consentVersion, CONSENT_VERSION); assert.equal(store.events[0].consentVersion, 'act1-consent-v1'); } console.log('✓ 新版在线处理告知需重新确认,旧同意存证保留'); let optionCount = 0; for (const [i, chapter] of CHAPTERS.entries()) { const nodes = new Map(chapter.nodes.map((n) => [n.id, n])); assert.equal(nodes.size, chapter.nodes.length, `${chapter.id} 节点唯一`); const seen = new Set(); const visiting = new Set(); const visit = (id: string) => { assert(!visiting.has(id), `${chapter.id}/${id} 循环`); if (seen.has(id)) return; const n = nodes.get(id); assert(n, `${chapter.id}/${id} 缺失`); visiting.add(id); if (n.type === 'chapterEnd') assert.equal(n.nextChapter, CHAPTERS[i + 1]?.id); else if (n.type === 'choice') { assert(n.options.length >= 2); assert.equal(new Set(n.options.map((o) => o.id)).size, n.options.length); for (const o of n.options) visit(o.next); optionCount += n.options.length; if (n.puzzle) { assert.equal(n.puzzle.hints.length, 3); for (const q of n.puzzle.questions) assert(q.answer >= 0 && q.answer < q.options.length); assert(n.options.some((o) => o.id.endsWith('-EXIT'))); } } else visit(n.next); visiting.delete(id); seen.add(id); }; visit(chapter.startNodeId); assert.equal(seen.size, nodes.size, `${chapter.id} 全部节点可达且可到章末`); for (const n of chapter.nodes) if ('pointId' in n) { assert(!pointIds.has(n.pointId), n.pointId); pointIds.add(n.pointId); if (n.type === 'narration') assert(n.variants.some((v) => v.signature.includes('沉默'))); } } assert.equal(pointIds.size, 51); console.log(`✓ 九章图结构、${pointIds.size} 个交互点、${optionCount} 条选项出口、谜题三档提示及沉默变体`); // 新增/改造讲述点八件套:C4-04/C5-03/C6-04/C7-06/C8-06/C9-03 六键追问模板 + maxQuestions=1 { const NEW_V = ['C4-04', 'C5-03', 'C6-04', 'C7-06', 'C8-06', 'C9-03']; const narrations = CHAPTERS.flatMap((c) => c.nodes).filter((n) => n.type === 'narration'); for (const pointId of NEW_V) { const n = narrations.find((n) => n.pointId === pointId); assert(n, `${pointId} 讲述点存在`); assert.equal(n!.followUp.maxQuestions, 1, `${pointId} maxQuestions`); for (const d of FOLLOW_UP_DIMENSIONS) { assert(typeof n!.followUps[d] === 'string' && n!.followUps[d]!.length > 0, `${pointId} followUps.${d}`); } assert(n!.variants.some((v) => v.signature.includes('沉默')), `${pointId} 沉默档变体`); } console.log('✓ 六个新增/改造讲述点:六键追问模板齐全、maxQuestions=1、沉默档变体在位'); } function texts(node: StoryNode): string[] { if (node.type === 'dialogue') return [node.text]; if (node.type === 'cutscene') return [node.caption]; if (node.type === 'choice') return [node.prompt, ...node.options.map((o) => o.label)]; return 'prompt' in node ? [node.prompt] : []; } async function step(e: StoryEngine, mode: number, picks: Record = {}) { const n = e.currentNode(); for (const text of texts(n)) assert(!e.renderText(text).includes('【若'), `条件泄漏 ${n.id}`); if (n.type === 'identity') await e.confirmIdentity({ name: '测试', age: '', city: '', gender: '', education: '', occupation: '' }); else if (n.type === 'narration') { if (mode === 0 || n.pointId === 'C3-02') await e.skipNarration(); else { await e.submitNarration('我喜欢回家后先安静喝杯茶,再慢慢讲今天的事。'); if (e.state.pendingFollowUp) await e.skipFollowUp(); } } else if (n.type === 'choice') { const selected = picks[n.pointId] ?? n.options[mode % n.options.length].id; await e.choose(selected, n.puzzle ? { answers: n.puzzle.questions.map((q) => q.answer), hintsUsed: mode % 4 } : undefined); } else await e.advance(); } for (const mode of [0, 1, 2]) { const store = new MemoryStore(); const progress = new MemoryProgressStore(); let engine = new StoryEngine(CHAPTERS, store, new MockJevClient(), undefined, progress); const visited = new Set(); for (let guard = 0; !engine.state.finished && guard < 1000; guard++) { visited.add(engine.state.chapterId); await step(engine, mode); if (engine.state.nodeId === 'c7-opening') { const restored = new StoryEngine(CHAPTERS, store, new MockJevClient(), undefined, progress); await restored.restore(progress.load()); assert.deepEqual(restored.state.flags, engine.state.flags); engine = restored; } } assert(engine.state.finished, `路线 ${mode} 必须结束`); assert.equal(visited.size, 9); assert.equal(new Set(store.events.map((e) => e.pointId)).size, 51); assert.deepEqual(deriveFlags(store.events), engine.state.flags); const state = buildState(store.events); for (const privatePoint of ['C3-02', 'C7-02', 'C9-05']) assert(!state.includes(privatePoint)); // 私密点事件本身也不带维度标签(仅叙事接续,不入画像) for (const e of store.events.filter((e) => ['C7-02', 'C9-05'].includes(e.pointId))) { assert.equal(e.dimensions.length, 0, `${e.pointId} 事件维度为空`); } assert(state.includes('情境「')); assert.equal(store.events.filter((e) => /^C[4-9]/.test(e.pointId) && e.kind === 'choice').every((e) => !!e.choicePrompt), true); } console.log('✓ 九章三条端到端路线:全沉默、讲述、谜题退出;跨幕恢复与旗标重建;私密节点隔离'); // 每个新增选项都实际运行至终点,不以图可达代替引擎验证。 for (const chapter of CHAPTERS.slice(3)) for (const node of chapter.nodes) if (node.type === 'choice') { for (const option of node.options) { const store = new MemoryStore(); const engine = new StoryEngine(CHAPTERS, store, new MockJevClient(), chapter.id); const seen = new Set(); for (let guard = 0; !engine.state.finished && guard < 1000; guard++) { seen.add(engine.state.nodeId); await step(engine, 0, { [node.pointId]: option.id }); } assert(engine.state.finished); assert(seen.has(option.next), `${node.pointId}/${option.id} 必须播放专属后续`); assert.equal(store.events.find((e) => e.pointId === node.pointId)?.optionId, option.id); } } console.log('✓ 全部新增选项逐一实走至季终,含三种治理结局与三种个人尾声'); for (const plan of ['delegate', 'observe', 'relay']) for (const ending of ['shared', 'local', 'pause']) { const store = new MemoryStore(); const engine = new StoryEngine(CHAPTERS, store, new MockJevClient(), 'chapter6'); let route = ''; let outcome = ''; for (let guard = 0; !engine.state.finished && guard < 500; guard++) { const n = engine.currentNode(); if (n.id === 'c8-route' && n.type === 'dialogue') route = engine.renderText(n.text); if (n.id === 'c9-institution' && n.type === 'dialogue') outcome = engine.renderText(n.text); await step(engine, 0, { 'C6-03': plan, 'C8-05': ending }); } assert(route.includes(plan === 'observe' ? '留在南岸' : plan === 'delegate' ? '禾记,弥核' : '转录来源')); assert(outcome.includes(ending === 'shared' ? '共同保管' : ending === 'local' ? '分存回函' : '空白的任务日历')); } console.log('✓ 3×3 见证方式与治理结局交叉验证:亲历/转录不混写,结局正确兑现'); // 结局轴 3:c9_05 → c9-ending-* 三路尾声各自兑现且汇合于季终 for (const [pick, node, marker] of [ ['together', 'c9-ending-together', '腾出一只手'], ['room', 'c9-ending-room', '自愿再开一次'], ['later', 'c9-ending-later', '走进这一日'], ] as const) { const store = new MemoryStore(); const engine = new StoryEngine(CHAPTERS, store, new MockJevClient(), 'chapter9'); const seen = new Set(); for (let guard = 0; !engine.state.finished && guard < 300; guard++) { seen.add(engine.state.nodeId); await step(engine, 0, { 'C9-05': pick }); } assert(engine.state.finished); assert(seen.has(node), `c9_05=${pick} 必须抵达 ${node}`); const endingNode = CHAPTERS[8].nodes.find((n) => n.id === node); assert(endingNode?.type === 'dialogue' && endingNode.text.includes(marker), `${node} 尾声文案`); assert.equal(engine.state.flags.c9_05, pick, '结局轴 3 变量自动派生'); } console.log('✓ 结局轴 3:c9_05 三路私密尾声各自兑现并汇合季终');