Files

283 lines
18 KiB
TypeScript

import assert from 'node:assert/strict';
import { ACT1 as chapters } from '../src/engine/chapters';
import { BRANCHES } from '../src/engine/branches';
import { decideInsertion } from '../src/engine/branching';
import { StoryEngine } from '../src/engine/engine';
import { makeEvent } from '../src/engine/eventStore';
import type { StoryEvent, EventStore } from '../src/engine/eventStore';
import { choiceFlags, deriveFlags, resolveConditionalText } from '../src/engine/flags';
import { MemoryProgressStore } from '../src/engine/progress';
import { buildState, assessRelationships, PROFILE_DIMENSIONS } from '../src/engine/scoring';
import type { Confidences, ProfileDimension } from '../src/engine/scoring';
import type { Chapter, StoryNode, ChoiceBehavior } from '../src/engine/types';
import { MockJevClient } from '../src/services/jev';
import type { JevQuestion } from '../src/services/jev';
const confidences = (low: Confidences = {}): Confidences => Object.assign(Object.fromEntries(PROFILE_DIMENSIONS.map((d) => [d.id, 0.9])), low);
const inserted = (id: string) => {
const b = BRANCHES.find((b) => b.id === id)!;
return makeEvent({ sessionId: 'test', chapterId: b.anchor.chapter, pointId: id, kind: 'branch_inserted', branchId: id, dimensions: [b.dimension], confidence: 0.3 });
};
const sample = (point: string, dimension: ProfileDimension) => makeEvent({ sessionId: 'test', chapterId: 'chapter1', pointId: point, kind: 'choice', dimensions: [dimension], optionId: 'A', optionLabel: 'A' });
const choose = (low: Confidences, events: StoryEvent[] = [], target = chapters[1], completedChapter?: Chapter) => decideInsertion({ confidences: confidences(low), events, branches: BRANCHES, target, completedChapter });
for (const [label, low, events, target, expected] of [
['单维低置信', { attachment: 0.5 }, [], chapters[1], 'b-att-a'],
['多维权重', { attachment: 0.4, values: 0.4, culture: 0.4 }, [], chapters[1], 'b-att-a'],
['缺口优先', { attachment: 0.59, values: 0.1 }, [], chapters[1], 'b-val-a'],
['稀缺优先', { attachment: 0.4, values: 0.4 }, [sample('one', 'attachment'), sample('two', 'attachment')], chapters[1], 'b-val-a'],
['重复换皮', { attachment: 0.2 }, [inserted('b-att-a')], chapters[2], 'b-att-b'],
['第3章初测不可搬A', { values: 0.2 }, [], chapters[2], 'b-val-b'],
['两皮耗尽', { attachment: 0.2 }, [inserted('b-att-a'), inserted('b-att-b')], chapters[2], undefined],
['占用不叠加', { values: 0.2 }, [inserted('b-att-a')], chapters[1], undefined],
['占用顺延下一章', { values: 0.2 }, [inserted('b-att-a')], chapters[2], 'b-val-b'],
['无定义维度', { risk: 0.1, mate_preference: 0.1 }, [], chapters[1], undefined],
['阈值边界', { attachment: 0.6 }, [], chapters[1], undefined],
['无效置信度', { attachment: NaN, values: -1 }, [], chapters[1], undefined],
['超时长', { attachment: 0.1 }, [], { ...chapters[1], estimatedMinutes: 38 }, undefined],
['时长等于45', { attachment: 0.1 }, [], { ...chapters[1], estimatedMinutes: 37 }, 'b-att-a'],
] as [string, Confidences, StoryEvent[], Chapter, string | undefined][]) {
assert.equal(choose(low, events, target).branch?.id, expected, label);
}
assert.deepEqual(choose({ risk: 0.1, mate_preference: 0.1 }).evidenceInsufficient, ['risk', 'mate_preference']);
assert.deepEqual(choose({ attachment: 0.1 }, [inserted('b-att-a'), inserted('b-att-b')], chapters[2]).evidenceInsufficient, ['attachment']);
assert.equal(choose({ culture: 0.2 }, [], chapters[1], chapters[0]).branch, undefined);
assert.equal(choose({ culture: 0.2 }, [sample('C1-05', 'culture')], chapters[1], chapters[0]).branch?.id, 'b-cul-a');
console.log('✓ 自适应决策矩阵:权重、缺口、稀缺、A/B、占用、证据不足、主线优先、时长/阈值边界');
class MemoryStore implements EventStore {
events: StoryEvent[] = [];
async list(sessionId?: string) { return this.events.filter((e) => !sessionId || e.sessionId === sessionId); }
async append(e: StoryEvent) { this.events.push(e); }
async purge() { this.events = []; }
}
class FixedJev extends MockJevClient {
dimension: ProfileDimension;
calls: unknown[] = [];
constructor(dimension: ProfileDimension = 'attachment') { super(); this.dimension = dimension; }
override async ask(state: unknown, questions: Record<string, JevQuestion>) {
const result = await super.ask(state, questions);
if (Object.keys(questions).length === 7) {
this.calls.push(state);
for (const d of PROFILE_DIMENSIONS) if (result[d.id]?.type === 'score') result[d.id].confidence = d.id === this.dimension ? 0.2 : 0.9;
}
return result;
}
}
async function step(e: StoryEngine, picks: Record<string, string> = {}, behavior?: ChoiceBehavior) {
const n = e.currentNode();
if (n.type === 'choice') {
await e.choose(picks[n.pointId] ?? n.options[0].id, behavior ?? (n.puzzle ? { answers: n.puzzle.questions.map((q) => q.answer), hintsUsed: 0 } : { decisionMs: 320 }));
} else if (n.type === 'narration') await e.skipNarration();
else if (n.type === 'identity') await e.confirmIdentity({ name: '测试', age: '', city: '', gender: '', occupation: '', education: '' });
else await e.advance();
}
async function walkTo(e: StoryEngine, id: string) {
for (let i = 0; i < 400 && !e.state.finished; i++) {
if (e.state.nodeId === id) return;
await step(e);
}
assert.fail(`未到达 ${id},当前位置 ${e.state.nodeId}`);
}
// 所有节点均可达,所有出口返回主线,不共享/改写主线 JSON。
const original = JSON.stringify(chapters);
const pointIds = new Set<string>();
for (const branch of BRANCHES) {
const index = new Map(branch.nodes.map((n) => [n.id, n]));
const reached = new Set<string>();
function visit(id: string, path = new Set<string>()) {
if (id === '$resume') return;
assert(!path.has(id), `支线循环 ${id}`);
const n = index.get(id);
assert(n, `无效跳转 ${id}`);
reached.add(id);
const nexts = n.type === 'choice' ? n.options.map((o) => o.next) : 'next' in n ? [n.next] : [];
assert(nexts.length, `没有出口 ${id}`);
for (const next of nexts) visit(next, new Set([...path, id]));
}
visit(branch.startNodeId);
assert.equal(reached.size, branch.nodes.length, branch.id);
for (const node of branch.nodes) {
if (node.type !== 'choice') continue;
assert(!pointIds.has(node.pointId));
pointIds.add(node.pointId);
assert.deepEqual(node.dimensions, [branch.dimension]);
if (node.puzzle) {
assert.equal(node.puzzle.questions.length, 2);
assert(node.puzzle.questions.every((q) => q.options.length === 4));
assert.equal(node.puzzle.hints.length, 3);
}
}
for (const health of ['weak', 'recovering']) {
for (const choiceNode of branch.nodes.filter((n) => n.type === 'choice')) {
for (const option of choiceNode.options) {
const store = new MemoryStore();
const progress = new MemoryProgressStore();
const engine = new StoryEngine(chapters, store, new FixedJev(branch.dimension), undefined, progress, BRANCHES);
await engine.restore({ sessionId: 'test', chapterId: branch.anchor.chapter, nodeId: branch.anchor.afterNode, flags: { wenjian_health: health }, insertions: [branch.id] });
await engine.advance();
assert.equal(engine.state.nodeId, branch.startNodeId);
for (let i = 0; engine.state.activeBranchId && i < 100; i++) {
const node = engine.currentNode();
const texts = node.type === 'choice' ? [node.prompt, ...node.options.map((o) => o.label)] : node.type === 'dialogue' ? [node.text] : [];
for (const text of texts) {
const rendered = engine.renderText(text);
assert(!/【若|工程注|§\d|Act 2|线追加|若 C2-05/.test(rendered), `${node.id} 泄漏: ${rendered}`);
}
const snap = progress.load()!;
const restored = new StoryEngine(chapters, store, new FixedJev(), undefined, progress, BRANCHES);
await restored.restore(snap);
assert.equal(restored.state.nodeId, engine.state.nodeId);
assert.equal(restored.state.activeBranchId, branch.id);
const behavior = node.id === choiceNode.id && node.type === 'choice' ? node.puzzle ? { answers: node.puzzle.questions.map((q) => q.answer), hintsUsed: 2, decisionMs: 400 } : { timedOut: option.id.endsWith('TIMEOUT'), decisionMs: 400 } : undefined;
await step(engine, { [choiceNode.pointId]: option.id }, behavior);
}
assert.equal(engine.state.activeBranchId, undefined);
const anchor = chapters.find((c) => c.id === branch.anchor.chapter)!.nodes.find((n) => n.id === branch.anchor.afterNode)!;
assert.equal(engine.state.nodeId, 'next' in anchor ? anchor.next : '');
const event = store.events.find((e) => e.pointId === choiceNode.pointId);
// CUL-A skip 不经过谜面;默认进入路径则每个目标点都应落事件。
assert(event, `${choiceNode.pointId} 缺事件`);
assert.equal(event.optionId, option.id);
assert.equal(event.branchId, branch.id);
const derived = deriveFlags(store.events);
for (const [flag, value] of Object.entries(derived)) assert.equal(engine.state.flags[flag], value, flag);
if (nodeIsPuzzle(choiceNode)) assert.equal(event.behavior?.solved, !option.id.endsWith('EXIT'));
}
}
}
}
function nodeIsPuzzle(n: StoryNode) { return n.type === 'choice' && !!n.puzzle; }
// 01 的实际支出与受领性质必须在 02 的全部三个入口一致,D 的粥棚只在路程之后可见。
const waterBranch = BRANCHES.find((b) => b.id === 'b-val-a')!;
const waterChoices = waterBranch.nodes.filter((n) => n.type === 'choice');
assert.equal(waterChoices.length, 2);
const waterRecords = ['施水一囊', '施水一杯', '售水两囊,得镯一支(CW-0322,有偿征集)', '水支出零,受赠镯一支(CW-0322,赠与代收)'];
for (let water = 0; water < 4; water++) {
const flags = choiceFlags('CB-VAL-A-01', waterChoices[0].options[water].id);
const opening = waterBranch.nodes.find((n) => n.id === waterBranch.startNodeId)!;
assert(opening.type === 'dialogue' && opening.text.includes('施粥棚开着'), '选前必须提供当日粥棚证据');
const crossroads = waterBranch.nodes.find((n) => n.type === 'dialogue' && n.text.includes('盛热粥的碗'))!;
const elapsed = waterBranch.nodes.find((n) => n.type === 'dialogue' && n.text.includes('一个时辰的路走完了'))!;
const nexts = new Map(waterBranch.nodes.map((n) => [n.id, n]));
let cursor = waterChoices[0].options[water].next;
let sawElapsed = false;
while (cursor !== waterChoices[1].id) {
if (cursor === elapsed.id) sawElapsed = true;
if (cursor === crossroads.id) assert(sawElapsed, '粥棚确认早于一个时辰路程');
const node = nexts.get(cursor)!;
assert(node && 'next' in node, `核账前路径中断 ${cursor}`);
cursor = node.next;
}
assert.equal(resolveConditionalText((crossroads as { text: string }).text, flags)?.includes('热粥'), water === 3);
for (const [ledger, option] of waterChoices[1].options.entries()) {
const node = nexts.get(option.next)!;
assert(node.type === 'dialogue');
const rendered = resolveConditionalText(node.text, { ...flags, wenjian_health: 'recovering' })!;
assert(rendered.includes(waterRecords[water]), `核账 ${water}/${ledger} 不守恒: ${rendered}`);
for (const [other, record] of waterRecords.entries()) if (other !== water) assert(!rendered.includes(record), `核账 ${water}/${ledger} 串账`);
}
}
const conB = BRANCHES.find((b) => b.id === 'b-con-b')!;
assert(conB.nodes[0].type === 'dialogue' && conB.nodes[0].text.includes('安门内'));
assert(conB.nodes.every((n) => n.type !== 'dialogue' || !n.text.includes('准备进安门')));
for (const [letter, value] of [['A', 'a_alert'], ['B', 'b_relay'], ['C', 'c_record'], ['TIMEOUT', 'self_rescue']] as const)
assert.equal(choiceFlags('CB-EMO-B-01', `CB-EMO-B-01-${letter}`).cb_emo_b_plate, value);
for (const [letter, value] of [['A', 'a_record'], ['B', 'b_mark'], ['C', 'c_leave']] as const)
assert.equal(choiceFlags('CB-EMO-B-02', `CB-EMO-B-02-${letter}`).cb_emo_b_vent, value);
assert.equal(choiceFlags('CB-VAL-A-01', 'CB-VAL-A-01-C').flag_thirst_days, 'true');
console.log('✓ 四路水量×三种核账、粥棚时间及可见性、门内入口、压板枚举');
assert.equal(JSON.stringify(chapters), original);
assert.equal(pointIds.size, 17);
console.log('✓ 十条支线全部选项/超时/跳过、温简双状态、逐节点恢复、事件与 flags、出口汇合');
for (const dimension of ['attachment', 'values', 'conflict_style', 'emotional_stability', 'culture'] as const) {
const store = new MemoryStore();
const jev = new FixedJev(dimension);
const e = new StoryEngine(chapters, store, jev, undefined, undefined, BRANCHES);
for (let i = 0; !e.state.finished && i < 400; i++) await step(e);
assert(e.state.finished);
const plans = store.events.filter((e) => e.kind === 'branch_inserted');
assert.equal(plans.length, 2);
assert(plans[0].branchId?.endsWith('-a') && plans[1].branchId?.endsWith('-b'));
// 章末插入置信度 = samplingCoverage(事件采样统计),不再调 Jev——锁新语义
assert.equal(jev.calls.length, 0);
// 支线本身贡献采样:走完 A→B 后该维度覆盖达标,不再标记证据不足(原断言基于旧的 Jev 置信度语义)
const count = store.events.length;
e.replay('chapter1');
assert.equal(e.insertions.length, 0);
assert.equal(e.state.activeBranchId, undefined);
assert.equal(store.events.length, count);
await walkTo(e, 'c1-end');
await e.advance();
assert.equal(e.insertions[0].id, plans[0].branchId);
}
console.log('✓ 五维各跑通三章、A→B换皮、末章证据不足、本局隔离、replay 清计划');
{
const store = new MemoryStore();
const progress = new MemoryProgressStore();
const jev = new FixedJev();
const e = new StoryEngine(chapters, store, jev, undefined, progress, BRANCHES);
await walkTo(e, 'c1-end');
const oldSnapshot = progress.load()!;
await Promise.all([e.advance(), e.advance(), e.advance()]);
assert.equal(jev.calls.length, 0); // 新语义:章末决策用采样统计,无 Jev 调用;幂等性看 branch_inserted 事件数
assert.equal(e.state.chapterId, 'chapter2');
assert.equal(store.events.filter((ev) => ev.kind === 'branch_inserted').length, 1);
const restored = new StoryEngine(chapters, store, jev, undefined, progress, BRANCHES);
await restored.restore(oldSnapshot);
await restored.advance();
assert.equal(jev.calls.length, 0);
assert.equal(restored.insertions.length, 1);
assert.equal(store.events.filter((ev) => ev.kind === 'branch_inserted').length, 1);
}
// 新语义:章末插入决策用 samplingCoverage(不调 Jev),Jev 抛错/空响应不影响插入;
// 唯一关闭路径是 adaptiveScoring === false(无 key 声明不打分)
for (const [jev, expectInserted] of [
[{ ask: async () => { throw new Error('test offline'); } }, true],
[{ ask: async () => ({}) }, true],
[{ adaptiveScoring: false, ask: async () => { assert.fail('缺 key 不应打分'); } }, false],
] as const) {
const store = new MemoryStore();
const e = new StoryEngine(chapters, store, jev, undefined, undefined, BRANCHES);
await e.restore({ sessionId: 'test', chapterId: 'chapter1', nodeId: 'c1-end', flags: {} });
await e.advance();
assert.equal(e.state.chapterId, 'chapter2');
assert.equal(e.insertions.length, expectInserted ? 1 : 0);
assert.equal(store.events.filter((ev) => ev.kind === 'branch_inserted').length, expectInserted ? 1 : 0);
}
console.log('✓ 重复点击与刷新幂等、Jev故障/无效响应/无key降级');
for (const branchId of ['b-cul-a', 'b-cul-b']) {
const branch = BRANCHES.find((b) => b.id === branchId)!;
const puzzle = branch.nodes.find((n) => n.type === 'choice' && n.puzzle)!;
assert(puzzle.type === 'choice' && puzzle.puzzle);
for (const [correct, expected] of [[2, 'true'], [1, 'partial'], [0, 'false']] as const) {
const store = new MemoryStore();
const e = new StoryEngine(chapters, store, new MockJevClient(), undefined, undefined, BRANCHES);
await e.restore({ sessionId: 'test', chapterId: branch.anchor.chapter, nodeId: puzzle.id, activeBranchId: branch.id, flags: {}, insertions: [branch.id] });
const answers = puzzle.puzzle.questions.map((q, i) => i < correct ? q.answer : (q.answer + 1) % q.options.length);
await e.choose(puzzle.options[0].id, { answers, hintsUsed: 1 });
assert.equal(e.state.flags[`c${branchId.replaceAll('-', '_')}_solved`], expected);
assert.deepEqual(store.events.at(-1)?.behavior?.answerCorrect, [correct > 0, correct > 1]);
}
const store = new MemoryStore();
const e = new StoryEngine(chapters, store, new MockJevClient(), undefined, undefined, BRANCHES);
await e.restore({ sessionId: 'test', chapterId: branch.anchor.chapter, nodeId: puzzle.id, activeBranchId: branch.id, flags: {}, insertions: [branch.id] });
await e.choose(puzzle.options[1].id);
assert.equal(e.state.flags[`c${branchId.replaceAll('-', '_')}_solved`], 'not_attempted');
assert.equal(e.state.flags[`c${branchId.replaceAll('-', '_')}_left`], 'true');
assert.equal(store.events.at(-1)?.behavior?.answers, undefined);
}
console.log('✓ 文化双问题分题记录 partial,主动离场独立于提示用尽/答错');
assert(!buildState([makeEvent({ sessionId: 'private', chapterId: 'chapter1', kind: 'profile', pointId: 'C1-01', dimensions: [], profile: { name: 'SECRET-NAME', city: '', age: '', gender: '', occupation: '', education: '' } })]).includes('SECRET-NAME'));
const threeAttachmentScenes = ['C2-01', 'C2-03', 'C2-06'].map((p) =>
makeEvent({ sessionId: 'test', chapterId: 'chapter2', pointId: p, kind: 'choice', dimensions: ['attachment'], optionId: 'a', optionLabel: 'a' }));
await assert.rejects(() => assessRelationships(threeAttachmentScenes, { ask: async () => ({}) }));
assert.equal(resolveConditionalText('【若 cb_val_a_water = d_redirect:粥棚】', { cb_val_a_water: 'a_give' }), '');
console.log('✓ 共享评分排除身份数据、无效响应不冒充低置信、条件文案不串线');