478 lines
23 KiB
TypeScript
478 lines
23 KiB
TypeScript
/**
|
||
* 内容完整性与引擎流程验证(不走浏览器):
|
||
* 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)、【若】标记零泄漏、差分显隐、条件不满足节点跳过无死路。
|
||
* 运行: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, narrationFlags } from '../src/engine/flags';
|
||
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': ['emotional_stability', 'attachment'],
|
||
'C3-03': ['values', 'emotional_stability'],
|
||
'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];
|
||
}
|
||
}
|
||
|
||
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', 'V 类讲述点为 C1-02/C2-02/C2-05/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(
|
||
FOLLOW_UP_DIMENSIONS.every((d) => typeof n.followUps?.[d] === 'string' && n.followUps[d]!.length > 0),
|
||
`${n.pointId}: 六维 followUps 追问模板齐全`,
|
||
);
|
||
}
|
||
|
||
// --- 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 as ChoiceNode).options[0].id);
|
||
} 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 {
|
||
await engine.submitNarration('(验证用占位讲述)');
|
||
if (engine.state.pendingFollowUp) await engine.answerFollowUp('(验证用追问回答)');
|
||
}
|
||
} else {
|
||
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 情绪解析结果');
|
||
|
||
// --- 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') {
|
||
await engine.submitNarration('(验证用占位讲述)');
|
||
if (engine.state.pendingFollowUp) await engine.answerFollowUp('(验证用追问回答)');
|
||
} else {
|
||
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++ < 60) {
|
||
const n = eng.currentNode();
|
||
if (n.type === 'choice') await eng.choose((n as ChoiceNode).options[0].id);
|
||
else if (n.type === 'identity') await eng.confirmIdentity(await eng.extractIdentity('占位自我介绍'));
|
||
else if (n.type === 'narration') await eng.skipNarration();
|
||
else 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(narrationFlags('C3-02', undefined, null).c3_02_mode === 'exit_room', 'flag 推导:C3-02 沉默退出 → c3_02_mode=exit_room');
|
||
{
|
||
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_early', '(讲述)').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);
|
||
} else if (node.type === 'identity') {
|
||
await eng.confirmIdentity(await eng.extractIdentity('占位自我介绍'));
|
||
} else if (node.type === 'narration') {
|
||
if (node.pointId === 'C3-02' && picker.C3_02_SILENT) await eng.skipNarration();
|
||
else {
|
||
await eng.submitNarration('(验证用占位讲述)');
|
||
if (eng.state.pendingFollowUp) await eng.answerFollowUp('(验证用追问回答)');
|
||
}
|
||
} else {
|
||
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 追加句节点被跳过');
|
||
const d5a = chapters[1].nodes.find((n) => n.id === 'c2-d5a');
|
||
check(
|
||
d5a?.type === 'dialogue' && !eng.renderText(d5a.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 d5a = chapters[1].nodes.find((n) => n.id === 'c2-d5a');
|
||
check(
|
||
d5a?.type === 'dialogue' && eng.renderText(d5a.text).includes('通讯频道'),
|
||
'wenjian_health=weak:温简台词的「通讯频道」内联段渲染',
|
||
);
|
||
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 = 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(
|
||
eng3.restore({ sessionId: 'ghost', chapterId: 'chapter1', nodeId: 'deleted-node', flags: {} }) &&
|
||
eng3.currentNode().id === chapters[0].startNodeId,
|
||
'持久化节点已不存在 → 降级到该章开头(不崩溃)',
|
||
);
|
||
check(!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 && Object.values(profile).every((v) => v === ''),
|
||
'Jev 故障:身份抽取降级为全空字段进手填确认页(不抛出)',
|
||
);
|
||
await eng2.confirmIdentity(profile!);
|
||
check(eng2.state.nodeId === 'c1-d2', 'Jev 故障:手填确认后流程照常推进');
|
||
}
|
||
|
||
if (failures > 0) {
|
||
console.error(`\n${failures} 项校验失败`);
|
||
process.exit(1);
|
||
}
|
||
console.log('\n全部校验通过'); |