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(); } }, );