feat: 自适应支线插入引擎(WIP:scoring/branching 抽取、挂点重定向、10 条支线 JSON、决策落库)
This commit is contained in:
+163
-20
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user