@homie-rocks/studio 0.30.1 → 0.31.0
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 +111 -1
- package/README.md +361 -22
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +142 -123
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +127 -8
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/demo.mjs +16 -4
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +116 -5
- package/lib/doctor.mjs +143 -16
- package/lib/feedback.mjs +3 -2
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +36 -35
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +693 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +132 -0
- package/lib/scaffold.mjs +88 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +424 -16
- package/netplay/netplay.ts +756 -40
- package/package.json +10 -1
- package/parts/PARTS.md +163 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +103 -19
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +3 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +13 -6
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +15 -5
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +15 -5
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +141 -23
- package/worker/parts.mjs +234 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
package/lib/characters.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 };
|
|
303
|
-
|
|
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
|
|
314
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|