import test from "node:test"; import assert from "node:assert/strict"; import { Worker as NodeWorker } from "node:worker_threads"; import { NullEngine } from "@babylonjs/core"; import { FormaRuntime } from "../engine/runtime.ts"; import { defaultProject } from "../engine/templates.ts"; import { entity, activeScene } from "../engine/schema.ts"; import { triangleAsset } from "./fixtures.ts"; function worker(source: string) { const w = new NodeWorker( 'const {parentPort}=require("node:worker_threads");global.self=global;global.postMessage=m=>parentPort.postMessage(m);' + source + ';parentPort.on("message",data=>self.onmessage({data}));', { eval: true }, ); const adapter: any = { onmessage: null, onerror: null, postMessage: (m: any) => w.postMessage(m), terminate: () => void w.terminate(), }; w.on("message", (m) => adapter.onmessage?.({ data: m })); w.on("error", (e) => adapter.onerror?.({ message: e.message })); return adapter as Worker; } function runtime() { const engine = new NullEngine({ renderWidth: 800, renderHeight: 600, textureSize: 512, deterministicLockstep: true, lockstepMaxSteps: 4, }); return new FormaRuntime( {} as HTMLCanvasElement, {}, { engine, headless: true, createWorker: worker, readAsset: async (uri) => new Uint8Array(Buffer.from(uri.slice(uri.indexOf(",") + 1), "base64")), }, ); } const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); test( "Babylon skeletal import, worker movement, Rapier collision and Stop restoration", { timeout: 15000 }, async () => { const r = runtime(), p = defaultProject(); p.assets = [triangleAsset(true)]; p.scripts = [ { id: "move_fixture", name: "Move fixture", fields: { speed: { type: "number", default: 4 } }, source: '({start(api){api.state.ticks=0;api.animate("Translate")},update(api,dt){api.state.ticks++;api.move([api.input.x*api.params.speed*dt,0,0]);api.patch(api.get().id,{components:{data:{ticks:api.state.ticks}}})}})', }, ]; const moving = entity( "Moving triangle", { mesh: { type: "model", assetId: "asset_triangle" }, collider: { shape: "capsule", radius: 0.3, height: 1.8, offset: [0, 0.9, 0], }, rigidbody: { type: "kinematic" }, script: { scriptId: "move_fixture" }, data: { ticks: 0 }, }, [0, 0.06, 0], "moving", ); activeScene(p).entities = [ entity( "Floor", { mesh: { type: "box", size: [12, 0.5, 12] }, collider: { shape: "box", size: [12, 0.5, 12] }, rigidbody: { type: "fixed" }, }, [0, -0.25, 0], "floor", ), entity( "Wall", { mesh: { type: "box", size: [0.4, 3, 12] }, collider: { shape: "box", size: [0.4, 3, 12] }, rigidbody: { type: "fixed" }, }, [2, 1.5, 0], "wall", ), moving, entity( "Camera", { camera: { targetId: "moving", offset: [0, 13, -10], fov: 0.72 } }, [0, 13, -10], "camera", ), ]; try { await r.play(p); assert.equal(r.importInfo.get("asset_triangle").skeletons, 1); assert.deepEqual(r.importInfo.get("asset_triangle").clips, ["Translate"]); assert.equal(r.animations.get("moving")!.length, 1); r.setInput({ x: 1, durationMs: 1400 }); await wait(1500); const snap = r.snapshot(); const current = snap.entities.find((n) => n.id === "moving")!; assert.ok(current.components.data.ticks > 10, JSON.stringify(snap.logs)); assert.ok( snap.animations.moving.some( (g) => g.name === "Translate" && g.frame !== null, ), ); assert.ok( !snap.logs.some((l) => l.level === "error"), JSON.stringify(snap.logs), ); const pos = current.transform.position; assert.ok( pos[0] > 1.1 && pos[0] < 1.55, "Kinematic body stops at the wall: " + pos, ); assert.ok( pos[1] > -0.2 && pos[1] < 0.3, "Kinematic body remains on floor: " + pos, ); await r.stop(p); assert.deepEqual( r.state.find((n) => n.id === "moving")!.transform.position, moving.transform.position, ); assert.equal( r.state.find((n) => n.id === "moving")!.components.data.ticks, 0, ); assert.equal(r.playing, false); } finally { r.dispose(); } }, ); test( "parent rebuild preserves children; queued model replacement uses fresh bytes", { timeout: 15000 }, async () => { const r = runtime(), p = defaultProject(); p.assets = [triangleAsset(true)]; const parent = entity("Parent", {}, [0, 0, 0], "parent"), child = entity( "Child", { mesh: { type: "box", size: [1, 1, 1] } }, [1, 0, 0], "child", ); child.parentId = parent.id; activeScene(p).entities = [parent, child]; try { await r.load(p); const childNode = r.nodes.get("child")!; parent.components.mesh = { type: "sphere", size: [1, 1, 1] }; await r.load(p); assert.equal(childNode.isDisposed(), false); assert.equal(childNode.parent, r.nodes.get("parent")); const n = entity( "Model", { mesh: { type: "model", assetId: "asset_triangle" } }, [0, 0, 0], "model", ); activeScene(p).entities.push(n); await r.load(p); assert.equal(r.animations.get("model")!.length, 1); p.assets[0] = triangleAsset(); await r.load(p); assert.equal(r.animations.get("model")!.length, 0); assert.ok( !r.logs.some((l) => l.level === "error"), JSON.stringify(r.logs), ); } finally { r.dispose(); } }, ); test( "runaway project script is terminated while runtime remains recoverable", { timeout: 10000 }, async () => { const r = runtime(), p = defaultProject(true); p.scripts.push({ id: "bad_loop", name: "Bad loop fixture", source: "({update(){while(true){}}})", fields: {}, }); activeScene(p).entities = [ entity("Loop", { script: { scriptId: "bad_loop" } }, [0, 0, 0], "loop"), ]; try { await r.play(p); await wait(2100); assert.ok(r.logs.some((l) => l.message.includes("1500"))); await r.stop(p); assert.equal(r.playing, false); assert.equal(r.state.length, 1); } finally { r.dispose(); } }, ); test( "fixed orthographic camera, alpha events and legacy projection restore", { timeout: 10000 }, async () => { const r = runtime(), p = defaultProject(true); p.settings.rendering = { toneMapping: false }; p.scripts = [ { id: "fade", name: "Fade", fields: {}, source: '({start(api){api.patch("visual",{components:{material:{alpha:.3,unlit:true}}})}})', }, ]; activeScene(p).entities = [ entity( "Camera", { camera: { mode: "fixed", projection: "orthographic", orthoWidth: 24, aspect: 16 / 9, lookAt: [0, 0, 0], }, }, [10, 20, 30], "camera", ), entity( "Visual", { mesh: { type: "box", size: [1, 1, 1] }, material: { color: "#ff8800", alpha: 1 }, script: { scriptId: "fade" }, }, [0, 0, 0], "visual", ), ]; try { await r.play(p); for ( let i = 0; i < 60 && r.state.find((n) => n.id === "visual")?.components.material.alpha !== 0.3; i++ ) await wait(50); assert.equal(r.gameCamera.mode, 1); assert.deepEqual(r.gameCamera.position.asArray(), [10, 20, 30]); assert.equal(r.gameCamera.orthoLeft, -12); assert.equal(r.gameCamera.orthoTop, 6.75); assert.equal(r.gameCamera.viewport.height, 0.75); assert.equal( r.scene.imageProcessingConfiguration.toneMappingEnabled, false, ); const mat: any = r.nodes.get("visual")!.getChildMeshes()[0].material; assert.equal(mat.alpha, 0.3); assert.equal(mat.unlit, true); await r.stop(p); assert.equal( r.state.find((n) => n.id === "visual")!.components.material.alpha, 1, ); const legacy = defaultProject(true); activeScene(legacy).entities = [ entity( "Camera", { camera: { offset: [0, 13, -10] } }, [0, 13, -10], "camera", ), ]; await r.play(legacy); await wait(50); assert.equal(r.gameCamera.mode, 0); assert.equal(r.gameCamera.viewport.height, 1); assert.equal( r.scene.imageProcessingConfiguration.toneMappingEnabled, true, ); } finally { r.dispose(); } }, ); test("changing a parent material or transform does not repaint child entities", async () => { const r = runtime(), p = defaultProject(true); const parent = entity( "Parent", { mesh: { type: "box" }, material: { color: "#ff0000", alpha: 0.5 } }, [0, 0, 0], "parent", ); const child = entity( "Child", { mesh: { type: "box" }, material: { color: "#00ff00", alpha: 1 } }, [2, 0, 0], "child", ); child.parentId = parent.id; activeScene(p).entities = [parent, child]; try { await r.load(p); parent.transform.position = [3, 0, 0]; parent.components.material.roughness = 0.1; await r.load(p); const mat: any = r.nodes.get("child")!.getChildMeshes()[0].material; assert.deepEqual(mat.albedoColor.asArray(), [0, 1, 0]); assert.equal(mat.alpha, 1); assert.equal( ( r.nodes .get("parent")! .getChildMeshes() .find((m) => m.metadata.entityId === "parent")!.material as any ).roughness, 0.1, ); } finally { r.dispose(); } }); import { sceneAnimationGlb } from "./fixtures.ts"; test("imported material, morph, camera and light animation targets are independent per entity", async () => { const r = runtime(), p = defaultProject(true), bytes = sceneAnimationGlb(); p.assets = [ { id: "scene", name: "scene.glb", kind: "model", uri: "data:model/gltf-binary;base64," + bytes.toString("base64"), }, ]; activeScene(p).entities = ["a", "b"].map((id) => entity( id, { mesh: { type: "model", assetId: "scene" }, ...(id === "a" ? { camera: { mode: "imported" }, animator: { autoplay: "Scene" } } : {}), }, [0, 0, 0], id, ), ); try { await r.play(p); assert.ok(!r.logs.some((l) => l.level === "error"), JSON.stringify(r.logs)); const a = r.containers.get("a")!, b = r.containers.get("b")!; assert.equal(r.scene.activeCamera, a.cameras[0]); const group = r.animations.get("a")![0]; assert.ok(group.isStarted, "autoplay starts the imported timeline"); group.pause(); group.goToFrame(group.to); const am: any = a.materials[0], bm: any = b.materials[0]; const close = (value: number, expected: number) => assert.ok(Math.abs(value - expected) < 0.0001, `${value} != ${expected}`); close(am.metallic, 0.9); close(am.roughness, 0.8); close(am.albedoColor.b, 1); close(am.alpha, 0.5); close(bm.metallic, 0.1); close(bm.roughness, 0.2); close(bm.albedoColor.r, 1); close(a.cameras[0].fov, 1.1); close(b.cameras[0].fov, 0.7); close(a.cameras[1].orthoLeft!, -4); close(a.cameras[1].orthoRight!, 4); close(a.cameras[1].orthoBottom!, -3); close(a.cameras[1].orthoTop!, 3); close(a.lights[0].intensity, 20); close(b.lights[0].intensity, 10); close(a.morphTargetManagers[0].getTarget(0).influence, 1); close(b.morphTargetManagers[0].getTarget(0).influence, 0); activeScene(p).entities = activeScene(p).entities.filter( (n) => n.id === "b", ); await r.stop(p); await r.load(p); assert.equal(r.containers.has("a"), false); assert.equal(r.containers.get("b")!.meshes[0].isDisposed(), false); } finally { r.dispose(); } }); test( "Mixed 2D/3D scene, worker physics commands, animation and Stop restoration", { timeout: 15000 }, async () => { const { project2D } = await import("../engine/template2d.ts"); const p = project2D(true), r = runtime(); p.scripts.push({ id: "two_d_script", name: "2D test", fields: {}, source: "({start(api){api.impulse2D([1,0]);},update(api,dt){api.state.ticks=(api.state.ticks||0)+1;api.patch(api.get().id,{components:{data:{ticks:api.state.ticks}}});},collision2d(api,event){api.patch(api.get().id,{components:{data:{collided:event.otherId}}})}})", }); activeScene(p).entities.find( (n) => n.id === "crate_2d", )!.components.script = { scriptId: "two_d_script" }; activeScene(p).entities.push( entity( "3D fall", { mesh: { type: "box", size: [1, 1, 1] }, collider: { shape: "box", size: [1, 1, 1] }, rigidbody: { type: "dynamic" }, }, [0, 3, -6], "three_d", ), ); try { await r.play(p); assert.ok(r.physics2d); assert.equal(r.scene.activeCamera!.mode, 1); r.setInput({ x: 1, durationMs: 5000 }); const deadline = Date.now() + 4000; while (Date.now() < deadline) { const state = r.snapshot(); if ( state.entities.find((n) => n.id === "player_2d")!.transform .position[0] > 4 && state.entities.find((n) => n.id === "three_d")!.transform .position[1] < -1 && state.entities.find((n) => n.id === "crate_2d")!.components.data ?.collided ) break; await wait(50); } const snap = r.snapshot(), hero = snap.entities.find((n) => n.id === "player_2d")!, crate = snap.entities.find((n) => n.id === "crate_2d")!; assert.ok(hero.transform.position[0] > 4, "builtin controller moved"); assert.equal(hero.transform.position[2], 0); assert.ok(crate.components.data.ticks > 5, JSON.stringify(snap.logs)); assert.ok( snap.entities.find((n) => n.id === "three_d")!.transform.position[1] < 0, "3D body falls through 2D ground", ); assert.equal(snap.physics.player_2d.dimension, 2); assert.ok( crate.components.data.collided, "Worker receives 2D collision events", ); assert.ok( !snap.logs.some((l) => l.level === "error"), JSON.stringify(snap.logs), ); await r.stop(p); assert.equal(r.physics2d, undefined); assert.deepEqual( r.state.find((n) => n.id === "player_2d")!.transform.position, [3, 2, 0], ); r.setView2D(true); assert.equal(r.camera.mode, 1); r.setView2D(false); assert.equal(r.camera.mode, 0); } finally { r.dispose(); } }, ); test("Spawning a 2D prefab creates its physics world when the scene initially had none", async () => { const p = defaultProject(), r = runtime(); p.assets = [ { id: "prefab2d", kind: "prefab", name: "2D body", entities: [ entity( "Body", { sprite: {}, collider2d: { shape: "box", size: [1, 1] }, rigidbody2d: { type: "fixed" }, }, [0, 0, 0], "body2d", ), ], }, ]; try { await r.play(p); assert.equal(r.physics2d, undefined); await (r as any).spawn("prefab2d", [4, 3, -2]); assert.equal(r.physics2d!.entries.size, 1); const n = r.state[0]; assert.notEqual(n.id, "body2d"); assert.deepEqual(n.transform.position, [4, 3, -2]); assert.ok(r.graphics2d.visuals.has(n.id)); } finally { r.dispose(); } });