679 lines
29 KiB
TypeScript
679 lines
29 KiB
TypeScript
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<FollowUpDimension, string> = {
|
||
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<string, StoryNode> = new Map();
|
||
readonly insertions: BranchPack[] = [];
|
||
private branches: BranchPack[];
|
||
private chapterTransition: Promise<void> | 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<void> {
|
||
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<void> {
|
||
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<boolean> {
|
||
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<void> {
|
||
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<NarrationResult | null> {
|
||
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<NarrationResult> {
|
||
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<NarrationResult> {
|
||
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<FollowUpDimension | null> {
|
||
if (node.followUp.maxQuestions < 1) return null;
|
||
const questions: Record<string, JevQuestion> = 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<NarrationResult> {
|
||
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<NarrationResult> {
|
||
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<NarrationResult> {
|
||
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<SceneVariant> {
|
||
// 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<IdentityProfile> {
|
||
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<string, JevQuestion> = {
|
||
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<string, JevAnswer>;
|
||
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<void> {
|
||
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<boolean> {
|
||
return (await this.store.list()).some((e) => e.kind === 'consent');
|
||
}
|
||
|
||
async grantConsent(): Promise<void> {
|
||
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();
|
||
}
|
||
}
|