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
+3 -3
View File
@@ -48,7 +48,7 @@
"id": "c1-01",
"type": "identity",
"pointId": "C1-01",
"prompt": "登记界面浮现,五项字段以档案卡形式呈现:城市、年龄、性别、职业、学历。用一两句话介绍自己,想到哪说到哪,没有格式——不是审查,是锚定。(性别一栏可以选「不便透露」——无妨,锚不必每个刻度都示人。)",
"prompt": "登记界面浮现。五项字段以档案卡形式呈现:城市、年龄、性别、职业、学历。再留一个称呼——你希望路上的人怎么喊你。用一两句话介绍自己,想到哪说到哪,没有格式——不是审查,是锚定。(性别一栏可以选「不便透露」——无妨,锚不必每个刻度都示人。)",
"dimensions": [],
"next": "c1-d2"
},
@@ -56,7 +56,7 @@
"id": "c1-d2",
"type": "dialogue",
"speaker": "馆长",
"text": "好了。从现在起,档案编号 C-2049 就是你。这是你的第一枚锚。",
"text": "好了。从现在起,档案编号 C-2049 就是你——这是你的第一枚锚。编号是档案馆给的;称呼是你自己的。{name}——路上的人这么喊你的时候,记得应一声。",
"sprite": "curator",
"next": "c1-d3"
},
@@ -161,7 +161,7 @@
"id": "c1-d5",
"type": "dialogue",
"speaker": "阿棠",
"text": "(集结甲板,她第一个冲过来)来了来了!第五个人!哎你就是那个研究苍梧七年的?那你得给我们讲讲,那城底下到底是不是真的有——",
"text": "(集结甲板,她第一个冲过来)来了来了!第五个人!{name},对吧?哎你就是那个研究苍梧七年的?那你得给我们讲讲,那城底下到底是不是真的有——",
"sprite": "atang",
"next": "c1-d5a"
},
+1 -1
View File
@@ -328,7 +328,7 @@
"id": "c3-d5b",
"type": "dialogue",
"speaker": "阿棠",
"text": "(打断)规程哥,我腿这样,背绳子就是背个拖累。(看你,收起嬉皮笑脸,这是她全章第一次没拿腔拿调)我这个人,认死理:绳子交给谁,命就交给谁。我不想交给规程,我想交给一个人。",
"text": "(打断)规程哥,我腿这样,背绳子就是背个拖累。(看你,收起嬉皮笑脸,这是她全章第一次没拿腔拿调){name}。我这个人,认死理:绳子交给谁,命就交给谁。我不想交给规程,我想交给一个人。",
"sprite": "atang",
"next": "c3-d5c"
},
+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);
+4 -3
View File
@@ -29,11 +29,12 @@ export default function App() {
}, []);
useEffect(() => {
void engine.hasConsent().then((ok) => {
void (async () => {
const ok = await engine.hasConsent();
// 同意门之后才恢复剧情进度(刷新/热更新前的断点)
if (ok) engine.restore(progress.load());
if (ok) await engine.restore(progress.load());
setConsent(ok ? 'granted' : 'needed');
});
})();
}, [engine, progress]);
if (consent !== 'granted') {
+32 -5
View File
@@ -20,6 +20,8 @@ export interface EngineState {
pendingFollowUp: PendingFollowUp | null;
/** 分支变量(附录 A):从本轨迹的选择/讲述事件增量推导,驱动条件文本 */
flags: Flags;
/** 玩家自取称呼(C1-01 profile.name),驱动文案里的 {name} 占位符;未设置时回退"你" */
playerName: string;
/** 全部三章完成后为 true → 进入画像报告 */
finished: boolean;
}
@@ -60,6 +62,15 @@ function newSessionId(): string {
: `s-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* 称呼预填的轻量本地启发式(故意不走 Jev:typed 模型抽不出任意字符串名字)。
* 只认"我叫X / 叫我X / 称呼我X"三种自报句式,取不到就留空给用户手填。
*/
function guessName(transcript: string): string {
const m = transcript.match(/(?:我叫|叫我|称呼我)([一-龥A-Za-z][一-龥A-Za-z·]{0,11})/);
return m ? m[1] : '';
}
/**
* 剧情引擎:状态机。当前章节/节点、按选择推进、事件落 EventStore。
* 与 UI 完全解耦——UI 只读 state、调 advance/choose/submitNarration。
@@ -89,6 +100,7 @@ export class StoryEngine {
activeVariant: null,
pendingFollowUp: null,
flags: {},
playerName: '',
finished: false,
};
this.skipHiddenNodes();
@@ -139,8 +151,9 @@ export class StoryEngine {
/**
* 从持久化位置恢复。持久化的 nodeId 在新版章节数据里已不存在时,
* 降级到该章开头而不是崩溃;章节本身不存在则不恢复(返回 false)。
* 恢复同时从该轨迹的 profile 事件里取回玩家称呼(旧数据缺 name 字段时回退"你")。
*/
restore(snapshot: ProgressSnapshot | null): boolean {
async restore(snapshot: ProgressSnapshot | null): Promise<boolean> {
if (!snapshot) return false;
const chapter = this.chapters.find((c) => c.id === snapshot.chapterId);
if (!chapter) return false;
@@ -152,6 +165,9 @@ export class StoryEngine {
this.state.activeVariant = null;
this.state.pendingFollowUp = null;
this.state.finished = false;
const events = await this.store.list(snapshot.sessionId);
const profile = [...events].reverse().find((e) => e.kind === 'profile')?.profile;
this.state.playerName = profile?.name ?? '';
this.skipHiddenNodes();
this.persist();
return true;
@@ -173,10 +189,12 @@ export class StoryEngine {
/**
* 渲染文本:求值文案中的全部【若 …】条件标记(节点级 + 内联段),
* 返回玩家可见文本。条件标记本身永不渲染给玩家。
* 并把 {name} 占位符替换为玩家称呼(未设置时回退"你")。
* 条件标记与占位符本身永不渲染给玩家。
*/
renderText(text: string): string {
return resolveConditionalText(text, this.state.flags) ?? '';
const resolved = resolveConditionalText(text, this.state.flags) ?? '';
return resolved.replace(/\{name\}/g, this.state.playerName || '你');
}
/** B 类选择:落事件并按选项跳转 */
@@ -427,7 +445,7 @@ export class StoryEngine {
answers = await this.jev.ask({ transcript }, questions);
} catch (e) {
console.warn('[engine] 身份字段抽取失败,降级为手填确认页', e);
return { city: '', age: '', gender: '', occupation: '', education: '' };
return { name: guessName(transcript), city: '', age: '', gender: '', occupation: '', education: '' };
}
const choiceVal = (id: string): string => {
const a = answers[id];
@@ -443,7 +461,14 @@ export class StoryEngine {
let city = choiceVal('city');
const mentioned = answers.city_mentioned;
if (mentioned?.type === 'noul' && mentioned.probability < 0.5) city = '';
return { city, age, gender: choiceVal('gender'), occupation: choiceVal('occupation'), education: choiceVal('education') };
return {
name: guessName(transcript),
city,
age,
gender: choiceVal('gender'),
occupation: choiceVal('occupation'),
education: choiceVal('education'),
};
}
/** C1-01 确认页通过后调用:append profile 事件并推进 */
@@ -461,6 +486,7 @@ export class StoryEngine {
}),
);
this.state.nodeId = node.next;
this.state.playerName = profile.name.trim();
this.skipHiddenNodes();
this.persist();
}
@@ -501,6 +527,7 @@ export class StoryEngine {
this.state.activeVariant = null;
this.state.pendingFollowUp = null;
this.state.flags = {};
this.state.playerName = '';
this.state.finished = false;
this.skipHiddenNodes();
this.persist();
+2
View File
@@ -122,6 +122,8 @@ export interface ChapterEndNode extends BaseNode {
/** 身份登记字段(C1-01):匹配硬过滤字段(ADR-0003),用户有权直接修改确认 */
export interface IdentityProfile {
/** 称呼:玩家自取的名字(NPC 在路上喊玩家用)。非硬过滤字段,同为用户可直接修改的 profile 数据 */
name: string;
city: string;
age: string;
gender: string;
+2 -1
View File
@@ -184,6 +184,7 @@ function NarrationView({
}
const IDENTITY_FIELDS: { key: keyof IdentityProfile; label: string; placeholder: string }[] = [
{ key: 'name', label: '称呼', placeholder: '你希望路上的人怎么喊你' },
{ key: 'city', label: '城市', placeholder: '如:杭州' },
{ key: 'age', label: '年龄段', placeholder: '如:28-32' },
{ key: 'gender', label: '性别', placeholder: '男 / 女 / 其他 / 不便透露' },
@@ -253,7 +254,7 @@ function IdentityView({ node, engine, onDone }: { node: IdentityNode; engine: St
className="secondary"
disabled={busy}
onClick={() => {
setProfile({ city: '', age: '', gender: '', occupation: '', education: '' });
setProfile({ name: '', city: '', age: '', gender: '', occupation: '', education: '' });
setPhase('confirm');
}}
>
+6 -1
View File
@@ -43,11 +43,15 @@ export function ReportPage({ store, jev, onReplay }: Props) {
const [scores, setScores] = useState<DimensionScore[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [retry, setRetry] = useState(0);
const [playerName, setPlayerName] = useState('');
useEffect(() => {
let cancelled = false;
(async () => {
const events = await store.list();
// 最近一次身份登记的称呼(旧数据缺 name 字段时回退空串 → 只显示编号)
const name = [...events].reverse().find((e) => e.kind === 'profile')?.profile?.name ?? '';
if (!cancelled) setPlayerName(name);
const state = buildState(events);
// 7 维 score 问题,criteria 为 10 级有序数组(索引 0-9 对应 1-10 级)
const criteria = Array.from({ length: 10 }, (_, i) => `${i + 1} 级`);
@@ -120,6 +124,7 @@ export function ReportPage({ store, jev, onReplay }: Props) {
return (
<div className="report">
<h1>人格画像报告 · Act 1</h1>
<p className="hint">C-2049{playerName ? `「${playerName}」` : ''}的档案</p>
<section className="badge-banner">
<div className="badge-medal">勋</div>
<div>
@@ -143,7 +148,7 @@ export function ReportPage({ store, jev, onReplay }: Props) {
</div>
))}
</section>
<ShareCard badgeDimension={top.id} scores={scores} />
<ShareCard badgeDimension={top.id} scores={scores} playerName={playerName} />
<button className="secondary" onClick={onReplay}>
重玩 Act 1(新轨迹,历史保留)
</button>
+6 -2
View File
@@ -5,16 +5,18 @@ import type { DimensionScore } from './ReportPage';
interface Props {
badgeDimension: string;
scores: DimensionScore[];
/** 玩家自取称呼(C1-01 profile.name),空串时只显示编号 */
playerName?: string;
}
/** 分享卡:canvas 生成 PNG 下载(勋章 + 7 维分数) */
export function ShareCard({ badgeDimension, scores }: Props) {
export function ShareCard({ badgeDimension, scores, playerName }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
draw();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scores, badgeDimension]);
}, [scores, badgeDimension, playerName]);
function draw(): HTMLCanvasElement {
const canvas = canvasRef.current!;
@@ -38,6 +40,8 @@ export function ShareCard({ badgeDimension, scores }: Props) {
ctx.fillStyle = '#c9b48a';
ctx.font = '28px serif';
ctx.fillText('苍梧疑冢 · 人格画像', W / 2, 80);
ctx.font = '22px serif';
ctx.fillText(`C-2049${playerName ? `「${playerName}」` : ''}的档案`, W / 2, 118);
// 勋章圆
ctx.beginPath();