@looop-games/cli 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,40 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.19] - 2026-07-22
18
+
19
+ ### Added
20
+
21
+ - **Your 3D models bake themselves.** `looop dev`, `looop test`, and
22
+ `looop publish` now notice when a GLB your entities reference has changed
23
+ (or its `<model>.bake.json` zone config has) and quietly regenerate the
24
+ server-side hit data — `<model>.skeleton.json` + `<model>.mesh.json`,
25
+ committed next to the model — so the server always shoots at the surface you
26
+ see. A model that can't bake stops the command with the reason and the fix
27
+ (a scale-carrying export, an unsupported fifth skin influence, a zone
28
+ pattern matching no bone), never a silently stale surface. Force a re-bake
29
+ or debug one with the new **`looop model bake <file.glb>`**. Baking parses
30
+ your GLB with the browser's own loader in a headless page — the exact code
31
+ that draws it — so what gets baked is what gets drawn. The loader ships
32
+ inside the engine, not with the `looop` command, so a game that never
33
+ touches a 3D model downloads nothing extra. Requires engine 0.1.32 or
34
+ newer; run `looop update` if your game pins an older one.
35
+
36
+ ## [0.1.18] - 2026-07-15
37
+
38
+ ### Added
39
+
40
+ - **`looop dev`, `looop test`, and `looop publish` understand entity
41
+ components.** A game that declares behavior in `components/` folders (the
42
+ entity system — see `shared/practices/entities.md` in your installed engine)
43
+ now works across the whole command surface: `dev` bundles your components
44
+ into the local room server, `test` runs the entity graph lint out of the
45
+ installed engine before anything else (it runs even when eslint isn't
46
+ installed in the game), and `publish` bundles them into your game's own
47
+ multiplayer server — a component game is server-backed automatically,
48
+ exactly like a primitives game. Requires engine 0.1.27 or newer; run
49
+ `looop update` if your game pins an older one.
50
+
17
51
  ## [0.1.17] - 2026-07-14
18
52
 
19
53
  ### Added
package/bin/looop.mjs CHANGED
@@ -32,6 +32,7 @@ Usage:
32
32
  looop lint [--fix] Check the game against the Looop rules (runs inside 'looop test')
33
33
  looop changelog [<v>] What changed in the engine (default: everything newer than your pin)
34
34
  looop update Move this game to the latest engine release (re-pins looop.engine)
35
+ looop model bake <glb> Re-bake a 3D model's server-side hit data now (normally automatic)
35
36
  looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
36
37
  looop feedback Send the unsent reports under notes/feedback/ to the Looop team
37
38
  looop login Authenticate this machine as your Looop player account
