fix: 首启同意门(consent 事件存证)+ 条件渲染系统(flags 推导 27 变量)+ 10 类内容-界面不一致修复

This commit is contained in:
Jayway Liu
2026-10-03 18:21:09 +08:00
parent d2a31ab38c
commit b0c7ef2daf
11 changed files with 501 additions and 29 deletions
+105 -2
View File
@@ -6,12 +6,15 @@
* 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 { StoryEngine } from '../src/engine/engine';
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 { SqliteEventStore } from '../src/engine/sqliteEventStore';
import type { Chapter, ChoiceNode, NarrationNode } from '../src/engine/types';
import { FOLLOW_UP_DIMENSIONS } from '../src/engine/types';
@@ -294,8 +297,108 @@ for (const cls of [LocalStorageEventStore, SqliteEventStore, MemoryStore]) {
);
}
// --- 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('【若')), '渲染输出零【若】标记泄漏(差分路径)');
}
if (failures > 0) {
console.error(`\n${failures} 项校验失败`);
process.exit(1);
}
console.log('\n全部校验通过');
console.log('\n全部校验通过');