Files

188 lines
9.6 KiB
TypeScript
Raw Permalink 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.
import assert from 'node:assert/strict';
import { CHAPTERS } from '../src/engine/chapters';
import { CONSENT_VERSION, StoryEngine } from '../src/engine/engine';
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
import { deriveFlags } from '../src/engine/flags';
import { MemoryProgressStore } from '../src/engine/progress';
import { buildState } from '../src/engine/scoring';
import { MockJevClient } from '../src/services/jev';
import type { StoryNode } from '../src/engine/types';
import { FOLLOW_UP_DIMENSIONS } from '../src/engine/types';
class MemoryStore implements EventStore {
events: StoryEvent[] = [];
async append(e: StoryEvent) { this.events.push(e); }
async list(session?: string) { return this.events.filter((e) => !session || e.sessionId === session); }
async purge() { this.events = []; }
}
const pointIds = new Set<string>();
{
const store = new MemoryStore();
store.events.push({ id: 'old', ts: 1, sessionId: 'old', chapterId: 'chapter1', kind: 'consent', pointId: 'CONSENT', dimensions: [], consentVersion: 'act1-consent-v1' });
const engine = new StoryEngine(CHAPTERS, store, new MockJevClient());
assert.equal(await engine.hasConsent(), false);
await engine.grantConsent();
assert.equal(await engine.hasConsent(), true);
assert.equal(store.events.at(-1)?.consentVersion, CONSENT_VERSION);
assert.equal(store.events[0].consentVersion, 'act1-consent-v1');
}
console.log('✓ 新版在线处理告知需重新确认,旧同意存证保留');
let optionCount = 0;
for (const [i, chapter] of CHAPTERS.entries()) {
const nodes = new Map(chapter.nodes.map((n) => [n.id, n]));
assert.equal(nodes.size, chapter.nodes.length, `${chapter.id} 节点唯一`);
const seen = new Set<string>();
const visiting = new Set<string>();
const visit = (id: string) => {
assert(!visiting.has(id), `${chapter.id}/${id} 循环`);
if (seen.has(id)) return;
const n = nodes.get(id);
assert(n, `${chapter.id}/${id} 缺失`);
visiting.add(id);
if (n.type === 'chapterEnd') assert.equal(n.nextChapter, CHAPTERS[i + 1]?.id);
else if (n.type === 'choice') {
assert(n.options.length >= 2);
assert.equal(new Set(n.options.map((o) => o.id)).size, n.options.length);
for (const o of n.options) visit(o.next);
optionCount += n.options.length;
if (n.puzzle) {
assert.equal(n.puzzle.hints.length, 3);
for (const q of n.puzzle.questions) assert(q.answer >= 0 && q.answer < q.options.length);
assert(n.options.some((o) => o.id.endsWith('-EXIT')));
}
} else visit(n.next);
visiting.delete(id);
seen.add(id);
};
visit(chapter.startNodeId);
assert.equal(seen.size, nodes.size, `${chapter.id} 全部节点可达且可到章末`);
for (const n of chapter.nodes) if ('pointId' in n) {
assert(!pointIds.has(n.pointId), n.pointId);
pointIds.add(n.pointId);
if (n.type === 'narration') assert(n.variants.some((v) => v.signature.includes('沉默')));
}
}
assert.equal(pointIds.size, 51);
console.log(`✓ 九章图结构、${pointIds.size} 个交互点、${optionCount} 条选项出口、谜题三档提示及沉默变体`);
// 新增/改造讲述点八件套:C4-04/C5-03/C6-04/C7-06/C8-06/C9-03 六键追问模板 + maxQuestions=1
{
const NEW_V = ['C4-04', 'C5-03', 'C6-04', 'C7-06', 'C8-06', 'C9-03'];
const narrations = CHAPTERS.flatMap((c) => c.nodes).filter((n) => n.type === 'narration');
for (const pointId of NEW_V) {
const n = narrations.find((n) => n.pointId === pointId);
assert(n, `${pointId} 讲述点存在`);
assert.equal(n!.followUp.maxQuestions, 1, `${pointId} maxQuestions`);
for (const d of FOLLOW_UP_DIMENSIONS) {
assert(typeof n!.followUps[d] === 'string' && n!.followUps[d]!.length > 0, `${pointId} followUps.${d}`);
}
assert(n!.variants.some((v) => v.signature.includes('沉默')), `${pointId} 沉默档变体`);
}
console.log('✓ 六个新增/改造讲述点:六键追问模板齐全、maxQuestions=1、沉默档变体在位');
}
function texts(node: StoryNode): string[] {
if (node.type === 'dialogue') return [node.text];
if (node.type === 'cutscene') return [node.caption];
if (node.type === 'choice') return [node.prompt, ...node.options.map((o) => o.label)];
return 'prompt' in node ? [node.prompt] : [];
}
async function step(e: StoryEngine, mode: number, picks: Record<string, string> = {}) {
const n = e.currentNode();
for (const text of texts(n)) assert(!e.renderText(text).includes('【若'), `条件泄漏 ${n.id}`);
if (n.type === 'identity') await e.confirmIdentity({ name: '测试', age: '', city: '', gender: '', education: '', occupation: '' });
else if (n.type === 'narration') {
if (mode === 0 || n.pointId === 'C3-02') await e.skipNarration();
else {
await e.submitNarration('我喜欢回家后先安静喝杯茶,再慢慢讲今天的事。');
if (e.state.pendingFollowUp) await e.skipFollowUp();
}
} else if (n.type === 'choice') {
const selected = picks[n.pointId] ?? n.options[mode % n.options.length].id;
await e.choose(selected, n.puzzle ? { answers: n.puzzle.questions.map((q) => q.answer), hintsUsed: mode % 4 } : undefined);
} else await e.advance();
}
for (const mode of [0, 1, 2]) {
const store = new MemoryStore();
const progress = new MemoryProgressStore();
let engine = new StoryEngine(CHAPTERS, store, new MockJevClient(), undefined, progress);
const visited = new Set<string>();
for (let guard = 0; !engine.state.finished && guard < 1000; guard++) {
visited.add(engine.state.chapterId);
await step(engine, mode);
if (engine.state.nodeId === 'c7-opening') {
const restored = new StoryEngine(CHAPTERS, store, new MockJevClient(), undefined, progress);
await restored.restore(progress.load());
assert.deepEqual(restored.state.flags, engine.state.flags);
engine = restored;
}
}
assert(engine.state.finished, `路线 ${mode} 必须结束`);
assert.equal(visited.size, 9);
assert.equal(new Set(store.events.map((e) => e.pointId)).size, 51);
assert.deepEqual(deriveFlags(store.events), engine.state.flags);
const state = buildState(store.events);
for (const privatePoint of ['C3-02', 'C7-02', 'C9-05']) assert(!state.includes(privatePoint));
// 私密点事件本身也不带维度标签(仅叙事接续,不入画像)
for (const e of store.events.filter((e) => ['C7-02', 'C9-05'].includes(e.pointId))) {
assert.equal(e.dimensions.length, 0, `${e.pointId} 事件维度为空`);
}
assert(state.includes('情境「'));
assert.equal(store.events.filter((e) => /^C[4-9]/.test(e.pointId) && e.kind === 'choice').every((e) => !!e.choicePrompt), true);
}
console.log('✓ 九章三条端到端路线:全沉默、讲述、谜题退出;跨幕恢复与旗标重建;私密节点隔离');
// 每个新增选项都实际运行至终点,不以图可达代替引擎验证。
for (const chapter of CHAPTERS.slice(3)) for (const node of chapter.nodes) if (node.type === 'choice') {
for (const option of node.options) {
const store = new MemoryStore();
const engine = new StoryEngine(CHAPTERS, store, new MockJevClient(), chapter.id);
const seen = new Set<string>();
for (let guard = 0; !engine.state.finished && guard < 1000; guard++) {
seen.add(engine.state.nodeId);
await step(engine, 0, { [node.pointId]: option.id });
}
assert(engine.state.finished);
assert(seen.has(option.next), `${node.pointId}/${option.id} 必须播放专属后续`);
assert.equal(store.events.find((e) => e.pointId === node.pointId)?.optionId, option.id);
}
}
console.log('✓ 全部新增选项逐一实走至季终,含三种治理结局与三种个人尾声');
for (const plan of ['delegate', 'observe', 'relay']) for (const ending of ['shared', 'local', 'pause']) {
const store = new MemoryStore();
const engine = new StoryEngine(CHAPTERS, store, new MockJevClient(), 'chapter6');
let route = '';
let outcome = '';
for (let guard = 0; !engine.state.finished && guard < 500; guard++) {
const n = engine.currentNode();
if (n.id === 'c8-route' && n.type === 'dialogue') route = engine.renderText(n.text);
if (n.id === 'c9-institution' && n.type === 'dialogue') outcome = engine.renderText(n.text);
await step(engine, 0, { 'C6-03': plan, 'C8-05': ending });
}
assert(route.includes(plan === 'observe' ? '留在南岸' : plan === 'delegate' ? '禾记,弥核' : '转录来源'));
assert(outcome.includes(ending === 'shared' ? '共同保管' : ending === 'local' ? '分存回函' : '空白的任务日历'));
}
console.log('✓ 3×3 见证方式与治理结局交叉验证:亲历/转录不混写,结局正确兑现');
// 结局轴 3:c9_05 → c9-ending-* 三路尾声各自兑现且汇合于季终
for (const [pick, node, marker] of [
['together', 'c9-ending-together', '腾出一只手'],
['room', 'c9-ending-room', '自愿再开一次'],
['later', 'c9-ending-later', '走进这一日'],
] as const) {
const store = new MemoryStore();
const engine = new StoryEngine(CHAPTERS, store, new MockJevClient(), 'chapter9');
const seen = new Set<string>();
for (let guard = 0; !engine.state.finished && guard < 300; guard++) {
seen.add(engine.state.nodeId);
await step(engine, 0, { 'C9-05': pick });
}
assert(engine.state.finished);
assert(seen.has(node), `c9_05=${pick} 必须抵达 ${node}`);
const endingNode = CHAPTERS[8].nodes.find((n) => n.id === node);
assert(endingNode?.type === 'dialogue' && endingNode.text.includes(marker), `${node} 尾声文案`);
assert.equal(engine.state.flags.c9_05, pick, '结局轴 3 变量自动派生');
}
console.log('✓ 结局轴 3:c9_05 三路私密尾声各自兑现并汇合季终');