import type { BranchPack, Chapter, ChoiceBehavior, FollowUpDimension, IdentityProfile, NarrationNode, SceneVariant, StoryNode } from './types'; import { decideInsertion } from './branching'; import { extractConfidences, scoreProfile } from './scoring'; import { FOLLOW_UP_DIMENSIONS, FOLLOW_UP_PRIORITY } from './types'; import type { EventStore, StoryEvent } from './eventStore'; import { makeEvent } from './eventStore'; import type { JevAnswer, JevClient, JevQuestion } from '../services/jev'; import type { Flags } from './flags'; import { choiceFlags, narrationFlags, resolveConditionalText } from './flags'; import type { ProgressSnapshot, ProgressStore } from './progress'; /** 告知文案版本:文案变动时升版,consent 事件存证所同意的版本(ADR-0004) */ export const CONSENT_VERSION = 'act1-consent-v1'; export interface EngineState { sessionId: string; chapterId: string; nodeId: string; /** 当前场景变体(讲述点情绪签名解析结果,驱动 UI 场景配色) */ activeVariant: SceneVariant | null; /** 追问补全:讲述后检测到缺失维度时挂起,等用户回答或"记不清了" */ pendingFollowUp: PendingFollowUp | null; /** 分支变量(附录 A):从本轨迹的选择/讲述事件增量推导,驱动条件文本 */ flags: Flags; /** 玩家自取称呼(C1-01 profile.name),驱动文案里的 {name} 占位符;未设置时回退"你" */ playerName: string; /** 全部三章完成后为 true → 进入画像报告 */ finished: boolean; activeBranchId?: string; } export interface NarrationResult { variant: SceneVariant; event: StoryEvent; } /** 一次挂起的追问:原始讲述文本先存着,回答合并后才落 narration 事件 */ export interface PendingFollowUp { pointId: string; dimension: FollowUpDimension; /** 模板化追问话术(取自节点 JSON 的 followUps) */ prompt: string; /** 已问次数(本轮上限 1,字段预留第二次追问位置) */ asked: number; /** 用户最初的讲述文本,追问回答将合并进来 */ baseTranscript: string; } /** 六维缺失检测的 noul 问题文案(一次 Jev 调用并行问完) */ const MISSING_DETECTION_INSTRUCTIONS: Record = { visual: '这段讲述是否包含视觉细节(颜色、形状、光线、物件外观等)', auditory: '这段讲述是否包含听觉细节(声音、语气、环境音等)', olfactory_gustatory: '这段讲述是否包含嗅觉或味觉细节(气味、味道等)', person: '这段讲述是否提及具体的人物', time_place: '这段讲述是否提及具体的时间或地点', emotion: '这段讲述是否有明确的情绪表达', }; /** noul 概率低于此阈值视为该维度缺失 */ const MISSING_THRESHOLD = 0.5; function newSessionId(): string { return typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `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。 */ export class StoryEngine { readonly state: EngineState; private chapters: Chapter[]; private store: EventStore; private jev: JevClient; private progress?: ProgressStore; private nodeIndex: Map = new Map(); readonly insertions: BranchPack[] = []; private branches: BranchPack[]; private chapterTransition: Promise | null = null; constructor(chapters: Chapter[], store: EventStore, jev: JevClient, startChapterId?: string, progress?: ProgressStore, branches: BranchPack[] = []) { if (chapters.length === 0) throw new Error('StoryEngine: no chapters'); this.branches = branches; this.chapters = chapters; this.store = store; this.jev = jev; this.progress = progress; const start = chapters.find((c) => c.id === startChapterId) ?? chapters[0]; for (const c of chapters) { for (const n of c.nodes) this.nodeIndex.set(`${c.id}/${n.id}`, n); } this.state = { sessionId: newSessionId(), chapterId: start.id, nodeId: start.startNodeId, activeVariant: null, pendingFollowUp: null, flags: {}, playerName: '', finished: false, }; this.skipHiddenNodes(); } currentChapter(): Chapter { const c = this.chapters.find((c) => c.id === this.state.chapterId); if (!c) throw new Error(`unknown chapter ${this.state.chapterId}`); return c; } currentNode(): StoryNode { const n = this.nodeIndex.get(`${this.state.chapterId}/${this.state.nodeId}`); if (!n) throw new Error(`unknown node ${this.state.chapterId}/${this.state.nodeId}`); return n; } /** dialogue / cutscene / chapterEnd 前进一步 */ async advance(): Promise { if (this.chapterTransition) return this.chapterTransition; const node = this.currentNode(); if (node.type === 'dialogue' || node.type === 'cutscene') { this.moveTo(node.next); } else if (node.type === 'chapterEnd') { this.chapterTransition = this.finishChapter(node.nextChapter); try { await this.chapterTransition; } finally { this.chapterTransition = null; } return; } else { throw new Error(`advance() 不适用于 ${node.type} 节点`); } this.skipHiddenNodes(); this.persist(); } /** 章末只用本 session 的累计证据决策,故障不阻断主线。 */ private async finishChapter(nextChapter?: string): Promise { const sessionId = this.state.sessionId; const chapterId = this.state.chapterId; if (this.branches.length && this.jev.adaptiveScoring !== false) { try { const events = await this.store.list(sessionId); const target = this.chapters.find((c) => c.id === nextChapter); // 事件先于快照落盘;中途刷新时复用原计划。 const existing = events.find((e) => e.kind === 'branch_inserted' && e.chapterId === nextChapter); if (existing) { const pack = this.branches.find((b) => b.id === existing.branchId); if (pack) this.loadInsertion(pack); } else { const confidences = extractConfidences(await scoreProfile(events, this.jev)); if (sessionId !== this.state.sessionId) return; const decision = decideInsertion({ confidences, events, branches: this.branches, target, completedChapter: this.currentChapter() }); for (const dimension of decision.evidenceInsufficient) { if (events.some((e) => e.kind === 'evidence_insufficient' && e.dimensions.includes(dimension))) continue; await this.store.append(makeEvent({ sessionId, chapterId, kind: 'evidence_insufficient', pointId: `EVIDENCE-${dimension}`, dimensions: [dimension], confidence: confidences[dimension] })); } const branch = decision.branch; if (branch) { this.validateInsertion(branch); await this.store.append(makeEvent({ sessionId, chapterId: branch.anchor.chapter, kind: 'branch_inserted', pointId: branch.id, branchId: branch.id, dimensions: [branch.dimension], confidence: confidences[branch.dimension] })); if (sessionId !== this.state.sessionId) return; this.loadInsertion(branch); } } } catch (e) { console.warn('[engine] 章末评分/支线计划失败,本章不插入,继续主线', e); } } if (sessionId !== this.state.sessionId) return; if (nextChapter) { this.state.chapterId = nextChapter; this.state.nodeId = this.currentChapter().startNodeId; this.state.activeVariant = null; } else this.state.finished = true; this.state.activeBranchId = undefined; this.skipHiddenNodes(); this.persist(); } private validateInsertion(branch: BranchPack): void { const anchor = this.nodeIndex.get(`${branch.anchor.chapter}/${branch.anchor.afterNode}`); const ids = new Set(branch.nodes.map((n) => n.id)); if (!anchor || !('next' in anchor) || !ids.has(branch.startNodeId) || ids.size !== branch.nodes.length) throw new Error(`无效支线 ${branch.id}`); for (const node of branch.nodes) { if (node.type === 'chapterEnd' || this.chapters.find((c) => c.id === branch.anchor.chapter)?.nodes.some((n) => n.id === node.id)) throw new Error(`无效支线节点 ${node.id}`); const nexts = node.type === 'choice' ? node.options.map((o) => o.next) : [node.next]; if (nexts.some((next) => next !== '$resume' && !ids.has(next))) throw new Error(`无效支线出口 ${node.id}`); } } private loadInsertion(branch: BranchPack): void { if (this.insertions.some((b) => b.id === branch.id)) return; if (this.insertions.some((b) => b.anchor.chapter === branch.anchor.chapter)) throw new Error('每章至多一条支线'); this.validateInsertion(branch); this.insertions.push(branch); for (const node of branch.nodes) this.nodeIndex.set(`${branch.anchor.chapter}/${node.id}`, node); } private clearInsertions(): void { for (const b of this.insertions) for (const n of b.nodes) this.nodeIndex.delete(`${b.anchor.chapter}/${n.id}`); this.insertions.length = 0; this.state.activeBranchId = undefined; } private moveTo(next: string): void { if (next === '$resume') { const branch = this.insertions.find((b) => b.id === this.state.activeBranchId); const anchor = branch && this.nodeIndex.get(`${branch.anchor.chapter}/${branch.anchor.afterNode}`); if (!anchor || !('next' in anchor)) throw new Error('支线缺少返回挂点'); this.state.nodeId = anchor.next; this.state.activeBranchId = undefined; return; } const insertion = this.insertions.find((b) => b.anchor.chapter === this.state.chapterId && b.anchor.afterNode === this.state.nodeId); this.state.nodeId = insertion ? insertion.startNodeId : next; if (insertion) this.state.activeBranchId = insertion.id; } private persist(): void { this.progress?.save({ sessionId: this.state.sessionId, chapterId: this.state.chapterId, nodeId: this.state.nodeId, flags: { ...this.state.flags }, activeBranchId: this.state.activeBranchId, insertions: this.insertions.map((b) => b.id), }); } /** * 从持久化位置恢复。持久化的 nodeId 在新版章节数据里已不存在时, * 降级到该章开头而不是崩溃;章节本身不存在则不恢复(返回 false)。 * 恢复同时从该轨迹的 profile 事件里取回玩家称呼(旧数据缺 name 字段时回退"你")。 */ async restore(snapshot: ProgressSnapshot | null): Promise { if (!snapshot) return false; const chapter = this.chapters.find((c) => c.id === snapshot.chapterId); if (!chapter) return false; const events = await this.store.list(snapshot.sessionId); this.clearInsertions(); const ids = new Set([...(snapshot.insertions ?? []), ...events.filter((e) => e.kind === 'branch_inserted').map((e) => e.branchId)]); for (const id of ids) { const branch = this.branches.find((b) => b.id === id); if (branch) { try { this.loadInsertion(branch); } catch (e) { console.warn('[engine] 无法恢复支线', e); } } } const nodeExists = this.nodeIndex.has(`${snapshot.chapterId}/${snapshot.nodeId}`); this.state.sessionId = snapshot.sessionId; this.state.chapterId = snapshot.chapterId; this.state.nodeId = nodeExists ? snapshot.nodeId : chapter.startNodeId; this.state.flags = { ...snapshot.flags }; this.state.activeVariant = null; this.state.pendingFollowUp = null; this.state.finished = false; const profile = [...events].reverse().find((e) => e.kind === 'profile')?.profile; this.state.activeBranchId = this.insertions.find((b) => b.anchor.chapter === snapshot.chapterId && b.nodes.some((n) => n.id === this.state.nodeId))?.id; this.state.playerName = profile?.name ?? ''; this.skipHiddenNodes(); this.persist(); return true; } /** * 条件节点跳过:节点级【若 …】条件不满足的 dialogue / cutscene 对玩家不可见, * 直接走到 next(条件分支不产生死路)。每次状态迁移后调用。 */ private skipHiddenNodes(): void { for (let guard = 0; guard < 100; guard++) { const node = this.currentNode(); if (node.type !== 'dialogue' && node.type !== 'cutscene') return; const text = node.type === 'dialogue' ? node.text : node.caption; if (resolveConditionalText(text, this.state.flags) !== null) return; this.moveTo(node.next); } } /** * 渲染文本:求值文案中的全部【若 …】条件标记(节点级 + 内联段), * 并把 {name} 占位符替换为玩家称呼(未设置时回退"你")。 * 条件标记与占位符本身永不渲染给玩家。 */ renderText(text: string): string { const resolved = resolveConditionalText(text, this.state.flags) ?? ''; return resolved.replace(/\{name\}/g, this.state.playerName || '你'); } /** B 类选择:落事件并按选项跳转 */ async choose(optionId: string, behavior?: ChoiceBehavior): Promise { const node = this.currentNode(); if (node.type !== 'choice') throw new Error('当前节点不是选择点'); if (behavior?.decisionMs !== undefined && (!Number.isFinite(behavior.decisionMs) || behavior.decisionMs < 0)) throw new Error('无效决策耗时'); if (behavior?.interrupted) behavior = { ...behavior, decisionMs: undefined }; if (behavior?.timedOut && node.timer?.optionId !== optionId) throw new Error('无效超时选项'); if (node.puzzle) { const puzzle = node.puzzle; if (optionId.endsWith('-EXIT')) behavior = { hintsUsed: behavior?.hintsUsed ?? 0, solved: false }; else { if (!behavior?.answers || behavior.answers.length !== puzzle.questions.length || behavior.answers.some((a, i) => !Number.isInteger(a) || a < 0 || a >= puzzle.questions[i].options.length)) throw new Error('请完成谜面两问'); const hints = behavior.hintsUsed ?? 0; if (!Number.isInteger(hints) || hints < 0 || hints > puzzle.hints.length) throw new Error('无效提示档数'); const answerCorrect = behavior.answers.map((a, i) => a === puzzle.questions[i].answer); behavior = { ...behavior, hintsUsed: hints, answerCorrect, solved: answerCorrect.every(Boolean) }; } } const option = node.options.find((o) => o.id === optionId); if (!option) throw new Error(`未知选项 ${optionId}`); await this.store.append( makeEvent({ sessionId: this.state.sessionId, chapterId: this.state.chapterId, kind: 'choice', pointId: node.pointId, dimensions: node.dimensions, optionId: option.id, optionLabel: this.renderText(node.puzzle ? `${node.prompt} ${option.label};${node.puzzle.questions.map((q, i) => `${q.prompt} ${q.options[behavior?.answers?.[i] ?? -1] ?? '未作答'}`).join(';')}` : option.label), behavior, branchId: this.state.activeBranchId, }), ); Object.assign(this.state.flags, choiceFlags(node.pointId, option.id, behavior)); this.moveTo(option.next); this.skipHiddenNodes(); this.persist(); } /** * V 类讲述提交。追问补全流程(CONTEXT「追问补全」): * 文本讲述 → Jev 六维缺失检测 → 有缺口则挂起追问(返回 null, * state.pendingFollowUp 挂起,UI 展示追问话术)→ answerFollowUp/skipFollowUp 收尾; * 无缺口或沉默则直接走完:落 narration 事件 + 情绪签名→场景变体(ADR-0003 通道 1)。 */ async submitNarration(transcript: string | null): Promise { const node = this.currentNode(); if (node.type !== 'narration') throw new Error('当前节点不是讲述点'); if (node.pointId === 'C3-02') throw new Error('问心之室仅记录路径,不接收讲述文本'); if (this.state.pendingFollowUp) throw new Error('有未处理的追问,请先 answerFollowUp / skipFollowUp'); if (transcript === null) return this.finalizeNarration(node, null); // Jev 故障(CORS / 网络)时降级:跳过追问环节,剧情继续,不卡死 let missing: FollowUpDimension | null = null; try { missing = await this.detectMissing(node, transcript); } catch (e) { console.warn('[engine] 缺失检测失败,跳过追问继续剧情', e); } if (missing) { this.state.pendingFollowUp = { pointId: node.pointId, dimension: missing, prompt: node.followUps[missing]!, asked: 1, baseTranscript: transcript, }; return null; } return this.finalizeNarration(node, transcript); } /** 追问被回答:落 followup 事件,回答合并进讲述文本,再走情绪签名选择 */ async answerFollowUp(answer: string): Promise { const { node, pending } = this.takePendingFollowUp(); await this.store.append( makeEvent({ sessionId: this.state.sessionId, chapterId: this.state.chapterId, kind: 'followup', pointId: node.pointId, dimensions: node.dimensions, followUpDimension: pending.dimension, transcript: answer, }), ); this.state.pendingFollowUp = null; return this.finalizeNarration(node, `${pending.baseTranscript}\n${answer}`); } /** "记不清了"是合法回答(ADR-0004)——跳过也落事件,对什么沉默是数据 */ async skipFollowUp(): Promise { const { node, pending } = this.takePendingFollowUp(); await this.store.append( makeEvent({ sessionId: this.state.sessionId, chapterId: this.state.chapterId, kind: 'followup_skipped', pointId: node.pointId, dimensions: node.dimensions, followUpDimension: pending.dimension, transcript: null, }), ); this.state.pendingFollowUp = null; return this.finalizeNarration(node, pending.baseTranscript); } private takePendingFollowUp(): { node: NarrationNode; pending: PendingFollowUp } { const pending = this.state.pendingFollowUp; if (!pending) throw new Error('当前没有待处理的追问'); const node = this.currentNode(); if (node.type !== 'narration' || node.pointId !== pending.pointId) { throw new Error('追问挂起状态与当前节点不一致'); } return { node, pending }; } /** * 缺失检测:一次 Jev 调用并行问 6 个 noul 问题;概率 <0.5 视为缺失; * 按幻境渲染价值优先级取一个(FOLLOW_UP_PRIORITY),且节点 JSON 里要有对应模板。 * 预算纪律:每个讲述点最多追问 1 次(maxQuestions ≥2 的第二轮留给后续迭代)。 */ private async detectMissing(node: NarrationNode, transcript: string): Promise { if (node.followUp.maxQuestions < 1) return null; const questions: Record = Object.fromEntries( FOLLOW_UP_DIMENSIONS.map((d) => [d, { type: 'noul', instructions: MISSING_DETECTION_INSTRUCTIONS[d] }]), ); const answers = await this.jev.ask({ transcript, pointId: node.pointId }, questions); for (const dim of FOLLOW_UP_PRIORITY) { const a = answers[dim]; if (node.followUps[dim] && a?.type === 'noul' && a.probability < MISSING_THRESHOLD) return dim; } return null; } /** 讲述收尾:落 narration 事件、解析情绪签名 → 场景变体、推进节点 */ private async finalizeNarration(node: NarrationNode, transcript: string | null): Promise { const variant = await this.resolveVariant(node, transcript); const event = makeEvent({ sessionId: this.state.sessionId, chapterId: this.state.chapterId, kind: 'narration', pointId: node.pointId, dimensions: node.dimensions, variantId: variant.variantId, emotionSignature: variant.signature, transcript, }); await this.store.append(event); Object.assign(this.state.flags, narrationFlags(node.pointId, variant.variantId, transcript)); this.state.activeVariant = variant; this.moveTo(node.next); this.skipHiddenNodes(); this.persist(); return { variant, event }; } async resolvePrivateIllusion(mode: 'farewell_done' | 'farewell_partial' | 'exit_room'): Promise { const node = this.currentNode(); if (node.type !== 'narration' || node.pointId !== 'C3-02') throw new Error('当前节点不是问心之室'); const variant = await this.resolveVariant(node, null); const event = makeEvent({ sessionId: this.state.sessionId, chapterId: this.state.chapterId, kind: 'narration', pointId: node.pointId, dimensions: [], variantId: variant.variantId, narrationMode: mode, transcript: null, }); await this.store.append(event); this.state.flags.c3_02_mode = mode; this.state.flags.c3_02_fantasy_density = variant.variantId === 'generic_fog' ? 'fog' : 'partial'; this.state.activeVariant = variant; this.moveTo(node.next); this.skipHiddenNodes(); this.persist(); return { variant, event }; } /** 沉默永远是合法选项(CONTEXT:在什么面前沉默本身也是数据);沉默路径不触发追问 */ async skipNarration(): Promise { const node = this.currentNode(); if (node.type === 'narration' && node.pointId === 'C3-02') return this.resolvePrivateIllusion('exit_room'); const r = await this.submitNarration(null); if (!r) throw new Error('unreachable: 沉默路径不触发追问'); return r; } private async resolveVariant(node: NarrationNode, transcript: string | null): Promise { // variantFrom:复用此前讲述点的情绪解析(如 C3-02 问心幻境由 C1-02 记忆驱动) if (node.variantFrom) { const events = await this.store.list(this.state.sessionId); const source = [...events].reverse().find((e) => e.pointId === node.variantFrom && e.variantId); const match = source && node.variants.find((v) => v.variantId === source.variantId); if (match) return match; } // 沉默 → 取"沉默/模糊"档变体(签名含"沉默"者),否则最后一档(幻境模糊,设定自洽) if (transcript === null) { const silent = node.variants.find((v) => v.signature.includes('沉默')); return silent ?? node.variants[node.variants.length - 1]; } // 通道 1:Jev 从讲述文本提取情绪签名,作为预写变体的选择器(只做选择题)。 // Jev 故障时降级到"沉默/通用"档变体继续剧情,console.warn 记录,不向用户报错。 try { const answer = await this.jev.ask( { transcript, pointId: node.pointId }, { emotion_signature: { type: 'choice', instructions: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`, criteria: Object.fromEntries(node.variants.map((v) => [v.signature, v.label])), }, }, ); const picked = answer.emotion_signature; if (picked?.type === 'choice') { const match = node.variants.find((v) => v.signature === picked.choice); if (match) return match; } } catch (e) { console.warn('[engine] 情绪签名解析失败,降级到沉默/通用档变体', e); } return node.variants.find((v) => v.signature.includes('沉默')) ?? node.variants[0]; } /** * C1-01 身份登记:从自由介绍文本中用 Jev 抽取结构化字段(匹配硬过滤字段)。 * 抽取不出来的字段留空,由用户在确认页补填。 */ async extractIdentity(transcript: string): Promise { const node = this.currentNode(); if (node.type !== 'identity') throw new Error('当前节点不是身份登记点'); const ageBands = ['18-22', '23-27', '28-32', '33-40', '41及以上']; const questions: Record = { gender: { type: 'choice', instructions: '从这段自我介绍中判断说话人的性别', criteria: { 男: '明确为男性', 女: '明确为女性', 其他: '非二元性别', 未提及: '文本中没有可判断的信息' }, }, age: { type: 'score', instructions: '根据自我介绍判断说话人所属的年龄段', criteria: ageBands, }, occupation: { type: 'choice', instructions: '从这段自我介绍中判断说话人的职业类别', criteria: { 互联网: '互联网/软件/产品经理等', 学术科研: '高校、科研院所、教师、研究员', 金融: '银行、证券、投资等', 体制内: '公务员、事业单位、国企', 自由职业: '自由职业、个体、创作者', 学生: '在校学生', 其他: '以上都不是', 未提及: '文本中没有可判断的信息', }, }, education: { type: 'choice', instructions: '从这段自我介绍中判断说话人的最高学历', criteria: { 博士: '博士', 硕士: '硕士(含在读)', 本科: '本科', 大专及以下: '大专或以下', 未提及: '文本中没有可判断的信息', }, }, city_mentioned: { type: 'noul', instructions: '自我介绍中是否提及了说话人所在的城市', }, city: { type: 'choice', instructions: '自我介绍中提到的城市是哪一个(首发城市杭州,其余为常见城市兜底)', criteria: { 杭州: '杭州', 北京: '北京', 上海: '上海', 深圳: '深圳', 广州: '广州', 南京: '南京', 成都: '成都', 西安: '西安', 武汉: '武汉', 未提及: '没有提及,或不在以上城市中', }, }, }; // Jev 故障(CORS / 网络)时降级:返回全空字段,直接进入可手填的确认页 let answers: Record; try { answers = await this.jev.ask({ transcript }, questions); } catch (e) { console.warn('[engine] 身份字段抽取失败,降级为手填确认页', e); return { name: guessName(transcript), city: '', age: '', gender: '', occupation: '', education: '' }; } const choiceVal = (id: string): string => { const a = answers[id]; return a?.type === 'choice' && a.choice !== '未提及' ? a.choice : ''; }; let age = ''; const ageAnswer = answers.age; // 年龄段是 score 分档:置信度太低(视为未提及)则留空给用户补填 if (ageAnswer?.type === 'score' && ageAnswer.confidence >= 0.4) { const idx = Math.max(0, Math.min(ageBands.length - 1, Math.round(ageAnswer.score))); age = ageBands[idx]; } let city = choiceVal('city'); const mentioned = answers.city_mentioned; if (mentioned?.type === 'noul' && mentioned.probability < 0.5) city = ''; return { name: guessName(transcript), city, age, gender: choiceVal('gender'), occupation: choiceVal('occupation'), education: choiceVal('education'), }; } /** C1-01 确认页通过后调用:append profile 事件并推进 */ async confirmIdentity(profile: IdentityProfile): Promise { const node = this.currentNode(); if (node.type !== 'identity') throw new Error('当前节点不是身份登记点'); await this.store.append( makeEvent({ sessionId: this.state.sessionId, chapterId: this.state.chapterId, kind: 'profile', pointId: node.pointId, dimensions: node.dimensions, profile, }), ); this.state.playerName = profile.name.trim(); this.moveTo(node.next); this.skipHiddenNodes(); this.persist(); } /** * 同意存证(ADR-0004):告知与同意的 UX 由 ConsentGate 承担, * 同意动作在这里 append 一条 consent 事件(含版本与时间戳)。 * 事件存在于任意轨迹即视为已同意——同意门全剧只出现一次。 */ async hasConsent(): Promise { return (await this.store.list()).some((e) => e.kind === 'consent'); } async grantConsent(): Promise { if (await this.hasConsent()) return; await this.store.append( makeEvent({ sessionId: this.state.sessionId, chapterId: this.state.chapterId, kind: 'consent', pointId: 'CONSENT', dimensions: [], consentVersion: CONSENT_VERSION, }), ); } /** * 重玩(ADR-0004):产生新轨迹(新 sessionId),不修改旧事件。 * 默认重玩当前章,也可指定章节。 */ replay(chapterId?: string): void { const chapter = this.chapters.find((c) => c.id === (chapterId ?? this.state.chapterId)); if (!chapter) throw new Error(`unknown chapter ${chapterId}`); this.state.sessionId = newSessionId(); this.clearInsertions(); this.state.chapterId = chapter.id; this.state.nodeId = chapter.startNodeId; this.state.activeVariant = null; this.state.pendingFollowUp = null; this.state.flags = {}; this.state.playerName = ''; this.state.finished = false; this.skipHiddenNodes(); this.persist(); } }