184 lines
7.3 KiB
TypeScript
184 lines
7.3 KiB
TypeScript
/**
|
||
* 内容完整性与引擎流程验证(不走浏览器):
|
||
* 1. 章节 JSON 的选择点 ID、维度标签与 docs/story-framework.md 第 4 节一一对应;
|
||
* 2. 节点图完整(startNodeId 存在、所有 next 跳转有效、全部节点可达);
|
||
* 3. 用 MockJevClient + 内存 EventStore 跑通三章 → 16 个选择点全部落事件 → Jev 7 维打分。
|
||
* 运行:pnpm verify
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import { StoryEngine } from '../src/engine/engine';
|
||
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
|
||
import type { Chapter, ChoiceNode, NarrationNode } from '../src/engine/types';
|
||
import { MockJevClient } from '../src/services/jev';
|
||
|
||
// story-framework.md 第 4 节的权威选择点地图(ID → 期望维度标签)
|
||
const EXPECTED: Record<string, string[]> = {
|
||
'C1-01': [],
|
||
'C1-02': ['attachment', 'self_disclosure'],
|
||
'C1-03': ['values'],
|
||
'C1-04': ['values', 'conflict_style'],
|
||
'C1-05': ['culture'],
|
||
'C1-06': ['risk'],
|
||
'C2-01': ['values', 'conflict_style'],
|
||
'C2-02': ['values', 'emotional_stability'],
|
||
'C2-03': ['risk', 'conflict_style'],
|
||
'C2-04': ['culture'],
|
||
'C2-05': ['attachment', 'reciprocity'],
|
||
'C2-06': ['conflict_style', 'values'],
|
||
'C3-01': ['culture'],
|
||
'C3-02': ['emotional_stability', 'attachment'],
|
||
'C3-03': ['values', 'emotional_stability'],
|
||
'C3-04': ['conflict_style', 'values'],
|
||
'C3-05': ['mate_preference'],
|
||
'C3-06': ['attachment', 'mate_preference'],
|
||
};
|
||
|
||
class MemoryStore implements EventStore {
|
||
events: StoryEvent[] = [];
|
||
async append(e: StoryEvent) {
|
||
this.events.push(e);
|
||
}
|
||
async list(sessionId?: string) {
|
||
return sessionId ? this.events.filter((e) => e.sessionId === sessionId) : [...this.events];
|
||
}
|
||
}
|
||
|
||
const chapters = [1, 2, 3].map(
|
||
(i) => JSON.parse(readFileSync(new URL(`../content/chapters/chapter${i}.json`, import.meta.url), 'utf8')) as Chapter,
|
||
);
|
||
|
||
let failures = 0;
|
||
function check(ok: boolean, msg: string) {
|
||
if (!ok) {
|
||
failures++;
|
||
console.error(`✗ ${msg}`);
|
||
} else {
|
||
console.log(`✓ ${msg}`);
|
||
}
|
||
}
|
||
|
||
// --- 1. 结构校验 ---
|
||
const seenPoints = new Map<string, string[]>();
|
||
for (const ch of chapters) {
|
||
const ids = new Set(ch.nodes.map((n) => n.id));
|
||
check(ids.has(ch.startNodeId), `${ch.id}: startNodeId 存在`);
|
||
const reachable = new Set<string>([ch.startNodeId]);
|
||
for (let changed = true; changed; ) {
|
||
changed = false;
|
||
for (const n of ch.nodes) {
|
||
if (!reachable.has(n.id)) continue;
|
||
const nexts: string[] =
|
||
n.type === 'choice'
|
||
? n.options.map((o) => o.next)
|
||
: n.type === 'chapterEnd'
|
||
? []
|
||
: [n.next];
|
||
for (const nx of nexts) {
|
||
check(ids.has(nx), `${ch.id}/${n.id}: next "${nx}" 有效`);
|
||
if (!reachable.has(nx)) {
|
||
reachable.add(nx);
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
check(reachable.size === ch.nodes.length, `${ch.id}: 全部 ${ch.nodes.length} 个节点可达`);
|
||
for (const n of ch.nodes) {
|
||
if (n.type === 'choice' || n.type === 'narration' || n.type === 'identity') {
|
||
check(!seenPoints.has(n.pointId), `${n.pointId}: 选择点 ID 全局唯一`);
|
||
seenPoints.set(n.pointId, n.dimensions);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 2. 与母本选择点地图对照 ---
|
||
const expectedIds = Object.keys(EXPECTED).sort();
|
||
const actualIds = [...seenPoints.keys()].sort();
|
||
check(JSON.stringify(actualIds) === JSON.stringify(expectedIds), `选择点 ID 集合与母本一致(${actualIds.length} 个)`);
|
||
for (const [id, dims] of Object.entries(EXPECTED)) {
|
||
check(
|
||
JSON.stringify(seenPoints.get(id) ?? null) === JSON.stringify(dims),
|
||
`${id}: 维度标签 ${JSON.stringify(seenPoints.get(id))} == 母本 ${JSON.stringify(dims)}`,
|
||
);
|
||
}
|
||
|
||
// --- 3. V 类讲述点变体结构 ---
|
||
const narrationNodes = chapters.flatMap((c) => c.nodes.filter((n) => n.type === 'narration')) as NarrationNode[];
|
||
check(narrationNodes.map((n) => n.pointId).sort().join(',') === 'C1-02,C2-02,C2-05,C3-02', 'V 类讲述点为 C1-02/C2-02/C2-05/C3-02');
|
||
for (const n of narrationNodes) {
|
||
check(n.variants.length >= 2, `${n.pointId}: 有 ${n.variants.length} 个预写场景变体`);
|
||
check(
|
||
n.variants.every((v) => v.signature && v.variantId && v.label),
|
||
`${n.pointId}: 情绪签名 → variantId 映射完整`,
|
||
);
|
||
}
|
||
check(narrationNodes.find((n) => n.pointId === 'C3-02')?.variantFrom === 'C1-02', 'C3-02 变体由 C1-02 驱动(variantFrom)');
|
||
|
||
// --- 4. Mock 模式端到端跑通三章 ---
|
||
const store = new MemoryStore();
|
||
const jev = new MockJevClient();
|
||
const engine = new StoryEngine(chapters, store, jev);
|
||
let guard = 0;
|
||
while (!engine.state.finished && guard++ < 200) {
|
||
const node = engine.currentNode();
|
||
if (node.type === 'choice') {
|
||
await engine.choose((node as ChoiceNode).options[0].id);
|
||
} else if (node.type === 'identity') {
|
||
const profile = await engine.extractIdentity('我叫阿黎,29岁,在杭州做互联网产品经理,硕士学历');
|
||
await engine.confirmIdentity(profile);
|
||
} else if (node.type === 'narration') {
|
||
// C2-05 走"沉默"路径验证合法选项,其余讲述
|
||
if ((node as NarrationNode).pointId === 'C2-05') await engine.skipNarration();
|
||
else await engine.submitNarration('(验证用占位讲述)');
|
||
} else {
|
||
engine.advance();
|
||
}
|
||
}
|
||
check(engine.state.finished, '三章流程跑通(Mock 模式)');
|
||
const eventPoints = store.events.map((e) => e.pointId).sort();
|
||
check(
|
||
JSON.stringify(eventPoints) === JSON.stringify(expectedIds),
|
||
`事件轨迹覆盖全部 ${expectedIds.length} 个选择点`,
|
||
);
|
||
const c305 = store.events.find((e) => e.pointId === 'C3-02');
|
||
const c102 = store.events.find((e) => e.pointId === 'C1-02');
|
||
check(!!c305?.variantId && c305.variantId === c102?.variantId, 'C3-02 幻境变体复用 C1-02 情绪解析结果');
|
||
|
||
// --- 5. 画像报告:7 维 score 问题 ---
|
||
const criteria = Array.from({ length: 10 }, (_, i) => `${i + 1} 级`);
|
||
const dims = ['attachment', 'conflict_style', 'values', 'emotional_stability', 'risk', 'culture', 'mate_preference'];
|
||
const answers = await jev.ask(
|
||
store.events,
|
||
Object.fromEntries(dims.map((d) => [d, { type: 'score' as const, instructions: d, criteria }])),
|
||
);
|
||
check(
|
||
dims.every((d) => answers[d]?.type === 'score' && answers[d].confidence >= 0 && answers[d].confidence <= 1),
|
||
'Jev 7 维 score 打分返回(含 0-1 置信度)',
|
||
);
|
||
|
||
// --- 6. 重玩语义:新轨迹不修改旧事件 ---
|
||
const before = store.events.length;
|
||
const oldSession = engine.state.sessionId;
|
||
engine.replay('chapter1');
|
||
check(engine.state.sessionId !== oldSession, '重玩产生新 sessionId');
|
||
while (engine.currentNode().type !== 'choice') {
|
||
const n = engine.currentNode();
|
||
if (n.type === 'identity') {
|
||
await engine.confirmIdentity(await engine.extractIdentity('占位自我介绍'));
|
||
} else if (n.type === 'narration') {
|
||
await engine.submitNarration('(验证用占位讲述)');
|
||
} else {
|
||
engine.advance();
|
||
}
|
||
}
|
||
const firstChoice = engine.currentNode() as ChoiceNode;
|
||
await engine.choose(firstChoice.options[0].id);
|
||
check(store.events.length === before + 3, '重玩追加新事件(profile + narration + choice),旧事件不变');
|
||
check((await store.list(oldSession)).length === before, '旧轨迹事件完整保留');
|
||
|
||
if (failures > 0) {
|
||
console.error(`\n${failures} 项校验失败`);
|
||
process.exit(1);
|
||
}
|
||
console.log('\n全部校验通过');
|