/** * 内容完整性与引擎流程验证(不走浏览器): * 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 = { '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(); 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([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') { 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 === '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) => String(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, description: 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') engine.advance(); const firstChoice = engine.currentNode() as ChoiceNode; await engine.choose(firstChoice.options[0].id); check(store.events.length === before + 1, '重玩追加新事件,旧事件不变'); check((await store.list(oldSession)).length === before, '旧轨迹事件完整保留'); if (failures > 0) { console.error(`\n${failures} 项校验失败`); process.exit(1); } console.log('\n全部校验通过');