@homie-rocks/studio 0.30.2 → 0.31.1

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +204 -124
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
@@ -11,7 +11,7 @@
11
11
  * skeleton shares it
12
12
  *
13
13
  * Both are recorded in assets/manifest.json (the character, kind character or creature; the library, kind clip) with
14
- * where every clip came from and its licence, so RIGHTS.md, the remix carry-over and `assets check` see them. The model
14
+ * where every clip came from and its licence, so RIGHTS.md and `assets check` see them. The model
15
15
  * names its clip library in its scene's extras (homie.anims), so the game's `loadCharacter(models, url)` finds it.
16
16
  *
17
17
  * addCharacter(root, game, { item | file, ... }) in, optimised, clips baked, recorded
@@ -24,8 +24,8 @@ import { createHash } from 'node:crypto';
24
24
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
25
  import { dirname, join, resolve } from 'node:path';
26
26
  import { LIMITS, checkGlb } from '../assets/safety.mjs';
27
- import { readManifest, recordAsset } from './asset-manifest.mjs';
28
- import { VERBS, VERB_IDS, bakeLibrary, findClip, rigOf, writeLibrary } from './clips.mjs';
27
+ import { readManifest, recordAsset, writeManifest } from './asset-manifest.mjs';
28
+ import { VERBS, VERB_IDS, bakeLibrary, findClip, gaitOf, rigOf, writeLibrary } from './clips.mjs';
29
29
  import { readDecisions } from './decisions.mjs';
30
30
  import { FAMILIES, normaliseRig, skeletonOf, socketBone } from './rig.mjs';
31
31
  import { GAME_ID } from './studio.mjs';
