@looop-games/cli 0.1.18 → 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,25 @@ 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
+
17
36
  ## [0.1.18] - 2026-07-15
18
37
 
19
38
  ### 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;
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
@@ -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
+ }
package/lib/publish.mjs CHANGED
@@ -68,6 +68,14 @@ export async function publish({
68
68
  } = {}) {
69
69
  const project = findProject(cwd);
70
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
+
71
79
  // A publish is GLOBAL, PERMANENT and IRREVERSIBLE — the first one claims the
72
80
  // name for this account forever. A lane is a throwaway experiment in a folder
73
81
  // named after it, and the slug defaults to the folder name, so a bare publish
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.18",
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",