feat: MVP 工程脚手架——剧情引擎、Act 1 三章内容、Jev 服务层、画像报告与分享卡、Tauri 壳

This commit is contained in:
Jayway Liu
2026-10-03 10:05:10 +08:00
parent f57eabe12c
commit bf9d7dad5b
54 changed files with 8040 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
import type { Chapter, NarrationNode, SceneVariant, StoryNode } from './types';
import type { EventStore, StoryEvent } from './eventStore';
import { makeEvent } from './eventStore';
import type { JevClient } from '../services/jev';
export interface EngineState {
sessionId: string;
chapterId: string;
nodeId: string;
/** 当前场景变体(讲述点情绪签名解析结果,驱动 UI 场景配色) */
activeVariant: SceneVariant | null;
/** 全部三章完成后为 true → 进入画像报告 */
finished: boolean;
}
export interface NarrationResult {
variant: SceneVariant;
event: StoryEvent;
}
function newSessionId(): string {
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `s-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* 剧情引擎:状态机。当前章节/节点、按选择推进、事件落 EventStore。
* 与 UI 完全解耦——UI 只读 state、调 advance/choose/submitNarration。
*/
export class StoryEngine {
readonly state: EngineState;
private chapters: Chapter[];
private store: EventStore;
private jev: JevClient;
private nodeIndex: Map<string, StoryNode> = new Map();
constructor(chapters: Chapter[], store: EventStore, jev: JevClient, startChapterId?: string) {
if (chapters.length === 0) throw new Error('StoryEngine: no chapters');
this.chapters = chapters;
this.store = store;
this.jev = jev;
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,
finished: false,
};
}
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 前进一步 */
advance(): void {
const node = this.currentNode();
if (node.type === 'dialogue' || node.type === 'cutscene') {
this.state.nodeId = 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;
}
} else {
throw new Error(`advance() 不适用于 ${node.type} 节点`);
}
}
/** B 类选择:落事件并按选项跳转 */
async choose(optionId: string): Promise<void> {
const node = this.currentNode();
if (node.type !== 'choice') throw new Error('当前节点不是选择点');
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: option.label,
}),
);
this.state.nodeId = option.next;
}
/**
* V 类讲述:落事件(transcript 为 ASR 占位,沉默为 null),
* 并解析情绪签名 → 场景变体(ADR-0003 通道 1,只做选择)。
*/
async submitNarration(transcript: string | null): Promise<NarrationResult> {
const node = this.currentNode();
if (node.type !== 'narration') throw new Error('当前节点不是讲述点');
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);
this.state.activeVariant = variant;
this.state.nodeId = node.next;
return { variant, event };
}
/** 沉默永远是合法选项(CONTEXT:在什么面前沉默本身也是数据) */
async skipNarration(): Promise<NarrationResult> {
return this.submitNarration(null);
}
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 从讲述文本提取情绪签名,作为预写变体的选择器(只做选择题)
const answer = await this.jev.ask(
{ transcript, pointId: node.pointId },
{
emotion_signature: {
type: 'choice',
description: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`,
options: node.variants.map((v) => v.signature),
},
},
);
const picked = answer.emotion_signature;
if (picked?.type === 'choice') {
const match = node.variants.find((v) => v.signature === picked.selected);
if (match) return match;
}
return node.variants[0];
}
/**
* 重玩(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.state.chapterId = chapter.id;
this.state.nodeId = chapter.startNodeId;
this.state.activeVariant = null;
this.state.finished = false;
}
}