Keep creation as a living DAG and archive playable products separately.
Opening accept no longer locks the graph; session.json stays creation-only while play starts from an instance snapshot. Sidebar products nest run saves, and play reading fills the feed at a denser type size. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -31,7 +31,6 @@ import {
|
||||
normalizeQuestions,
|
||||
} from "../skills/question-protocol.js";
|
||||
import {
|
||||
CREATION_SEALED_WAITING_MESSAGE,
|
||||
SLOT_CREATION_SEALED_BY_OPENING,
|
||||
isOpeningSealArtifact,
|
||||
} from "../skills/opening-seal.js";
|
||||
@@ -785,21 +784,6 @@ export function applyEvent(
|
||||
};
|
||||
}
|
||||
|
||||
if (session.slots[SLOT_CREATION_SEALED_BY_OPENING]) {
|
||||
return {
|
||||
session: waiting(touch({ ...next, resumeContext: undefined }), {
|
||||
kind: "input",
|
||||
message: CREATION_SEALED_WAITING_MESSAGE,
|
||||
}),
|
||||
effects: [
|
||||
{
|
||||
type: "emit_message",
|
||||
message: "创作已收口并保存。请切换到「游玩」开始。",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// 常规定稿用户输入 → 等总管下一步
|
||||
return {
|
||||
session: touch(running({ ...next, resumeContext: undefined })),
|
||||
@@ -1178,14 +1162,14 @@ export function applyEvent(
|
||||
pendingDecision: undefined,
|
||||
currentWorkerId: undefined,
|
||||
};
|
||||
// 开场白终节点:选定后落库、关 DAG,不再问「下一步想写什么」。
|
||||
// 开场白终节点:落库开场,再回到分层图。创作流程保留,产物靠「保存」拆出。
|
||||
if (isOpeningSealArtifact(artifact)) {
|
||||
return {
|
||||
session: waiting(cleared, {
|
||||
kind: "input",
|
||||
message: CREATION_SEALED_WAITING_MESSAGE,
|
||||
}),
|
||||
effects: [{ type: "seal_creation_opening" }],
|
||||
session: touch(running(cleared)),
|
||||
effects: [
|
||||
{ type: "seal_creation_opening" },
|
||||
{ type: "propose_next_creation_step" },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (inferLifecycleStage(session) === "play") {
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
isInstanceStep,
|
||||
findStepByRef,
|
||||
findModuleByName,
|
||||
hasAcceptedCloserStep,
|
||||
parseCreationFlow,
|
||||
DESIGN_FLOW_WORKER_ID,
|
||||
DESIGN_STEP_WORKER_ID,
|
||||
@@ -114,9 +115,8 @@ import {
|
||||
SLOT_ASKED_QUESTIONS,
|
||||
} from "../skills/question-protocol.js";
|
||||
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
|
||||
import { defaultPlaySlots } from "../skills/play-slots.js";
|
||||
import {
|
||||
closeCreationFlowRaw,
|
||||
CREATION_SEALED_WAITING_MESSAGE,
|
||||
mergeOpeningTablePatch,
|
||||
OPENING_CURRENT_VARS_TAG,
|
||||
OPENING_INITIAL_VARS_TAG,
|
||||
@@ -427,6 +427,23 @@ export class PhaseRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
/** 收口已验收但未落印时补封印,并保证有可上场的运行规格 */
|
||||
prepareEnterPlay(): void {
|
||||
if (
|
||||
this.hasAcceptedCloser() &&
|
||||
!this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]
|
||||
) {
|
||||
this.sealCreationOpening();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.session.slots[SLOT_CREATION_SEALED_BY_OPENING] ||
|
||||
this.session.slots.designInstanceReady
|
||||
) {
|
||||
this.ensurePlaySpecReady();
|
||||
}
|
||||
}
|
||||
|
||||
getAvailableSkills(): SkillIndexEntry[] {
|
||||
return this.availableSkills;
|
||||
}
|
||||
@@ -1402,10 +1419,6 @@ export class PhaseRuntime {
|
||||
* 验收后回到分层图:点节点即确认并进入,底栏写意见再发即改编排。
|
||||
*/
|
||||
private async awaitCreationStepPick(): Promise<void> {
|
||||
if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) {
|
||||
this.onMessage(CREATION_SEALED_WAITING_MESSAGE);
|
||||
return;
|
||||
}
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
@@ -1474,7 +1487,7 @@ export class PhaseRuntime {
|
||||
}
|
||||
|
||||
/**
|
||||
* 选定开场白后:把候选落成 输出.开场白 / 初值表,关闭 DAG。
|
||||
* 选定开场白后:把候选落成 输出.开场白 / 初值表。不关 DAG,创作流程继续。
|
||||
*/
|
||||
private sealCreationOpening(): void {
|
||||
const selectedRaw = this.session.slots[SLOT_OPENING_SELECTED_INDEX];
|
||||
@@ -1515,17 +1528,6 @@ export class PhaseRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
const closed = closeCreationFlowRaw(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (closed) {
|
||||
this.blackboard.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content: closed,
|
||||
source: "runtime",
|
||||
});
|
||||
}
|
||||
|
||||
const n = payload?.candidates.length ?? (openingText ? 1 : 0);
|
||||
const which =
|
||||
n > 1 && payload
|
||||
@@ -1534,7 +1536,7 @@ export class PhaseRuntime {
|
||||
? "开场白"
|
||||
: "开场";
|
||||
this.onMessage(
|
||||
`[创作收口] 已选定${which},工作流计划已关闭。可切换到「游玩」。`,
|
||||
`[创作] 已选定${which}。可保存为产物后开玩,或继续补节点。`,
|
||||
);
|
||||
|
||||
this.session = {
|
||||
@@ -1542,10 +1544,37 @@ export class PhaseRuntime {
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[SLOT_CREATION_SEALED_BY_OPENING]: true,
|
||||
[CREATION_FLOW_TAG]: closed ?? this.session.slots[CREATION_FLOW_TAG],
|
||||
[OPENING_OUTPUT_TAG]: openingText || this.session.slots[OPENING_OUTPUT_TAG],
|
||||
},
|
||||
};
|
||||
this.ensurePlaySpecReady();
|
||||
}
|
||||
|
||||
private hasAcceptedCloser(): boolean {
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
return hasAcceptedCloserStep(flow, this.readAcceptedStepIds());
|
||||
}
|
||||
|
||||
/** 收口后若还没有运行规格,用草稿或默认槽位补一份,才能进游玩 */
|
||||
private ensurePlaySpecReady(): void {
|
||||
const finalTag = this.blackboard.getContentByTag(WORKER_SET_FINAL_TAG)?.trim();
|
||||
const draft = this.blackboard.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim();
|
||||
if (!finalTag) {
|
||||
const spec = draft?.trim()
|
||||
? draft
|
||||
: JSON.stringify({ play_slots: defaultPlaySlots() });
|
||||
this.writeWorkerTagContent(WORKER_SET_FINAL_TAG, spec, "opening-setup");
|
||||
}
|
||||
if (this.session.slots.designInstanceReady) return;
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
designInstanceReady: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 再编排:解开开场收口,让 design-flow 能在收口前插入节点 */
|
||||
@@ -2667,7 +2696,6 @@ export class PhaseRuntime {
|
||||
/** invoke_main_agent 副作用:running 时调用总管 LLM,链式推进直到需用户介入 */
|
||||
private async maybeRunMainAgent(effects: PhaseEffect[]): Promise<void> {
|
||||
if (inferLifecycleStage(this.session) === "play") return;
|
||||
if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) return;
|
||||
if (this.mainAgent && effects.some((e) => e.type === "invoke_main_agent")) {
|
||||
await this.runMainAgent();
|
||||
}
|
||||
|
||||
@@ -400,8 +400,8 @@ export function buildFocus(
|
||||
actorType: "user",
|
||||
actorLabel: "你",
|
||||
action:
|
||||
reason.message && /创作已收口/.test(reason.message)
|
||||
? "创作已收口"
|
||||
reason.message && /创作已收口|可保存为产物/.test(reason.message)
|
||||
? "可保存产物"
|
||||
: "补充说明",
|
||||
detail: reason.message?.slice(0, 120),
|
||||
};
|
||||
|
||||
@@ -395,7 +395,17 @@ export async function handleBooksApi(
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const session = await sessionManager.startNewPlayRun(active.id);
|
||||
let instanceId: string | undefined;
|
||||
try {
|
||||
const raw = await readBody(req);
|
||||
if (raw.trim()) {
|
||||
const body = JSON.parse(raw) as { instanceId?: string };
|
||||
instanceId = body.instanceId?.trim() || undefined;
|
||||
}
|
||||
} catch {
|
||||
instanceId = undefined;
|
||||
}
|
||||
const session = await sessionManager.startNewPlayRun(active.id, instanceId);
|
||||
json(res, 200, { session });
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
@@ -420,7 +430,11 @@ export async function handleBooksApi(
|
||||
...s,
|
||||
kindLabel: s.kind === "instance" ? "创作定稿" : "游玩进度",
|
||||
}));
|
||||
json(res, 200, { saves });
|
||||
json(res, 200, {
|
||||
saves,
|
||||
playWorkingInstanceId:
|
||||
sessionManager.playWorkingInstanceId(bookId) ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : "读取存档失败",
|
||||
@@ -490,6 +504,28 @@ export async function handleBooksApi(
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((req.method === "PATCH" || req.method === "PUT") && !isLoad) {
|
||||
const body = JSON.parse(await readBody(req)) as { label?: string };
|
||||
try {
|
||||
const save = sessionManager.renameGameSnapshot(
|
||||
bookId,
|
||||
saveId,
|
||||
body.label ?? "",
|
||||
);
|
||||
json(res, 200, {
|
||||
save: {
|
||||
...save,
|
||||
kindLabel: save.kind === "instance" ? "创作定稿" : "游玩进度",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : "重命名失败",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
try {
|
||||
sessionManager.deleteGameSnapshot(bookId, saveId);
|
||||
|
||||
+182
-123
@@ -11,14 +11,13 @@ import type { RunSnapshot, RunSnapshotMeta, SnapshotKind } from "../types/run-sn
|
||||
import { toRunSnapshotMeta } from "../types/run-snapshot.js";
|
||||
import { materializeInstanceSnapshotPayload } from "../book/snapshot-filters.js";
|
||||
import {
|
||||
CREATION_SEALED_WAITING_MESSAGE,
|
||||
INSTANCE_OPENING_SNAPSHOT_LABEL,
|
||||
OPENING_OUTPUT_TAG,
|
||||
SLOT_CREATION_SEALED_BY_OPENING,
|
||||
} from "../skills/opening-seal.js";
|
||||
import {
|
||||
PLAY_WORKING_SNAPSHOT_ID,
|
||||
PLAY_WORKING_SNAPSHOT_LABEL,
|
||||
SLOT_PLAY_INSTANCE_ID,
|
||||
SLOT_PLAY_LAYER_ACTIVE,
|
||||
isPlayLayerActive,
|
||||
} from "../skills/play-turn.js";
|
||||
@@ -209,6 +208,10 @@ export type SessionView = {
|
||||
/** 已从定稿开出聊天层(不是只把顶栏标成游玩) */
|
||||
playLayerActive: boolean;
|
||||
playReady: boolean;
|
||||
/** 已落档至少一份产物(instance 快照) */
|
||||
hasProduct: boolean;
|
||||
/** 当前游玩所依据的产物 id */
|
||||
playInstanceId?: string;
|
||||
skillCatalog: SkillCatalogEntry[];
|
||||
toolTrace: ToolTraceEntry[];
|
||||
burst: BurstState;
|
||||
@@ -371,11 +374,11 @@ export class SessionManager {
|
||||
|
||||
setLifecycleStage(sessionId: string, stage: LifecycleStage): SessionView {
|
||||
const s = this.require(sessionId);
|
||||
const session = s.runtime.getSession();
|
||||
if (stage === "play" && !canEnterPlay(session)) {
|
||||
throw new Error("实例尚未就绪,无法进入游玩");
|
||||
}
|
||||
if (stage === "play") {
|
||||
s.runtime.prepareEnterPlay();
|
||||
if (!canEnterPlay(s.runtime.getSession())) {
|
||||
throw new Error("实例尚未就绪,无法进入游玩");
|
||||
}
|
||||
this.enterPlayLayer(s, { resume: true });
|
||||
} else {
|
||||
this.enterDesignLayer(s);
|
||||
@@ -609,8 +612,11 @@ export class SessionManager {
|
||||
let blackboardItems = s.runtime.getBlackboard().exportItems();
|
||||
|
||||
if (kind === "instance") {
|
||||
if (!canEnterPlay(runtimeSession) && !runtimeSession.slots.startupCompleted) {
|
||||
throw new Error("须先验收 Worker 集,才能保存创作定稿");
|
||||
s.runtime.prepareEnterPlay();
|
||||
runtimeSession = structuredClone(s.runtime.getSession());
|
||||
blackboardItems = s.runtime.getBlackboard().exportItems();
|
||||
if (!canEnterPlay(runtimeSession)) {
|
||||
throw new Error("须先完成收口或验收 Worker 集,才能保存产物");
|
||||
}
|
||||
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
|
||||
runtimeSession,
|
||||
@@ -626,6 +632,8 @@ export class SessionManager {
|
||||
bookId: s.bookId,
|
||||
label: trimmed,
|
||||
kind,
|
||||
instanceId:
|
||||
kind === "run" ? this.currentPlayInstanceId(s) : undefined,
|
||||
orchestratorId: skillPackId,
|
||||
runtimeSession,
|
||||
blackboardItems,
|
||||
@@ -665,25 +673,30 @@ export class SessionManager {
|
||||
return this.saveGameSnapshot(sessionId, label, "run", note);
|
||||
}
|
||||
|
||||
/** 从已验收实例开一条新的游玩线(清空 run 层状态) */
|
||||
async startNewPlayRun(sessionId: string): Promise<SessionView> {
|
||||
/** 从已落档产物开一条新的游玩线(清空 run 层状态) */
|
||||
async startNewPlayRun(
|
||||
sessionId: string,
|
||||
instanceId?: string,
|
||||
): Promise<SessionView> {
|
||||
const s = this.require(sessionId);
|
||||
if (!s.bookId) throw new Error("仅绑定作品时可新建游玩");
|
||||
const session = s.runtime.getSession();
|
||||
if (!canEnterPlay(session)) {
|
||||
throw new Error("须先验收 Worker 集,才能开始游玩");
|
||||
this.persistCreationIfDesign(s);
|
||||
s.runtime.prepareEnterPlay();
|
||||
if (!canEnterPlay(s.runtime.getSession())) {
|
||||
throw new Error("须先验收 Worker 集或完成收口,才能开始游玩");
|
||||
}
|
||||
this.archivePlayWorking(s.bookId);
|
||||
this.enterPlayLayer(s, { resume: false });
|
||||
this.persist(s);
|
||||
this.enterPlayLayer(s, { resume: false, instanceId });
|
||||
return this.toView(sessionId);
|
||||
}
|
||||
|
||||
/** 进入游玩层:resume 时恢复「当前游玩」,否则从定稿新开一条聊天 */
|
||||
/** 进入游玩层:resume 时恢复「当前游玩」,否则从产物新开一条聊天 */
|
||||
private enterPlayLayer(
|
||||
s: ManagedSession,
|
||||
opts: { resume: boolean },
|
||||
opts: { resume: boolean; instanceId?: string },
|
||||
): void {
|
||||
this.persistCreationIfDesign(s);
|
||||
s.runtime.prepareEnterPlay();
|
||||
const session = s.runtime.getSession();
|
||||
if (!canEnterPlay(session)) {
|
||||
throw new Error("实例尚未就绪,无法进入游玩");
|
||||
@@ -704,11 +717,11 @@ export class SessionManager {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.beginNewPlayFromInstance(s);
|
||||
this.beginNewPlayFromInstance(s, opts.instanceId);
|
||||
if (s.bookId) this.savePlayWorking(s);
|
||||
}
|
||||
|
||||
/** 回到创作层:先把当前聊天写入「当前游玩」,再加载定稿 */
|
||||
/** 回到创作层:先把当前聊天写入「当前游玩」,再恢复创作过程 */
|
||||
private enterDesignLayer(s: ManagedSession): void {
|
||||
const session = s.runtime.getSession();
|
||||
if (
|
||||
@@ -721,33 +734,18 @@ export class SessionManager {
|
||||
if (s.bookId && isPlayLayerActive(session.slots)) {
|
||||
this.savePlayWorking(s);
|
||||
}
|
||||
const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null;
|
||||
if (instance) {
|
||||
this.restoreSnapshotInPlace(s, instance, "design");
|
||||
return;
|
||||
}
|
||||
let runtimeSession = structuredClone(s.runtime.getSession());
|
||||
let blackboardItems = s.runtime.getBlackboard().exportItems();
|
||||
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
|
||||
runtimeSession,
|
||||
blackboardItems,
|
||||
}));
|
||||
runtimeSession.id = session.id;
|
||||
runtimeSession.slots = {
|
||||
...runtimeSession.slots,
|
||||
uiLifecycleStage: "design",
|
||||
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
|
||||
};
|
||||
runtimeSession.phase = "waiting_user";
|
||||
runtimeSession.waitingReason = { kind: "input" };
|
||||
s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems);
|
||||
this.restoreCreationFromDisk(s);
|
||||
}
|
||||
|
||||
private beginNewPlayFromInstance(s: ManagedSession): void {
|
||||
private beginNewPlayFromInstance(s: ManagedSession, instanceId?: string): void {
|
||||
const keepId = s.runtime.getSession().id;
|
||||
let runtimeSession: RuntimeSession;
|
||||
let blackboardItems: import("../types/blackboard.js").BlackboardItem[];
|
||||
const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null;
|
||||
const instance = s.bookId
|
||||
? instanceId
|
||||
? this.loadInstanceSnapshot(s.bookId, instanceId)
|
||||
: this.latestInstanceSnapshot(s.bookId)
|
||||
: null;
|
||||
if (instance) {
|
||||
runtimeSession = structuredClone(instance.runtimeSession);
|
||||
blackboardItems = structuredClone(instance.blackboardItems);
|
||||
@@ -764,6 +762,7 @@ export class SessionManager {
|
||||
...runtimeSession.slots,
|
||||
uiLifecycleStage: "play",
|
||||
[SLOT_PLAY_LAYER_ACTIVE]: true,
|
||||
[SLOT_PLAY_INSTANCE_ID]: instance?.id,
|
||||
startupCompleted: true,
|
||||
};
|
||||
runtimeSession.phase = "waiting_user";
|
||||
@@ -807,6 +806,13 @@ export class SessionManager {
|
||||
...runtimeSession.slots,
|
||||
uiLifecycleStage: layer,
|
||||
[SLOT_PLAY_LAYER_ACTIVE]: layer === "play" ? true : undefined,
|
||||
[SLOT_PLAY_INSTANCE_ID]:
|
||||
layer === "play"
|
||||
? snapshot.instanceId ||
|
||||
(typeof runtimeSession.slots[SLOT_PLAY_INSTANCE_ID] === "string"
|
||||
? runtimeSession.slots[SLOT_PLAY_INSTANCE_ID]
|
||||
: undefined)
|
||||
: undefined,
|
||||
startupCompleted: true,
|
||||
};
|
||||
if (layer === "play") {
|
||||
@@ -817,12 +823,12 @@ export class SessionManager {
|
||||
runtimeSession.waitingReason = { kind: "input" };
|
||||
}
|
||||
} else {
|
||||
const sealed = Boolean(runtimeSession.slots[SLOT_CREATION_SEALED_BY_OPENING]);
|
||||
runtimeSession.phase = "waiting_user";
|
||||
runtimeSession.waitingReason = {
|
||||
kind: "input",
|
||||
...(sealed ? { message: CREATION_SEALED_WAITING_MESSAGE } : {}),
|
||||
};
|
||||
if (runtimeSession.phase === "done") {
|
||||
runtimeSession.phase = "waiting_user";
|
||||
}
|
||||
if (!runtimeSession.waitingReason) {
|
||||
runtimeSession.waitingReason = { kind: "input" };
|
||||
}
|
||||
runtimeSession.pendingArtifactId = undefined;
|
||||
runtimeSession.pendingDecision = undefined;
|
||||
runtimeSession.currentWorkerId = undefined;
|
||||
@@ -868,6 +874,7 @@ export class SessionManager {
|
||||
bookId: s.bookId,
|
||||
label: PLAY_WORKING_SNAPSHOT_LABEL,
|
||||
kind: "run",
|
||||
instanceId: this.currentPlayInstanceId(s),
|
||||
orchestratorId:
|
||||
sessionSkillPackId(runtimeSession) ??
|
||||
(book ? bookSkillPackId(book) : "") ??
|
||||
@@ -909,19 +916,91 @@ export class SessionManager {
|
||||
s.runtime.getSession().id,
|
||||
INSTANCE_OPENING_SNAPSHOT_LABEL,
|
||||
"instance",
|
||||
"进入游玩前自动保存定稿",
|
||||
"开玩前自动保存产物",
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("[会话] 进入游玩前保存定稿失败", err);
|
||||
console.warn("[会话] 开玩前保存产物失败", err);
|
||||
}
|
||||
}
|
||||
|
||||
private currentPlayInstanceId(s: ManagedSession): string | undefined {
|
||||
const raw = s.runtime.getSession().slots[SLOT_PLAY_INSTANCE_ID];
|
||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||
if (!s.bookId) return undefined;
|
||||
return this.latestInstanceSnapshot(s.bookId)?.id;
|
||||
}
|
||||
|
||||
playWorkingInstanceId(bookId: string): string | undefined {
|
||||
const working = loadRunSnapshotFile(bookId, PLAY_WORKING_SNAPSHOT_ID);
|
||||
if (!working || working.kind !== "run") return undefined;
|
||||
return working.instanceId;
|
||||
}
|
||||
|
||||
renameGameSnapshot(
|
||||
bookId: string,
|
||||
snapshotId: string,
|
||||
label: string,
|
||||
): RunSnapshotMeta {
|
||||
if (!getBook(bookId)) throw new Error("Book 不存在");
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) throw new Error("名称不能为空");
|
||||
const snapshot = loadRunSnapshotFile(bookId, snapshotId);
|
||||
if (!snapshot) throw new Error("存档不存在");
|
||||
const next: RunSnapshot = { ...snapshot, label: trimmed };
|
||||
saveRunSnapshotFile(next);
|
||||
return toRunSnapshotMeta(next);
|
||||
}
|
||||
|
||||
private latestInstanceSnapshot(bookId: string): RunSnapshot | null {
|
||||
const meta = this.listGameSnapshots(bookId).find((s) => s.kind === "instance");
|
||||
if (!meta) return null;
|
||||
return loadRunSnapshotFile(bookId, meta.id);
|
||||
}
|
||||
|
||||
private loadInstanceSnapshot(bookId: string, snapshotId: string): RunSnapshot {
|
||||
const snapshot = loadRunSnapshotFile(bookId, snapshotId);
|
||||
if (!snapshot) throw new Error("产物不存在");
|
||||
if (snapshot.kind !== "instance") throw new Error("该存档不是产物定稿");
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private restoreCreationFromDisk(s: ManagedSession): void {
|
||||
const keepId = s.runtime.getSession().id;
|
||||
if (!s.bookId) {
|
||||
this.clearPlayLayerOnSession(s, keepId);
|
||||
return;
|
||||
}
|
||||
const snap = loadBookSession(s.bookId);
|
||||
if (!snap?.runtimeSession) {
|
||||
this.clearPlayLayerOnSession(s, keepId);
|
||||
return;
|
||||
}
|
||||
const runtimeSession = structuredClone(snap.runtimeSession);
|
||||
runtimeSession.id = keepId;
|
||||
runtimeSession.slots = {
|
||||
...runtimeSession.slots,
|
||||
uiLifecycleStage: "design",
|
||||
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
|
||||
};
|
||||
s.runtime.restoreFromCheckpoint(runtimeSession, snap.blackboardItems);
|
||||
s.messages = snap.messages.map((m) => ({ ...m })) as ChatMessage[];
|
||||
s.branchState = this.deserializeBranchState(snap.messageBranchState);
|
||||
}
|
||||
|
||||
private clearPlayLayerOnSession(s: ManagedSession, keepId: string): void {
|
||||
const runtimeSession = structuredClone(s.runtime.getSession());
|
||||
runtimeSession.id = keepId;
|
||||
runtimeSession.slots = {
|
||||
...runtimeSession.slots,
|
||||
uiLifecycleStage: "design",
|
||||
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
|
||||
};
|
||||
s.runtime.restoreFromCheckpoint(
|
||||
runtimeSession,
|
||||
s.runtime.getBlackboard().exportItems(),
|
||||
);
|
||||
}
|
||||
|
||||
private playOpeningMessage(text: string): ChatMessage {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
@@ -935,7 +1014,7 @@ export class SessionManager {
|
||||
};
|
||||
}
|
||||
|
||||
/** 从存档读档:替换当前作品进度,可继续创作 */
|
||||
/** 读档:产物开玩,游玩存档续玩。不覆盖创作过程。 */
|
||||
async loadGameSnapshot(bookId: string, snapshotId: string): Promise<SessionView> {
|
||||
const snapshot = loadRunSnapshotFile(bookId, snapshotId);
|
||||
if (!snapshot) throw new Error("存档不存在");
|
||||
@@ -946,47 +1025,36 @@ export class SessionManager {
|
||||
throw new Error("存档与当前作品 skill 包不匹配,无法读档");
|
||||
}
|
||||
|
||||
this.dropBookSessions(bookId);
|
||||
|
||||
const newSessionId = randomUUID();
|
||||
const messages: ChatMessage[] = snapshot.messages.map((m) => ({ ...m })) as ChatMessage[];
|
||||
let runtimeSession = structuredClone(snapshot.runtimeSession);
|
||||
let blackboardItems = snapshot.blackboardItems;
|
||||
let sessionId = this.activeBookSessions.get(bookId);
|
||||
if (!sessionId) {
|
||||
await this.openBook(bookId);
|
||||
sessionId = this.activeBookSessions.get(bookId);
|
||||
}
|
||||
if (!sessionId) throw new Error("无法打开作品");
|
||||
const s = this.require(sessionId);
|
||||
|
||||
if (snapshot.kind === "instance") {
|
||||
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
|
||||
runtimeSession,
|
||||
blackboardItems,
|
||||
}));
|
||||
runtimeSession.slots = {
|
||||
...runtimeSession.slots,
|
||||
uiLifecycleStage: "design",
|
||||
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
|
||||
};
|
||||
} else {
|
||||
runtimeSession.slots = {
|
||||
...runtimeSession.slots,
|
||||
uiLifecycleStage: "play",
|
||||
[SLOT_PLAY_LAYER_ACTIVE]: true,
|
||||
};
|
||||
this.persistCreationIfDesign(s);
|
||||
if (isPlayLayerActive(s.runtime.getSession().slots)) {
|
||||
this.archivePlayWorking(bookId);
|
||||
}
|
||||
this.beginNewPlayFromInstance(s, snapshot.id);
|
||||
this.savePlayWorking(s);
|
||||
return this.toView(
|
||||
sessionId,
|
||||
true,
|
||||
`已用产物「${snapshot.label}」开玩。创作流程仍在「创作」里。`,
|
||||
);
|
||||
}
|
||||
|
||||
runtimeSession.id = newSessionId;
|
||||
|
||||
const resumeHint =
|
||||
snapshot.kind === "instance"
|
||||
? `已加载实例「${snapshot.label}」。可开始运行,或调整角色设定后再模拟。`
|
||||
: `已从存档「${snapshot.label}」读档,可继续创作。`;
|
||||
|
||||
return this.mountRestoredSession({
|
||||
sessionId: newSessionId,
|
||||
bookId,
|
||||
skillPackId: runSnapshotSkillPackId(snapshot) ?? "",
|
||||
runtimeSession,
|
||||
blackboardItems,
|
||||
messages,
|
||||
resumeHint,
|
||||
});
|
||||
this.persistCreationIfDesign(s);
|
||||
this.restoreSnapshotInPlace(s, snapshot, "play");
|
||||
this.savePlayWorking(s);
|
||||
return this.toView(
|
||||
sessionId,
|
||||
true,
|
||||
`已从存档「${snapshot.label}」继续游玩。`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 删除单个存档 */
|
||||
@@ -1438,9 +1506,6 @@ export class SessionManager {
|
||||
? s.runtime.getSession().artifacts.find((a) => a.id === pendingId)
|
||||
: undefined;
|
||||
const stageBefore = inferLifecycleStage(s.runtime.getSession());
|
||||
const sealedBefore = Boolean(
|
||||
s.runtime.getSession().slots[SLOT_CREATION_SEALED_BY_OPENING],
|
||||
);
|
||||
try {
|
||||
const owned = await this.runExclusive(id, s, () =>
|
||||
s.runtime.acceptArtifact(undefined, opts),
|
||||
@@ -1468,8 +1533,6 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
this.persist(s);
|
||||
this.maybeSaveOpeningInstance(s, sealedBefore);
|
||||
this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
@@ -1862,43 +1925,32 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 选定开场后正式保存一份 instance 定稿(可进游玩的规格+开局) */
|
||||
private maybeSaveOpeningInstance(
|
||||
s: ManagedSession,
|
||||
sealedBefore: boolean,
|
||||
): void {
|
||||
if (sealedBefore) return;
|
||||
/** 游玩中只更新工作副本,不覆盖创作过程 */
|
||||
private persist(s: ManagedSession): void {
|
||||
if (!s.bookId) return;
|
||||
const book = getBook(s.bookId);
|
||||
if (!book) return;
|
||||
const session = s.runtime.getSession();
|
||||
if (!session.slots[SLOT_CREATION_SEALED_BY_OPENING]) return;
|
||||
if (!canEnterPlay(session)) {
|
||||
s.messages.push(
|
||||
this.msg(
|
||||
"system",
|
||||
"[创作收口] 开场已选定,但 Worker 集尚未验收,未写入创作定稿存档。",
|
||||
),
|
||||
);
|
||||
if (
|
||||
isPlayLayerActive(session.slots) ||
|
||||
inferLifecycleStage(session) === "play"
|
||||
) {
|
||||
this.savePlayWorking(s);
|
||||
this.syncBookSkill(s.bookId, session);
|
||||
updateBook(s.bookId, { activeSessionId: session.id });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const meta = this.saveGameSnapshot(
|
||||
session.id,
|
||||
INSTANCE_OPENING_SNAPSHOT_LABEL,
|
||||
"instance",
|
||||
"选定开场后自动保存",
|
||||
);
|
||||
s.messages.push(
|
||||
this.msg("system", `[创作收口] 已保存创作定稿「${meta.label}」。`),
|
||||
);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
s.messages.push(
|
||||
this.msg("system", `[创作收口] 开场已选定,定稿存档未写入:${detail}`),
|
||||
);
|
||||
}
|
||||
this.writeBookSession(s);
|
||||
}
|
||||
|
||||
private persist(s: ManagedSession): void {
|
||||
/** 切到游玩前把当前创作过程写入 session.json */
|
||||
private persistCreationIfDesign(s: ManagedSession): void {
|
||||
if (!s.bookId) return;
|
||||
if (isPlayLayerActive(s.runtime.getSession().slots)) return;
|
||||
this.writeBookSession(s);
|
||||
}
|
||||
|
||||
private writeBookSession(s: ManagedSession): void {
|
||||
if (!s.bookId) return;
|
||||
const book = getBook(s.bookId);
|
||||
if (!book) return;
|
||||
@@ -2545,6 +2597,13 @@ export class SessionManager {
|
||||
lifecycleStage,
|
||||
playLayerActive: isPlayLayerActive(session.slots),
|
||||
playReady: canEnterPlay(session),
|
||||
hasProduct: Boolean(
|
||||
s.bookId &&
|
||||
this.listGameSnapshots(s.bookId).some((item) => item.kind === "instance"),
|
||||
),
|
||||
playInstanceId: isPlayLayerActive(session.slots)
|
||||
? this.currentPlayInstanceId(s)
|
||||
: undefined,
|
||||
skillCatalog,
|
||||
toolTrace: buildToolTrace(messages),
|
||||
burst: buildBurstState(messages, session),
|
||||
|
||||
@@ -177,7 +177,7 @@ function buildWorldSimulatorCatalog(
|
||||
id: "declare-ready",
|
||||
stage: "design",
|
||||
label: "实例就绪",
|
||||
purpose: "Worker 集已 accept 即可进游玩;若走了开场白终节点,选定后会自动保存定稿。",
|
||||
purpose: "收口或 Worker 集验收后可保存产物;产物可单独开玩。",
|
||||
status: hasAcceptedWorkerSet(session) ? "done" : "pending",
|
||||
});
|
||||
|
||||
|
||||
@@ -2292,7 +2292,23 @@ export function isCloserModule(
|
||||
name?: string,
|
||||
): boolean {
|
||||
if (module?.closer === true) return true;
|
||||
return (name ?? "").trim() === "开场白与开场变量";
|
||||
const t = (name ?? "").trim();
|
||||
return t === "开场白与开场变量" || t.startsWith("开场白与开场变量#") || t === "开场白";
|
||||
}
|
||||
|
||||
export function hasAcceptedCloserStep(
|
||||
flow: CreationFlow | null | undefined,
|
||||
acceptedStepIds: readonly string[],
|
||||
catalog?: ModuleCatalog | null,
|
||||
): boolean {
|
||||
if (flow?.steps.length) {
|
||||
return flow.steps.some(
|
||||
(s) =>
|
||||
isCloserModule(findModuleByName(catalog, s.name), s.name) &&
|
||||
isStepAccepted(s, acceptedStepIds),
|
||||
);
|
||||
}
|
||||
return acceptedStepIds.some((id) => isCloserModule(null, id));
|
||||
}
|
||||
|
||||
export function findCloserStepIndex(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 开场白终节点:选定后落库、关 DAG。
|
||||
* 开场白终节点:选定后落库开场。不关 DAG;产物靠用户「保存」拆出。
|
||||
* 能力 `opening-setup` 与磁盘 worker `opening-generator` 共用这条收口。
|
||||
*/
|
||||
import {
|
||||
@@ -22,21 +22,34 @@ export const SLOT_CREATION_SEALED_BY_OPENING = "creationSealedByOpening";
|
||||
export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex";
|
||||
|
||||
export const CREATION_SEALED_WAITING_MESSAGE =
|
||||
"开场已选定,创作已收口并保存。可切换到「游玩」。";
|
||||
"开场已选定。可保存为产物后开玩,或继续补节点。";
|
||||
|
||||
export const INSTANCE_OPENING_SNAPSHOT_LABEL = "创作定稿(开场)";
|
||||
export const INSTANCE_OPENING_SNAPSHOT_LABEL = "定稿";
|
||||
|
||||
export function isOpeningLockTag(tag: string): boolean {
|
||||
const t = tag.trim();
|
||||
return t === OPENING_OUTPUT_TAG || t === OPENING_INITIAL_VARS_TAG;
|
||||
}
|
||||
|
||||
/** 收口步 id / name(含「开场白与开场变量#2」) */
|
||||
export function isCloserStepRef(id: string | undefined): boolean {
|
||||
const t = (id ?? "").trim();
|
||||
if (!t) return false;
|
||||
return (
|
||||
t === "开场白与开场变量" ||
|
||||
t.startsWith("开场白与开场变量#") ||
|
||||
t === "开场白"
|
||||
);
|
||||
}
|
||||
|
||||
export function isOpeningSealArtifact(artifact: {
|
||||
workerId?: string;
|
||||
stepId?: string;
|
||||
outputTags?: readonly string[];
|
||||
}): boolean {
|
||||
if ((artifact.workerId ?? "").trim() === "opening-generator") return true;
|
||||
return (artifact.outputTags ?? []).includes(OPENING_SETUP_ARTIFACT_TAG);
|
||||
if ((artifact.outputTags ?? []).includes(OPENING_SETUP_ARTIFACT_TAG)) return true;
|
||||
return isCloserStepRef(artifact.stepId);
|
||||
}
|
||||
|
||||
export type OpeningVariable = {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
export const SLOT_PLAY_TURN_QUEUE = "playTurnQueue";
|
||||
export const SLOT_PLAY_LAYER_ACTIVE = "playLayerActive";
|
||||
/** 当前游玩线所依据的产物 snapshot id */
|
||||
export const SLOT_PLAY_INSTANCE_ID = "playInstanceId";
|
||||
|
||||
export const PLAY_WORKING_SNAPSHOT_ID = "play-working";
|
||||
export const PLAY_WORKING_SNAPSHOT_LABEL = "当前游玩";
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||
import type { RuntimeSession } from "../types/runtime.js";
|
||||
import { isPlayLayerActive } from "./play-turn.js";
|
||||
import {
|
||||
SLOT_CREATION_ACCEPTED_UNITS,
|
||||
parseAcceptedUnits,
|
||||
} from "./creation-units.js";
|
||||
import {
|
||||
isCloserStepRef,
|
||||
SLOT_CREATION_SEALED_BY_OPENING,
|
||||
} from "./opening-seal.js";
|
||||
import {
|
||||
deriveDesignStageScope,
|
||||
deriveOnDemandWorkerScope,
|
||||
@@ -41,8 +49,14 @@ export function hasAcceptedWorkerSet(session: RuntimeSession): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasAcceptedCloser(session: RuntimeSession): boolean {
|
||||
if (Boolean(session.slots[SLOT_CREATION_SEALED_BY_OPENING])) return true;
|
||||
const accepted = parseAcceptedUnits(session.slots[SLOT_CREATION_ACCEPTED_UNITS]);
|
||||
return accepted.some((id) => isCloserStepRef(id));
|
||||
}
|
||||
|
||||
export function canEnterPlay(session: RuntimeSession): boolean {
|
||||
return hasAcceptedWorkerSet(session);
|
||||
return hasAcceptedWorkerSet(session) || hasAcceptedCloser(session);
|
||||
}
|
||||
|
||||
export function inferLifecycleStage(session: RuntimeSession): LifecycleStage {
|
||||
|
||||
@@ -16,6 +16,8 @@ export type RunSnapshot = {
|
||||
bookId: string;
|
||||
label: string;
|
||||
kind: SnapshotKind;
|
||||
/** 游玩存档所属的产物(instance)id */
|
||||
instanceId?: string;
|
||||
/** skill 包 id(兼容旧字段名) */
|
||||
orchestratorId?: string;
|
||||
runtimeSession: RuntimeSession;
|
||||
@@ -32,6 +34,8 @@ export type RunSnapshotMeta = {
|
||||
bookId: string;
|
||||
label: string;
|
||||
kind: SnapshotKind;
|
||||
/** 游玩存档所属的产物(instance)id */
|
||||
instanceId?: string;
|
||||
/** skill 包 id(兼容旧字段名) */
|
||||
orchestratorId?: string;
|
||||
createdAt: string;
|
||||
@@ -44,6 +48,7 @@ export function toRunSnapshotMeta(snapshot: RunSnapshot): RunSnapshotMeta {
|
||||
bookId: snapshot.bookId,
|
||||
label: snapshot.label,
|
||||
kind: snapshot.kind,
|
||||
instanceId: snapshot.instanceId,
|
||||
orchestratorId: snapshot.orchestratorId,
|
||||
createdAt: snapshot.createdAt,
|
||||
note: snapshot.note,
|
||||
|
||||
Reference in New Issue
Block a user