Files
jev-match-game/scripts/verify.ts
T

184 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 内容完整性与引擎流程验证(不走浏览器):
* 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全部校验通过');