feat: 第一季九章完整剧本与引擎(chapters 4-9、相处线索报告替代七维评分、采样覆盖驱动支线插入、consent v2、报告页隐私区块完善)+ 跨 agent 断点修复
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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';
|
||||
|
||||
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, 49);
|
||||
console.log(`✓ 九章图结构、${pointIds.size} 个交互点、${optionCount} 条选项出口、谜题三档提示及沉默变体`);
|
||||
|
||||
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, 49);
|
||||
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));
|
||||
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 见证方式与治理结局交叉验证:亲历/转录不混写,结局正确兑现');
|
||||
Reference in New Issue
Block a user