From 512651b973a56cad7b9ff34c5ba86518b6bfa356 Mon Sep 17 00:00:00 2001 From: Jayway Liu <14022498+bluejayway@user.noreply.gitee.com> Date: Sat, 3 Oct 2026 22:13:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=87=AA=E5=AE=9A=E4=B9=89=E4=B8=BB?= =?UTF-8?q?=E8=A7=92=E7=A7=B0=E5=91=BC=EF=BC=88profile.name=20+=20{name}?= =?UTF-8?q?=20=E5=8D=A0=E4=BD=8D=E7=AC=A6=20+=20=E8=87=AA=E6=8A=A5?= =?UTF-8?q?=E5=8F=A5=E5=BC=8F=E9=A2=84=E5=A1=AB=20+=20=E6=8A=A5=E5=91=8A/?= =?UTF-8?q?=E5=88=86=E4=BA=AB=E5=8D=A1=E5=B1=95=E7=A4=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/chapters/chapter1.json | 6 ++--- content/chapters/chapter3.json | 2 +- scripts/verify.ts | 44 +++++++++++++++++++++++++++++----- src/App.tsx | 7 +++--- src/engine/engine.ts | 37 ++++++++++++++++++++++++---- src/engine/types.ts | 2 ++ src/ui/StoryPlayer.tsx | 3 ++- src/ui/report/ReportPage.tsx | 7 +++++- src/ui/report/ShareCard.tsx | 8 +++++-- 9 files changed, 94 insertions(+), 22 deletions(-) diff --git a/content/chapters/chapter1.json b/content/chapters/chapter1.json index 108bc0f..d292bb1 100644 --- a/content/chapters/chapter1.json +++ b/content/chapters/chapter1.json @@ -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" }, diff --git a/content/chapters/chapter3.json b/content/chapters/chapter3.json index dd9a0e9..1d7a2fd 100644 --- a/content/chapters/chapter3.json +++ b/content/chapters/chapter3.json @@ -328,7 +328,7 @@ "id": "c3-d5b", "type": "dialogue", "speaker": "阿棠", - "text": "(打断)规程哥,我腿这样,背绳子就是背个拖累。(看你,收起嬉皮笑脸,这是她全章第一次没拿腔拿调)我这个人,认死理:绳子交给谁,命就交给谁。我不想交给规程,我想交给一个人。", + "text": "(打断)规程哥,我腿这样,背绳子就是背个拖累。(看你,收起嬉皮笑脸,这是她全章第一次没拿腔拿调){name}。我这个人,认死理:绳子交给谁,命就交给谁。我不想交给规程,我想交给一个人。", "sprite": "atang", "next": "c3-d5c" }, diff --git a/scripts/verify.ts b/scripts/verify.ts index f21d071..2989f4f 100644 --- a/scripts/verify.ts +++ b/scripts/verify.ts @@ -418,7 +418,7 @@ async function walkAll(eng: StoryEngine, picker: Record = {}) { `推进后持久化当前位置(${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 = {}) { // 内容更新导致持久化节点消失 → 降级该章开头 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); diff --git a/src/App.tsx b/src/App.tsx index 6807ec7..244efc7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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') { diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 7725d14..b42324c 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -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 { 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(); diff --git a/src/engine/types.ts b/src/engine/types.ts index 9a8e0e3..ae4b644 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -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; diff --git a/src/ui/StoryPlayer.tsx b/src/ui/StoryPlayer.tsx index 868e307..b902836 100644 --- a/src/ui/StoryPlayer.tsx +++ b/src/ui/StoryPlayer.tsx @@ -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'); }} > diff --git a/src/ui/report/ReportPage.tsx b/src/ui/report/ReportPage.tsx index b6a2542..ea8d568 100644 --- a/src/ui/report/ReportPage.tsx +++ b/src/ui/report/ReportPage.tsx @@ -43,11 +43,15 @@ export function ReportPage({ store, jev, onReplay }: Props) { const [scores, setScores] = useState(null); const [error, setError] = useState(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 (

人格画像报告 · Act 1

+

C-2049{playerName ? `「${playerName}」` : ''}的档案

勋
@@ -143,7 +148,7 @@ export function ReportPage({ store, jev, onReplay }: Props) {
))}
- + diff --git a/src/ui/report/ShareCard.tsx b/src/ui/report/ShareCard.tsx index ac99fb0..72fb77d 100644 --- a/src/ui/report/ShareCard.tsx +++ b/src/ui/report/ShareCard.tsx @@ -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(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();