Add shared-scene 2D authoring, sprite animation and Rapier2D physics
CI / validate (push) Canceled after 0s

This commit is contained in:
emil28092005
2026-09-12 04:58:59 +03:00
parent 6f497d1977
commit 3afac743a4
32 changed files with 4999 additions and 270 deletions
+105 -16
View File
@@ -73,8 +73,14 @@ test("real MCP HTTP + stdio share revisions, models, scripts, resources and expo
.length,
);
const prompts = await client.listPrompts();
assert.deepEqual(prompts.prompts.map((p) => p.name), ["create_scene"]);
const prompt = await client.getPrompt({ name: "create_scene", arguments: { theme: "architecture" } });
assert.deepEqual(
prompts.prompts.map((p) => p.name),
["create_scene"],
);
const prompt = await client.getPrompt({
name: "create_scene",
arguments: { theme: "architecture" },
});
assert.match(JSON.stringify(prompt.messages), /architecture/);
const invoke = async (name: string, args: any = {}) => {
const r = await client.callTool({ name, arguments: args });
@@ -185,18 +191,101 @@ test("real MCP HTTP + stdio share revisions, models, scripts, resources and expo
}
});
test('MCP scene ZIP import replaces the scene atomically and Undo restores the prior project',async()=>{
const dir=await mkdtemp(path.join(os.tmpdir(),'forma-scene-'));
const s=await createService({projectDir:dir,port:0,blank:true});const client=new Client({name:'scene-test',version:'1'});
try{
await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${s.port}/mcp`),{requestInit:{headers:{authorization:'Bearer '+s.token}}}));
const original=structuredClone(s.service.store.project);
const {zipSync}=await import('fflate');const {sceneAnimationGlb}=await import('./fixtures.ts');
const archive=zipSync({'scene.glb':sceneAnimationGlb()});
const result=await client.callTool({name:'asset_import_glb',arguments:{expectedRevision:0,name:'scene.zip',base64:Buffer.from(archive).toString('base64'),asScene:true}});
assert.ok(!result.isError,JSON.stringify(result.content));
const p=s.service.store.project,n=p.scenes[0].entities[0];assert.equal(p.revision,1);assert.equal(n.components.camera.mode,'imported');assert.equal(n.components.animator.autoplay,'Scene');
const undo=await client.callTool({name:'history_undo',arguments:{expectedRevision:1}});assert.ok(!undo.isError,JSON.stringify(undo.content));
assert.equal(s.service.store.project.id,original.id);assert.equal(s.service.store.project.assets.length,0);
}finally{await client.close();await s.close();await rm(dir,{recursive:true,force:true});}
test("MCP scene ZIP import replaces the scene atomically and Undo restores the prior project", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "forma-scene-"));
const s = await createService({ projectDir: dir, port: 0, blank: true });
const client = new Client({ name: "scene-test", version: "1" });
try {
await client.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${s.port}/mcp`),
{ requestInit: { headers: { authorization: "Bearer " + s.token } } },
),
);
const original = structuredClone(s.service.store.project);
const { zipSync } = await import("fflate");
const { sceneAnimationGlb } = await import("./fixtures.ts");
const archive = zipSync({ "scene.glb": sceneAnimationGlb() });
const result = await client.callTool({
name: "asset_import_glb",
arguments: {
expectedRevision: 0,
name: "scene.zip",
base64: Buffer.from(archive).toString("base64"),
asScene: true,
},
});
assert.ok(!result.isError, JSON.stringify(result.content));
const p = s.service.store.project,
n = p.scenes[0].entities[0];
assert.equal(p.revision, 1);
assert.equal(n.components.camera.mode, "imported");
assert.equal(n.components.animator.autoplay, "Scene");
const undo = await client.callTool({
name: "history_undo",
arguments: { expectedRevision: 1 },
});
assert.ok(!undo.isError, JSON.stringify(undo.content));
assert.equal(s.service.store.project.id, original.id);
assert.equal(s.service.store.project.assets.length, 0);
} finally {
await client.close();
await s.close();
await rm(dir, { recursive: true, force: true });
}
});
test("MCP imports image assets, configures XY scenes, saves portable sprites and rejects escaping paths", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "forma-2d-"));
const s = await createService({ projectDir: dir, port: 0, blank: true }),
client = new Client({ name: "2d-test", version: "1" });
try {
await client.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${s.port}/mcp`),
{ requestInit: { headers: { authorization: "Bearer " + s.token } } },
),
);
const call = (name: string, args: any) =>
client.callTool({ name, arguments: args });
const created = await call("scene_create", {
name: "XY",
mode: "2d",
expectedRevision: s.service.store.project.revision,
});
assert.ok(!created.isError, JSON.stringify(created));
const { activeScene } = await import("../engine/schema.ts");
assert.equal(activeScene(s.service.store.project).mode, "2d");
const png =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLttAAAAABJRU5ErkJggg==";
const imported = await call("asset_import_image", {
name: "pixel.png",
base64: png,
instantiate: true,
expectedRevision: s.service.store.project.revision,
});
assert.ok(!imported.isError, JSON.stringify(imported));
const project = s.service.store.project;
assert.equal(project.assets[0].kind, "image");
assert.equal(
activeScene(project).entities[0].components.sprite.assetId,
project.assets[0].id,
);
const bad = await call("asset_import_image", {
name: "pixel.png",
path: "../escape.png",
expectedRevision: project.revision,
});
assert.ok(bad.isError);
assert.equal(s.service.store.project.revision, project.revision);
await call("project_save", {});
const restored = unpackProject(
new Uint8Array(await readFile(path.join(dir, "project.forma"))),
);
assert.deepEqual(restored, project);
} finally {
await client.close();
await s.close();
await rm(dir, { recursive: true, force: true });
}
});