Files
jev-match-game/scripts/verify.ts
T

590 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 内容完整性与引擎流程验证(不走浏览器):
* 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<string, string[]> = {
'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<string, string[]>();
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<string>([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<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
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<string, string> = {}) {
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<Record<string, JevAnswer>> {
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');