feat: 自定义主角称呼(profile.name + {name} 占位符 + 自报句式预填 + 报告/分享卡展示)
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -328,7 +328,7 @@
|
||||
"id": "c3-d5b",
|
||||
"type": "dialogue",
|
||||
"speaker": "阿棠",
|
||||
"text": "(打断)规程哥,我腿这样,背绳子就是背个拖累。(看你,收起嬉皮笑脸,这是她全章第一次没拿腔拿调)我这个人,认死理:绳子交给谁,命就交给谁。我不想交给规程,我想交给一个人。",
|
||||
"text": "(打断)规程哥,我腿这样,背绳子就是背个拖累。(看你,收起嬉皮笑脸,这是她全章第一次没拿腔拿调){name}。我这个人,认死理:绳子交给谁,命就交给谁。我不想交给规程,我想交给一个人。",
|
||||
"sprite": "atang",
|
||||
"next": "c3-d5c"
|
||||
},
|
||||
|
||||
+38
-6
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user