feat: MVP 工程脚手架——剧情引擎、Act 1 三章内容、Jev 服务层、画像报告与分享卡、Tauri 壳
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user