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:
2026-09-01 00:52:05 +08:00
co-authored by Cursor
parent bd5d88abfa
commit f37388761b
22 changed files with 930 additions and 303 deletions
+18
View File
@@ -25,6 +25,7 @@ import {
spawnRepeatableCreationStep,
spawnInstanceFromPrototype,
hasSelectableCreationWork,
hasAcceptedCloserStep,
removeUnstartedInstance,
looksLikeCreationFlowDoc,
parseCreationFlow,
@@ -1269,4 +1270,21 @@ modules:
if ("error" in ok) return;
expect(ok.step.depends_on).toEqual(["生成规则#1"]);
});
it("detects accepted closer step", () => {
const flow = parseCreationFlow(
JSON.stringify({
steps: [
{
id: "开场白与开场变量",
name: "开场白与开场变量",
depends_on: [],
},
],
}),
);
expect(hasAcceptedCloserStep(flow, [])).toBe(false);
expect(hasAcceptedCloserStep(flow, ["开场白与开场变量"])).toBe(true);
expect(hasAcceptedCloserStep(null, ["开场白与开场变量"])).toBe(true);
});
});
+7
View File
@@ -37,6 +37,13 @@ describe("opening-seal", () => {
outputTags: ["输出.开场白"],
}),
).toBe(true);
expect(
isOpeningSealArtifact({
workerId: "design-step",
stepId: "开场白与开场变量",
outputTags: ["artifact.flat.v1"],
}),
).toBe(true);
expect(
isOpeningSealArtifact({
workerId: "design-step",
+18 -16
View File
@@ -422,7 +422,7 @@ describe("phase machine", () => {
]);
});
it("accepting opening-setup seals creation instead of next_intent", () => {
it("accepting opening-setup materializes opening then returns to the graph", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
@@ -441,17 +441,17 @@ describe("phase machine", () => {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.waitingReason?.kind).toBe("input");
expect(accepted.session.waitingReason).toMatchObject({
kind: "input",
});
expect(accepted.effects).toEqual([{ type: "seal_creation_opening" }]);
expect(accepted.session.phase).toBe("running");
expect(accepted.effects).toEqual([
{ type: "seal_creation_opening" },
{ type: "propose_next_creation_step" },
]);
expect(
accepted.effects.some((e) => e.type === "invoke_main_agent"),
).toBe(false);
});
it("accepting opening-generator also seals creation", () => {
it("accepting opening-generator also materializes opening then returns to the graph", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "opening-generator",
@@ -469,11 +469,14 @@ describe("phase machine", () => {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.waitingReason?.kind).toBe("input");
expect(accepted.effects).toEqual([{ type: "seal_creation_opening" }]);
expect(accepted.session.phase).toBe("running");
expect(accepted.effects).toEqual([
{ type: "seal_creation_opening" },
{ type: "propose_next_creation_step" },
]);
});
it("sealed creation still blocks input until play", () => {
it("sealed creation still lets the user keep designing", () => {
let session = createSession("default");
session = {
...session,
@@ -481,14 +484,13 @@ describe("phase machine", () => {
waitingReason: { kind: "input" },
slots: { creationSealedByOpening: true, designInstanceReady: true },
};
const blocked = applyEvent(session, {
const continued = applyEvent(session, {
type: "user_submitted_input",
payload: { text: "我买金首饰" },
payload: { text: "再补一个 NPC" },
});
expect(blocked.effects).toEqual([
{ type: "emit_message", message: "创作已收口并保存。请切换到「游玩」开始。" },
]);
expect(blocked.effects.some((e) => e.type === "run_play_turn")).toBe(false);
expect(continued.session.phase).toBe("running");
expect(continued.effects).toEqual([{ type: "invoke_main_agent" }]);
expect(continued.effects.some((e) => e.type === "run_play_turn")).toBe(false);
});
it("play input starts a play turn even after opening seal", () => {
+39
View File
@@ -293,4 +293,43 @@ describe("phase runtime", () => {
flow?.steps.find((s) => s.name === "开场白与开场变量")?.depends_on,
).toEqual(["美学纲领与交互范式"]);
});
it("prepareEnterPlay seals when closer is already accepted", () => {
const session = createSession("default");
const blackboard = new Blackboard();
blackboard.write({
tag: "设计.创作流程",
content: JSON.stringify({
status: "open",
steps: [
{
id: "开场白与开场变量",
name: "开场白与开场变量",
depends_on: [],
},
],
}),
source: "runtime",
});
const runtime = new PhaseRuntime({
initialSession: {
...session,
slots: {
...session.slots,
creationAcceptedUnits: ["开场白与开场变量"],
},
},
initialBlackboardItems: blackboard.exportItems(),
});
runtime.prepareEnterPlay();
expect(runtime.getSession().slots.creationSealedByOpening).toBe(true);
expect(runtime.getSession().slots.designInstanceReady).toBe(true);
expect(
runtime.getBlackboard().getContentByTag("设计.worker集")?.trim(),
).toBeTruthy();
const flow = parseCreationFlow(
runtime.getBlackboard().getContentByTag("设计.创作流程"),
);
expect(flow?.status).not.toBe("closed");
});
});
+36 -1
View File
@@ -64,6 +64,20 @@ describe("run snapshot store", () => {
expect(loaded!.note).toBe("测试快照");
});
it("stores instanceId on run snapshots and lists it", async () => {
const { saveRunSnapshot, loadRunSnapshot, listRunSnapshots } = await import(
"../src/book/run-snapshot-store.js"
);
const bookId = randomUUID();
const instanceId = randomUUID();
const snapshot = makeSnapshot(bookId, "巷战");
snapshot.instanceId = instanceId;
saveRunSnapshot(snapshot);
expect(loadRunSnapshot(bookId, snapshot.id)?.instanceId).toBe(instanceId);
expect(listRunSnapshots(bookId)[0].instanceId).toBe(instanceId);
});
it("listRunSnapshots returns saved items newest first", async () => {
const { saveRunSnapshot, listRunSnapshots } = await import(
"../src/book/run-snapshot-store.js"
@@ -156,6 +170,7 @@ describe("run snapshot store", () => {
runtime: { getSession: () => { slots: Record<string, unknown> }; getBlackboard: () => { write: (i: object) => void } };
};
managed.runtime.getSession().slots.startupCompleted = true;
managed.runtime.getSession().slots.designInstanceReady = true;
const bb = managed.runtime.getBlackboard();
bb.write({ tag: "情境.实验.设定", content: "囚徒困境", source: "worker" });
bb.write({ tag: "运行.事件流", content: "第1轮", source: "worker" });
@@ -168,6 +183,26 @@ describe("run snapshot store", () => {
expect(stored.blackboardItems.some((i) => i.tag === "运行.事件流")).toBe(false);
const loaded = await mgr.loadGameSnapshot(book.id, save.id);
expect(loaded.hints.some((h) => h.includes("实例"))).toBe(true);
expect(loaded.hints.some((h) => h.includes("产物"))).toBe(true);
});
it("renameGameSnapshot updates the label", async () => {
const { createBook } = await import("../src/book/store.js");
const { SessionManager } = await import("../src/server/session-manager.js");
const { loadRunSnapshot } = await import("../src/book/run-snapshot-store.js");
const book = createBook({ title: "改名测试" });
const mgr = new SessionManager();
const view = await mgr.createForBook(book.id, "world-simulator");
const managed = mgr["require"](view.id) as {
runtime: { getSession: () => { slots: Record<string, unknown> } };
};
managed.runtime.getSession().slots.startupCompleted = true;
managed.runtime.getSession().slots.designInstanceReady = true;
const save = mgr.saveGameSnapshot(view.id, "定稿", "instance");
const renamed = mgr.renameGameSnapshot(book.id, save.id, "丧尸街巷");
expect(renamed.label).toBe("丧尸街巷");
expect(loadRunSnapshot(book.id, save.id)?.label).toBe("丧尸街巷");
});
});
+12
View File
@@ -3,6 +3,7 @@ import { Blackboard } from "../src/blackboard/blackboard.js";
import { createSession } from "../src/runtime/phase-machine.js";
import {
buildInstanceWorkerDeclaration,
canEnterPlay,
inferLifecycleStage,
isWorkerDeclared,
readWorkerSetYamlForDeclaration,
@@ -108,4 +109,15 @@ describe("worker-declaration", () => {
};
expect(inferLifecycleStage(s3)).toBe("play");
});
it("closer accepted is enough to enter play", () => {
const session = {
...createSession(),
slots: {
...createSession().slots,
creationAcceptedUnits: ["开场白与开场变量"],
},
};
expect(canEnterPlay(session)).toBe(true);
});
});