feat: 自定义主角称呼(profile.name + {name} 占位符 + 自报句式预填 + 报告/分享卡展示)

This commit is contained in:
Jayway Liu
2026-10-03 22:13:31 +08:00
parent 316cdfaffe
commit 512651b973
9 changed files with 94 additions and 22 deletions
+38 -6
View File
@@ -418,7 +418,7 @@ async function walkAll(eng: StoryEngine, picker: Record<string, string> = {}) {
`推进后持久化当前位置(${snap?.chapterId}/${snap?.nodeId})`,
);
const eng2 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
const restored = eng2.restore(progress.load());
const restored = await eng2.restore(progress.load());
check(
restored && eng2.currentNode().id === 'c1-d5' && eng2.state.sessionId === snap?.sessionId,
'重建引擎从持久化位置恢复(sessionId 一致,事件轨迹续接)',
@@ -427,11 +427,11 @@ async function walkAll(eng: StoryEngine, picker: Record<string, string> = {}) {
// 内容更新导致持久化节点消失 → 降级该章开头
const eng3 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
check(
eng3.restore({ sessionId: 'ghost', chapterId: 'chapter1', nodeId: 'deleted-node', flags: {} }) &&
(await eng3.restore({ sessionId: 'ghost', chapterId: 'chapter1', nodeId: 'deleted-node', flags: {} })) &&
eng3.currentNode().id === chapters[0].startNodeId,
'持久化节点已不存在 → 降级到该章开头(不崩溃)',
);
check(!eng3.restore({ sessionId: 'ghost', chapterId: 'chapter99', nodeId: 'x', flags: {} }), '持久化章节不存在 → 不恢复');
check(!(await eng3.restore({ sessionId: 'ghost', chapterId: 'chapter99', nodeId: 'x', flags: {} })), '持久化章节不存在 → 不恢复');
// replay:新 sessionId + 持久化位置重置(条件不满足的开场节点会被跳过,落点是跳过后的首个可见节点)
eng2.replay('chapter2');
const after = progress.load();
@@ -461,7 +461,7 @@ class FailingJev extends MockJevClient {
const silentVariant = c102.variants.find((v) => v.signature.includes('沉默'))!;
const ev = s.events.find((e) => e.kind === 'narration' && e.pointId === 'C1-02');
check(ev?.variantId === silentVariant.variantId, 'Jev 故障:情绪签名解析降级到沉默/通用档变体');
// 12b. 身份抽取失败 → 全空字段(UI 直接进可手填确认页),不抛出
// 12b. 身份抽取失败 → 硬过滤字段全空(称呼走本地启发式仍可预填),UI 直接进可手填确认页,不抛出
const eng2 = new StoryEngine(chapters, new MemoryStore(), new FailingJev());
await walkTo(eng2, 'c1-01');
let profile: IdentityProfile | null = null;
@@ -472,13 +472,45 @@ class FailingJev extends MockJevClient {
threw = true;
}
check(
!threw && profile !== null && Object.values(profile).every((v) => v === ''),
'Jev 故障:身份抽取降级为全空字段进手填确认页(不抛出)',
!threw &&
profile !== null &&
profile.city === '' &&
profile.age === '' &&
profile.gender === '' &&
profile.occupation === '' &&
profile.education === '',
'Jev 故障:身份抽取降级为空字段进手填确认页(不抛出)',
);
await eng2.confirmIdentity(profile!);
check(eng2.state.nodeId === 'c1-d2', 'Jev 故障:手填确认后流程照常推进');
}
// --- 13. 玩家称呼:{name} 占位符替换、预填启发式、恢复后带回 ---
{
// 13a. 启发式:只认"我叫/叫我/称呼我 X",其余句式不瞎猜
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new MockJevClient());
await walkTo(eng, 'c1-01');
const p1 = await eng.extractIdentity('我叫林晚,29岁,在杭州做互联网产品经理,硕士学历');
check(p1.name === '林晚', `称呼预填:「我叫林晚」→ "${p1.name}"`);
const p2 = await eng.extractIdentity('29岁,杭州,产品经理');
check(p2.name === '', '称呼预填:无自报句式 → 留空手填(不硬抽)');
// 13b. {name} 替换与回退
await eng.confirmIdentity(p1);
check(eng.renderText('馆长:{name},登记完成。') === '馆长:林晚,登记完成。', '{name} 替换为玩家称呼');
const eng2 = new StoryEngine(chapters, new MemoryStore(), new MockJevClient());
check(eng2.renderText('馆长:{name},登记完成。') === '馆长:你,登记完成。', '未设置称呼时 {name} 回退「你」');
// 13c. 称呼随轨迹事件走:restore 后从该 session 的 profile 事件取回(旧事件缺 name 不崩溃)
await walkTo(eng, 'c1-d5');
const d2 = chapters[0].nodes.find((n) => n.id === 'c1-d2');
check(d2?.type === 'dialogue' && eng.renderText(d2.text).includes('林晚'), '馆长登记台词中的 {name} 渲染为称呼');
const progress = new MemoryProgressStore();
progress.save({ sessionId: eng.state.sessionId, chapterId: 'chapter1', nodeId: 'c1-d5', flags: { ...eng.state.flags } });
const eng3 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
await eng3.restore(progress.load());
check(eng3.state.playerName === '林晚', '断点恢复后从 profile 事件取回称呼');
}
if (failures > 0) {
console.error(`\n${failures} 项校验失败`);
process.exit(1);