feat: 自适应支线插入引擎(WIP:scoring/branching 抽取、挂点重定向、10 条支线 JSON、决策落库)

This commit is contained in:
Jayway Liu
2026-10-04 06:57:32 +08:00
parent 512651b973
commit ea9fa0fe56
37 changed files with 4311 additions and 817 deletions
+185
View File
@@ -0,0 +1,185 @@
/** 将已定稿支线册的正文/选项/后续转换为静态 JSON。运行:pnpm exec tsx scripts/build-adaptive-branches.ts */
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import type { BranchPack, ChoiceNode, DialogueNode, StoryNode } from '../src/engine/types';
const source = readFileSync(new URL('../docs/script/adaptive-branches.md', import.meta.url), 'utf8');
const sections = [...source.matchAll(/^## B-(ATT|CON|EMO|VAL|CUL) 变体 ([AB]).*\n([\s\S]*?)(?=^## B-|^# 支线|^# 附录)/gm)];
const dimensions = { ATT: 'attachment', CON: 'conflict_style', EMO: 'emotional_stability', VAL: 'values', CUL: 'culture' } as const;
const sprites: Record<string, string> = { 沈铎: 'shenduo', 阿棠: 'atang', 温简: 'wenjian', 老鬼: 'laogui' };
const clean = (s: string) => s.replace(/^> ?/gm, '').replace(/\*\*/g, '').replace(/\n[ \t]*/g, '').trim();
const conditional = (normal: string, weak: string) => `【若 wenjian_health != weak:${normal}】【若 wenjian_health = weak:${weak}】`;
const waterLedger = [
['a_give', '施水一囊,未收物。'],
['b_cup', '施水一杯,未收物。'],
['c_trade', '售水两囊,得镯一支(CW-0322,有偿征集)。'],
['d_redirect', '水支出零,受赠镯一支(CW-0322,赠与代收)。'],
] as const;
const ledgerDetails = waterLedger.map(([value, text]) => `【若 cb_val_a_water = ${value}:${text}】`).join('');
function adapt(text: string, code: string, skin: string): string {
let t = text.replace(/【工程注:[\s\S]*?】/g, '').replace(/(`[^)]*)/g, '')
.replace(/(主线等值情报[^)]*)/g, '').replace(/(Act 2 伏笔[^)]*)/g, '')
.replace(/(氛围换色[^)]*)/g, '').replace(/(剧本明示[^)]*)/g, '')
.replace('(C2-05 的他,懂的人自然懂。)', '')
.replace('(轻伤演出:肩头淤青)', ',肩头淤青')
.replace('(背景演出)', '')
.replace('(或摊上最年长的)', '')
.replace('这地方刚塌过(或:这地方还没探完)', '这地方还没探完')
.replace('石柱(或:门框)', '牢固的支点');
if (skin === 'A') {
const replacements: [string, string][] = [
['温简一遍遍校准呼叫频率', conditional('温简一遍遍校准呼叫频率', '通讯频道里,温简在营地一遍遍校准呼叫频率')],
['(轻声)', conditional('(轻声)', '(营地通讯频道,声音虚弱)')],
['我和温简上塬,带足信标,沿路插', conditional('我和温简上塬,带足信标,沿路插', '我带老鬼上塬,温简在频道里报坐标,带足信标,沿路插')],
['队长和老鬼留守坐标点做呼叫中继,', conditional('队长和老鬼留守坐标点做呼叫中继,', '队长留守坐标点做呼叫中继,')],
['又看看你和温简', conditional('又看看你和温简', '又看看你和老鬼')],
['温简与你并肩,用只有两个人听见的音量说', conditional('温简与你并肩,用只有两个人听见的音量说', '温简切到与你单独通话的频道,虚弱地说')],
['说完他加快半步去追阿棠,留你一个人在后面走。', conditional('说完他加快半步去追阿棠,留你一个人在后面走。', '说完频道安静下去,留你一个人在后面走。')],
['温简在旁边低头走路,睫毛动了一下,什么都没说。', conditional('温简在旁边低头走路,睫毛动了一下,什么都没说。', '频道里温简的呼吸停了一拍,什么都没说。')],
['温简在记录板的边角,无意识地画着什么。', conditional('温简在记录板的边角,无意识地画着什么。', '通讯频道里,传来温简翻页的声音。')],
['(什么都没说,看你。所有人都在看你。)', conditional('(什么都没说,看你。所有人都在看你。)', '(频道里只有他虚弱的呼吸声。所有人都在等你。)')],
['(贴着你的耳朵喊)', conditional('(贴着你的耳朵喊)', '(在通讯频道里喊,电流声盖掉半句)')],
['五个人叠着喘', conditional('五个人叠着喘', '四个人叠着喘')],
['五个人的脸', conditional('五个人的脸', '四个人的脸')],
['温简最后开口', conditional('温简最后开口', '温简最后在频道里虚弱地开口')],
['(把铭文描进记录板,越描越慢)', conditional('(把铭文描进记录板,越描越慢)', '(营地频道,他对着传回的铭文照片描进记录板,声音虚弱)')],
];
for (const [from, to] of replacements) t = t.replaceAll(from, to);
}
// 支线册以代码字体标注变量名;游戏条件解析只接受裸变量名。
return t.replace(/【若\s*`?([a-z0-9_]+)`?\s*(!=|=)\s*`?([a-z0-9_]+)`?\s*:/g, '【若 $1 $2 $3:');
}
function polish(text: string): string {
return text.replace('(若 C2-05玩家与他有同行约定,追加一句他的低语:「正好,我步子慢,站得稳。」)', '【若 c2_05_depth = deep:他低语:「正好,我步子慢,站得稳。」】')
.replace('(C2-03 已埋的约束)', '')
.replace('(C2-04)', '')
.replace('星图盘上复原的苍龙心宿', '苍龙心宿')
.replace('(呼应 C2-01 的规程设定)', '')
.replace('说了全支线最重要的一句台词', '低声说')
.replace('冲你们喊了全支线唯一一句话', '冲你们喊')
.replace('——但这半方纹样,是后来§3-2 解谜时她第一个认出来的。', '')
.replace('——这句"账先欠着",是 Act 2 重回此地的钩子。', '')
.replace('(Act 2 沈铎线的一粒沙)', '')
.replace('(各线统一,他溜之前)', '')
.replace('你关掉的这条线,Act 2 永远不会再开——', '你关掉了这条线——')
.replace('(§3-1「有人修过路」的直接延续)', '')
.replace('(§3-1)', '')
.replace('守陵人的窄阶(若 B-VAL-B 已触发)或你们的探查路线', '你们的探查路线')
.replace('后来 §3-2 解谜', '待会儿上台基辨纹时')
.replace(/八月大火昏见——此铭是[\s\S]*?解题后台词指向未来。/, '')
.replace('与解谜线同一时刻、同一片黄昏。进 §2-5。', '正是黄昏。')
.replace('(沈铎的规程与主角方的红线一致:记录者,不取不扰)', '')
.replace('想想你在星图盘上,刚把什么补回苍龙的心口。', '大火是星名,苍龙的心宿。')
.replace('这和星图盘是一套思路。「火中,而后城安」,等的是星;这段铭文,记的也是星。', '这和老人说的「火中,而后城安」是一套思路。等的是星,记的也是星。')
.replace('【场景】', '').replace('【场景(坑道内)】', '')
.replace('——这句问话没有选项,悬在你头上,带回队里', '——这句问话悬在你头上,带回队里');
}
const exits: Record<string, string> = {
'ATT-A': '阿棠带路,全队沿观星台的侧阶向上走。黄昏落在阶石上。',
'ATT-B': '阿棠在记录板上写下「第 27 号坑·一家人俑」。采集收尾,沈铎招手,全队向台基合拢。',
'CON-A': '纠纷了结,日头西斜。从草市抄近路抵达宫墟外围,正是黄昏。',
'CON-B': '鱼牙人退走了。你们从庭院向台基合拢。',
'EMO-A': '风停,浮沙被刮走整整一层。主街中段露出一截石阶和圆形基座的边缘。阿棠用袖子擦着刻纹:「哎……这纹路,观星台。它这是——给咱们开门呢?」黄昏前,你们登台。',
'EMO-B': '采集收尾,全队向台基合拢。陶范的纹样与「铸完那一批,再没有下一批」的话,跟着你们一起上去。',
'VAL-A': '账毕,日头西斜。黄昏时,你们登上观星台。',
'VAL-B': '【若 cb_val_b_medicine != b_refer:少年拄着木枝,一瘸一拐把你们引到台基背阴面,亲手拨开荒草:「这边。我阿爷说,给神走的路修得高,给人走的路修得低。走低的,不摔跤。」】【若 cb_val_b_medicine = b_refer:老人和少年已往城里去。阿棠照着少年的喊话,在排水沟边找到荒草,拨开它。她学着少年的口气说:「给神走的路修得高,给人走的路修得低。走低的,不摔跤。」】',
};
mkdirSync(new URL('../content/branches/', import.meta.url), { recursive: true });
for (const match of sections) {
const code = match[1] as keyof typeof dimensions;
const skin = match[2];
const body = match[3].replace(/^> ?/gm, '');
const id = `b-${code.toLowerCase()}-${skin.toLowerCase()}`;
const nodes: StoryNode[] = [];
let counter = 0;
function dialogue(text: string, next = ''): DialogueNode[] {
const out: DialogueNode[] = [];
for (let paragraph of text.split(/\n\s*\n/)) {
if (!paragraph.trim() || /^\s*(?:#{1,3} |【正文】|---|【工程注)/.test(paragraph)) continue;
const raw = clean(paragraph);
if (!paragraph) continue;
const speech = raw.match(/^(沈铎|阿棠|温简|老鬼|马四|鱼牙人|妇人|守墓老人|守陵少年)(([^)]*))?:([\s\S]*)$/);
const speaker = speech ? speech[1] : '旁白';
let spoken = polish(adapt(speech ? (speech[2] ?? '') + speech[3] : raw, code, skin));
if (spoken.startsWith('【') && !spoken.startsWith('【若') && spoken.endsWith('】')) spoken = spoken.slice(1, -1);
out.push({ id: `${id}-d${++counter}`, type: 'dialogue', speaker, text: spoken, sprite: sprites[speaker] ?? 'scene', next: '' });
}
out.forEach((n, i) => { n.next = out[i + 1]?.id ?? next; });
nodes.push(...out);
return out;
}
if (code !== 'CUL') {
const choices = [...body.matchAll(/【选项((CB-[A-Z]+-[AB]-\d+))[^]*?】([\s\S]*?)(?=\n###)/g)];
let previousResults: DialogueNode[] = [];
let previousEnd = 0;
for (const choice of choices) {
const point = choice[1];
const intro = body.slice(previousEnd, choice.index);
const opening = dialogue(intro, point.toLowerCase());
previousResults.forEach((n) => { n.next = opening[0]?.id ?? point.toLowerCase(); });
// 每个选项以空行分隔;只取玩家台词,括号中的测量解释不入游戏。
const optionBlocks = choice[2].split(/\n\s*\n/).filter((s) => /^\*\*[A-D]\./.test(s.trim()));
const resultStart = body.indexOf(`### 各选项后续(${point})`, choice.index!);
const resultEnd = body.indexOf('\n### ', resultStart + 4);
const resultText = body.slice(resultStart, resultEnd < 0 ? body.length : resultEnd);
const results = [...resultText.matchAll(/^- \*\*([A-D])\*\*:([\s\S]*?)(?=^- \*\*[A-D]\*\*:|$(?![\s\S]))/gm)];
const choiceNode: ChoiceNode = { id: point.toLowerCase(), type: 'choice', pointId: point, prompt: '你怎么做?', dimensions: [dimensions[code]], options: [] };
previousResults = [];
for (const block of optionBlocks) {
const letter = block.trim()[2];
const label = adapt(clean(block.replace(/^\*\*[A-D]\./, '').split(/\n(/)[0]), code, skin);
const result = results.find((r) => r[1] === letter);
if (!result) throw new Error(`缺少后续 ${point}-${letter}`);
const after = dialogue(result[2]);
if (point === 'CB-VAL-A-02' && letter === 'B') after[0].text = after[0].text.replace('每项实数,', `每项实数:${ledgerDetails}`);
if (point === 'CB-VAL-A-02' && letter === 'C') after[0].text = after[0].text.replace('按实数记了,', `按实数记了:${ledgerDetails}`);
choiceNode.options.push({ id: `${point}-${letter}`, label, next: after[0].id });
previousResults.push(after.at(-1)!);
}
if (code === 'EMO' && point.endsWith('01')) {
const timeout = dialogue(skin === 'A' ? '你继续听沈铎指挥点人。队员在警戒线内一一应答;风声盖住阿棠的声音,她举手示意自己还在。' : '沈铎按既定预案接管排险,其他队员退出阵列。阿棠在受训人员协助下安全离开。');
choiceNode.options.push({ id: `${point}-TIMEOUT`, label: skin === 'A' ? '听沈铎指挥点人' : '由沈铎接管排险', next: timeout[0].id });
previousResults.push(timeout.at(-1)!);
choiceNode.timer = { seconds: skin === 'A' ? 10 : 8, optionId: `${point}-TIMEOUT` };
}
nodes.push(choiceNode);
previousEnd = resultEnd;
if (choiceNode.options.length < 3) throw new Error(`选项不完整 ${point}`);
}
const lastSection = body.slice(previousEnd).split('### 选项设计说明')[0];
const closing = code === 'VAL' && skin === 'B' ? [] : dialogue(lastSection);
const exit = dialogue(exits[`${code}-${skin}`], '$resume');
if (closing.length) closing.at(-1)!.next = exit[0].id;
previousResults.forEach((n) => { n.next = closing[0]?.id ?? exit[0].id; });
} else {
const point = `CB-CUL-${skin}-01`;
const beforePuzzle = body.slice(0, body.indexOf('### 谜面'));
const opening = dialogue(beforePuzzle.split('【前置选择')[0], skin === 'A' ? 'cb-cul-a-00' : point.toLowerCase());
void opening;
if (skin === 'A') {
const skipped = dialogue(body.slice(body.indexOf('### skip 线正文'), body.indexOf('【场景(坑道内)】')).replace(/###.*\n/, ''), '$resume');
const interior = dialogue(body.slice(body.indexOf('【场景(坑道内)】'), body.indexOf('### 谜面')), point.toLowerCase());
nodes.push({ id: 'cb-cul-a-00', type: 'choice', pointId: 'CB-CUL-A-00', prompt: '下去看看,还是赶路?', dimensions: ['culture'], untimed: true,
options: [{ id: 'CB-CUL-A-00-A', label: '下去看看', next: interior[0].id }, { id: 'CB-CUL-A-00-B', label: '赶路要紧', next: skipped[0].id }] });
}
const puzzleText = body.slice(body.indexOf('### 谜面'), body.indexOf('**谜底**'));
const questionBlocks = [...puzzleText.matchAll(/- \*\*(第[一二]问)\*\*:([^\n]+)\n([\s\S]*?)(?=\n- \*\*第|$(?![\s\S]))/gm)];
const hintsText = body.slice(body.indexOf('### 三档难度提示'), body.indexOf('### 解题后正文'));
const hints = [...hintsText.matchAll(/^\d\. ([\s\S]*?)(?=^\d\. |$(?![\s\S]))/gm)].map((h) => polish(clean(h[1])));
const after = dialogue(body.slice(body.indexOf('### 解题后正文'), body.indexOf('### 选项设计说明')));
const explanation = dialogue(body.slice(body.indexOf('**谜底**'), body.indexOf('### 三档难度提示')).replace('**谜底**:', '温简:'), after[0].id);
const exit = dialogue(skin === 'A' ? '半小时到,沈铎封道拍照。爬出断口时,日头贴着观星台的圆盘往下坠。阿棠眯着眼:「该去台上,看那颗星了。」' : '俑道尽头有侧阶直通台基顶。沈铎说:「记录。上去。」全队拾级登台。', '$resume');
after.at(-1)!.next = exit[0].id;
nodes.push({ id: point.toLowerCase(), type: 'choice', pointId: point, dimensions: ['culture'],
prompt: skin === 'A' ? '主壁铭文:「隹八月壬辰,大火見,王命作冊:城南垣,徙。」旁刻一人在高台举火,台下众人负土而行。残刻干支表起甲子、止癸酉,十枚围成一旬。' : '前排九俑胸口刻字:文、武、成、康、昭、穆、共、懿、孝。单数位微偏左,双数位微偏右,交错成两列。',
puzzle: { questions: questionBlocks.map((q, i) => ({ prompt: clean(q[2]), options: [...q[3].matchAll(/^ - [A-D]\. (.+)$/gm)].map((o) => clean(o[1])), answer: skin === 'A' && i === 1 ? 2 : 1 })), hints, exitLabel: skin === 'B' ? '上台基' : '请温简释读' },
options: [{ id: `${point}-ANSWER`, label: '确认释读', next: explanation[0].id }, { id: `${point}-EXIT`, label: skin === 'B' ? '上台基' : '请温简释读', next: explanation[0].id }] });
}
const pack: BranchPack = { id, dimension: dimensions[code], anchor: { chapter: skin === 'A' ? 'chapter2' : 'chapter3', afterNode: skin === 'A' ? 'c2-branch-anchor' : 'c3-d1b' }, estimatedMinutes: 8, startNodeId: nodes[0].id, nodes };
writeFileSync(new URL(`../content/branches/${id}.json`, import.meta.url), JSON.stringify(pack, null, 2) + '\n');
console.log(`${id}: ${nodes.length} nodes`);
}
if (sections.length !== 10) throw new Error(`预期十条支线,实际 ${sections.length}`);
+279
View File
@@ -0,0 +1,279 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { BRANCHES } from '../src/engine/branches';
import { decideInsertion } from '../src/engine/branching';
import { StoryEngine } from '../src/engine/engine';
import { makeEvent } from '../src/engine/eventStore';
import type { StoryEvent, EventStore } from '../src/engine/eventStore';
import { choiceFlags, deriveFlags, resolveConditionalText } from '../src/engine/flags';
import { MemoryProgressStore } from '../src/engine/progress';
import { buildState, scoreProfile, PROFILE_DIMENSIONS } from '../src/engine/scoring';
import type { Confidences, ProfileDimension } from '../src/engine/scoring';
import type { Chapter, StoryNode, ChoiceBehavior } from '../src/engine/types';
import { MockJevClient } from '../src/services/jev';
import type { JevQuestion } from '../src/services/jev';
const chapters: Chapter[] = [1, 2, 3].map((i) => JSON.parse(readFileSync(new URL(`../content/chapters/chapter${i}.json`, import.meta.url), 'utf8')));
const confidences = (low: Confidences = {}): Confidences => Object.assign(Object.fromEntries(PROFILE_DIMENSIONS.map((d) => [d.id, 0.9])), low);
const inserted = (id: string) => {
const b = BRANCHES.find((b) => b.id === id)!;
return makeEvent({ sessionId: 'test', chapterId: b.anchor.chapter, pointId: id, kind: 'branch_inserted', branchId: id, dimensions: [b.dimension], confidence: 0.3 });
};
const sample = (point: string, dimension: ProfileDimension) => makeEvent({ sessionId: 'test', chapterId: 'chapter1', pointId: point, kind: 'choice', dimensions: [dimension], optionId: 'A', optionLabel: 'A' });
const choose = (low: Confidences, events: StoryEvent[] = [], target = chapters[1], completedChapter?: Chapter) => decideInsertion({ confidences: confidences(low), events, branches: BRANCHES, target, completedChapter });
for (const [label, low, events, target, expected] of [
['单维低置信', { attachment: 0.5 }, [], chapters[1], 'b-att-a'],
['多维权重', { attachment: 0.4, values: 0.4, culture: 0.4 }, [], chapters[1], 'b-att-a'],
['缺口优先', { attachment: 0.59, values: 0.1 }, [], chapters[1], 'b-val-a'],
['稀缺优先', { attachment: 0.4, values: 0.4 }, [sample('one', 'attachment'), sample('two', 'attachment')], chapters[1], 'b-val-a'],
['重复换皮', { attachment: 0.2 }, [inserted('b-att-a')], chapters[2], 'b-att-b'],
['第3章初测不可搬A', { values: 0.2 }, [], chapters[2], 'b-val-b'],
['两皮耗尽', { attachment: 0.2 }, [inserted('b-att-a'), inserted('b-att-b')], chapters[2], undefined],
['占用不叠加', { values: 0.2 }, [inserted('b-att-a')], chapters[1], undefined],
['占用顺延下一章', { values: 0.2 }, [inserted('b-att-a')], chapters[2], 'b-val-b'],
['无定义维度', { risk: 0.1, mate_preference: 0.1 }, [], chapters[1], undefined],
['阈值边界', { attachment: 0.6 }, [], chapters[1], undefined],
['无效置信度', { attachment: NaN, values: -1 }, [], chapters[1], undefined],
['超时长', { attachment: 0.1 }, [], { ...chapters[1], estimatedMinutes: 38 }, undefined],
['时长等于45', { attachment: 0.1 }, [], { ...chapters[1], estimatedMinutes: 37 }, 'b-att-a'],
] as [string, Confidences, StoryEvent[], Chapter, string | undefined][]) {
assert.equal(choose(low, events, target).branch?.id, expected, label);
}
assert.deepEqual(choose({ risk: 0.1, mate_preference: 0.1 }).evidenceInsufficient, ['risk', 'mate_preference']);
assert.deepEqual(choose({ attachment: 0.1 }, [inserted('b-att-a'), inserted('b-att-b')], chapters[2]).evidenceInsufficient, ['attachment']);
assert.equal(choose({ culture: 0.2 }, [], chapters[1], chapters[0]).branch, undefined);
assert.equal(choose({ culture: 0.2 }, [sample('C1-05', 'culture')], chapters[1], chapters[0]).branch?.id, 'b-cul-a');
console.log('✓ 自适应决策矩阵:权重、缺口、稀缺、A/B、占用、证据不足、主线优先、时长/阈值边界');
class MemoryStore implements EventStore {
events: StoryEvent[] = [];
async list(sessionId?: string) { return this.events.filter((e) => !sessionId || e.sessionId === sessionId); }
async append(e: StoryEvent) { this.events.push(e); }
}
class FixedJev extends MockJevClient {
dimension: ProfileDimension;
calls: unknown[] = [];
constructor(dimension: ProfileDimension = 'attachment') { super(); this.dimension = dimension; }
override async ask(state: unknown, questions: Record<string, JevQuestion>) {
const result = await super.ask(state, questions);
if (Object.keys(questions).length === 7) {
this.calls.push(state);
for (const d of PROFILE_DIMENSIONS) if (result[d.id]?.type === 'score') result[d.id].confidence = d.id === this.dimension ? 0.2 : 0.9;
}
return result;
}
}
async function step(e: StoryEngine, picks: Record<string, string> = {}, behavior?: ChoiceBehavior) {
const n = e.currentNode();
if (n.type === 'choice') {
await e.choose(picks[n.pointId] ?? n.options[0].id, behavior ?? (n.puzzle ? { answers: n.puzzle.questions.map((q) => q.answer), hintsUsed: 0 } : { decisionMs: 320 }));
} else if (n.type === 'narration') await e.skipNarration();
else if (n.type === 'identity') await e.confirmIdentity({ name: '测试', age: '', city: '', gender: '', occupation: '', education: '' });
else await e.advance();
}
async function walkTo(e: StoryEngine, id: string) {
for (let i = 0; i < 400 && !e.state.finished; i++) {
if (e.state.nodeId === id) return;
await step(e);
}
assert.fail(`未到达 ${id},当前位置 ${e.state.nodeId}`);
}
// 所有节点均可达,所有出口返回主线,不共享/改写主线 JSON。
const original = JSON.stringify(chapters);
const pointIds = new Set<string>();
for (const branch of BRANCHES) {
const index = new Map(branch.nodes.map((n) => [n.id, n]));
const reached = new Set<string>();
function visit(id: string, path = new Set<string>()) {
if (id === '$resume') return;
assert(!path.has(id), `支线循环 ${id}`);
const n = index.get(id);
assert(n, `无效跳转 ${id}`);
reached.add(id);
const nexts = n.type === 'choice' ? n.options.map((o) => o.next) : 'next' in n ? [n.next] : [];
assert(nexts.length, `没有出口 ${id}`);
for (const next of nexts) visit(next, new Set([...path, id]));
}
visit(branch.startNodeId);
assert.equal(reached.size, branch.nodes.length, branch.id);
for (const node of branch.nodes) {
if (node.type !== 'choice') continue;
assert(!pointIds.has(node.pointId));
pointIds.add(node.pointId);
assert.deepEqual(node.dimensions, [branch.dimension]);
if (node.puzzle) {
assert.equal(node.puzzle.questions.length, 2);
assert(node.puzzle.questions.every((q) => q.options.length === 4));
assert.equal(node.puzzle.hints.length, 3);
}
}
for (const health of ['weak', 'recovering']) {
for (const choiceNode of branch.nodes.filter((n) => n.type === 'choice')) {
for (const option of choiceNode.options) {
const store = new MemoryStore();
const progress = new MemoryProgressStore();
const engine = new StoryEngine(chapters, store, new FixedJev(branch.dimension), undefined, progress, BRANCHES);
await engine.restore({ sessionId: 'test', chapterId: branch.anchor.chapter, nodeId: branch.anchor.afterNode, flags: { wenjian_health: health }, insertions: [branch.id] });
await engine.advance();
assert.equal(engine.state.nodeId, branch.startNodeId);
for (let i = 0; engine.state.activeBranchId && i < 100; i++) {
const node = engine.currentNode();
const texts = node.type === 'choice' ? [node.prompt, ...node.options.map((o) => o.label)] : node.type === 'dialogue' ? [node.text] : [];
for (const text of texts) {
const rendered = engine.renderText(text);
assert(!/【若|工程注|§\d|Act 2|线追加|若 C2-05/.test(rendered), `${node.id} 泄漏: ${rendered}`);
}
const snap = progress.load()!;
const restored = new StoryEngine(chapters, store, new FixedJev(), undefined, progress, BRANCHES);
await restored.restore(snap);
assert.equal(restored.state.nodeId, engine.state.nodeId);
assert.equal(restored.state.activeBranchId, branch.id);
const behavior = node.id === choiceNode.id && node.type === 'choice' ? node.puzzle ? { answers: node.puzzle.questions.map((q) => q.answer), hintsUsed: 2, decisionMs: 400 } : { timedOut: option.id.endsWith('TIMEOUT'), decisionMs: 400 } : undefined;
await step(engine, { [choiceNode.pointId]: option.id }, behavior);
}
assert.equal(engine.state.activeBranchId, undefined);
const anchor = chapters.find((c) => c.id === branch.anchor.chapter)!.nodes.find((n) => n.id === branch.anchor.afterNode)!;
assert.equal(engine.state.nodeId, 'next' in anchor ? anchor.next : '');
const event = store.events.find((e) => e.pointId === choiceNode.pointId);
// CUL-A skip 不经过谜面;默认进入路径则每个目标点都应落事件。
assert(event, `${choiceNode.pointId} 缺事件`);
assert.equal(event.optionId, option.id);
assert.equal(event.branchId, branch.id);
const derived = deriveFlags(store.events);
for (const [flag, value] of Object.entries(derived)) assert.equal(engine.state.flags[flag], value, flag);
if (nodeIsPuzzle(choiceNode)) assert.equal(event.behavior?.solved, !option.id.endsWith('EXIT'));
}
}
}
}
function nodeIsPuzzle(n: StoryNode) { return n.type === 'choice' && !!n.puzzle; }
// 01 的实际支出与受领性质必须在 02 的全部三个入口一致,D 的粥棚只在路程之后可见。
const waterBranch = BRANCHES.find((b) => b.id === 'b-val-a')!;
const waterChoices = waterBranch.nodes.filter((n) => n.type === 'choice');
assert.equal(waterChoices.length, 2);
const waterRecords = ['施水一囊', '施水一杯', '售水两囊,得镯一支(CW-0322,有偿征集)', '水支出零,受赠镯一支(CW-0322,赠与代收)'];
for (let water = 0; water < 4; water++) {
const flags = choiceFlags('CB-VAL-A-01', waterChoices[0].options[water].id);
const opening = waterBranch.nodes.find((n) => n.id === waterBranch.startNodeId)!;
assert(opening.type === 'dialogue' && opening.text.includes('施粥棚开着'), '选前必须提供当日粥棚证据');
const crossroads = waterBranch.nodes.find((n) => n.type === 'dialogue' && n.text.includes('盛热粥的碗'))!;
const elapsed = waterBranch.nodes.find((n) => n.type === 'dialogue' && n.text.includes('一个时辰的路走完了'))!;
const nexts = new Map(waterBranch.nodes.map((n) => [n.id, n]));
let cursor = waterChoices[0].options[water].next;
let sawElapsed = false;
while (cursor !== waterChoices[1].id) {
if (cursor === elapsed.id) sawElapsed = true;
if (cursor === crossroads.id) assert(sawElapsed, '粥棚确认早于一个时辰路程');
const node = nexts.get(cursor)!;
assert(node && 'next' in node, `核账前路径中断 ${cursor}`);
cursor = node.next;
}
assert.equal(resolveConditionalText((crossroads as { text: string }).text, flags)?.includes('热粥'), water === 3);
for (const [ledger, option] of waterChoices[1].options.entries()) {
const node = nexts.get(option.next)!;
assert(node.type === 'dialogue');
const rendered = resolveConditionalText(node.text, { ...flags, wenjian_health: 'recovering' })!;
assert(rendered.includes(waterRecords[water]), `核账 ${water}/${ledger} 不守恒: ${rendered}`);
for (const [other, record] of waterRecords.entries()) if (other !== water) assert(!rendered.includes(record), `核账 ${water}/${ledger} 串账`);
}
}
const conB = BRANCHES.find((b) => b.id === 'b-con-b')!;
assert(conB.nodes[0].type === 'dialogue' && conB.nodes[0].text.includes('安门内'));
assert(conB.nodes.every((n) => n.type !== 'dialogue' || !n.text.includes('准备进安门')));
for (const [letter, value] of [['A', 'a_alert'], ['B', 'b_relay'], ['C', 'c_record'], ['TIMEOUT', 'self_rescue']] as const)
assert.equal(choiceFlags('CB-EMO-B-01', `CB-EMO-B-01-${letter}`).cb_emo_b_plate, value);
for (const [letter, value] of [['A', 'a_record'], ['B', 'b_mark'], ['C', 'c_leave']] as const)
assert.equal(choiceFlags('CB-EMO-B-02', `CB-EMO-B-02-${letter}`).cb_emo_b_vent, value);
assert.equal(choiceFlags('CB-VAL-A-01', 'CB-VAL-A-01-C').flag_thirst_days, 'true');
console.log('✓ 四路水量×三种核账、粥棚时间及可见性、门内入口、压板枚举');
assert.equal(JSON.stringify(chapters), original);
assert.equal(pointIds.size, 17);
console.log('✓ 十条支线全部选项/超时/跳过、温简双状态、逐节点恢复、事件与 flags、出口汇合');
for (const dimension of ['attachment', 'values', 'conflict_style', 'emotional_stability', 'culture'] as const) {
const store = new MemoryStore();
const jev = new FixedJev(dimension);
const e = new StoryEngine(chapters, store, jev, undefined, undefined, BRANCHES);
for (let i = 0; !e.state.finished && i < 400; i++) await step(e);
assert(e.state.finished);
const plans = store.events.filter((e) => e.kind === 'branch_inserted');
assert.equal(plans.length, 2);
assert(plans[0].branchId?.endsWith('-a') && plans[1].branchId?.endsWith('-b'));
assert.equal(jev.calls.length, 3);
assert(store.events.some((e) => e.kind === 'evidence_insufficient' && e.dimensions.includes(dimension)));
assert(String(jev.calls[2]).includes('CB-'));
const count = store.events.length;
e.replay('chapter1');
assert.equal(e.insertions.length, 0);
assert.equal(e.state.activeBranchId, undefined);
assert.equal(store.events.length, count);
await walkTo(e, 'c1-end');
await e.advance();
assert.equal(e.insertions[0].id, plans[0].branchId);
assert(!String(jev.calls.at(-1)).includes(plans[0].sessionId));
}
console.log('✓ 五维各跑通三章、A→B换皮、末章证据不足、本局隔离、replay 清计划');
{
const store = new MemoryStore();
const progress = new MemoryProgressStore();
const jev = new FixedJev();
const e = new StoryEngine(chapters, store, jev, undefined, progress, BRANCHES);
await walkTo(e, 'c1-end');
const oldSnapshot = progress.load()!;
await Promise.all([e.advance(), e.advance(), e.advance()]);
assert.equal(jev.calls.length, 1);
assert.equal(e.state.chapterId, 'chapter2');
assert.equal(store.events.filter((ev) => ev.kind === 'branch_inserted').length, 1);
const restored = new StoryEngine(chapters, store, jev, undefined, progress, BRANCHES);
await restored.restore(oldSnapshot);
await restored.advance();
assert.equal(jev.calls.length, 1);
assert.equal(restored.insertions.length, 1);
assert.equal(store.events.filter((ev) => ev.kind === 'branch_inserted').length, 1);
}
for (const jev of [
{ ask: async () => { throw new Error('test offline'); } },
{ ask: async () => ({}) },
{ adaptiveScoring: false, ask: async () => { assert.fail('缺 key 不应打分'); } },
]) {
const store = new MemoryStore();
const e = new StoryEngine(chapters, store, jev, undefined, undefined, BRANCHES);
await e.restore({ sessionId: 'test', chapterId: 'chapter1', nodeId: 'c1-end', flags: {} });
await e.advance();
assert.equal(e.state.chapterId, 'chapter2');
assert.equal(e.insertions.length, 0);
assert.equal(store.events.length, 0);
}
console.log('✓ 重复点击与刷新幂等、Jev故障/无效响应/无key降级');
for (const branchId of ['b-cul-a', 'b-cul-b']) {
const branch = BRANCHES.find((b) => b.id === branchId)!;
const puzzle = branch.nodes.find((n) => n.type === 'choice' && n.puzzle)!;
assert(puzzle.type === 'choice' && puzzle.puzzle);
for (const [correct, expected] of [[2, 'true'], [1, 'partial'], [0, 'false']] as const) {
const store = new MemoryStore();
const e = new StoryEngine(chapters, store, new MockJevClient(), undefined, undefined, BRANCHES);
await e.restore({ sessionId: 'test', chapterId: branch.anchor.chapter, nodeId: puzzle.id, activeBranchId: branch.id, flags: {}, insertions: [branch.id] });
const answers = puzzle.puzzle.questions.map((q, i) => i < correct ? q.answer : (q.answer + 1) % q.options.length);
await e.choose(puzzle.options[0].id, { answers, hintsUsed: 1 });
assert.equal(e.state.flags[`c${branchId.replaceAll('-', '_')}_solved`], expected);
assert.deepEqual(store.events.at(-1)?.behavior?.answerCorrect, [correct > 0, correct > 1]);
}
const store = new MemoryStore();
const e = new StoryEngine(chapters, store, new MockJevClient(), undefined, undefined, BRANCHES);
await e.restore({ sessionId: 'test', chapterId: branch.anchor.chapter, nodeId: puzzle.id, activeBranchId: branch.id, flags: {}, insertions: [branch.id] });
await e.choose(puzzle.options[1].id);
assert.equal(e.state.flags[`c${branchId.replaceAll('-', '_')}_solved`], 'not_attempted');
assert.equal(e.state.flags[`c${branchId.replaceAll('-', '_')}_left`], 'true');
assert.equal(store.events.at(-1)?.behavior?.answers, undefined);
}
console.log('✓ 文化双问题分题记录 partial,主动离场独立于提示用尽/答错');
assert(!buildState([makeEvent({ sessionId: 'private', chapterId: 'chapter1', kind: 'profile', pointId: 'C1-01', dimensions: [], profile: { name: 'SECRET-NAME', city: '', age: '', gender: '', occupation: '', education: '' } })]).includes('SECRET-NAME'));
await assert.rejects(() => scoreProfile([], { ask: async () => ({}) }));
assert.equal(resolveConditionalText('【若 cb_val_a_water = d_redirect:粥棚】', { cb_val_a_water: 'a_give' }), '');
console.log('✓ 共享评分排除身份数据、无效响应不冒充低置信、条件文案不串线');
+2 -2
View File
@@ -51,7 +51,7 @@ const chapters = [1, 2, 3].map(
// --- 1. C1-01 身份抽取(走引擎真实代码路径) ---
const store = new MemoryStore();
const engine = new StoryEngine(chapters, store, jev);
while (engine.currentNode().type !== 'identity') engine.advance();
while (engine.currentNode().type !== 'identity') await engine.advance();
const intro = '我叫林晚,今年29岁,在杭州一家互联网公司做产品经理,硕士毕业五年了。';
const profile = await engine.extractIdentity(intro);
console.log('\n[1] C1-01 身份抽取');
@@ -60,7 +60,7 @@ console.log(' 抽取:', JSON.stringify(profile));
// --- 2. C1-02 情绪签名 → 场景变体(choice 格式实测) ---
await engine.confirmIdentity(profile);
while (engine.currentNode().type !== 'narration') engine.advance();
while (engine.currentNode().type !== 'narration') await engine.advance();
const farewell = '外婆走的那天我在外地考试,没赶上最后一面。后来我每年清明都回去,院子里的桂花树还在。';
let result = await engine.submitNarration(farewell);
console.log('\n[2] C1-02 情绪签名 → 场景变体');
+58 -17
View File
@@ -14,7 +14,8 @@ 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 { 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';
@@ -37,8 +38,8 @@ const EXPECTED: Record<string, string[]> = {
'C2-05': ['attachment', 'reciprocity'],
'C2-06': ['conflict_style', 'values'],
'C3-01': ['culture'],
'C3-02': ['emotional_stability', 'attachment'],
'C3-03': ['values', 'emotional_stability'],
'C3-02': [],
'C3-03': ['values'],
'C3-04': ['conflict_style', 'values'],
'C3-05': ['mate_preference'],
'C3-06': ['attachment', 'mate_preference'],
@@ -115,7 +116,7 @@ for (const [id, dims] of Object.entries(EXPECTED)) {
// --- 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');
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(
@@ -126,11 +127,16 @@ for (const n of narrationNodes) {
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 === '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();
@@ -139,19 +145,20 @@ 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);
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 {
engine.advance();
await engine.advance();
}
}
check(engine.state.finished, '三章流程跑通(Mock 模式)');
@@ -167,6 +174,23 @@ const c305 = store.events.find((e) => e.pointId === 'C3-02' && e.kind === 'narra
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'];
@@ -189,10 +213,11 @@ while (engine.currentNode().type !== 'choice') {
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 {
engine.advance();
await engine.advance();
}
}
const firstChoice = engine.currentNode() as ChoiceNode;
@@ -226,12 +251,12 @@ class FixedNoulJev extends MockJevClient {
/** 推进到指定节点(途中选择点取第一项、身份点直接确认、讲述点沉默略过) */
async function walkTo(eng: StoryEngine, nodeId: string) {
let g = 0;
while (eng.currentNode().id !== nodeId && g++ < 60) {
while (eng.currentNode().id !== nodeId && g++ < 250) {
const n = eng.currentNode();
if (n.type === 'choice') await eng.choose((n as ChoiceNode).options[0].id);
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 eng.advance();
else await eng.advance();
}
}
@@ -322,7 +347,7 @@ check(choiceFlags('C2-01', 'b_exposure').wenjian_health === 'weak', 'flag 推导
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 === 'exit_room', 'flag 推导:C3-02 沉默退出 → c3_02_mode=exit_room');
check(narrationFlags('C3-02', undefined, null).c3_02_mode === undefined, 'C3-02 不从文本推导模式');
{
const f = choiceFlags('C3-06', 'c_confide_wenjian');
check(
@@ -330,7 +355,7 @@ check(narrationFlags('C3-02', undefined, null).c3_02_mode === 'exit_room', 'flag
'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');
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> = {}) {
@@ -345,17 +370,17 @@ async function walkAll(eng: StoryEngine, picker: Record<string, string> = {}) {
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);
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' && picker.C3_02_SILENT) await eng.skipNarration();
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 {
eng.advance();
await eng.advance();
}
}
return { seen, rendered, finished: eng.state.finished };
@@ -511,8 +536,24 @@ class FailingJev extends MockJevClient {
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}:恢复后叙事状态一致`);
}
if (failures > 0) {
// 主线失败先退出,避免掩盖原有回归。
console.error(`\n${failures} 项校验失败`);
process.exit(1);
}
console.log('\n全部校验通过');
console.log('\n全部校验通过');
await import('./verify-adaptive');