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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("丧尸街巷");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user