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) { 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 = {}, 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(); for (const branch of BRANCHES) { const index = new Map(branch.nodes.map((n) => [n.id, n])); const reached = new Set(); function visit(id: string, path = new Set()) { 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('✓ 共享评分排除身份数据、无效响应不冒充低置信、条件文案不串线');