@@ -87,6 +88,17 @@ try {
87
88
  case 'update':
88
89
  await update();
89
90
  break;
91
+ case 'model': {
92
+ // `model` is the accessor for 3D-model tooling; `bake` is its first verb.
93
+ // Baking is normally automatic (dev/test/publish re-bake changed models);
94
+ // this is the explicit escape hatch.
95
+ const [verb, glb] = rest.filter((a) => !a.startsWith('--'));
96
+ if (verb !== 'bake') throw new Error(`Unknown model command: ${verb ?? '(none)'} — try: looop model bake <file.glb>`);
97
+ if (!glb) throw new Error('Name the model: looop model bake <file.glb>');
98
+ const { bakeModel } = await import('../lib/model-bake.mjs');
99
+ await bakeModel(glb);
100
+ break;
101
+ }
90
102
  case 'publish':
91
103
  await publish({ slug: flag('slug') });
92
104
  break;
@@ -17,6 +17,7 @@
17
17
  // class past the setInterval/park cost boundary. Keeping it external means the
18
18
  // worker's room code is always ours.
19
19
  import { assertNoOverrideCycle, PRIMITIVES_DIR, SERVER_BARREL, renderBarrel, scanPrimitives } from './primitives.mjs';
20
+ import { scanEntityComponents, renderEntityComponents } from './room-server.mjs';
20
21
  import { shadowResolvePlugin } from './shadow-resolve.mjs';
21
22
  import { join } from 'node:path';
22
23
 
@@ -29,17 +30,19 @@ export const PRIMITIVES_ENTRY = SERVER_BARREL;
29
30
  // engine import in the primitives bundle is rewritten to this.
30
31
  export const ENGINE_ARTIFACT_MODULE = './rooms-runtime.js';
31
32
 
32
- export async function bundlePrimitives(projectDir, { esbuildImpl } = {}) {
33
+ export async function bundlePrimitives(projectDir, { esbuildImpl, engineSharedDir } = {}) {
33
34
  // The barrel is GENERATED, not authored. A creator adds a primitive by adding
34
- // a file to `overrides/shared/ui/room/primitives/` — there is no list to keep
35
- // in sync, and therefore no way for the list and the folder to disagree.
35
+ // a file to `overrides/shared/ui/room/primitives/` — and an entity component
36
+ // by adding a folder under components/ there is no list to keep in sync,
37
+ // and therefore no way for the list and the folder to disagree.
36
38
  const primitives = scanPrimitives(projectDir); // throws on a reserved index.js
37
- if (!primitives.length) {
39
+ const engineShared = engineSharedDir ?? join(projectDir, 'node_modules/@looop-games/engine/shared');
40
+ const entityComponents = await scanEntityComponents(projectDir, engineShared); // throws on a malformed component
41
+ if (!primitives.length && !entityComponents.length) {
38
42
  throw new Error(
39
- `no server primitives to bundle — ${PRIMITIVES_DIR}/ is empty (this is not a server-backed game)`,
43
+ `nothing to bundle — ${PRIMITIVES_DIR}/ and components/ are both empty (this is not a server-backed game)`,
40
44
  );
41
45
  }
42
- const engineShared = join(projectDir, 'node_modules/@looop-games/engine/shared');
43
46
  assertNoOverrideCycle(primitives, projectDir, engineShared);
44
47
 
45
48
  const esbuild = esbuildImpl ?? (await import('esbuild'));
@@ -68,7 +71,7 @@ export async function bundlePrimitives(projectDir, { esbuildImpl } = {}) {
68
71
  // against the game root so `./overrides/...` means what it says. Same shape
69
72
  // the dev room server uses (lib/room-server.mjs) — one contract, two builds.
70
73
  stdin: {
71
- contents: renderBarrel(primitives),
74
+ contents: `${renderBarrel(primitives)}\n${renderEntityComponents(entityComponents)}\n`,
72
75
  resolveDir: projectDir,
73
76
  sourcefile: 'looop-primitives.js',
74
77
  loader: 'js',
package/lib/dev.mjs CHANGED
@@ -53,6 +53,13 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
53
53
  // the platform's login-gated registry (no-op when the pinned version is in).
54
54
  const engine = await ensureEngine(project.dir, { log });
55
55
 
56
+ // Any 3D model the entity definitions reference re-bakes when its GLB (or
57
+ // bake config) changed — the server must hit the surface the player sees. A
58
+ // bake failure stops the stack with the model named rather than serving a
59
+ // room that shoots at yesterday's shape.
60
+ const { ensureBaked } = await import('./model-bake.mjs');
61
+ await ensureBaked({ dir: project.dir, log });
62
+
56
63
  // Which ports, and may we take them? An explicit --port is obeyed as given
57
64
  // (takeover included). With no flag we step around anyone else's stack —
58
65
  // another lane, another game, an unrelated app — and only ever reclaim our
package/lib/lint.mjs CHANGED
@@ -57,21 +57,46 @@ async function loadRulesFromEngine(projectDir) {
57
57
  return { configs: mod.configs ?? mod.default?.configs ?? null, why: 'ok' };
58
58
  }
59
59
 
60
+ // The entity graph lint — the SECOND shared step, alongside the eslint rules.
61
+ // Like the rules, the implementation lives in the ENGINE (shared/ui/room/
62
+ // entity/lint-run.mjs), so every gate that checks a game runs the identical
63
+ // pass — there is no second copy to drift. An engine too old to carry it
64
+ // skips — same degradation contract as the rules.
65
+ async function loadEntityLintFromEngine(projectDir) {
66
+ let engineDir;
67
+ try {
68
+ ({ dir: engineDir } = resolveEngine(projectDir));
69
+ } catch {
70
+ return null;
71
+ }
72
+ const entry = join(engineDir, 'shared', 'ui', 'room', 'entity', 'lint-run.mjs');
73
+ if (!existsSync(entry)) return null;
74
+ const mod = await import(pathToFileURL(entry).href);
75
+ return mod.runEntityLint ?? null;
76
+ }
77
+
60
78
  export async function lintCmd({
61
79
  cwd = process.cwd(),
62
80
  fix = false,
63
81
  log = console.log,
64
82
  loadEslint = loadFromProject,
65
83
  loadRules = loadRulesFromEngine,
84
+ loadEntityLint = loadEntityLintFromEngine,
66
85
  } = {}) {
67
86
  const project = findProject(cwd);
68
87
 
88
+ // The entity graph check runs regardless of the eslint tooling below — a
89
+ // missing dev tool must never switch off a structural guarantee. It skips
90
+ // itself silently for games with no entity folders.
91
+ const runEntity = await loadEntityLint(project.dir);
92
+ const entity = runEntity ? await runEntity(project.dir, { log }) : { ok: true, skipped: true };
93
+
69
94
  const ESLint = await loadEslint(project.dir);
70
95
  if (!ESLint) {
71
96
  log('⚠️ looop lint: eslint is not installed in this game — skipping the rule checks.');
72
97
  log(' To turn them on (recommended — they catch bugs that silently pass your tests):');
73
98
  log(' npm i -D eslint');
74
- return { ok: true, skipped: true, errorCount: 0, warningCount: 0 };
99
+ return { ok: entity.ok, skipped: true, errorCount: 0, warningCount: 0, entity };
75
100
  }
76
101
 
77
102
  const { configs, why } = await loadRules(project.dir);
@@ -87,7 +112,7 @@ export async function lintCmd({
87
112
  log('⚠️ looop lint: your engine is older than the Looop rules — skipping.');
88
113
  log(' Get an engine that carries them: looop update');
89
114
  }
90
- return { ok: true, skipped: true, errorCount: 0, warningCount: 0 };
115
+ return { ok: entity.ok, skipped: true, errorCount: 0, warningCount: 0, entity };
91
116
  }
92
117
 
93
118
  // A game that ships its own eslint.config.js owns its rules — we do not
@@ -131,10 +156,11 @@ export async function lintCmd({
131
156
  }
132
157
 
133
158
  return {
134
- ok: errorCount === 0,
159
+ ok: errorCount === 0 && entity.ok,
135
160
  skipped: false,
136
161
  errorCount,
137
162
  warningCount,
163
+ entity,
138
164
  files: results.map((r) => relative(project.dir, r.filePath)),
139
165
  };
140
166
  }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ // Thin executable wrapper over the model baker. The normal front door is
3
+ // `looop model bake <glb>` (bin/looop.mjs); this file exists so other tooling
4
+ // can exec the same implementation directly. One baker, two entrances.
5
+ import { bakeModel } from './model-bake.mjs';
6
+
7
+ const glb = process.argv[2];
8
+ if (!glb) {
9
+ console.error('usage: model-bake <file.glb> (bakes <file>.skeleton.json + <file>.mesh.json beside it)');
10
+ process.exit(2);
11
+ }
12
+ try {
13
+ await bakeModel(glb);
14
+ } catch (err) {
15
+ console.error(`model bake: ${err.message}`);
16
+ process.exit(1);
17
+ }
@@ -0,0 +1,545 @@
1
+ // model bake — turn a rigged GLB into the artifacts the room needs to hit the
2
+ // model's true animated surface:
3
+ //
4
+ // <model>.skeleton.json bones (parent, rest transform) + baked clips — what
5
+ // shared/ui/room/lib/rig.js poses
6
+ // <model>.mesh.json vertices, skin weights, triangles, per-triangle zone
7
+ // bytes, broadphase radius — what skin-hit.js shoots
8
+ //
9
+ // Both are emitted TOGETHER from one read of one loaded model, because they share
10
+ // a bone ordering: the mesh's skin indices are meaningless against a differently
11
+ // ordered skeleton.
12
+ //
13
+ // The GLB is parsed by three.js's own GLTFLoader in a REAL browser (playwright),
14
+ // not by a node-side parser. The browser's loader is the same code that will draw
15
+ // the model at runtime, so whatever it decides about the asset (bone order, node
16
+ // transforms, interleaved buffers) is by construction what the drawn model does.
17
+ // A second parser would be a second opinion, and a second opinion is exactly the
18
+ // drift class this pipeline exists to delete. The page is hermetic: every request
19
+ // (three, the loader, the GLB bytes) is fulfilled from local disk via route
20
+ // interception — no network, no server.
21
+ //
22
+ // Baking is normally AUTOMATIC: dev/test/publish call `ensureBaked()` and re-bake
23
+ // any referenced GLB whose content hash no longer matches the artifact's recorded
24
+ // source hash. `looop model bake <glb>` is the explicit escape hatch (debugging,
25
+ // forcing a re-bake). Artifacts are committed next to the model like any other
26
+ // game file.
27
+ //
28
+ // ── Optional bake config: <model>.bake.json next to the GLB ──────────────────
29
+ // {
30
+ // "zones": { "head": ["Head", "Neck*"], "arm": ["*_Arm*"] },
31
+ // "clips": ["fly", "idle"] // subset to bake; default = all
32
+ // }
33
+ // Zones use the dominant-bone rule (a triangle belongs to whichever bone most
34
+ // influences its vertices — the same rule the big engines use to size their
35
+ // bone-attached proxy shapes, pointed at triangles). Patterns match bone
36
+ // names, `*` is a wildcard. Without config the mesh gets NO zones: hits still land
37
+ // on the exact surface, damage is uniform, and `hitRay` reports `zone: null`.
38
+ //
39
+ // ── What a bake rejects, loudly ──────────────────────────────────────────────
40
+ // - scale tracks in a clip (the runtime rig is uniform-scale; a scaling clip
41
+ // cannot be reproduced, so it fails the bake instead of silently drifting)
42
+ // - >4 skin influences per vertex, non-uniform bone scale, non-unit rotations
43
+ // (scale left in a matrix — the drifter's 17° bug, caught at bake)
44
+ // - skin weights outside [0,1] or not summing to ~1 (the fingerprint of reading
45
+ // interleaved attributes through `.array` instead of the accessors)
46
+ // Root-bone POSITION tracks are stripped by default and reported: the entity's
47
+ // own movement owns where the creature is, and keeping root motion would apply
48
+ // it twice.
49
+
50
+ import { createHash } from 'node:crypto';
51
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
52
+ import { join, resolve, basename } from 'node:path';
53
+
54
+ // Version stamped into artifacts. Bump when the bake OUTPUT changes shape or
55
+ // meaning — it participates in the staleness hash, so old artifacts re-bake.
56
+ export const BAKE_VERSION = 1;
57
+
58
+ // ── pure helpers (unit-tested without a browser) ─────────────────────────────
59
+
60
+ // Sort bones parent-first and produce old→new remap. rig.js poses in one forward
61
+ // pass, so every parent must precede its children; a GLB does not promise that.
62
+ export function sortBonesParentFirst(raw) {
63
+ const order = [];
64
+ const placed = new Set();
65
+ for (let guard = 0; order.length < raw.length && guard <= raw.length; guard++) {
66
+ for (let i = 0; i < raw.length; i++) {
67
+ if (placed.has(i)) continue;
68
+ if (raw[i].parent < 0 || placed.has(raw[i].parent)) { order.push(i); placed.add(i); }
69
+ }
70
+ }
71
+ if (order.length !== raw.length) throw new Error('skeleton is not a tree (parent cycle)');
72
+ const remap = new Int32Array(raw.length);
73
+ order.forEach((old, neu) => { remap[old] = neu; });
74
+ return { order, remap };
75
+ }
76
+
77
+ // Bone-name pattern matching for the zones config. `*` matches any run of
78
+ // characters; matching is case-sensitive (bone names are authored identifiers).
79
+ export function matchesPattern(name, pattern) {
80
+ if (!pattern.includes('*')) return name === pattern;
81
+ const rx = new RegExp(`^${pattern.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`);
82
+ return rx.test(name);
83
+ }
84
+
85
+ // Map each bone to a zone byte from the config. Zone 0 is always the implicit
86
+ // "body" (every unmatched bone); config keys become zones 1..n in key order.
87
+ export function boneZonesFromConfig(boneNames, zonesConfig) {
88
+ const names = ['body', ...Object.keys(zonesConfig)];
89
+ const byBone = new Uint8Array(boneNames.length);
90
+ boneNames.forEach((bn, i) => {
91
+ for (let z = 1; z < names.length; z++) {
92
+ if (zonesConfig[names[z]].some((p) => matchesPattern(bn, p))) { byBone[i] = z; return; }
93
+ }
94
+ });
95
+ return { names, byBone };
96
+ }
97
+
98
+ // The dominant bone of a triangle: the bone with the highest summed skin weight
99
+ // across its three vertices. `acc` is scratch sized to the bone count.
100
+ export function dominantBone(skinIndex, skinWeight, influences, index, tri, acc) {
101
+ acc.fill(0);
102
+ for (let k = 0; k < 3; k++) {
103
+ const v = index[tri * 3 + k];
104
+ for (let j = 0; j < influences; j++) acc[skinIndex[v * influences + j]] += skinWeight[v * influences + j];
105
+ }
106
+ let best = 0, bestW = -1;
107
+ for (let b = 0; b < acc.length; b++) if (acc[b] > bestW) { bestW = acc[b]; best = b; }
108
+ return best;
109
+ }
110
+
111
+ export function sha256(buf) {
112
+ return createHash('sha256').update(buf).digest('hex');
113
+ }
114
+
115
+ // The staleness key an artifact records: GLB bytes + bake config + bake version.
116
+ // If any of the three changes, the artifact is stale and auto-bake re-runs.
117
+ export function sourceHash(glbBytes, configText) {
118
+ return sha256(Buffer.concat([
119
+ Buffer.from(`bake${BAKE_VERSION}!`),
120
+ Buffer.from(sha256(glbBytes)),
121
+ Buffer.from(sha256(Buffer.from(configText ?? ''))),
122
+ ]));
123
+ }
124
+
125
+ export function artifactPaths(glbPath) {
126
+ const stem = glbPath.replace(/\.glb$/i, '');
127
+ return { skeleton: `${stem}.skeleton.json`, mesh: `${stem}.mesh.json`, config: `${stem}.bake.json` };
128
+ }
129
+
130
+ // Is the artifact pair current for this GLB + config? (Missing → stale.)
131
+ export function isStale(glbPath) {
132
+ const { skeleton, mesh, config } = artifactPaths(glbPath);
133
+ if (!existsSync(skeleton) || !existsSync(mesh)) return true;
134
+ try {
135
+ const want = sourceHash(readFileSync(glbPath), existsSync(config) ? readFileSync(config, 'utf8') : '');
136
+ const have = JSON.parse(readFileSync(skeleton, 'utf8'))?._source?.hash;
137
+ return have !== want;
138
+ } catch {
139
+ return true; // unreadable artifact = stale artifact
140
+ }
141
+ }
142
+
143
+ // ── the in-page bake ─────────────────────────────────────────────────────────
144
+ // Runs inside the browser with three + GLTFLoader importable. Everything it
145
+ // returns is plain JSON (typed arrays as b64).
146
+
147
+ const PAGE_HTML = `<!doctype html><html><head>
148
+ </head><body><script type="module">
149
+ import { THREE, GLTFLoader } from '/vendor/three-gltf.js';
150
+ window.THREE = THREE; window.GLTFLoader = GLTFLoader; window.__ready = true;
151
+ </script></body></html>`;
152
+
153
+ // Serialized into the page. `cfg` = { zones, clips } (already parsed).
154
+ async function bakeInPage(page, cfg) {
155
+ return page.evaluate(async (config) => {
156
+ const THREE = window.THREE;
157
+ const notes = [];
158
+
159
+ const buf = await (await fetch('/model.glb')).arrayBuffer();
160
+ const gltf = await new window.GLTFLoader().parseAsync(buf, '/');
161
+ const scene = gltf.scene;
162
+ scene.updateMatrixWorld(true);
163
+
164
+ // The mesh: the largest SkinnedMesh (a model may carry extras — eyes, teeth).
165
+ const skinned = [];
166
+ scene.traverse((o) => { if (o.isSkinnedMesh) skinned.push(o); });
167
+ if (!skinned.length) return { error: 'no skinned mesh in this GLB — is the model rigged?' };
168
+ skinned.sort((a, b) => b.geometry.attributes.position.count - a.geometry.attributes.position.count);
169
+ const sk = skinned[0];
170
+ if (skinned.length > 1) {
171
+ notes.push(`model has ${skinned.length} skinned meshes — baked the largest ` +
172
+ `(${sk.geometry.attributes.position.count} verts); the others are visual-only`);
173
+ }
174
+
175
+ // REST = BIND. skin-hit derives the inverse bind from the rig's rest pose, so
176
+ // the rest transforms recorded here MUST be the bind pose — not whatever pose
177
+ // the file happened to save. three's Skeleton.pose() restores exactly that.
178
+ sk.skeleton.pose();
179
+ scene.updateMatrixWorld(true);
180
+
181
+ const bones = sk.skeleton.bones;
182
+ const srcIdx = new Map(bones.map((b, i) => [b, i]));
183
+ const raw = bones.map((b) => ({
184
+ name: b.name,
185
+ parent: srcIdx.has(b.parent) ? srcIdx.get(b.parent) : -1,
186
+ t: [b.position.x, b.position.y, b.position.z],
187
+ r: [b.quaternion.x, b.quaternion.y, b.quaternion.z, b.quaternion.w],
188
+ s: [b.scale.x, b.scale.y, b.scale.z],
189
+ }));
190
+
191
+ // Parent-first sort (rig.js poses in one forward pass); remap carried into
192
+ // skinIndex below. Mirrors sortBonesParentFirst — this copy runs in-page.
193
+ const order = [];
194
+ const placed = new Set();
195
+ for (let guard = 0; order.length < raw.length && guard <= raw.length; guard++) {
196
+ for (let i = 0; i < raw.length; i++) {
197
+ if (placed.has(i)) continue;
198
+ if (raw[i].parent < 0 || placed.has(raw[i].parent)) { order.push(i); placed.add(i); }
199
+ }
200
+ }
201
+ if (order.length !== raw.length) return { error: 'skeleton is not a tree (parent cycle)' };
202
+ const remap = new Int32Array(raw.length);
203
+ order.forEach((old, neu) => { remap[old] = neu; });
204
+ const outBones = order.map((old) => {
205
+ const b = raw[old];
206
+ return { name: b.name, parent: b.parent < 0 ? -1 : remap[b.parent], t: b.t, r: b.r, s: b.s };
207
+ });
208
+ const boneNames = outBones.map((b) => b.name);
209
+
210
+ // Model-space root: from scene origin to the skeleton's parent. The artifact
211
+ // is baked in the GLB's own space; the GAME places it in the world at runtime
212
+ // (rig.evaluate's `root`), so the artifact stays game-agnostic.
213
+ const topBone = bones.find((b) => !srcIdx.has(b.parent)) || bones[0];
214
+ const rootM = topBone.parent ? topBone.parent.matrixWorld.clone() : new THREE.Matrix4();
215
+ const rp = new THREE.Vector3(), rq = new THREE.Quaternion(), rs = new THREE.Vector3();
216
+ rootM.decompose(rp, rq, rs);
217
+
218
+ // ── mesh, in scene space at bind pose ────────────────────────────────────
219
+ const geo = sk.geometry;
220
+ const V = geo.attributes.position.count;
221
+ const v3 = new THREE.Vector3();
222
+ const position = new Float32Array(V * 3);
223
+ for (let i = 0; i < V; i++) {
224
+ sk.getVertexPosition(i, v3); // bind-pose surface (skeleton is posed to bind)
225
+ position[i * 3] = v3.x; position[i * 3 + 1] = v3.y; position[i * 3 + 2] = v3.z;
226
+ }
227
+
228
+ const si = geo.attributes.skinIndex, sw = geo.attributes.skinWeight;
229
+ if (geo.attributes.skinIndex1) return { error: 'mesh uses >4 skin influences (skinIndex1) — unsupported' };
230
+ const inf = si.itemSize;
231
+ const skinIndex = new Uint16Array(V * inf);
232
+ const skinWeight = new Float32Array(V * inf);
233
+ // Through the ACCESSORS, never `.array`: interleaved buffers make `.array`
234
+ // return neighbouring attributes — silently, with the right length.
235
+ const get = (attr, i, k) => (k === 0 ? attr.getX(i) : k === 1 ? attr.getY(i) : k === 2 ? attr.getZ(i) : attr.getW(i));
236
+ for (let v = 0; v < V; v++) {
237
+ for (let k = 0; k < inf; k++) {
238
+ skinIndex[v * inf + k] = remap[get(si, v, k)];
239
+ skinWeight[v * inf + k] = get(sw, v, k);
240
+ }
241
+ }
242
+
243
+ const idxAttr = geo.index;
244
+ const index = new Uint32Array(idxAttr ? idxAttr.count : V);
245
+ if (idxAttr) for (let i = 0; i < idxAttr.count; i++) index[i] = idxAttr.getX(i);
246
+ else for (let i = 0; i < V; i++) index[i] = i;
247
+ const T = index.length / 3;
248
+
249
+ // ── zones: dominant bone → configured zone, per triangle ─────────────────
250
+ let zones = null;
251
+ if (config.zones && Object.keys(config.zones).length) {
252
+ const names = ['body', ...Object.keys(config.zones)];
253
+ const match = (name, pattern) => {
254
+ if (!pattern.includes('*')) return name === pattern;
255
+ const rx = new RegExp('^' + pattern.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
256
+ return rx.test(name);
257
+ };
258
+ const byBone = new Uint8Array(outBones.length);
259
+ const unmatchedPatterns = [];
260
+ for (let z = 1; z < names.length; z++) {
261
+ for (const p of config.zones[names[z]]) {
262
+ if (!boneNames.some((bn) => match(bn, p))) unmatchedPatterns.push(p);
263
+ }
264
+ }
265
+ if (unmatchedPatterns.length) {
266
+ return { error: `zone patterns match no bone: ${unmatchedPatterns.join(', ')} — bones are: ${boneNames.join(', ')}` };
267
+ }
268
+ boneNames.forEach((bn, i) => {
269
+ for (let z = 1; z < names.length; z++) {
270
+ if (config.zones[names[z]].some((p) => match(bn, p))) { byBone[i] = z; return; }
271
+ }
272
+ });
273
+ const zoneTri = new Uint8Array(T);
274
+ const acc = new Float64Array(outBones.length);
275
+ for (let t = 0; t < T; t++) {
276
+ acc.fill(0);
277
+ for (let k = 0; k < 3; k++) {
278
+ const v = index[t * 3 + k];
279
+ for (let j = 0; j < inf; j++) acc[skinIndex[v * inf + j]] += skinWeight[v * inf + j];
280
+ }
281
+ let best = 0, bestW = -1;
282
+ for (let b = 0; b < acc.length; b++) if (acc[b] > bestW) { bestW = acc[b]; best = b; }
283
+ zoneTri[t] = byBone[best];
284
+ }
285
+ const counts = {};
286
+ names.forEach((n) => { counts[n] = 0; });
287
+ for (let t = 0; t < T; t++) counts[names[zoneTri[t]]]++;
288
+ zones = { names, zoneTri, counts };
289
+ }
290
+
291
+ // ── clips: every GLB animation, rotation + position tracks ───────────────
292
+ const wanted = config.clips ?? null;
293
+ const clips = {};
294
+ const rootBoneName = outBones.find((b) => b.parent === -1)?.name;
295
+ for (const anim of gltf.animations ?? []) {
296
+ if (wanted && !wanted.includes(anim.name)) continue;
297
+ const tracks = [];
298
+ for (const tr of anim.tracks) {
299
+ const m = tr.name.match(/^(.*)\.(quaternion|position|scale)$/);
300
+ if (!m) { notes.push(`clip "${anim.name}": skipped unrecognised track ${tr.name}`); continue; }
301
+ const [, nodeName, prop] = m;
302
+ if (!boneNames.includes(nodeName)) {
303
+ notes.push(`clip "${anim.name}": track for "${nodeName}" targets a non-bone node — skipped`);
304
+ continue;
305
+ }
306
+ if (prop === 'scale') {
307
+ return { error: `clip "${anim.name}" has a SCALE track on bone "${nodeName}". The room's rig is uniform-scale ` +
308
+ `and cannot reproduce it, so the hit surface would drift off the drawn model. Strip scale tracks in your export.` };
309
+ }
310
+ if (prop === 'position' && nodeName === rootBoneName) {
311
+ notes.push(`clip "${anim.name}": stripped root-motion position track on "${nodeName}" — ` +
312
+ `the entity's own movement carries the creature; keeping it would apply the motion twice`);
313
+ continue;
314
+ }
315
+ const entry = { bone: nodeName, times: Array.from(tr.times) };
316
+ if (prop === 'quaternion') entry.rot = Array.from(tr.values);
317
+ else entry.pos = Array.from(tr.values);
318
+ tracks.push(entry);
319
+ }
320
+ clips[anim.name] = { duration: anim.duration, tracks };
321
+ }
322
+
323
+ // ── broadphase radius: worst case across rest + every baked clip ─────────
324
+ // The sphere exists to REJECT cheaply, so it must never reject a true hit:
325
+ // measure every pose the clips can produce, then carry 3% slack (a ray
326
+ // grazing an extremity is near-tangent, and float error then discards a hit
327
+ // that visibly connects).
328
+ let radius = 0;
329
+ const measure = () => {
330
+ scene.updateMatrixWorld(true);
331
+ sk.skeleton.update?.();
332
+ for (let i = 0; i < V; i++) {
333
+ sk.getVertexPosition(i, v3);
334
+ const d = Math.hypot(v3.x, v3.y, v3.z);
335
+ if (d > radius) radius = d;
336
+ }
337
+ };
338
+ measure(); // rest pose
339
+ const mixer = new THREE.AnimationMixer(scene);
340
+ for (const anim of gltf.animations ?? []) {
341
+ if (wanted && !wanted.includes(anim.name)) continue;
342
+ const action = mixer.clipAction(anim);
343
+ action.play();
344
+ const STEPS = 24;
345
+ for (let s = 0; s <= STEPS; s++) {
346
+ mixer.setTime((anim.duration * s) / STEPS);
347
+ measure();
348
+ }
349
+ action.stop();
350
+ }
351
+ sk.skeleton.pose(); // back to bind
352
+ scene.updateMatrixWorld(true);
353
+ radius *= 1.03;
354
+
355
+ // ── weight sanity, while the source is still in hand ─────────────────────
356
+ let min = Infinity, max = -Infinity, outOfRange = 0, badSums = 0;
357
+ for (let i = 0; i < skinWeight.length; i++) {
358
+ const w = skinWeight[i];
359
+ if (w < min) min = w; if (w > max) max = w;
360
+ if (w < -1e-4 || w > 1 + 1e-4) outOfRange++;
361
+ }
362
+ for (let v = 0; v < V; v++) {
363
+ let s = 0;
364
+ for (let k = 0; k < inf; k++) s += skinWeight[v * inf + k];
365
+ if (Math.abs(s - 1) > 0.02) badSums++;
366
+ }
367
+
368
+ const b64 = (ta) => {
369
+ const u8 = new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
370
+ let s = '';
371
+ const CH = 0x8000;
372
+ for (let i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH));
373
+ return btoa(s);
374
+ };
375
+
376
+ return {
377
+ notes,
378
+ root: { t: [rp.x, rp.y, rp.z], r: [rq.x, rq.y, rq.z, rq.w], s: [rs.x, rs.y, rs.z] },
379
+ bones: outBones,
380
+ clips,
381
+ mesh: {
382
+ vertexCount: V, triangleCount: T, influences: inf, radius,
383
+ position: { b64: b64(position) },
384
+ skinIndex: { b64: b64(skinIndex) },
385
+ skinWeight: { b64: b64(skinWeight) },
386
+ index: { b64: b64(index) },
387
+ ...(zones ? { zones: { names: zones.names, tri: { b64: b64(zones.zoneTri) } } } : {}),
388
+ },
389
+ weightCheck: { min, max, outOfRange, badSums },
390
+ stats: {
391
+ V, T, inf, radius, bones: outBones.length,
392
+ clips: Object.keys(clips),
393
+ ...(zones ? { zoneCounts: zones.counts } : {}),
394
+ },
395
+ };
396
+ }, cfg);
397
+ }
398
+
399
+ // ── locating the loader bundle (served to the hermetic page) ─────────────────
400
+ // three + GLTFLoader ship VENDORED inside the engine (shared/ui/room/vendor/
401
+ // three-gltf.js, one self-contained ESM file) rather than as a dependency of
402
+ // this CLI — a 2D game must never download three just to own a `looop` binary.
403
+ // The engine is also the right owner of the version pin: the code that DRAWS
404
+ // models lives there, so the loader that bakes and the loader that draws move
405
+ // together, on engine releases. The bake never touches the network.
406
+ const VENDOR_REL = 'shared/ui/room/vendor/three-gltf.js';
407
+ function threeBundle(dir) {
408
+ const candidates = [
409
+ // A standalone game: the engine installed under node_modules.
410
+ join(dir, 'node_modules/@looop-games/engine', VENDOR_REL),
411
+ // This CLI running from inside the engine repo itself.
412
+ join(import.meta.dirname, '../../..', VENDOR_REL),
413
+ ];
414
+ for (const c of candidates) if (existsSync(c)) return c;
415
+ throw new Error(
416
+ 'the installed engine does not include the model loader '
417
+ + `(${VENDOR_REL}) — run \`npx looop update\` to get a newer engine`,
418
+ );
419
+ }
420
+
421
+ // ── the driver ───────────────────────────────────────────────────────────────
422
+
423
+ export async function bakeModel(glbPath, { dir = process.cwd(), log = console.log } = {}) {
424
+ const abs = resolve(glbPath);
425
+ if (!existsSync(abs)) throw new Error(`no such file: ${glbPath}`);
426
+ const paths = artifactPaths(abs);
427
+ const glbBytes = readFileSync(abs);
428
+ const configText = existsSync(paths.config) ? readFileSync(paths.config, 'utf8') : '';
429
+ let cfg = {};
430
+ if (configText) {
431
+ try { cfg = JSON.parse(configText); } catch (e) {
432
+ throw new Error(`${basename(paths.config)} is not valid JSON: ${e.message}`);
433
+ }
434
+ }
435
+
436
+ const { chromium } = await import('playwright');
437
+ const bundle = threeBundle(dir);
438
+ const browser = await chromium.launch();
439
+ try {
440
+ const page = await browser.newPage();
441
+ const pageErrors = [];
442
+ page.on('pageerror', (e) => pageErrors.push(e.message));
443
+ await page.route('**/*', (route) => {
444
+ const url = new URL(route.request().url());
445
+ const p = url.pathname;
446
+ if (p === '/' || p === '/bake.html') return route.fulfill({ contentType: 'text/html', body: PAGE_HTML });
447
+ if (p === '/model.glb') return route.fulfill({ contentType: 'model/gltf-binary', body: glbBytes });
448
+ if (p === '/vendor/three-gltf.js') {
449
+ return route.fulfill({ contentType: 'text/javascript', body: readFileSync(bundle) });
450
+ }
451
+ return route.fulfill({ status: 404, body: 'not baked in' });
452
+ });
453
+ await page.goto('http://bake.looop.invalid/bake.html');
454
+ await page.waitForFunction(() => window.__ready === true, { timeout: 30_000 });
455
+
456
+ const baked = await bakeInPage(page, cfg);
457
+ if (baked.error) {
458
+ throw new Error(`${baked.error}${pageErrors.length ? `\n(page errors: ${pageErrors.join('; ')})` : ''}`);
459
+ }
460
+
461
+ // ── validate before writing — a bad artifact must never reach the room ───
462
+ const problems = [];
463
+ const uni = (s, what) => {
464
+ const m = Math.max(Math.abs(s[0]), Math.abs(s[1]), Math.abs(s[2])) || 1;
465
+ if (Math.abs(s[0] - s[1]) / m > 1e-3 || Math.abs(s[0] - s[2]) / m > 1e-3) problems.push(`${what} scale is non-uniform: ${s}`);
466
+ return (s[0] + s[1] + s[2]) / 3;
467
+ };
468
+ const rootS = uni(baked.root.s, 'root');
469
+ baked.bones.forEach((b, i) => {
470
+ if (b.parent >= i) problems.push(`bone ${i} ${b.name}: parent ${b.parent} does not precede it`);
471
+ const L = Math.hypot(...b.r);
472
+ if (Math.abs(L - 1) > 1e-3) problems.push(`bone ${i} ${b.name}: rotation not unit (${L.toFixed(4)}) — scale left in the matrix`);
473
+ b.sAvg = uni(b.s, `bone ${i} ${b.name}`);
474
+ });
475
+ if (!(baked.mesh.radius > 0)) problems.push('broadphase radius is not positive');
476
+ const w = baked.weightCheck;
477
+ if (w.outOfRange > 0) problems.push(`${w.outOfRange} skin weights outside [0,1] (range ${w.min.toFixed(3)}..${w.max.toFixed(3)}) — attributes read wrongly?`);
478
+ if (w.badSums > 0) problems.push(`${w.badSums} vertices whose weights do not sum to ~1`);
479
+ if (problems.length) throw new Error(`BAKE INVALID:\n ${problems.join('\n ')}`);
480
+
481
+ const source = { hash: sourceHash(glbBytes, configText), glb: basename(abs), bakeVersion: BAKE_VERSION };
482
+ const note = 'GENERATED by the Looop model baker — do not hand-edit; edit the GLB or its .bake.json and re-bake.';
483
+ writeFileSync(paths.skeleton, JSON.stringify({
484
+ _generated: note,
485
+ _source: source,
486
+ root: { t: baked.root.t, r: baked.root.r, s: rootS },
487
+ bones: baked.bones.map((b) => ({ name: b.name, parent: b.parent, t: b.t, r: b.r, s: b.sAvg })),
488
+ clips: baked.clips,
489
+ }, null, 1) + '\n');
490
+ writeFileSync(paths.mesh, JSON.stringify({ _generated: note, _source: source, ...baked.mesh }) + '\n');
491
+
492
+ const s = baked.stats;
493
+ log(`baked ${basename(abs)} → ${basename(paths.skeleton)} + ${basename(paths.mesh)}`);
494
+ log(` bones ${s.bones} vertices ${s.V} triangles ${s.T} radius ${s.radius.toFixed(3)}`);
495
+ log(` clips: ${s.clips.length ? s.clips.join(', ') : '(none in this GLB)'}`);
496
+ if (s.zoneCounts) log(` zones: ${Object.entries(s.zoneCounts).map(([k, v]) => `${k} ${v}`).join(' ')}`);
497
+ for (const n of baked.notes) log(` note: ${n}`);
498
+ return { paths, stats: s, notes: baked.notes };
499
+ } finally {
500
+ await browser.close();
501
+ }
502
+ }
503
+
504
+ // ── auto-bake: the staleness sweep dev/test/publish run ──────────────────────
505
+ // The game's entity definitions say which models matter: any string field ending
506
+ // in `.glb` inside entities/**/entity.json is a model reference. Model paths are
507
+ // GAME-ROOT-relative — the same string is the presenter's fetch URL (the page
508
+ // sits at the game root) and the room registry key — so they resolve against the
509
+ // game dir here too. Each referenced GLB whose artifacts are missing or stale
510
+ // re-bakes. A bake failure is a real failure — surfaced with the model named,
511
+ // never swallowed: a stale artifact means the server shoots at a shape the
512
+ // player no longer sees.
513
+
514
+ export function referencedModels(gameDir) {
515
+ const found = new Set();
516
+ const scan = (dir) => {
517
+ if (!existsSync(dir)) return;
518
+ for (const e of readdirSync(dir)) {
519
+ const p = join(dir, e);
520
+ const st = statSync(p);
521
+ if (st.isDirectory()) { if (e !== 'node_modules') scan(p); continue; }
522
+ if (!/entity\.json$/.test(e)) continue;
523
+ const walk = (v) => {
524
+ if (typeof v === 'string') { if (/\.glb$/i.test(v)) found.add(resolve(gameDir, v)); }
525
+ else if (Array.isArray(v)) v.forEach(walk);
526
+ else if (v && typeof v === 'object') Object.values(v).forEach(walk);
527
+ };
528
+ try { walk(JSON.parse(readFileSync(p, 'utf8'))); } catch { /* a broken entity.json fails its own gate */ }
529
+ }
530
+ };
531
+ scan(join(gameDir, 'entities'));
532
+ return [...found].filter(existsSync);
533
+ }
534
+
535
+ export async function ensureBaked({ dir = process.cwd(), log = console.log } = {}) {
536
+ const models = referencedModels(dir);
537
+ const baked = [];
538
+ for (const glb of models) {
539
+ if (!isStale(glb)) continue;
540
+ log(`model ${basename(glb)} changed — re-baking so the server hits the surface you see…`);
541
+ await bakeModel(glb, { dir, log });
542
+ baked.push(glb);
543
+ }
544
+ return { models, baked };
545
+ }
@@ -127,8 +127,19 @@ export function scanPrimitives(projectDir) {
127
127
  // other. (They used to disagree: publish looked for `_local/primitives/index.js`
128
128
  // while dev looked for a `partykit.json` that nothing ever created, so a
129
129
  // creator's primitive ran in production and silently did not run in dev.)
130
+ //
131
+ // TWO trees make a game server-backed: vendored primitives (overrides/) and
132
+ // entity components (components/, or entity-local ones under entities/). The
133
+ // entity half is a cheap folder check, not a scan — a malformed component
134
+ // still makes the game server-backed; the build is where it fails loudly.
130
135
  export function isServerBacked(projectDir) {
131
- return scanPrimitives(projectDir).length > 0;
136
+ if (scanPrimitives(projectDir).length > 0) return true;
137
+ if (existsSync(join(projectDir, 'components'))) return true;
138
+ const entitiesDir = join(projectDir, 'entities');
139
+ if (!existsSync(entitiesDir)) return false;
140
+ return readdirSync(entitiesDir).some((name) => {
141
+ try { return existsSync(join(entitiesDir, name, 'components')); } catch { return false; }
142
+ });
132
143
  }
133
144
 
134
145
  // The one cycle shadowing makes possible, caught in the creator's own words.
package/lib/publish.mjs CHANGED
@@ -18,6 +18,7 @@ import { ensureEngine } from './engine.mjs';
18
18
  import { getToken, getApiBase } from './config.mjs';
19
19
  import { DEFAULT_API_BASE } from './llm-shim.mjs';
20
20
  import { bundlePrimitives } from './bundle-primitives.mjs';
21
+ import { scanEntityComponents } from './room-server.mjs';
21
22
  import {
22
23
  BROWSER_REGISTRY,
23
24
  SERVER_BARREL,
@@ -67,6 +68,14 @@ export async function publish({
67
68
  } = {}) {
68
69
  const project = findProject(cwd);
69
70
 
71
+ // Published rooms must hit the surface players see: re-bake any referenced
72
+ // model whose GLB or bake config changed before the game ships. Fails the
73
+ // publish, loudly, rather than deploying a room that shoots at an old shape.
74
+ {
75
+ const { ensureBaked } = await import('./model-bake.mjs');
76
+ await ensureBaked({ dir: project.dir, log });
77
+ }
78
+
70
79
  // A publish is GLOBAL, PERMANENT and IRREVERSIBLE — the first one claims the
71
80
  // name for this account forever. A lane is a throwaway experiment in a folder
72
81
  // named after it, and the slug defaults to the folder name, so a bare publish
@@ -119,13 +128,22 @@ export async function publish({
119
128
  // are killing is an override that uploads, serves, and silently never runs.
120
129
  assertNoInertOverride({ projectDir: project.dir, engine });
121
130
  const serverPrimitives = scanPrimitives(project.dir); // throws on a reserved index.js
122
- if (serverPrimitives.length) {
123
- const { source, externals } = await bundlePrimitives(project.dir);
131
+ const entityComponents = await scanEntityComponents(project.dir, engine.sharedDir); // throws on a malformed component
132
+ if (serverPrimitives.length || entityComponents.length) {
133
+ const { source, externals } = await bundlePrimitives(project.dir, { engineSharedDir: engine.sharedDir });
124
134
  files.set(SERVER_BARREL, Buffer.from(source, 'utf8'));
125
- files.set(BROWSER_REGISTRY, Buffer.from(renderBrowserRegistry(serverPrimitives), 'utf8'));
126
- const names = serverPrimitives.map((p) => p.type).join(', ');
135
+ // The browser prediction registry exists for PRIMITIVES only — entity
136
+ // components' client/shared files ship as ordinary game files, and the
137
+ // client imports its own manifests directly.
138
+ if (serverPrimitives.length) {
139
+ files.set(BROWSER_REGISTRY, Buffer.from(renderBrowserRegistry(serverPrimitives), 'utf8'));
140
+ }
141
+ const names = [
142
+ ...serverPrimitives.map((p) => p.type),
143
+ ...entityComponents.map((c) => `${c.name} (component)`),
144
+ ].join(', ');
127
145
  log(
128
- `Bundled server primitives — ${names} (${(source.length / 1024).toFixed(0)} KB, engine external${externals.length ? `: ${[...new Set(externals)].length} module(s)` : ''}).`,
146
+ `Bundled server code — ${names} (${(source.length / 1024).toFixed(0)} KB, engine external${externals.length ? `: ${[...new Set(externals)].length} module(s)` : ''}).`,
129
147
  );
130
148
  }
131
149
 
@@ -23,8 +23,9 @@
23
23
  // imports EXTERNAL and rewrites them to the release's pre-bundled
24
24
  // `rooms-runtime.js`, because the publish endpoint is a Worker with no bundler.
25
25
  // Same seam, two deliveries.)
26
- import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
27
- import { join } from 'node:path';
26
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
27
+ import { dirname, join, relative } from 'node:path';
28
+ import { pathToFileURL } from 'node:url';
28
29
  import {
29
30
  assertNoOverrideCycle,
30
31
  isServerBacked,
@@ -65,17 +66,76 @@ function engineAliasPlugin(sharedDir) {
65
66
  };
66
67
  }
67
68
 
69
+ // Entity components take the same trip as vendored primitives: scanned from
70
+ // the game tree, bundled, handed to the host — through the sibling
71
+ // `extraEntityComponents()` seam. The room bundle carries each component's
72
+ // MANIFEST (so channel gates are trusted — a world spec arrives from a
73
+ // client and may not define them) and its SIM entry: `server/sim.js` when
74
+ // the component has one, else `shared/sim.js` (predicted sims live in
75
+ // shared/ so the browser can run them too; server/ is for genuinely
76
+ // server-only adjudication and may import shared/ itself).
77
+ //
78
+ // The scan implementation lives in the ENGINE (shared/ui/room/entity/) at
79
+ // the same relative path in the repo and the built artifact. An engine too
80
+ // old to carry it means no entity components exist for that game — skip.
81
+ export async function scanEntityComponents(projectDir, sharedDir) {
82
+ const scanPath = join(sharedDir, 'ui', 'room', 'entity', 'scan.js');
83
+ if (!existsSync(scanPath)) return [];
84
+ const { scanGame } = await import(pathToFileURL(scanPath).href);
85
+ const { components, errors } = scanGame(projectDir);
86
+ if (errors.length) {
87
+ throw new Error(`this game's entity components cannot build a room server:\n - ${errors.join('\n - ')}`);
88
+ }
89
+ const posix = (p) => relative(projectDir, p).split('\\').join('/');
90
+ return [...components.values()].map((c) => {
91
+ const serverSim = join(c.dir, 'server', 'sim.js');
92
+ const sharedSim = join(c.dir, 'shared', 'sim.js');
93
+ const code = existsSync(serverSim) ? serverSim : existsSync(sharedSim) ? sharedSim : null;
94
+ return {
95
+ name: c.name,
96
+ manifestRel: posix(c.manifestPath),
97
+ codeRel: code ? posix(code) : null,
98
+ };
99
+ });
100
+ }
101
+
102
+ export function renderEntityComponents(components) {
103
+ if (!components.length) return 'export const extraEntityComponents = null;';
104
+ const lines = [];
105
+ const manifests = [];
106
+ const code = [];
107
+ components.forEach((c, i) => {
108
+ lines.push(`import entityManifest${i} from './${c.manifestRel}';`);
109
+ manifests.push(`entityManifest${i}`);
110
+ if (c.codeRel) {
111
+ lines.push(`import * as entityCode${i} from './${c.codeRel}';`);
112
+ code.push(` '${c.name}': entityCode${i},`);
113
+ }
114
+ });
115
+ return [
116
+ ...lines,
117
+ 'export const extraEntityComponents = {',
118
+ ` manifests: [${manifests.join(', ')}],`,
119
+ ' code: {',
120
+ ...code,
121
+ ' },',
122
+ '};',
123
+ ].join('\n');
124
+ }
125
+
68
126
  // The room the game actually gets: the engine's room class, subclassed to hand
69
127
  // the host the creator's primitives through the `extraPrimitives()` seam the
70
128
  // engine already reads (server.js). Identical in shape to what the publish
71
129
  // endpoint generates for the per-game Worker — one contract, two builders.
72
- function renderEntry(primitives) {
130
+ function renderEntry(primitives, entityComponents) {
73
131
  return [
74
132
  renderBarrel(primitives),
133
+ renderEntityComponents(entityComponents),
75
134
  "import LooopRoom from '/shared/ui/room/server.js';",
76
135
  '',
77
136
  'export default class GameRoom extends LooopRoom {',
78
137
  ' extraPrimitives() { return extraPrimitives; }',
138
+ ' extraEntityComponents() { return extraEntityComponents; }',
79
139
  '}',
80
140
  '',
81
141
  ].join('\n');
@@ -88,7 +148,8 @@ function renderEntry(primitives) {
88
148
  // dev must not quietly serve a room that is missing the creator's code.
89
149
  export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
90
150
  const primitives = scanPrimitives(projectDir); // throws on a reserved index.js
91
- if (!primitives.length) {
151
+ const entityComponents = await scanEntityComponents(projectDir, engine.sharedDir); // throws on a malformed component
152
+ if (!primitives.length && !entityComponents.length) {
92
153
  // Not server-backed (any more). A PREVIOUS run may have generated a room
93
154
  // server and a registry shadow; both must go, or dev keeps mounting a stale
94
155
  // `.looop/overrides/shared/.../index.js` that imports a primitive the creator
@@ -109,7 +170,7 @@ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
109
170
  // against the game root, so `./overrides/...` means what it says and no
110
171
  // machine-specific absolute path can leak into the output.
111
172
  stdin: {
112
- contents: renderEntry(primitives),
173
+ contents: renderEntry(primitives, entityComponents),
113
174
  resolveDir: projectDir,
114
175
  sourcefile: 'looop-room-server.js',
115
176
  loader: 'js',
@@ -156,9 +217,15 @@ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
156
217
  // same in dev (static mount fall-through) and in production (the import map):
157
218
  // /shared/ui/room/primitives/engine.js → not shadowed → the engine's list
158
219
  // /shared/ui/room/primitives/beacon.js → shadowed → the game's primitive
159
- const registryDir = join(projectDir, DEV_OVERRIDES_DIR, 'ui/room/primitives');
160
- mkdirSync(registryDir, { recursive: true });
161
- writeFileSync(join(registryDir, 'index.js'), renderBrowserRegistry(primitives));
220
+ if (primitives.length) {
221
+ const registryDir = join(projectDir, DEV_OVERRIDES_DIR, 'ui/room/primitives');
222
+ mkdirSync(registryDir, { recursive: true });
223
+ writeFileSync(join(registryDir, 'index.js'), renderBrowserRegistry(primitives));
224
+ } else {
225
+ // Entity components alone need no primitive-registry shadow — and a stale
226
+ // one from a removed primitive must not stay mounted.
227
+ rmSync(join(projectDir, DEV_OVERRIDES_DIR), { recursive: true, force: true });
228
+ }
162
229
 
163
230
  return outDir;
164
231
  }
package/lib/test-cmd.mjs CHANGED
@@ -63,6 +63,14 @@ const SMOKE_PRELOAD = new URL('./smoke-gpu-preload.mjs', import.meta.url).href;
63
63
 
64
64
  export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev, lintFn = lintCmd, runFn = run, patterns = [] } = {}) {
65
65
  const project = findProject(cwd);
66
+
67
+ // Freshness gate: referenced 3D models re-bake if their GLB or bake config
68
+ // changed, BEFORE anything runs — a stale artifact means every hit test in
69
+ // the suite exercises a surface the player no longer sees. A bake failure
70
+ // fails the gate with the model named.
71
+ const { ensureBaked } = await import('./model-bake.mjs');
72
+ await ensureBaked({ dir: project.dir, log });
73
+
66
74
  const all = discoverTestFiles(project.dir);
67
75
 
68
76
  // Scoped run (`looop test <pattern>…`): keep only files whose path contains a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",