feat: 自适应支线插入引擎(WIP:scoring/branching 抽取、挂点重定向、10 条支线 JSON、决策落库)
This commit is contained in:
+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