Files
jev-match-game/src/engine/engine.ts
T

679 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}