feat: 自适应支线插入引擎(WIP:scoring/branching 抽取、挂点重定向、10 条支线 JSON、决策落库)
This commit is contained in:
@@ -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}`);
|
||||
@@ -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('✓ 共享评分排除身份数据、无效响应不冒充低置信、条件文案不串线');
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user