@@ -65,7 +65,7 @@ async function docOf(input) {
65
65
  /** A clip source (a library item id, or a file): its Document renamed to the standard, and its clip names. */
66
66
  async function clipSource(from, { lib = null, cache = new Map() } = {}) {
67
67
  if (cache.has(from)) return cache.get(from);
68
- let bytes;
68
+ let bytes; let pack = null;
69
69
  if (existsSync(resolve(String(from)))) bytes = readFileSync(resolve(String(from)));
70
70
  else {
71
71
  const { fetchItemFile, libraryBase, loadIndex } = await import('./library.mjs');
@@ -75,10 +75,14 @@ async function clipSource(from, { lib = null, cache = new Map() } = {}) {
75
75
  if (!item) throw new Error(`no library item "${from}" to take clips from`);
76
76
  if (item.license !== 'cc0') throw new Error(`${from} is not CC0`);
77
77
  ({ bytes } = await fetchItemFile(L, item));
78
+ // Whose clips these are, kept with the clip library: the credit for them must not depend on a model of the same
79
+ // pack being in the game (a generated hero borrows the knight's clips and never has a knight).
80
+ const p = index.packs?.find((x) => x.id === item.pack);
81
+ pack = { id: item.pack, label: p?.label ?? item.pack, author: item.author ?? null, url: p?.url ?? item.origin ?? null };
78
82
  }
79
83
  const { doc } = await docOf(bytes);
80
84
  normaliseRig(doc, { merge: false, helpers: false, takeClips: false });
81
- const src = { from: String(from), doc, rig: rigOf(doc), names: doc.getRoot().listAnimations().map((a) => a.getName()) };
85
+ const src = { from: String(from), pack, doc, rig: rigOf(doc), names: doc.getRoot().listAnimations().map((a) => a.getName()) };
82
86
  cache.set(from, src);
83
87
  return src;
84
88
  }
@@ -98,7 +102,7 @@ async function pickSources(own, verbs, fallback, opts) {
98
102
  if (theirs) sources.push({ rig: fb.rig, anim: fb.doc.getRoot().listAnimations().find((a) => a.getName() === theirs), verb, from: fb.from });
99
103
  else missing.push(verb);
100
104
  }
101
- return { sources, missing, fallbackError: fb?.error ?? null };
105
+ return { sources, missing, fallbackError: fb?.error ?? null, packs: [own?.pack, fb?.pack].filter(Boolean) };
102
106
  }
103
107
 
104
108
  /** A clip library file's path in a game, for a skeleton. */
@@ -147,7 +151,7 @@ async function bakeFor(root, game, rig, own, verbs, { from = null, lib = null, c
147
151
  const fallback = from ?? CLIP_SOURCES[skel.family] ?? null;
148
152
  const existing = await existingLibrary(gdir, skel.id);
149
153
  const todo = verbs.filter((v) => !existing?.verbs.includes(v));
150
- const { sources, missing, fallbackError } = await pickSources(own, todo, fallback, { lib, cache });
154
+ const { sources, missing, fallbackError, packs: sourcePacks } = await pickSources(own, todo, fallback, { lib, cache });
151
155
  const t = await tools();
152
156
  const rel = animsPath(skel.id);
153
157
  const manifest = readManifest(root, game);
@@ -164,12 +168,16 @@ async function bakeFor(root, game, rig, own, verbs, { from = null, lib = null, c
164
168
  writeFileSync(join(gdir, rel), out.bytes);
165
169
  const allClips = [...(prev?.clips ?? []).filter((c) => !clips.some((n) => n.verb === c.verb)), ...clips].sort((a, b) => VERB_IDS.indexOf(a.verb) - VERB_IDS.indexOf(b.verb));
166
170
  const froms = [...new Set(allClips.map((c) => c.from).filter(Boolean))];
171
+ const knownPacks = { ...(prev?.from?.packs ?? {}) };
172
+ for (const p of sourcePacks ?? []) if (froms.some((f) => String(f).startsWith(`${p.id}/`))) knownPacks[p.id] = { label: p.label, author: p.author, url: p.url };
167
173
  // Clips taken from CC0 sources (KayKit, Kenney) stay CC0 when retargeted; a character's own clips carry its licence.
168
- const lic = license && license.kind !== 'cc0' && allClips.some((c) => c.from && !String(c.from).includes('/')) ? license : { kind: 'cc0', spdx: 'CC0-1.0', owner: null, attribution: null, remix: 'include', notes: `Clips from ${froms.join(', ') || 'the character itself'} (CC0), ${allClips.some((c) => c.retargeted) ? 'retargeted onto this skeleton at build time' : 'as they came'}.` };
174
+ const lic = license && license.kind !== 'cc0' && allClips.some((c) => c.from && !String(c.from).includes('/')) ? license : { kind: 'cc0', spdx: 'CC0-1.0', owner: null, attribution: null, notes: `Clips from ${froms.join(', ') || 'the character itself'} (CC0), ${allClips.some((c) => c.retargeted) ? 'retargeted onto this skeleton at build time' : 'as they came'}.` };
169
175
  const entry = {
170
176
  id: entryId, kind: 'clip', tier: 'clip', route: allClips.every((c) => !c.from || String(c.from).includes('/')) ? 'library' : 'imported',
171
177
  card: null,
172
- from: froms.length ? { library: 'homie-starter', items: froms } : null,
178
+ // `packs`: each source pack's name, author and page, kept across every later bake (and written by `assets remove`
179
+ // when a pack's last model leaves), so credits.json can credit the animation for as long as the clips stay.
180
+ from: froms.length ? { library: 'homie-starter', items: froms, ...(Object.keys(knownPacks).length ? { packs: knownPacks } : {}) } : null,
173
181
  files: [{ role: 'clip', path: rel, bytes: out.bytes.length, sha256: sha256(out.bytes) }],
174
182
  rig: { family: skel.family, skeleton: skel.id, fingerprint: skel.fingerprint },
175
183
  clips: allClips,
@@ -226,7 +234,8 @@ export async function addCharacter(root, game, opts = {}) {
226
234
  const raw = skeletonOf(ownDoc);
227
235
  if (raw.family === 'none') throw new Error(`${item?.id ?? opts.file} has no skeleton and no moving parts: add it as a prop (assets add without a rig), or rig it first (the animate guide)`);
228
236
  normaliseRig(ownDoc, { merge: false, helpers: false, takeClips: false });
229
- const own = ownDoc.getRoot().listAnimations().length ? { from: item?.id ?? 'own', doc: ownDoc, rig: rigOf(ownDoc), names: ownDoc.getRoot().listAnimations().map((a) => a.getName()) } : null;
237
+ const ownPack = item ? { id: item.pack, label: idx?.packs?.find((p) => p.id === item.pack)?.label ?? item.pack, author: item.author ?? null, url: idx?.packs?.find((p) => p.id === item.pack)?.url ?? item.origin ?? null } : null;
238
+ const own = ownDoc.getRoot().listAnimations().length ? { from: item?.id ?? 'own', pack: ownPack, doc: ownDoc, rig: rigOf(ownDoc), names: ownDoc.getRoot().listAnimations().map((a) => a.getName()) } : null;
230
239
 
231
240
  // The character as shipped: renamed, helpers out, merged, clips out.
232
241
  const { doc } = await docOf(bytes);
@@ -258,7 +267,7 @@ export async function addCharacter(root, game, opts = {}) {
258
267
  { what: 'optimise', tool: r.tool, ops: r.ops },
259
268
  ];
260
269
  const license = item
261
- ? { kind: 'cc0', spdx: 'CC0-1.0', owner: item.author ?? null, attribution: null, remix: 'include', notes: `From the Homie starter library (${item.pack}); the pack's own licence text: ${idx?.packs?.find((p) => p.id === item.pack)?.licenseFile ?? 'licenses/'}` }
270
+ ? { kind: 'cc0', spdx: 'CC0-1.0', owner: item.author ?? null, attribution: null, notes: `From the Homie starter library (${item.pack}); the pack's own licence text: ${idx?.packs?.find((p) => p.id === item.pack)?.licenseFile ?? 'licenses/'}` }
262
271
  : { kind: String(opts.license ?? ''), owner: opts.owner ?? (opts.license === 'generated' || opts.license === 'own' ? 'studio' : null), attribution: opts.attribution ?? null, notes: opts.notes ?? (opts.license === 'generated' ? 'Made through fal on the studio\'s own account (a concept image, then Meshy image-to-3D with its auto-rig); its clips are the starter library\'s CC0 clips retargeted onto its skeleton. fal\'s terms do not assign output ownership expressly (RIGHTS.md).' : null) };
263
272
  const entry = {
264
273
  id, kind, tier: kind === 'creature' ? 'npc' : 'hero', card: opts.card ?? null, route: item ? 'library' : (opts.route === 'generated' ? 'generated' : 'imported'),
@@ -271,7 +280,8 @@ export async function addCharacter(root, game, opts = {}) {
271
280
  review: { state: 'auto' },
272
281
  };
273
282
  const { entry: e, pinned } = recordAsset(root, game, entry);
274
- return { ok: true, command: 'assets add', game, asset: e.id, item: item?.id ?? null, route: e.route, raw: opts.rawPath ?? null, license: e.license.kind, model: rel, anims: anims.path, skeleton: rig.skeleton.id, family: rig.skeleton.family, verbs: anims.verbs, missing: anims.missing, clipsFrom: anims.clips.filter((c) => c.retargeted).map((c) => c.from).filter((v, i, a) => a.indexOf(v) === i), merged: rigged.merged, dropped: rigged.dropped, removedHelpers: rigged.removedHelpers.length, before: { tris: r.before.triangles, kb: kb(r.rawBytes), drawCalls: r.before.drawCalls }, after: { tris: after.triangles, kb: kb(r.bytes), heightM: after.heightM, drawCalls: after.drawCalls, bones: after.bones, animsKB: anims.bytes ? kb(anims.bytes) : null }, pinned, warnings: r.warnings, fallbackError: anims.fallbackError };
283
+ const gait = await recordGait(root, game, e.id).catch(() => null);
284
+ return { ok: true, command: 'assets add', game, asset: e.id, gait, item: item?.id ?? null, route: e.route, raw: opts.rawPath ?? null, license: e.license.kind, model: rel, anims: anims.path, skeleton: rig.skeleton.id, family: rig.skeleton.family, verbs: anims.verbs, missing: anims.missing, clipsFrom: anims.clips.filter((c) => c.retargeted).map((c) => c.from).filter((v, i, a) => a.indexOf(v) === i), merged: rigged.merged, dropped: rigged.dropped, removedHelpers: rigged.removedHelpers.length, before: { tris: r.before.triangles, kb: kb(r.rawBytes), drawCalls: r.before.drawCalls }, after: { tris: after.triangles, kb: kb(r.bytes), heightM: after.heightM, drawCalls: after.drawCalls, bones: after.bones, animsKB: anims.bytes ? kb(anims.bytes) : null }, pinned, warnings: r.warnings, fallbackError: anims.fallbackError };
275
285
  }
276
286
 
277
287
  /** Whether a library item is a character with a rig or moving parts (assets add sends those here). */
@@ -299,8 +309,91 @@ export async function bakeClips(root, game, asset, { verbs = [], from = null, li
299
309
  // The character's own record learns its verbs.
300
310
  const fresh = readManifest(root, game);
301
311
  const me = fresh.assets.find((x) => x.id === asset);
302
- if (me?.rig) { me.rig.verbs = r.verbs; me.measured = { ...(me.measured ?? {}), clips: r.verbs.length }; const { writeManifest } = await import('./asset-manifest.mjs'); writeManifest(root, game, fresh); }
303
- return { ok: true, command: 'anim add', game, asset, skeleton: r.skeleton, anims: r.path, verbs: r.verbs, added: want.filter((v) => r.verbs.includes(v)), missing: r.missing, kb: r.bytes ? kb(r.bytes) : null, why: r.fallbackError };
312
+ if (me?.rig) { me.rig.verbs = r.verbs; me.measured = { ...(me.measured ?? {}), clips: r.verbs.length }; writeManifest(root, game, fresh); }
313
+ const gait = await recordGait(root, game, asset).catch(() => null);
314
+ return { ok: true, command: 'anim add', game, asset, gait, skeleton: r.skeleton, anims: r.path, verbs: r.verbs, added: want.filter((v) => r.verbs.includes(v)), missing: r.missing, kb: r.bytes ? kb(r.bytes) : null, why: r.fallbackError };
315
+ }
316
+
317
+ const clipFile = (x) => (x?.files ?? []).find((f) => f.role === 'clip')?.path ?? null;
318
+ const clipVerbs = (x) => x?.clips?.map((c) => c.verb) ?? x?.measured?.verbs ?? [];
319
+
320
+ /**
321
+ * A character's clip libraries, from the manifest: { primary, supplemental, verbs, files, clips }.
322
+ *
323
+ * primary the clip record whose file the MODEL names (rig.anims), which is what the game loads. Only when no
324
+ * record has that file: the first library of its skeleton that is not declared supplemental.
325
+ * supplemental libraries of the same skeleton that say so ("rig": { "supplemental": true }): more verbs beside the
326
+ * base, which the game loads itself (loadCharacter(models, url, { anims: [base, extra] })).
327
+ *
328
+ * It used to be "the last clip record read for that skeleton": a second library of three verbs then stood in for the
329
+ * first, every original verb read as missing and the previews drew nothing. A second library that is neither named by
330
+ * the model nor declared is not in anyone's coverage (duplicateLibraries names it).
331
+ */
332
+ export function librariesFor(manifest, a) {
333
+ const all = manifest.assets.filter((x) => x.kind === 'clip' && x.rig?.skeleton && x.rig.skeleton === a?.rig?.skeleton);
334
+ const primary = all.find((x) => a.rig?.anims && clipFile(x) === a.rig.anims) ?? all.find((x) => x.rig?.supplemental !== true) ?? null;
335
+ const supplemental = all.filter((x) => x !== primary && x.rig?.supplemental === true);
336
+ const clips = [...(primary?.clips ?? []), ...supplemental.flatMap((x) => (x.clips ?? []).filter((c) => !(primary?.clips ?? []).some((p) => p.verb === c.verb)))];
337
+ const verbs = [...new Set([...(primary?.clips ? clipVerbs(primary) : a?.rig?.verbs ?? clipVerbs(primary)), ...supplemental.flatMap(clipVerbs)])];
338
+ return { primary, supplemental, verbs, clips, files: [a?.rig?.anims ?? clipFile(primary), ...supplemental.map(clipFile)].filter(Boolean) };
339
+ }
340
+
341
+ /**
342
+ * Clip records that are a SECOND library of a skeleton without saying what they are: Map(asset id -> what to do).
343
+ * `recordAsset` refuses one at registration; a manifest edited by hand can still hold one, and the check says so.
344
+ */
345
+ export function duplicateLibraries(manifest) {
346
+ const out = new Map();
347
+ const named = new Set(manifest.assets.map((a) => a.rig?.anims).filter(Boolean));
348
+ const bySkeleton = new Map();
349
+ for (const x of manifest.assets) if (x.kind === 'clip' && x.rig?.skeleton && x.rig.supplemental !== true) bySkeleton.set(x.rig.skeleton, [...(bySkeleton.get(x.rig.skeleton) ?? []), x]);
350
+ for (const [skeleton, list] of bySkeleton) {
351
+ if (list.length < 2) continue;
352
+ const keep = list.find((x) => named.has(clipFile(x))) ?? list.find((x) => clipFile(x) === animsPath(skeleton)) ?? list[0];
353
+ for (const x of list) if (x !== keep) out.set(x.id, `a second clip library for the skeleton ${skeleton} (its characters name ${keep.id}'s file): these verbs are in no character's coverage and no preview. Merge them into ${keep.id} (homie-studio anim add <game> <character> --verbs ${clipVerbs(x).join(',') || '…'} --from <this file>), or declare it ("rig": { "supplemental": true }) and load it in the game: loadCharacter(models, url, { anims: [<base>, <this>] })`);
354
+ }
355
+ return out;
356
+ }
357
+
358
+ /** A looping locomotion verb's default ground speed in animate (assets/animate.ts ANIM_TUNING), to compare a rig's against. */
359
+ const DEFAULT_SPEED = { walk: 1.5, run: 4.2 };
360
+
361
+ /**
362
+ * How fast a character's walk and run clips move its feet over the ground, measured on ITS rig (the shipped model,
363
+ * so in metres) from its clip libraries: { walk, run: { mps, seconds, default, feet }, method, note } or null when
364
+ * neither could be read. A heuristic (lib/clips.mjs gaitOf), cheap enough to run on every bake.
365
+ */
366
+ export async function measureGait(root, game, assetId) {
367
+ const m = readManifest(root, game);
368
+ const a = m.assets.find((x) => x.id === assetId);
369
+ const model = (a?.files ?? []).find((f) => f.role === 'model');
370
+ if (!a?.rig || !model) return null;
371
+ const gdir = join(root, 'games', game);
372
+ const t = await tools();
373
+ const read = async (rel) => { const d = await t.io.readBinary(readFileSync(join(gdir, rel))); d.setLogger(t.logger); return d; };
374
+ const rig = rigOf(await read(model.path));
375
+ const anims = [];
376
+ for (const f of librariesFor(m, a).files) if (existsSync(join(gdir, f))) for (const x of (await read(f)).getRoot().listAnimations()) if (!anims.some((y) => y.getName() === x.getName())) anims.push(x);
377
+ const out = {};
378
+ for (const verb of ['walk', 'run']) {
379
+ const anim = anims.find((x) => x.getName() === verb);
380
+ const g = anim ? gaitOf(rig, anim) : null;
381
+ if (g) out[verb] = { mps: g.mps, seconds: g.seconds, feet: g.feet, default: DEFAULT_SPEED[verb] };
382
+ }
383
+ if (!Object.keys(out).length) return null;
384
+ return { ...out, method: 'foot bones while down (lowest quarter of each foot\'s height), horizontal speed, averaged', note: 'a guide to start tuning walkSpeed and runSpeed from on this rig; not a verified foot contact' };
385
+ }
386
+
387
+ /** measureGait, kept on the character's own record (`rig.gait`): the same clips move a taller rig's feet further. */
388
+ export async function recordGait(root, game, assetId) {
389
+ const gait = await measureGait(root, game, assetId);
390
+ const m = readManifest(root, game);
391
+ const a = m.assets.find((x) => x.id === assetId);
392
+ if (!a?.rig) return gait;
393
+ if (JSON.stringify(a.rig.gait ?? null) === JSON.stringify(gait)) return gait;
394
+ if (gait) a.rig.gait = gait; else delete a.rig.gait;
395
+ writeManifest(root, game, m);
396
+ return gait;
304
397
  }
305
398
 
306
399
  /** Each character, its skeleton and its clips against the verbs the game needs. */
@@ -308,16 +401,18 @@ export function animPlan(root, game) {
308
401
  const m = readManifest(root, game);
309
402
  const doc = readDecisions(root, game);
310
403
  const need = verbsFor(doc);
311
- const libs = new Map(m.assets.filter((a) => a.kind === 'clip').map((a) => [a.rig?.skeleton, a]));
312
404
  const rows = m.assets.filter((a) => (a.kind === 'character' || a.kind === 'creature') && a.rig).map((a) => {
313
- const L = libs.get(a.rig.skeleton);
314
- const have = L?.clips?.map((c) => c.verb) ?? a.rig.verbs ?? [];
405
+ const libs = librariesFor(m, a);
406
+ const L = libs.primary ? { ...libs.primary, clips: libs.clips } : null;
407
+ const have = libs.verbs;
315
408
  return {
316
409
  id: a.id, kind: a.kind, route: a.route, family: a.rig.family, familyLabel: FAMILIES[a.rig.family]?.label ?? a.rig.family, skeleton: a.rig.skeleton, bones: a.rig.bones, anims: a.rig.anims,
410
+ // Every library file its clips are in (its own first, then any declared supplemental), and its measured gait.
411
+ libraries: libs.files, supplemental: libs.supplemental.map((x) => x.id), gait: a.rig.gait ?? null,
317
412
  clips: need.map((v) => { const c = L?.clips?.find((x) => x.verb === v); return { verb: v, note: VERBS[v]?.note ?? '', loop: Boolean(VERBS[v]?.loop), have: have.includes(v), source: c?.source ?? null, from: c?.from ?? null, retargeted: Boolean(c?.retargeted), seconds: c?.duration ?? null }; }),
318
413
  extra: have.filter((v) => !need.includes(v)),
319
414
  missing: need.filter((v) => !have.includes(v)),
320
- animsKB: L?.measured?.kb ?? null,
415
+ animsKB: libs.primary?.measured?.kb ?? null,
321
416
  };
322
417
  });
323
418
  const unrigged = m.assets.filter((a) => (a.kind === 'character' || a.kind === 'creature') && !a.rig).map((a) => a.id);
@@ -356,7 +451,9 @@ export async function animPreview(root, game, { asset = null, verbs = null, size
356
451
  const animsFile = join(gdir, row.anims);
357
452
  if (!existsSync(animsFile)) { out.push({ id: row.id, verbs: [], why: `${row.anims} is missing` }); continue; }
358
453
  const want = verbs ?? row.clips.filter((c) => c.have).map((c) => c.verb).concat(row.extra);
359
- const res = await r.frames(modelIn(row.id, model, { anims: readFileSync(animsFile).toString('base64') }), tokens, { verbs: want, count, size, sheet: true, loops: want.filter((v) => VERBS[v]?.loop) });
454
+ // Its own library, then every declared supplemental one that is on disk: all of them are in the preview.
455
+ const libraries = [row.anims, ...(row.libraries ?? []).filter((f) => f !== row.anims)].filter((f) => existsSync(join(gdir, f)));
456
+ const res = await r.frames(modelIn(row.id, model, { anims: libraries.map((f) => readFileSync(join(gdir, f)).toString('base64')) }), tokens, { verbs: want, count, size, sheet: true, loops: want.filter((v) => VERBS[v]?.loop) });
360
457
  const files = [];
361
458
  for (const v of res.verbs) {
362
459
  const file = join(dir, `${row.id}-${v.verb}.webp`);
@@ -370,7 +467,9 @@ export async function animPreview(root, game, { asset = null, verbs = null, size
370
467
  }
371
468
  let sheet = null;
372
469
  if (res.sheet) { sheet = join(dir, `${row.id}-sheet.jpg`); writeFileSync(sheet, dataUrlBytes(res.sheet).bytes); sheet = sheet.slice(root.length + 1); }
373
- out.push({ id: row.id, verbs: files, sheet, missing: res.missing });
470
+ // The ground speed its walk and run cover on this rig, measured again here and kept on its record.
471
+ const gait = await recordGait(root, game, row.id).catch(() => null);
472
+ out.push({ id: row.id, verbs: files, sheet, missing: res.missing, libraries, gait });
374
473
  log(`${row.id}: ${files.length} preview(s)`);
375
474
  }
376
475
  }, { log });
package/lib/check.mjs CHANGED
@@ -15,11 +15,20 @@
15
15
  * (up to `rounds` rounds). A person the game lists as a bot while it keeps their seat (an idle player driven by
16
16
  * the game's autopilot) is still that person. When no round counts, the result says which seat was missing from
17
17
  * each finished round and whether that browser lost its connection, instead of only "no round finished".
18
+ *
19
+ * THREE THINGS THAT ARE NOT THE SAME, each reported on its own:
20
+ * seated the shell has a room, a seat and a role (`seats[].seatedMs`)
21
+ * ready the play page's loading cover has lifted, so the game is what is on screen (`readiness[]`, from the
22
+ * page's `window.__shell.arrival`). A seated browser drawing 60 frames a second can still be showing
23
+ * "Loading the world…": the "playing" picture is only taken, and only named so, once the cover is gone.
24
+ * connected the socket held from the seat to the results (`connection[]`, `uninterrupted`). A round that both
25
+ * browsers finished is `ok`; whether one of them reconnected on the way is said beside it, not folded in.
18
26
  */
19
27
  import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
20
28
  import { cpus, loadavg, tmpdir } from 'node:os';
21
29
  import { join } from 'node:path';
22
30
  import { SOFTWARE_GL, chromeArgs, findChrome, measureFrames, noChrome } from './chrome.mjs';
31
+ import { reachSite, siteRefusal } from './net.mjs';
23
32
 
24
33
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
34
 
@@ -36,6 +45,68 @@ export const CHECK_STEPS = [
36
45
 
37
46
  class Stopped extends Error {}
38
47
 
48
+ /** How long a seated browser is given for its loading cover to lift (the shell lifts it itself 30 s after the page opened). */
49
+ export const READY_TIMEOUT_MS = 35_000;
50
+
51
+ /**
52
+ * Is the game on screen? `arrival` is the play page's `window.__shell.arrival` ({ phase, liftedMs, by }) or null.
53
+ * ready: true the cover lifted (`by`: 'game' when the game said it was playable, else the helper or the shell)
54
+ * ready: false still covered after the wait: a picture taken now shows the loading card, not the game
55
+ * ready: null this page reports no arrival state (an older shell, `?arrive=0`): not known, and said so
56
+ */
57
+ export function arrivalReadiness(arrival, { waitedMs = null } = {}) {
58
+ if (!arrival || typeof arrival !== 'object') return { ready: null, by: null, liftedMs: null, waitedMs, why: 'this page reports no arrival state: whether the game was on screen is not known' };
59
+ if (arrival.phase === 'done') return { ready: true, by: arrival.by ?? null, liftedMs: Number.isFinite(arrival.liftedMs) ? arrival.liftedMs : null, waitedMs };
60
+ return { ready: false, by: null, liftedMs: null, waitedMs, why: `the loading cover was still up${waitedMs !== null ? ` ${Math.round(waitedMs / 1000)} s after the seat` : ''}${arrival.step ? ` ("${String(arrival.step).slice(0, 60)}")` : ''}: seated and drawing is not ready` };
61
+ }
62
+
63
+ /*
64
+ * THE LINK (netplay revision 9, NETPLAY.md section 22). The helper says where a browser stands with its room in one
65
+ * word: `window.__homieNet.link` in the game's frame, and the play page keeps the last change it was told as
66
+ * `window.__shell.link` = { state, why, hosting }. (`__shell.link` is ALSO the room's invite address, a string,
67
+ * until the first change arrives: `linkOf` reads either and takes only a state it knows.)
68
+ *
69
+ * A browser that is `reconnecting`, `alone`, `offline` or `closed` is not in its room: whatever is sampled there (a
70
+ * frame, a press, a round that "finished" on a browser hosting by itself) is not two people playing together, and
71
+ * is labelled, never judged as play.
72
+ */
73
+ export const LINK_STATES = ['connecting', 'online', 'reconnecting', 'alone', 'offline', 'closed'];
74
+ export const CUT_OFF = ['reconnecting', 'alone', 'offline', 'closed'];
75
+ /** The link's state from whatever a page gave (the helper's word, the shell's { state }), or null: not said. */
76
+ export function linkOf(v) {
77
+ const state = typeof v === 'string' ? v : v && typeof v === 'object' ? v.state : null;
78
+ return LINK_STATES.includes(state) ? state : null;
79
+ }
80
+
81
+ /**
82
+ * The arrival's facts for a probe, from the play page's `window.__shell.arrival`: who was to say the game is
83
+ * playable (`mode`: 'auto' the helper, 'game' the game itself, 'seat' an older helper), who gave it the screen
84
+ * (`by`), when the cover lifted, and when the GAME ITSELF said it was ready (`explicitMs`, the page's clock; null:
85
+ * it never did), with how long after an automatic lift that was (`lateMs`). Null when the page reports none.
86
+ */
87
+ export function arrivalFacts(arrival) {
88
+ if (!arrival || typeof arrival !== 'object') return null;
89
+ const n = (v) => (Number.isFinite(v) ? Math.round(v) : null);
90
+ return { mode: ['auto', 'game', 'seat'].includes(arrival.mode) ? arrival.mode : null, by: typeof arrival.by === 'string' ? arrival.by : null, liftedMs: n(arrival.liftedMs), explicitMs: n(arrival.explicitMs), lateMs: n(arrival.lateMs) };
91
+ }
92
+
93
+ /**
94
+ * Completion and connection, apart. `connection`: [{ browser, reconnects (null when the page did not say) }].
95
+ * Returns { reconnects, uninterrupted: true | false | null, note }: one run establishes no cause either way.
96
+ */
97
+ export function connectionSummary(connection) {
98
+ const rows = Array.isArray(connection) ? connection : [];
99
+ const dropped = rows.filter((c) => c.reconnects > 0);
100
+ const unknown = rows.filter((c) => !Number.isFinite(c.reconnects));
101
+ // The link states a browser was seen in besides `online` (`cutOff`): a browser seen knocking again or playing
102
+ // alone was interrupted whether or not its counter moved (and when an older page reports no counter at all).
103
+ const cut = rows.filter((c) => (c.cutOff ?? []).length && !(c.reconnects > 0));
104
+ const cutNote = cut.map((c) => `the ${c.browser} was seen cut off from the room (${c.cutOff.join(', ')})`);
105
+ if (dropped.length || cut.length) return { reconnects: dropped.length ? dropped.reduce((s, c) => s + c.reconnects, 0) : null, uninterrupted: false, note: `${[...dropped.map((c) => `the ${c.browser} reconnected ${c.reconnects} time${c.reconnects === 1 ? '' : 's'}${(c.cutOff ?? []).length ? ` (seen ${c.cutOff.join(', ')})` : ''}`), ...cutNote].join(', ')} during the check: the round finished, the connection did not hold throughout. Run it again; one run does not say why` };
106
+ if (!rows.length || unknown.length) return { reconnects: null, uninterrupted: null, note: `reconnects were not reported${unknown.length ? ` by the ${unknown.map((c) => c.browser).join(' and the ')}` : ''}: whether the connection held is not known` };
107
+ return { reconnects: 0, uninterrupted: true, note: 'no reconnects: both connections held from the seat to the results' };
108
+ }
109
+
39
110
  /**
40
111
  * WHICH FINISHED ROUND PROVES THE TWO BROWSERS PLAYED TOGETHER. `browsers`: each `{ kind, seats, seen }`, where
41
112
  * `seats` is every seat it held (a Set) and `seen` the rounds it saw finish after both were seated (a Map from
@@ -63,6 +134,13 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
63
134
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
64
135
  const base = String(url).replace(/\/+$/, '');
65
136
  const play = `${base}/${game}/play`;
137
+ // One preflight from this process, in the same words as perf, shoot and port check (lib/net.mjs siteRefusal).
138
+ // Nothing listening on this computer stops here. Anything else this process was told (a name it cannot look up,
139
+ // no connection, an error answer) does NOT: a browser may still open a site Node.js cannot (its own DNS, a
140
+ // firewall that answers a script and a browser differently), and this check drives browsers. If they cannot open
141
+ // it either, that refusal (for a lookup: BLOCKED, this computer's network, local testing offered) is the report.
142
+ const cannotAsk = siteRefusal(await reachSite(base, { path: `/${game}/play`, timeout: 15_000 }), { command: 'check', play });
143
+ if (cannotAsk?.preflight === 'local') return cannotAsk;
66
144
  if (shots) mkdirSync(shots, { recursive: true });
67
145
  const profiles = [];
68
146
  const browsers = [];
@@ -105,7 +183,10 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
105
183
  const ua = await browser.userAgent();
106
184
  await page.setUserAgent(`${ua} ${userAgentTag}`);
107
185
  const t0 = Date.now();
108
- await page.goto(play, { waitUntil: 'domcontentloaded', timeout: 90_000 });
186
+ try { await page.goto(play, { waitUntil: 'domcontentloaded', timeout: 90_000 }); } catch (error) {
187
+ if (cannotAsk) return { ...cannotAsk, play, why: `${cannotAsk.why} (The ${kind.name} browser could not open it either: ${String(error?.message ?? error).split('\n')[0].slice(0, 120)}.)` };
188
+ throw error;
189
+ }
109
190
  players.push({ kind: kind.name, page, t0 });
110
191
  log(`${kind.name} opened ${play}`);
111
192
  }
@@ -150,7 +231,24 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
150
231
  if (report?.stopped?.()) break;
151
232
  }
152
233
  })();
153
- if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-playing.png`) }).catch(() => {});
234
+ // Ready is not seated: wait for each page's loading cover to lift before the picture called "playing". A browser
235
+ // still covered when the wait ends gets its picture under another name (-loading.png), so nobody reads a loading
236
+ // card as gameplay, and `readiness` says so. The check's verdict stays about seats and the round.
237
+ const readiness = await Promise.all(players.map(async (p, i) => {
238
+ const t = Date.now(); let arrival = null;
239
+ const read = () => p.page.evaluate(() => { const a = window.__shell?.arrival; return a ? { phase: a.phase, step: a.step, liftedMs: a.liftedMs, by: a.by } : null; }).catch(() => null);
240
+ while (Date.now() - t < READY_TIMEOUT_MS) {
241
+ arrival = await read();
242
+ if (!arrival || arrival.phase === 'done') break;
243
+ if (report?.stopped?.()) break;
244
+ await sleep(250);
245
+ }
246
+ const r = { browser: kinds[i].name, ...arrivalReadiness(arrival, { waitedMs: Date.now() - t }) };
247
+ if (shots) { r.shot = `${kinds[i].name}-${r.ready === false ? 'loading' : 'playing'}.png`; await p.page.screenshot({ path: join(shots, r.shot) }).catch(() => { r.shot = null; }); }
248
+ if (r.ready === false) log(`${kinds[i].name}: ${r.why}`);
249
+ return r;
250
+ }));
251
+ halt();
154
252
  await sleep(1500);
155
253
  await picture('Playing: computer and phone in one room');
156
254
  // How fast each browser draws the game while it plays (reported, never judged: a VM without a GPU draws
@@ -165,7 +263,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
165
263
  // person's row as a bot, and that is still the person). A round finished without one of them is a miss: its
166
264
  // cause is kept, and the check waits for the next round, up to `rounds` of them.
167
265
  const bothSeated = Math.max(...players.map((p) => p.seatedAt));
168
- for (const p of players) { p.seats = new Set([p.seat]); p.reconnects = null; p.reconnects0 = null; p.closed = null; p.seen = new Map(); }
266
+ for (const p of players) { p.seats = new Set([p.seat]); p.reconnects = null; p.reconnects0 = null; p.closed = null; p.seen = new Map(); p.cutOff = new Set(); }
169
267
  const t1 = Date.now();
170
268
  const budget = roundTimeoutMs * Math.max(1, rounds);
171
269
  const missed = new Map();
@@ -174,12 +272,14 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
174
272
  for (const p of players) {
175
273
  const s = await p.page.evaluate(() => {
176
274
  const sh = window.__shell;
177
- return sh ? { seat: sh.seat, room: sh.room, closed: sh.closed, reconnects: sh.stats?.reconnects ?? null, results: (sh.results ?? []).map((x) => ({ n: x.n, endsAt: x.endsAt, at: x.at, results: x.results })) } : null;
275
+ return sh ? { seat: sh.seat, room: sh.room, closed: sh.closed, reconnects: sh.stats?.reconnects ?? null, link: sh.link ?? null, results: (sh.results ?? []).map((x) => ({ n: x.n, endsAt: x.endsAt, at: x.at, results: x.results })) } : null;
178
276
  }).catch(() => null);
179
277
  if (!s) continue;
180
278
  if (Number.isInteger(s.seat)) p.seats.add(s.seat);
181
279
  if (Number.isFinite(s.reconnects)) { p.reconnects0 ??= s.reconnects; p.reconnects = s.reconnects; }
182
280
  if (s.closed) p.closed = s.closed;
281
+ // Where it stands with its room right now (the helper's link, as the page was told): anything but in the room is kept.
282
+ if (CUT_OFF.includes(linkOf(s.link))) p.cutOff.add(linkOf(s.link));
183
283
  for (const r of s.results) if (r.at > bothSeated && Array.isArray(r.results) && !p.seen.has(`${r.n}:${r.endsAt}`)) p.seen.set(`${r.n}:${r.endsAt}`, { ...r, room: s.room, ms: Date.now() - p.t0 });
184
284
  }
185
285
  const before = missed.size;
@@ -192,16 +292,16 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
192
292
  playingOn = false;
193
293
  await playing.catch(() => {});
194
294
  if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-round-over.png`) }).catch(() => {});
195
- const connection = players.map((p) => ({ browser: p.kind, seats: [...p.seats], reconnects: p.reconnects !== null && p.reconnects0 !== null ? p.reconnects - p.reconnects0 : null, closed: p.closed }));
295
+ const connection = players.map((p) => ({ browser: p.kind, seats: [...p.seats], reconnects: p.reconnects !== null && p.reconnects0 !== null ? p.reconnects - p.reconnects0 : null, closed: p.closed, ...(p.cutOff.size ? { cutOff: [...p.cutOff] } : {}) }));
196
296
  const load = { perCore: +(loadavg()[0] / Math.max(1, cpus().length)).toFixed(2), average: +loadavg()[0].toFixed(1), cores: cpus().length };
197
297
  if (!hit) {
198
298
  const misses = [...missed.values()];
199
- const dropped = connection.filter((c) => c.reconnects > 0).map((c) => `the ${c.browser} lost its connection ${c.reconnects} time${c.reconnects === 1 ? '' : 's'}`);
299
+ const dropped = connection.filter((c) => c.reconnects > 0 || c.cutOff).map((c) => (c.reconnects > 0 ? `the ${c.browser} lost its connection ${c.reconnects} time${c.reconnects === 1 ? '' : 's'}${c.cutOff ? ` (seen ${c.cutOff.join(', ')})` : ''}` : `the ${c.browser} was seen cut off from the room (${c.cutOff.join(', ')})`));
200
300
  const why = misses.length
201
301
  ? `${misses.length} round${misses.length === 1 ? '' : 's'} finished, and none had both browsers in it: ${misses.map((m) => `round ${m.n} left out the ${m.missing.join(' and the ')}`).join('; ')}.${dropped.length ? ` ${dropped.join(', ')} (a browser silent for 10 s is let go by the relay and its body plays as a bot until it is back).` : ' The game listed that seat\'s body as a bot with no seat: it may hand an idle player to a bot.'}${load.perCore > 1.5 ? ` This computer is busy (load ${load.average} on ${load.cores} cores): run the check again when it is quieter.` : ''}`
202
302
  : `no round finished within ${Math.round(budget / 1000)} s of both browsers being seated${load.perCore > 1.5 ? ` (this computer is busy: load ${load.average} on ${load.cores} cores)` : ''}`;
203
303
  step('round', 'fail', { note: misses.length ? `${misses.length} round(s) without both` : 'no round finished in time' });
204
- return { ok: false, command: 'check', why, play, room: seated[0].room, seated, missed: misses, connection, load, totalMs: Date.now() - started };
304
+ return { ok: false, command: 'check', why, play, room: seated[0].room, seated, readiness, missed: misses, connection, ...connectionSummary(connection), load, totalMs: Date.now() - started };
205
305
  }
206
306
  const humans = hit.people;
207
307
  step('round', humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...hit.overMs), note: `round ${hit.n}: ${humans} people, ${hit.results.length - humans} bot${hit.results.length - humans === 1 ? '' : 's'}${missed.size ? ` (after ${missed.size} round(s) without both)` : ''}; ${drawn}${software ? ' (software GL: not measurable here)' : ''}` });
@@ -214,7 +314,10 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
214
314
  seats: seated.map((s, i) => ({ browser: kinds[i].name, seat: s.seat, role: s.role, seatedMs: s.ms })),
215
315
  round: { n: hit.n, humans, bots: hit.results.length - humans, results: hit.results, overMs: hit.overMs },
216
316
  ...(missed.size ? { missed: [...missed.values()] } : {}),
317
+ readiness,
217
318
  connection,
319
+ // Completion (ok) and connection, apart: `uninterrupted` is false when a browser reconnected on the way.
320
+ ...connectionSummary(connection),
218
321
  frames: frames.map((f, i) => ({ browser: kinds[i].name, fps: f.fps, renderer: f.renderer })),
219
322
  ...(software ? { software: 'This machine has no GPU (WebGL is SwiftShader): seats, rooms and rounds are measured; the frame rate is not a person\'s.' } : {}),
220
323
  why: humans >= 2 ? undefined : `the round's results list ${humans} person(s); both browsers should be in it`,
package/lib/chrome.mjs CHANGED
@@ -49,6 +49,17 @@ export function findChrome() {
49
49
  ?? newestIn(join(home, '.cache', 'ms-playwright'), [['chrome-linux', 'chrome'], ['chrome-linux64', 'chrome']]);
50
50
  }
51
51
 
52
+ /**
53
+ * Is this address this computer's own (a local preview)? The play page's shell asks the same of its own hostname
54
+ * (worker/pages.mjs LOCAL) and leaves the join QR off the big screen there: a code that points at a loopback address
55
+ * cannot bring another phone in. A check that reads the big screen has to know which kind of site it is looking at.
56
+ */
57
+ export function isLoopbackUrl(url) {
58
+ let host;
59
+ try { host = new URL(String(url)).hostname; } catch { return false; }
60
+ return /^(localhost|.*\.localhost|127\..*|\[::1\]|0\.0\.0\.0)$/.test(host);
61
+ }
62
+
52
63
  /** Linux without a GPU renders with SwiftShader: say so with every number measured there. */
53
64
  export const SOFTWARE_GL = /swiftshader|llvmpipe|softpipe|software/i;
54
65
 
package/lib/clips.mjs CHANGED
@@ -12,6 +12,8 @@
12
12
  * bakeLibrary(to, sources, verbs) a skeleton's clip library: the target's joints (no mesh) and one clip per verb,
13
13
  * sampled at 30 fps, constant tracks dropped, quaternions kept continuous
14
14
  * writeLibrary(doc) resampled and meshopt-compressed bytes, with its measurements
15
+ * gaitOf(rig, anim) how fast a looping walk or run moves its feet over the ground on ONE rig (a heuristic
16
+ * from the foot bones while they are down): the ground speed at which they do not slide
15
17
  *
16
18
  * Retargeting is plain matrix and quaternion math (three.js's math classes, no renderer), so it runs in Node, in a
17
19
  * cloud session and in CI. A clip baked onto a skeleton plays on every character with that skeleton's fingerprint
@@ -163,6 +165,58 @@ function poseAt(rig, channels, t) {
163
165
  return world;
164
166
  }
165
167
 
168
+ /**
169
+ * How fast a looping, in-place locomotion clip moves its feet over the ground on `rig`, in the rig's world units a
170
+ * second (metres on a shipped character: its rig carries its scale). While a foot is down it travels backwards under
171
+ * a body that stays put; the speed it travels at is the ground speed at which that foot does not slide, which is what
172
+ * animate's `walkSpeed` and `runSpeed` should be for THIS rig. The same clip retargeted onto a taller rig gives a
173
+ * larger number, so a default tuned on one family is not right for another.
174
+ *
175
+ * Deliberately small: each foot counts as down in the lowest quarter of its own height range, and its horizontal
176
+ * speed is averaged over those frames. No contact solver, no slope, no toe roll: a guide to start tuning from, not a
177
+ * verified foot contact. `anim` may come from another Document (a clip library): its channels are matched by node
178
+ * name. Returns { mps, seconds, feet, contact } or null (no foot bones, a foot that never lifts, a clip with no length).
179
+ */
180
+ export function gaitOf(rig, anim, { fps = 30, feet = ['leftFoot', 'rightFoot'] } = {}) {
181
+ needThree();
182
+ const duration = durationOf(anim);
183
+ if (!(duration > 0)) return null;
184
+ const ch = new Map();
185
+ for (const c of anim.listChannels()) {
186
+ const rec = rig.byName.get(c.getTargetNode()?.getName());
187
+ if (!rec) continue;
188
+ if (!ch.has(rec.node)) ch.set(rec.node, {});
189
+ ch.get(rec.node)[c.getTargetPath()] = c.getSampler();
190
+ }
191
+ const recs = feet.map((n) => rig.byName.get(n)).filter(Boolean);
192
+ if (!recs.length || !ch.size) return null;
193
+ const frames = Math.max(8, Math.round(duration * fps));
194
+ const dt = duration / frames;
195
+ const track = recs.map(() => []);
196
+ for (let f = 0; f <= frames; f++) {
197
+ const pose = poseAt(rig, ch, f * dt);
198
+ recs.forEach((r, i) => { const p = pose.get(r).p; track[i].push([p.x, p.y, p.z]); });
199
+ }
200
+ const speeds = []; let down = 0; let all = 0;
201
+ for (const pts of track) {
202
+ let lo = Infinity; let hi = -Infinity;
203
+ for (const p of pts) { lo = Math.min(lo, p[1]); hi = Math.max(hi, p[1]); }
204
+ // A foot that never leaves the ground has no stance to tell from its swing.
205
+ if (!(hi - lo > 1e-5)) continue;
206
+ const line = lo + (hi - lo) * 0.25;
207
+ let sum = 0; let n = 0;
208
+ for (let f = 0; f < frames; f++) {
209
+ all += 1;
210
+ if (pts[f][1] > line || pts[f + 1][1] > line) continue;
211
+ sum += Math.hypot(pts[f + 1][0] - pts[f][0], pts[f + 1][2] - pts[f][2]) / dt; n += 1;
212
+ }
213
+ down += n;
214
+ if (n >= 2) speeds.push(sum / n);
215
+ }
216
+ if (!speeds.length) return null;
217
+ return { mps: +(speeds.reduce((a, b) => a + b, 0) / speeds.length).toFixed(2), seconds: +duration.toFixed(3), feet: speeds.length, contact: +(down / Math.max(1, all)).toFixed(2) };
218
+ }
219
+
166
220
  /** A clip's channels by node: Map(node -> { rotation, translation, scale } samplers). */
167
221
  function channelsOf(anim) {
168
222
  const out = new Map();