/** * 内容完整性与引擎流程验证(不走浏览器): * 1. 章节 JSON 的选择点 ID、维度标签与 docs/story-framework.md 第 4 节一一对应; * 2. 节点图完整(startNodeId 存在、所有 next 跳转有效、全部节点可达); * 3. 用 MockJevClient + 内存 EventStore 跑通三章 → 16 个选择点全部落事件 → Jev 7 维打分; * 4. 追问补全流程:缺失检测 → 追问事件 / 跳过事件("记不清了"合法)→ 回答合并进讲述文本; * 5. EventStore 各实现(含 SqliteEventStore)的 append-only 性质(接口层面断言无 update/delete; * SQLite 实连只能在 Tauri 运行时内进行,Node 下不实例化)。 * 6. 同意门(ADR-0004):consent 事件存证、幂等、跨会话跳过; * 7. 条件文本:flag 推导(附录 A)、【若】标记零泄漏、差分显隐、条件不满足节点跳过无死路; * 8. 删除全部个人数据(ADR-0004 数据删除权):purge 后事件列表为空、进度清空、 * consent 存证删除后回到未同意态、可重新同意并继续走剧情。 * 运行:pnpm verify */ import { readFileSync } from 'node:fs'; import { CONSENT_VERSION, StoryEngine } from '../src/engine/engine'; import { LocalStorageEventStore } from '../src/engine/eventStore'; import type { EventStore, StoryEvent } from '../src/engine/eventStore'; import { choiceFlags, deriveFlags, narrationFlags } from '../src/engine/flags'; import { buildState } from '../src/engine/scoring'; import { MemoryProgressStore } from '../src/engine/progress'; import { SqliteEventStore } from '../src/engine/sqliteEventStore'; import type { Chapter, ChoiceNode, IdentityProfile, NarrationNode } from '../src/engine/types'; import { FOLLOW_UP_DIMENSIONS } from '../src/engine/types'; import { MockJevClient } from '../src/services/jev'; import type { JevAnswer, JevQuestion } from '../src/services/jev'; // story-framework.md 第 4 节的权威选择点地图(ID → 期望维度标签) const EXPECTED: Record = { 'C1-01': [], 'C1-02': ['attachment', 'self_disclosure'], 'C1-03': ['values'], 'C1-04': ['values', 'conflict_style'], 'C1-05': ['culture'], 'C1-06': ['risk'], 'C2-01': ['values', 'conflict_style'], 'C2-02': ['values', 'emotional_stability'], 'C2-03': ['risk', 'conflict_style'], 'C2-04': ['culture'], 'C2-05': ['attachment', 'reciprocity'], 'C2-06': ['conflict_style', 'values'], 'C3-01': ['culture'], 'C3-02': [], 'C3-03': ['values'], 'C3-04': ['conflict_style', 'values'], 'C3-05': ['mate_preference'], 'C3-06': ['attachment', 'mate_preference'], }; class MemoryStore implements EventStore { events: StoryEvent[] = []; async append(e: StoryEvent) { this.events.push(e); } async list(sessionId?: string) { return sessionId ? this.events.filter((e) => e.sessionId === sessionId) : [...this.events]; } async purge() { this.events = []; } } const chapters = [1, 2, 3].map( (i) => JSON.parse(readFileSync(new URL(`../content/chapters/chapter${i}.json`, import.meta.url), 'utf8')) as Chapter, ); let failures = 0; function check(ok: boolean, msg: string) { if (!ok) { failures++; console.error(`✗ ${msg}`); } else { console.log(`✓ ${msg}`); } } // --- 1. 结构校验 --- const seenPoints = new Map(); for (const ch of chapters) { const ids = new Set(ch.nodes.map((n) => n.id)); check(ids.has(ch.startNodeId), `${ch.id}: startNodeId 存在`); const reachable = new Set([ch.startNodeId]); for (let changed = true; changed; ) { changed = false; for (const n of ch.nodes) { if (!reachable.has(n.id)) continue; const nexts: string[] = n.type === 'choice' ? n.options.map((o) => o.next) : n.type === 'chapterEnd' ? [] : [n.next]; for (const nx of nexts) { check(ids.has(nx), `${ch.id}/${n.id}: next "${nx}" 有效`); if (!reachable.has(nx)) { reachable.add(nx); changed = true; } } } } check(reachable.size === ch.nodes.length, `${ch.id}: 全部 ${ch.nodes.length} 个节点可达`); for (const n of ch.nodes) { if (n.type === 'choice' || n.type === 'narration' || n.type === 'identity') { check(!seenPoints.has(n.pointId), `${n.pointId}: 选择点 ID 全局唯一`); seenPoints.set(n.pointId, n.dimensions); } } } // --- 2. 与母本选择点地图对照 --- const expectedIds = Object.keys(EXPECTED).sort(); const actualIds = [...seenPoints.keys()].sort(); check(JSON.stringify(actualIds) === JSON.stringify(expectedIds), `选择点 ID 集合与母本一致(${actualIds.length} 个)`); for (const [id, dims] of Object.entries(EXPECTED)) { check( JSON.stringify(seenPoints.get(id) ?? null) === JSON.stringify(dims), `${id}: 维度标签 ${JSON.stringify(seenPoints.get(id))} == 母本 ${JSON.stringify(dims)}`, ); } // --- 3. V 类讲述点变体结构 --- const narrationNodes = chapters.flatMap((c) => c.nodes.filter((n) => n.type === 'narration')) as NarrationNode[]; check(narrationNodes.map((n) => n.pointId).sort().join(',') === 'C1-02,C2-02,C2-05,C3-02', '三个语音讲述点 + C3-02 私密路径节点均存在'); for (const n of narrationNodes) { check(n.variants.length >= 2, `${n.pointId}: 有 ${n.variants.length} 个预写场景变体`); check( n.variants.every((v) => v.signature && v.variantId && v.label), `${n.pointId}: 情绪签名 → variantId 映射完整`, ); } check(narrationNodes.find((n) => n.pointId === 'C3-02')?.variantFrom === 'C1-02', 'C3-02 变体由 C1-02 驱动(variantFrom)'); for (const n of narrationNodes) { check( n.pointId === 'C3-02' ? n.followUp.maxQuestions === 0 && Object.keys(n.followUps).length === 0 : FOLLOW_UP_DIMENSIONS.every((d) => typeof n.followUps?.[d] === 'string' && n.followUps[d]!.length > 0), `${n.pointId}: 六维 followUps 追问模板齐全`, ); } /** 自动遍历解谜点时提交真实两题答案,其他选择点无需行为参数。 */ function puzzleBehavior(node: ChoiceNode) { return node.puzzle ? { answers: node.puzzle.questions.map((q) => q.answer), hintsUsed: 0 } : undefined; } // --- 4. Mock 模式端到端跑通三章 --- const store = new MemoryStore(); const jev = new MockJevClient(); const engine = new StoryEngine(chapters, store, jev); let guard = 0; while (!engine.state.finished && guard++ < 200) { const node = engine.currentNode(); if (node.type === 'choice') { await engine.choose(node.options[0].id, puzzleBehavior(node)); } else if (node.type === 'identity') { const profile = await engine.extractIdentity('我叫阿黎,29岁,在杭州做互联网产品经理,硕士学历'); await engine.confirmIdentity(profile); } else if (node.type === 'narration') { // C2-05 走"沉默"路径验证合法选项,其余讲述;Mock 检测到缺失维度则回答追问 if ((node as NarrationNode).pointId === 'C2-05') await engine.skipNarration(); else if ((node as NarrationNode).pointId === 'C3-02') await engine.resolvePrivateIllusion('farewell_done'); else { await engine.submitNarration('(验证用占位讲述)'); if (engine.state.pendingFollowUp) await engine.answerFollowUp('(验证用追问回答)'); } } else { await engine.advance(); } } check(engine.state.finished, '三章流程跑通(Mock 模式)'); const eventPoints = store.events .filter((e) => e.kind === 'choice' || e.kind === 'narration' || e.kind === 'profile') .map((e) => e.pointId) .sort(); check( JSON.stringify(eventPoints) === JSON.stringify(expectedIds), `事件轨迹覆盖全部 ${expectedIds.length} 个选择点`, ); const c305 = store.events.find((e) => e.pointId === 'C3-02' && e.kind === 'narration'); const c102 = store.events.find((e) => e.pointId === 'C1-02' && e.kind === 'narration'); check(!!c305?.variantId && c305.variantId === c102?.variantId, 'C3-02 幻境变体复用 C1-02 情绪解析结果'); // C3-01 分题作答:事件、增量 flags 与事件重放结果必须一致。 { const puzzle = chapters[2].nodes.find((n) => n.id === 'c3-01') as ChoiceNode; check(!!puzzle.puzzle && puzzle.puzzle.questions.length === 2 && puzzle.puzzle.hints.length === 3, 'C3-01:两道独立选择题与三档提示'); for (const [correctCount, expected] of [[1, 'partial'], [2, 'true'], [0, 'false']] as const) { const s = new MemoryStore(); const e = new StoryEngine(chapters, s, new MockJevClient()); await e.restore({ sessionId: `c3-01-${correctCount}`, chapterId: 'chapter3', nodeId: puzzle.id, flags: {} }); const answers = puzzle.puzzle!.questions.map((q, i) => i < correctCount ? q.answer : (q.answer + 1) % q.options.length); await e.choose(puzzle.options[0].id, { answers, hintsUsed: 2 }); const event = s.events.at(-1); check(event?.behavior?.answers?.join(',') === answers.join(',') && event.behavior.answerCorrect?.join(',') === [correctCount > 0, correctCount > 1].join(',') && event.behavior.hintsUsed === 2, `C3-01 ${expected}:分题答案、正误与提示数落事件`); check(e.state.flags.c3_01_solved === expected && e.state.flags.c3_01_hints_used === '2' && deriveFlags(s.events).c3_01_solved === expected && deriveFlags(s.events).c3_01_hints_used === '2', `C3-01 ${expected}:即时与重放 flags 一致`); check(e.state.nodeId === 'c3-01-ok', `C3-01 ${expected}:提交后进入原有温简复原节点`); } } // --- 5. 画像报告:7 维 score 问题 --- const criteria = Array.from({ length: 10 }, (_, i) => `${i + 1} 级`); const dims = ['attachment', 'conflict_style', 'values', 'emotional_stability', 'risk', 'culture', 'mate_preference']; const answers = await jev.ask( store.events, Object.fromEntries(dims.map((d) => [d, { type: 'score' as const, instructions: d, criteria }])), ); check( dims.every((d) => answers[d]?.type === 'score' && answers[d].confidence >= 0 && answers[d].confidence <= 1), 'Jev 7 维 score 打分返回(含 0-1 置信度)', ); // --- 6. 重玩语义:新轨迹不修改旧事件 --- const before = store.events.length; const oldSession = engine.state.sessionId; engine.replay('chapter1'); check(engine.state.sessionId !== oldSession, '重玩产生新 sessionId'); while (engine.currentNode().type !== 'choice') { const n = engine.currentNode(); if (n.type === 'identity') { await engine.confirmIdentity(await engine.extractIdentity('占位自我介绍')); } else if (n.type === 'narration') { if (n.pointId === 'C3-02') { await engine.resolvePrivateIllusion('farewell_done'); continue; } await engine.submitNarration('(验证用占位讲述)'); if (engine.state.pendingFollowUp) await engine.answerFollowUp('(验证用追问回答)'); } else { await engine.advance(); } } const firstChoice = engine.currentNode() as ChoiceNode; await engine.choose(firstChoice.options[0].id); const added = store.events.slice(before); check( added.filter((e) => e.kind === 'profile').length === 1 && added.filter((e) => e.kind === 'narration').length === 1 && added.filter((e) => e.kind === 'choice').length === 1, `重玩追加新事件(profile + narration + choice,含追问事件共 ${added.length} 条),旧事件不变`, ); check((await store.list(oldSession)).length === before, '旧轨迹事件完整保留'); // --- 7. 追问补全流程(可控 noul 概率的 stub 驱动三条路径) --- /** noul 概率可控的 Jev stub:missing 里的维度返回 0.1(缺失),其余 0.9;非 noul 题走 Mock */ class FixedNoulJev extends MockJevClient { constructor(private missing: string[]) { super(); } override async ask(state: unknown, questions: Record): Promise> { const out = await super.ask(state, questions); for (const [id, q] of Object.entries(questions)) { if (q.type === 'noul') { out[id] = { type: 'noul', probability: this.missing.includes(id) ? 0.1 : 0.9, confidence: 0.9 }; } } return out; } } /** 推进到指定节点(途中选择点取第一项、身份点直接确认、讲述点沉默略过) */ async function walkTo(eng: StoryEngine, nodeId: string) { let g = 0; while (eng.currentNode().id !== nodeId && g++ < 250) { const n = eng.currentNode(); if (n.type === 'choice') await eng.choose(n.options[0].id, puzzleBehavior(n)); else if (n.type === 'identity') await eng.confirmIdentity(await eng.extractIdentity('占位自我介绍')); else if (n.type === 'narration') await eng.skipNarration(); else await eng.advance(); } } // 7a. 缺失检测 → 追问挂起 → 回答合并 { const s = new MemoryStore(); const eng = new StoryEngine(chapters, s, new FixedNoulJev(['visual'])); await walkTo(eng, 'c1-02'); const c102 = chapters[0].nodes.find((n) => n.id === 'c1-02') as NarrationNode; const r = await eng.submitNarration('去年夏天我离开了家乡'); const pending = eng.state.pendingFollowUp; check(r === null && pending?.dimension === 'visual', '缺失检测:视觉缺失(最高优先级)→ 追问挂起,不立即落 narration 事件'); check(pending?.prompt === c102.followUps.visual, '追问话术取自节点 JSON 的 followUps 模板'); check(eng.state.nodeId === 'c1-02', '追问挂起期间节点不推进'); let threw = false; try { await eng.submitNarration('重复提交'); } catch { threw = true; } check(threw, '追问挂起期间重复提交被拒(预算纪律:一个讲述点一次追问)'); const res = await eng.answerFollowUp('站台是灰蓝色的'); const narrationEv = s.events.find((e) => e.kind === 'narration'); const followupEv = s.events.find((e) => e.kind === 'followup'); check(!!followupEv && followupEv.followUpDimension === 'visual', '追问回答落 followup 事件(含缺失维度)'); check( !!narrationEv?.transcript?.includes('去年夏天我离开了家乡') && narrationEv.transcript.includes('站台是灰蓝色的'), '追问回答合并进讲述文本(state 合并后进情绪签名选择)', ); check(!!res.variant && eng.state.pendingFollowUp === null && eng.state.nodeId === 'c1-d4', '追问收尾:变体解析完成并推进,无第二次追问'); } // 7b. "记不清了"跳过 → followup_skipped 事件 { const s = new MemoryStore(); const eng = new StoryEngine(chapters, s, new FixedNoulJev(['person'])); await walkTo(eng, 'c2-02'); await eng.submitNarration('有件事我一直后悔'); check(eng.state.pendingFollowUp?.dimension === 'person', '缺失检测:人物缺失 → 追问挂起'); await eng.skipFollowUp(); const skipped = s.events.find((e) => e.kind === 'followup_skipped'); check(!!skipped && skipped.followUpDimension === 'person' && skipped.transcript === null, '跳过落 followup_skipped 事件(对什么沉默也是数据)'); const narrationEv = s.events.find((e) => e.kind === 'narration' && e.pointId === 'C2-02'); check(narrationEv?.transcript === '有件事我一直后悔', '跳过后讲述原文不变,流程继续'); } // 7c. 六维俱全 → 不追问,直接走完 { const s = new MemoryStore(); const eng = new StoryEngine(chapters, s, new FixedNoulJev([])); await walkTo(eng, 'c2-02'); const r = await eng.submitNarration('一段细节齐全的讲述'); check(r !== null && eng.state.pendingFollowUp === null, '无缺失维度时不触发追问'); check(!s.events.some((e) => e.kind === 'followup' || e.kind === 'followup_skipped'), '无追问时不产生追问事件'); } // --- 8. append-only:EventStore 实现不存在 update/delete 路径 --- // (SQLite 实连只能在 Tauri 运行时内进行,这里只做接口层面断言) for (const cls of [LocalStorageEventStore, SqliteEventStore, MemoryStore]) { const props = Object.getOwnPropertyNames(cls.prototype).filter((p) => p !== 'constructor'); check( !props.some((p) => ['update', 'delete', 'remove', 'set'].includes(p)), `${cls.name}: 只有 ${props.sort().join('/')}(append-only,无 update/delete)`, ); } // --- 9. 同意门(ADR-0004):consent 事件存证、幂等、跨会话跳过 --- { const s = new MemoryStore(); const eng = new StoryEngine(chapters, s, new MockJevClient()); check(!(await eng.hasConsent()), '同意门:无 consent 事件时需要展示同意门'); await eng.grantConsent(); const ev = s.events.find((e) => e.kind === 'consent'); check( !!ev && ev.consentVersion === CONSENT_VERSION && ev.ts > 0, `同意动作落 consent 事件(consentVersion=${ev?.consentVersion},含时间戳)`, ); await eng.grantConsent(); check(s.events.filter((e) => e.kind === 'consent').length === 1, '重复同意幂等(只存证一次)'); const eng2 = new StoryEngine(chapters, s, new MockJevClient()); check(await eng2.hasConsent(), 'consent 事件已存在则同意门跳过(全剧只出现一次)'); } // --- 10. 条件文本:flag 推导、标记不泄漏、差分显隐、空节点跳过 --- // 10a. 变量映射表(附录 A) check(choiceFlags('C2-03', 'c_scout').flag_fresh_collapse === 'true', 'flag 推导:C2-03 c_scout → flag_fresh_collapse=true'); check(choiceFlags('C2-01', 'b_exposure').wenjian_health === 'weak', 'flag 推导:C2-01 b_exposure → wenjian_health=weak'); check(choiceFlags('C2-01', 'a_weakest').wenjian_health === 'recovering', 'flag 推导:C2-01 a_weakest → wenjian_health=recovering'); check(choiceFlags('C2-01', 'd_process').wenjian_health === 'weak', 'flag 推导(S4 修订):C2-01 d_process → wenjian_health=weak(议事真空期老鬼先行分药)'); check(!('flag_prophecy_heard' in narrationFlags('C2-02', 'intel_full', '(讲述)')), 'flag 推导(S3 修订):flag_prophecy_heard 已废除,C2-02 四档等价'); check(narrationFlags('C3-02', undefined, null).c3_02_mode === undefined, 'C3-02 不从文本推导模式'); { const f = choiceFlags('C3-06', 'c_confide_wenjian'); check( f.c3_06_response === 'c_confide' && f.c3_06_confidant === 'wenjian' && f.flag_arch_doubt === 'true', 'flag 推导:C3-06 队友子分支 → c_confide / confidant=wenjian / flag_arch_doubt', ); } check(narrationFlags('C2-05', 'wenjian_close_late', '(讲述)').flag_wenjian_secret === 'true', 'flag 推导:C2-05 浅暴露 → flag_wenjian_secret'); /** 走查三章并收集途经节点与渲染文本;picker 可指定选择点的选项 */ async function walkAll(eng: StoryEngine, picker: Record = {}) { const seen: string[] = []; const rendered: string[] = []; let guard = 0; while (!eng.state.finished && guard++ < 250) { const node = eng.currentNode(); seen.push(node.id); if (node.type === 'dialogue') rendered.push(eng.renderText(node.text)); else if (node.type === 'cutscene') rendered.push(eng.renderText(node.caption)); else if (node.type === 'choice') rendered.push(eng.renderText(node.prompt), ...node.options.map((o) => eng.renderText(o.label))); else if (node.type === 'narration' || node.type === 'identity') rendered.push(eng.renderText(node.prompt)); if (node.type === 'choice') { await eng.choose(picker[node.pointId] ?? node.options[0].id, puzzleBehavior(node)); } else if (node.type === 'identity') { await eng.confirmIdentity(await eng.extractIdentity('占位自我介绍')); } else if (node.type === 'narration') { if (node.pointId === 'C3-02') await eng.resolvePrivateIllusion(picker.C3_02_SILENT ? 'exit_room' : 'farewell_done'); else { await eng.submitNarration('(验证用占位讲述)'); if (eng.state.pendingFollowUp) await eng.answerFollowUp('(验证用追问回答)'); } } else { await eng.advance(); } } return { seen, rendered, finished: eng.state.finished }; } // 10b. 默认路径(全取首选项):标记零泄漏 + 条件节点按 flag 跳过 { const s = new MemoryStore(); const eng = new StoryEngine(chapters, s, new MockJevClient()); const { seen, rendered, finished } = await walkAll(eng); check(finished, '条件系统下默认路径三章跑通'); check(rendered.every((t) => !t.includes('【若')), '渲染输出零【若】标记泄漏(默认路径)'); check(seen.includes('c2-d1') && !seen.includes('c2-d1v'), '落点 a_safe:变体 A 节点可见、变体 B 节点被跳过'); check(!seen.includes('c3-d1a'), '未选 c_scout:flag_fresh_collapse 差分节点被跳过(无死路,继续走到 c3-d1b)'); check(seen.includes('c3-d1b'), '跳过差分节点后落到主线下一节点'); check(!seen.includes('c3-d6c'), '动机非 d_trust:§3-7 追加句节点被跳过'); // S4 修订:温简在场/留营的双版本台词移到了 c2-d4f(C2-03 分歧前) const d4f = chapters[1].nodes.find((n) => n.id === 'c2-d4f'); check( d4f?.type === 'dialogue' && eng.renderText(d4f.text).includes('你定,我跟着') && !eng.renderText(d4f.text).includes('等信儿'), 'wenjian_health=recovering:温简在场版台词渲染,留营版不渲染', ); } // 10c. 差分路径:c_scout 塌梁 + b_exposure 温简虚弱 + C3-02 沉默退出 { const s = new MemoryStore(); const eng = new StoryEngine(chapters, s, new MockJevClient()); const { seen, rendered, finished } = await walkAll(eng, { 'C2-03': 'c_scout', 'C2-01': 'b_exposure', C3_02_SILENT: '1' }); check(finished, '条件系统下差分路径三章跑通'); check(seen.includes('c3-d1a'), '选了 c_scout:flag_fresh_collapse 差分节点可见'); check(eng.state.flags.flag_fresh_collapse === 'true' && eng.state.flags.wenjian_health === 'weak', '引擎 flags 随选择增量更新'); const d4f = chapters[1].nodes.find((n) => n.id === 'c2-d4f'); check( d4f?.type === 'dialogue' && eng.renderText(d4f.text).includes('在营里等信儿'), 'wenjian_health=weak:温简留营版(通讯频道)台词渲染', ); const d5a = chapters[1].nodes.find((n) => n.id === 'c2-d5a'); check( d5a?.type === 'dialogue' && eng.renderText(d5a.text).includes('解谜要记录员'), 'wenjian_health=weak:§2-5 温简归队时刻渲染(他自己背着记录板跟来了)', ); const cut = chapters[2].nodes.find((n) => n.id === 'c3-cutscene'); check( cut?.type === 'cutscene' && eng.renderText(cut.caption).includes('不出来的人'), 'C3-02 沉默退出:cutscene 追加馆长退出台词(exit_room 内联段)', ); check(rendered.every((t) => !t.includes('【若')), '渲染输出零【若】标记泄漏(差分路径)'); } // --- 11. 进度持久化与恢复(真机刷新/热更新断点续玩) --- { const s = new MemoryStore(); const progress = new MemoryProgressStore(); const eng = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress); await walkTo(eng, 'c1-d5'); // 途经 C1-01 身份点与 C1-03 选择点(产生 flag) const snap = progress.load(); check( snap?.chapterId === 'chapter1' && snap.nodeId === 'c1-d5', `推进后持久化当前位置(${snap?.chapterId}/${snap?.nodeId})`, ); const eng2 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress); const restored = await eng2.restore(progress.load()); check( restored && eng2.currentNode().id === 'c1-d5' && eng2.state.sessionId === snap?.sessionId, '重建引擎从持久化位置恢复(sessionId 一致,事件轨迹续接)', ); check(eng2.state.flags.c1_03_motive === 'a_truth', '恢复后 flags 随快照还原(条件文本不断档)'); // 内容更新导致持久化节点消失 → 降级该章开头 const eng3 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress); check( (await eng3.restore({ sessionId: 'ghost', chapterId: 'chapter1', nodeId: 'deleted-node', flags: {} })) && eng3.currentNode().id === chapters[0].startNodeId, '持久化节点已不存在 → 降级到该章开头(不崩溃)', ); check(!(await eng3.restore({ sessionId: 'ghost', chapterId: 'chapter99', nodeId: 'x', flags: {} })), '持久化章节不存在 → 不恢复'); // replay:新 sessionId + 持久化位置重置(条件不满足的开场节点会被跳过,落点是跳过后的首个可见节点) eng2.replay('chapter2'); const after = progress.load(); check( after?.chapterId === 'chapter2' && after.nodeId === eng2.state.nodeId && eng2.state.sessionId !== snap?.sessionId, 'replay 产生新 sessionId 并重置持久化位置到目标章(跳过条件隐藏节点后的首个可见节点)', ); } // --- 12. Jev 调用失败优雅降级(CORS / 网络故障不卡死) --- class FailingJev extends MockJevClient { override async ask(): Promise> { throw new Error('模拟 Jev 故障:CORS / network'); } } { // 12a. 讲述流程:缺失检测失败 → 不追问;情绪签名失败 → 沉默/通用档变体;节点照常推进 const s = new MemoryStore(); const eng = new StoryEngine(chapters, s, new FailingJev()); await walkTo(eng, 'c1-02'); const r = await eng.submitNarration('去年夏天我离开了家乡,站台上都是雾'); check(r !== null && eng.state.pendingFollowUp === null, 'Jev 故障:缺失检测失败降级为不追问,流程继续'); check(eng.state.nodeId === 'c1-d4', 'Jev 故障:变体解析失败后节点照常推进'); const c102 = chapters[0].nodes.find((n) => n.id === 'c1-02') as NarrationNode; const silentVariant = c102.variants.find((v) => v.signature.includes('沉默'))!; const ev = s.events.find((e) => e.kind === 'narration' && e.pointId === 'C1-02'); check(ev?.variantId === silentVariant.variantId, 'Jev 故障:情绪签名解析降级到沉默/通用档变体'); // 12b. 身份抽取失败 → 硬过滤字段全空(称呼走本地启发式仍可预填),UI 直接进可手填确认页,不抛出 const eng2 = new StoryEngine(chapters, new MemoryStore(), new FailingJev()); await walkTo(eng2, 'c1-01'); let profile: IdentityProfile | null = null; let threw = false; try { profile = await eng2.extractIdentity('我叫阿黎,29岁,在杭州'); } catch { threw = true; } check( !threw && profile !== null && profile.city === '' && profile.age === '' && profile.gender === '' && profile.occupation === '' && profile.education === '', 'Jev 故障:身份抽取降级为空字段进手填确认页(不抛出)', ); await eng2.confirmIdentity(profile!); check(eng2.state.nodeId === 'c1-d2', 'Jev 故障:手填确认后流程照常推进'); } // --- 13. 玩家称呼:{name} 占位符替换、预填启发式、恢复后带回 --- { // 13a. 启发式:只认"我叫/叫我/称呼我 X",其余句式不瞎猜 const s = new MemoryStore(); const eng = new StoryEngine(chapters, s, new MockJevClient()); await walkTo(eng, 'c1-01'); const p1 = await eng.extractIdentity('我叫林晚,29岁,在杭州做互联网产品经理,硕士学历'); check(p1.name === '林晚', `称呼预填:「我叫林晚」→ "${p1.name}"`); const p2 = await eng.extractIdentity('29岁,杭州,产品经理'); check(p2.name === '', '称呼预填:无自报句式 → 留空手填(不硬抽)'); // 13b. {name} 替换与回退 await eng.confirmIdentity(p1); check(eng.renderText('馆长:{name},登记完成。') === '馆长:林晚,登记完成。', '{name} 替换为玩家称呼'); const eng2 = new StoryEngine(chapters, new MemoryStore(), new MockJevClient()); check(eng2.renderText('馆长:{name},登记完成。') === '馆长:你,登记完成。', '未设置称呼时 {name} 回退「你」'); // 13c. 称呼随轨迹事件走:restore 后从该 session 的 profile 事件取回(旧事件缺 name 不崩溃) await walkTo(eng, 'c1-d5'); const d2 = chapters[0].nodes.find((n) => n.id === 'c1-d2'); check(d2?.type === 'dialogue' && eng.renderText(d2.text).includes('林晚'), '馆长登记台词中的 {name} 渲染为称呼'); const progress = new MemoryProgressStore(); progress.save({ sessionId: eng.state.sessionId, chapterId: 'chapter1', nodeId: 'c1-d5', flags: { ...eng.state.flags } }); const eng3 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress); await eng3.restore(progress.load()); check(eng3.state.playerName === '林晚', '断点恢复后从 profile 事件取回称呼'); } // C3-02 的三种路径只落叙事模式,不采集或评分室内讲述。 for (const mode of ['farewell_done', 'farewell_partial', 'exit_room'] as const) { const s = new MemoryStore(); const e = new StoryEngine(chapters, s, new MockJevClient()); await walkTo(e, 'c3-02'); const r = await e.resolvePrivateIllusion(mode); const recorded = s.events.find((ev) => ev.pointId === 'C3-02'); check(recorded?.kind === 'narration' && recorded.narrationMode === mode && recorded.transcript === null && recorded.dimensions.length === 0, `问心之室 ${mode}:不留文本或维度标签`); check(e.state.flags.c3_02_mode === mode && !buildState(s.events).includes('C3-02'), `问心之室 ${mode}:叙事变量生效且画像隔离`); check(r.event.id === recorded?.id && e.state.nodeId === 'c3-cutscene', `问心之室 ${mode}:石门照常开启`); const restored = deriveFlags(s.events); check(restored.c3_02_mode === mode && restored.c3_02_fantasy_density === e.state.flags.c3_02_fantasy_density, `问心之室 ${mode}:恢复后叙事状态一致`); } // --- 14. 删除全部个人数据(ADR-0004 数据删除权):purge 整库物理清空 + 进度清空 + 回到未同意态 --- { const s = new MemoryStore(); const progress = new MemoryProgressStore(); const eng = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress); await eng.grantConsent(); await walkTo(eng, 'c1-d5'); // 途经 C1-01 身份点、C1-02 讲述点、C1-03 选择点 check((await s.list()).length > 0 && progress.load() !== null, '删除前:事件流与进度快照均非空'); await s.purge(); progress.clear(); check((await s.list()).length === 0, 'purge 后事件列表为空(全部事件物理删除)'); check(progress.load() === null, 'clear 后进度快照为空'); check(!(await eng.hasConsent()), 'purge 后 consent 存证一并删除 → 回到未同意状态(同意门重新出现)'); await s.purge(); check((await s.list()).length === 0, 'purge 幂等:重复清空不报错且仍为空'); // 删除后可重新同意并正常继续走剧情(存储清空后仍可 append,事件不串旧轨迹) await eng.grantConsent(); check(s.events.filter((e) => e.kind === 'consent').length === 1, '重新同意:新 consent 存证落库(只存证一次)'); await walkTo(eng, 'c1-05'); // 从 c1-d5 出发途经 C1-04 选择点 check( s.events.some((e) => e.kind === 'choice') && !s.events.some((e) => e.kind === 'profile'), '删除后可正常继续走剧情并落新事件,旧轨迹事件不残留(无已删除的 profile 事件)', ); } if (failures > 0) { // 主线失败先退出,避免掩盖原有回归。 console.error(`\n${failures} 项校验失败`); process.exit(1); } console.log('\n全部校验通过'); await import('./verify-adaptive');