feat: 自适应支线插入引擎(WIP:scoring/branching 抽取、挂点重定向、10 条支线 JSON、决策落库)

This commit is contained in:
Jayway Liu
2026-10-04 06:57:32 +08:00
parent 512651b973
commit ea9fa0fe56
37 changed files with 4311 additions and 817 deletions
+163 -20
View File
@@ -1,4 +1,6 @@
import type { Chapter, FollowUpDimension, IdentityProfile, NarrationNode, SceneVariant, StoryNode } from './types';
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';
@@ -24,6 +26,7 @@ export interface EngineState {
playerName: string;
/** 全部三章完成后为 true → 进入画像报告 */
finished: boolean;
activeBranchId?: string;
}
export interface NarrationResult {
@@ -82,9 +85,13 @@ export class StoryEngine {
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) {
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;
@@ -119,18 +126,15 @@ export class StoryEngine {
}
/** dialogue / cutscene / chapterEnd 前进一步 */
advance(): void {
async advance(): Promise<void> {
if (this.chapterTransition) return this.chapterTransition;
const node = this.currentNode();
if (node.type === 'dialogue' || node.type === 'cutscene') {
this.state.nodeId = node.next;
this.moveTo(node.next);
} else if (node.type === 'chapterEnd') {
if (node.nextChapter) {
this.state.chapterId = node.nextChapter;
this.state.nodeId = this.currentChapter().startNodeId;
this.state.activeVariant = null;
} else {
this.state.finished = true;
}
this.chapterTransition = this.finishChapter(node.nextChapter);
try { await this.chapterTransition; } finally { this.chapterTransition = null; }
return;
} else {
throw new Error(`advance() 不适用于 ${node.type} 节点`);
}
@@ -138,13 +142,99 @@ export class StoryEngine {
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),
});
}
@@ -157,6 +247,15 @@ export class StoryEngine {
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;
@@ -165,8 +264,8 @@ 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.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();
@@ -183,7 +282,7 @@ export class StoryEngine {
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.state.nodeId = node.next;
this.moveTo(node.next);
}
}
@@ -198,9 +297,23 @@ export class StoryEngine {
}
/** B 类选择:落事件并按选项跳转 */
async choose(optionId: string): Promise<void> {
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(
@@ -211,11 +324,13 @@ export class StoryEngine {
pointId: node.pointId,
dimensions: node.dimensions,
optionId: option.id,
optionLabel: option.label,
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));
this.state.nodeId = option.next;
Object.assign(this.state.flags, choiceFlags(node.pointId, option.id, behavior));
this.moveTo(option.next);
this.skipHiddenNodes();
this.persist();
}
@@ -229,6 +344,7 @@ export class StoryEngine {
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 / 网络)时降级:跳过追问环节,剧情继续,不卡死
@@ -331,7 +447,31 @@ export class StoryEngine {
await this.store.append(event);
Object.assign(this.state.flags, narrationFlags(node.pointId, variant.variantId, transcript));
this.state.activeVariant = variant;
this.state.nodeId = node.next;
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 };
@@ -339,6 +479,8 @@ export class StoryEngine {
/** 沉默永远是合法选项(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;
@@ -485,8 +627,8 @@ export class StoryEngine {
profile,
}),
);
this.state.nodeId = node.next;
this.state.playerName = profile.name.trim();
this.moveTo(node.next);
this.skipHiddenNodes();
this.persist();
}
@@ -522,6 +664,7 @@ export class StoryEngine {
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;