@homie-rocks/studio 0.30.2 → 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 +76 -1
- package/README.md +343 -21
- 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 +136 -121
- 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/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +116 -5
- package/lib/doctor.mjs +2 -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 +81 -20
- 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 +61 -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/optimise.mjs
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* bottom centre, scaled to its size in metres, welded and simplified to its triangle budget (meshoptimizer),
|
|
7
7
|
* pictures resized and re-encoded as WebP (sharp), meshopt geometry, nothing unused. Raw files are never
|
|
8
8
|
* changed; the result says what each step did, before and after.
|
|
9
|
+
* `lo: { ratio }` also writes `<name>_lo.glb` beside it: the same model through the same steps (the same
|
|
10
|
+
* height, pivot, palette and pictures, so the two line up) with `ratio` of the shipped triangles, for the far
|
|
11
|
+
* band of @homie-rocks/render's LodProps. A rigged model is skipped (its skin is one mesh with its bones).
|
|
12
|
+
* The result's `lo` is { path, triangles, bytes, sha256 }, or null with the reason in `warnings`.
|
|
9
13
|
* inspectModel(input) triangles, vertices, draw calls, materials, pictures and their GPU memory,
|
|
10
14
|
* bones and clips, the bounding box in metres, the Khronos glTF-Validator's errors, and the file's own safety
|
|
11
15
|
* check (assets/safety.mjs).
|
|
@@ -312,7 +316,10 @@ const linearToSrgb255 = (v) => { const c = Math.max(0, Math.min(1, v)); return M
|
|
|
312
316
|
* usually says metallic 1 and leaves the rest to its texture)
|
|
313
317
|
* Returns { ok, glb (the bytes), bytes (their count), sha256, out, before, after, ops, warnings }.
|
|
314
318
|
*/
|
|
315
|
-
|
|
319
|
+
/** Where a model's low copy goes: `<name>_lo.glb` beside it. */
|
|
320
|
+
export const loPathOf = (out) => String(out).replace(/(\.glb)?$/i, '_lo.glb');
|
|
321
|
+
|
|
322
|
+
export async function optimiseModel(input, { out = null, triangles = 1500, texture = 512, height = null, quality = 82, rigged = false, metal = false, lo = null, log = () => {} } = {}) {
|
|
316
323
|
const t = await modelTools();
|
|
317
324
|
const { fn, mo, sharp, io } = t;
|
|
318
325
|
const raw = await selfContained(input, LIMITS.import);
|
|
@@ -410,7 +417,35 @@ export async function optimiseModel(input, { out = null, triangles = 1500, textu
|
|
|
410
417
|
if (!safe.ok) warnings.push(...safe.problems.map((p) => `the result: ${p}`));
|
|
411
418
|
if (out) { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, bytes); }
|
|
412
419
|
log(`optimised: ${before.triangles} -> ${after.triangles} triangles, ${Math.round(raw.byteLength / 1024)} -> ${Math.round(bytes.byteLength / 1024)} KB`);
|
|
413
|
-
|
|
420
|
+
|
|
421
|
+
/*
|
|
422
|
+
* THE LOW COPY (`lo: { ratio }`), for the far band of a prop drawn at two levels of detail (@homie-rocks/render
|
|
423
|
+
* LodProps: the full model near, this one far). It is the SAME raw file through the SAME steps with a smaller
|
|
424
|
+
* triangle budget, never a second recipe: the same scale to the card's height, the same pivot, the same palette
|
|
425
|
+
* and pictures, so the two models sit on each other exactly and share a material. A rigged model has none: its
|
|
426
|
+
* skin is one mesh bound to its bones, and a character is not scattered by the hundred.
|
|
427
|
+
*/
|
|
428
|
+
let low = null;
|
|
429
|
+
if (lo) {
|
|
430
|
+
const ratio = Number(lo.ratio);
|
|
431
|
+
if (!(ratio > 0 && ratio < 1)) warnings.push(`no low copy: lo.ratio is the share of the shipped triangles to keep, between 0 and 1 (0.2 keeps a fifth), not ${JSON.stringify(lo.ratio)}`);
|
|
432
|
+
else if (hasRig) warnings.push('no low copy: a rigged model is one skinned mesh with its bones, and is not drawn at two levels of detail');
|
|
433
|
+
else {
|
|
434
|
+
const want = Math.max(4, Math.round(after.triangles * ratio));
|
|
435
|
+
const loOut = out ? loPathOf(out) : null;
|
|
436
|
+
const r = await optimiseModel(raw, { out: null, triangles: want, texture, height, quality, rigged: false, metal });
|
|
437
|
+
if (r.after.triangles >= after.triangles) warnings.push(`no low copy: the shape would not simplify below its ${after.triangles} triangles`);
|
|
438
|
+
else if (!r.ok) warnings.push(`no low copy: ${r.warnings.join('; ')}`);
|
|
439
|
+
else {
|
|
440
|
+
if (loOut) writeFileSync(loOut, r.glb);
|
|
441
|
+
low = { path: loOut, triangles: r.after.triangles, bytes: r.bytes, sha256: r.sha256, ratio, glb: r.glb };
|
|
442
|
+
ops.push(`low copy at ${ratio} (${after.triangles} to ${r.after.triangles} triangles)`);
|
|
443
|
+
if (r.after.triangles > want * 1.25) warnings.push(`the low copy has ${r.after.triangles} triangles, not the ${want} asked (ratio ${ratio}): the shape resists`);
|
|
444
|
+
log(`low copy: ${r.after.triangles} triangles, ${Math.round(r.bytes / 1024)} KB`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return { ok: safe.ok, out, glb: bytes, bytes: bytes.byteLength, sha256: sha256(bytes), rawBytes: raw.byteLength, rawSha256: sha256(raw), before, after, ops, warnings, ...(lo ? { lo: low } : {}), tool: `@gltf-transform/functions ${await toolVersion()}` };
|
|
414
449
|
}
|
|
415
450
|
|
|
416
451
|
let version = null;
|
|
@@ -427,8 +462,8 @@ async function toolVersion() {
|
|
|
427
462
|
}
|
|
428
463
|
|
|
429
464
|
/**
|
|
430
|
-
* A grey box .glb of `size` metres (x, y, z), its pivot at the bottom centre: what a
|
|
431
|
-
*
|
|
465
|
+
* A grey box .glb of `size` metres (x, y, z), its pivot at the bottom centre: what a refused model is drawn as,
|
|
466
|
+
* and a stand-in for one that is not there yet.
|
|
432
467
|
*/
|
|
433
468
|
export async function placeholderGlb(size = [0.5, 0.5, 0.5], { name = 'placeholder', colour = [0.54, 0.56, 0.6, 1] } = {}) {
|
|
434
469
|
const { core, io } = await modelTools();
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GAME PARTS IN THE BUILD (parts/PARTS.md). Three small things lib/build.mjs and the deploy plan call, so the build
|
|
3
|
+
* itself stays one line longer in each place:
|
|
4
|
+
*
|
|
5
|
+
* partsPlugin(root, game) esbuild resolves `@parts/<host>/<id>` (a part brought in) and `@parts/<id>` (one of
|
|
6
|
+
* the studio's own) to the part's entry, so adding a part and one import line is a
|
|
7
|
+
* running piece. A game that imports a brought-in part is recorded against it and
|
|
8
|
+
* credited (the existing credits.json), whether or not the add named the game.
|
|
9
|
+
* buildParts(root, dist) site/dist/parts/: every packed version of every SHARED part, byte for byte as
|
|
10
|
+
* hashed, and index.json, what the Worker answers /.well-known/homie-parts.json from.
|
|
11
|
+
* What is shared is sharedPartsOf() and nothing else. A private part is never copied:
|
|
12
|
+
* what is not in dist cannot be served.
|
|
13
|
+
* partsPublishReport(root) before a game that uses parts goes online: each part's licence and attribution, and
|
|
14
|
+
* plainly where licences cannot be combined.
|
|
15
|
+
*/
|
|
16
|
+
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import {
|
|
19
|
+
assetRecords, licenceIssues, licenseOfPart, listOwnParts, packPart, packedDir, packedVersions, publicPart, readOrigins, readPart, safePath, shareProblems,
|
|
20
|
+
sharedPartsOf, syncPartCredits, vendorDir, verifyFiles, writeOrigins,
|
|
21
|
+
} from './parts.mjs';
|
|
22
|
+
import { gameFacts, partItems } from './parts-store.mjs';
|
|
23
|
+
|
|
24
|
+
/* ------------------------------------------------------------------ importing a part */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where `@parts/…` points: { path } or { error }. `@parts/<host>/<id>[/<file>]` is a part the lock names;
|
|
28
|
+
* `@parts/<id>[/<file>]` is one of the studio's own. With no file it is the part's `entry`.
|
|
29
|
+
*/
|
|
30
|
+
export function resolvePartImport(root, spec, lock = readOrigins(root)) {
|
|
31
|
+
const segs = String(spec).replace(/^@parts\//, '').split('/').filter(Boolean);
|
|
32
|
+
let dir = null; let rest = []; let ref = null; let vendored = false;
|
|
33
|
+
if (segs.length >= 2 && lock.parts[`${segs[0]}/${segs[1]}`]) { ref = `${segs[0]}/${segs[1]}`; dir = vendorDir(root, ref); rest = segs.slice(2); vendored = true; }
|
|
34
|
+
else if (segs.length >= 1 && existsSync(join(root, 'parts', segs[0], 'part.json'))) { ref = segs[0]; dir = join(root, 'parts', segs[0]); rest = segs.slice(1); }
|
|
35
|
+
if (!dir) return { error: `'${spec}' names a part this studio does not have. Parts here: ${[...Object.keys(lock.parts), ...listOwnParts(root).map((o) => o.id)].join(', ') || 'none'}. Bring one in with part_add ("<studio site>/<part id>").` };
|
|
36
|
+
if (!existsSync(dir)) return { error: `'${spec}': parts/origins.json names ${ref} but its files are gone. Add it again (${ref}@${lock.parts[ref].version}).` };
|
|
37
|
+
if (rest.length) {
|
|
38
|
+
const file = rest.join('/');
|
|
39
|
+
if (!safePath(file) || !existsSync(join(dir, file))) return { error: `'${spec}': the ${ref} part has no file ${file}` };
|
|
40
|
+
return { path: join(dir, file), ref, vendored };
|
|
41
|
+
}
|
|
42
|
+
let part;
|
|
43
|
+
try { part = readPart(dir); } catch (error) { return { error: `'${spec}': ${error.message}` }; }
|
|
44
|
+
if (!part.entry) return { error: `'${spec}': the ${ref} part has no code to import (it is ${part.kind}: files only). Name a file: '@parts/${ref}/<path>'.` };
|
|
45
|
+
if (!existsSync(join(dir, part.entry))) return { error: `'${spec}': the ${ref} part's entry ${part.entry} is missing` };
|
|
46
|
+
return { path: join(dir, part.entry), ref, vendored };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The esbuild plugin (lib/build.mjs passes it to every game's bundle). `game`: the game being bundled, to credit. */
|
|
50
|
+
export function partsPlugin(root, game = null) {
|
|
51
|
+
return {
|
|
52
|
+
name: 'homie-parts',
|
|
53
|
+
setup(build) {
|
|
54
|
+
const used = new Set();
|
|
55
|
+
let lock = null;
|
|
56
|
+
build.onStart(() => { used.clear(); lock = null; });
|
|
57
|
+
build.onResolve({ filter: /^@parts\// }, (args) => {
|
|
58
|
+
try { lock ??= readOrigins(root); } catch (error) { return { errors: [{ text: error.message }] }; }
|
|
59
|
+
const r = resolvePartImport(root, args.path, lock);
|
|
60
|
+
if (r.error) return { errors: [{ text: r.error }] };
|
|
61
|
+
if (r.vendored) used.add(r.ref);
|
|
62
|
+
return { path: r.path };
|
|
63
|
+
});
|
|
64
|
+
// A brought-in part a game imports is that game's to credit, even when it was added without naming the game.
|
|
65
|
+
build.onEnd(() => {
|
|
66
|
+
if (!game || !used.size || !existsSync(join(root, 'games', game))) return;
|
|
67
|
+
try {
|
|
68
|
+
const now = readOrigins(root);
|
|
69
|
+
let changed = false;
|
|
70
|
+
for (const ref of used) { const e = now.parts[ref]; if (e && !(e.games ?? []).includes(game)) { e.games = [...(e.games ?? []), game].sort(); changed = true; } }
|
|
71
|
+
if (changed) writeOrigins(root, now);
|
|
72
|
+
syncPartCredits(root, game, now);
|
|
73
|
+
} catch { /* a credit line is never worth a failed bundle; the publish report says what is missing */ }
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ------------------------------------------------------------------ the site's parts */
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* site/dist/parts/: the shared parts and their index. Returns { shared: [{ id, versions }], kept: <private count> }.
|
|
83
|
+
* A part marked shared that cannot be (someone set `share` by hand past the checks) STOPS the build with the
|
|
84
|
+
* reasons: publishing a file of unknown rights quietly is the one thing this must never do.
|
|
85
|
+
*/
|
|
86
|
+
export function buildParts(root, dist, { studio = {}, log = () => {} } = {}) {
|
|
87
|
+
const out = join(dist, 'parts');
|
|
88
|
+
rmSync(out, { recursive: true, force: true });
|
|
89
|
+
const own = listOwnParts(root);
|
|
90
|
+
const records = assetRecords(root);
|
|
91
|
+
for (const o of own) {
|
|
92
|
+
if (o.part?.share !== true) continue;
|
|
93
|
+
const problems = shareProblems(o.part, { dir: o.dir, records }).filter((p) => !(p.field === 'files' && /^the files differ/.test(p.problem)));
|
|
94
|
+
if (problems.length) throw new Error(`parts/${o.id} is marked shared but cannot be: ${problems.map((p) => `${p.field}: ${p.problem}`).slice(0, 4).join('; ')}. Fix it, or stop sharing it (part_share with share: false); the site was not built with it.`);
|
|
95
|
+
// Edited since it was packed: the packed version is what is public; the edit needs a version of its own.
|
|
96
|
+
if (!verifyFiles(o.dir, o.part).ok || !packedVersions(root, o.id).includes(o.part.version)) {
|
|
97
|
+
const packed = verifyFiles(o.dir, o.part).ok ? packPart(root, o.id) : { ok: false };
|
|
98
|
+
if (!packed.ok) log(`warning: parts/${o.id} changed since version ${packedVersions(root, o.id).at(-1) ?? '(none)'} was shared; the site shares that version. To share the change, raise "version" in its part.json and share it again.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// The one answer to "what does this studio share".
|
|
102
|
+
const sharing = sharedPartsOf(root);
|
|
103
|
+
const names = new Map();
|
|
104
|
+
const index = [];
|
|
105
|
+
for (const { id, versions, part } of sharing) {
|
|
106
|
+
for (const v of versions) {
|
|
107
|
+
const src = packedDir(root, id, v);
|
|
108
|
+
const packed = readPart(src);
|
|
109
|
+
if (!verifyFiles(src, packed).ok) throw new Error(`parts/_packed/${id}/${v} no longer matches its hashes: a shared version never changes. Restore it from git.`);
|
|
110
|
+
const dest = join(out, id, v);
|
|
111
|
+
for (const f of packed.files) { mkdirSync(dirname(join(dest, f.path)), { recursive: true }); cpSync(join(src, f.path), join(dest, f.path)); }
|
|
112
|
+
writeFileSync(join(dest, 'part.json'), `${JSON.stringify(publicPart({ ...packed, share: true }))}\n`);
|
|
113
|
+
}
|
|
114
|
+
// The game it came out of, by the name the studio gives it.
|
|
115
|
+
const g = part.from?.game;
|
|
116
|
+
if (g && !names.has(g)) names.set(g, gameFacts(root, g).name);
|
|
117
|
+
index.push({ ...part, ...(g ? { from: { ...part.from, name: names.get(g) } } : {}), url: `/parts/${id}/${versions.at(-1)}/`, page: `/parts/${id}/`, versions });
|
|
118
|
+
log(`shared part ${id} (${versions.join(', ')})`);
|
|
119
|
+
}
|
|
120
|
+
mkdirSync(out, { recursive: true });
|
|
121
|
+
writeFileSync(join(out, 'index.json'), `${JSON.stringify({ v: 1, studio: { name: studio.name ?? null }, parts: index.sort((a, b) => a.id.localeCompare(b.id)) })}\n`);
|
|
122
|
+
return { shared: sharing.map(({ id, versions }) => ({ id, versions })), kept: own.length - sharing.length };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ------------------------------------------------------------------ before going online */
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What a deploy plan shows about parts, and changes nothing:
|
|
129
|
+
*
|
|
130
|
+
* games each game that uses brought-in parts: every part's licence, what it asks, its attribution and the game
|
|
131
|
+
* it came from, and plainly where licences cannot be combined (licenceIssues)
|
|
132
|
+
* sharing the studio's own parts this deploy makes live (sharedPartsOf)
|
|
133
|
+
* ok false when a game's licences cannot be combined: the caller decides whether that stops it
|
|
134
|
+
*
|
|
135
|
+
* `partsPlanLines(report)` is the same in plain lines: said before a deploy (its plan) and before a studio is listed
|
|
136
|
+
* (`publish`, lib/directory.mjs), in the same words.
|
|
137
|
+
*/
|
|
138
|
+
export function partsPublishReport(root) {
|
|
139
|
+
let lock;
|
|
140
|
+
try { lock = readOrigins(root); } catch (error) { return { ok: false, games: [], sharing: [], why: error.message }; }
|
|
141
|
+
const byGame = new Map();
|
|
142
|
+
for (const [ref, e] of Object.entries(lock.parts)) for (const g of e.games ?? []) { if (!byGame.has(g)) byGame.set(g, []); byGame.get(g).push([ref, e]); }
|
|
143
|
+
const games = [];
|
|
144
|
+
for (const [game, list] of [...byGame].sort(([a], [b]) => a.localeCompare(b))) {
|
|
145
|
+
if (!existsSync(join(root, 'games', game, 'game.json'))) continue;
|
|
146
|
+
const issues = licenceIssues(partItems(root, { game }), { game: gameFacts(root, game) });
|
|
147
|
+
games.push({
|
|
148
|
+
game,
|
|
149
|
+
parts: list.sort(([a], [b]) => a.localeCompare(b)).map(([ref, e]) => { const lic = licenseOfPart(e.license); return { ref, name: e.name, version: e.version, license: lic?.id ?? null, asks: lic?.asks ?? 'nothing is known: it names no licence', attribution: e.attribution || null, from: e.from ?? null }; }),
|
|
150
|
+
issues, ok: !issues.some((i) => i.level === 'conflict'),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const sharing = sharedPartsOf(root).map(({ id, versions, part }) => ({ id, version: versions.at(-1), license: part.license ?? null, from: part.from ?? null }));
|
|
154
|
+
return { ok: games.every((g) => g.ok), games, sharing };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function partsPlanLines(report, { at = 'deploy' } = {}) {
|
|
158
|
+
const L = [];
|
|
159
|
+
if (report.why) L.push(`Parts: ${report.why}`);
|
|
160
|
+
for (const g of report.games) {
|
|
161
|
+
L.push(`Parts in ${g.game} from other studios (their licences travel with the game):`);
|
|
162
|
+
for (const p of g.parts) L.push(` ${p.name} ${p.version} (${p.ref})${p.from?.name || p.from?.game ? `, from ${p.from.name ?? p.from.game}` : ''}: ${p.license ?? 'NO LICENCE'}; asks ${p.asks}${p.attribution ? `; credit: ${p.attribution}` : ''}`);
|
|
163
|
+
for (const i of g.issues) L.push(` ${i.level === 'conflict' ? 'CANNOT BE COMBINED' : i.level === 'warn' ? 'look' : 'note'} ${i.parts.join(' + ')}: ${i.problem}${i.fix ? ` (${i.fix})` : ''}`);
|
|
164
|
+
if (!g.issues.length) L.push(' Their licences can be combined, and each is credited in the game\'s credits.');
|
|
165
|
+
}
|
|
166
|
+
// The same lines before a deploy and before a listing; only where a shared part becomes visible differs.
|
|
167
|
+
if (report.sharing.length) L.push(`Parts this studio shares (${at === 'publish' ? 'the parts catalog shows them once the studio is listed' : 'live with this deploy'}): ${report.sharing.map((s) => `${s.id} ${s.version} (${s.license})`).join(', ')}`);
|
|
168
|
+
return L;
|
|
169
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio parts …`: the plumbing behind the four chat tools (lib/parts-tools.mjs; parts/PARTS.md). A creator
|
|
3
|
+
* works in chat and never types these; an agent with a shell and no MCP connection may.
|
|
4
|
+
*
|
|
5
|
+
* parts find <words> [--kind <kind>] [--tag <tag>] [--license <SPDX>] [--builds <package or rig>]
|
|
6
|
+
* parts new <id> [--kind <kind>] [--name "<Name>"] [--from <game> <paths in the game…>]
|
|
7
|
+
* parts add <host>/<id>[@<version>] [--game <id>] [--overwrite] [--no-install] (again: the newer version)
|
|
8
|
+
* --no-install: bring the part in and install nothing. package.json and package-lock.json are not touched and
|
|
9
|
+
* npm is only asked what the studio has (`npm ls`). Each package the part builds on that is missing is named
|
|
10
|
+
* "NOT INSTALLED" with the one npm command that installs it. The part will not build until that is run.
|
|
11
|
+
* parts share <id> parts unshare <id> (live after the studio's next deploy)
|
|
12
|
+
* parts check [<id>] (what stands in the way of sharing; rewrites the hashes)
|
|
13
|
+
*/
|
|
14
|
+
import { newPart } from './parts.mjs';
|
|
15
|
+
import { addPart, checkParts, findParts, sharePart } from './parts-store.mjs';
|
|
16
|
+
|
|
17
|
+
export const PARTS_VERBS = ['find', 'new', 'add', 'share', 'unshare', 'check'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What `parts add` takes, in the words the command prints when it is not given a part. `--no-install` is said as
|
|
21
|
+
* what it does: nothing is installed and package.json is not written (npm writes it, when npm installs). A test
|
|
22
|
+
* runs the flag through the real command and holds these words to what happened.
|
|
23
|
+
*/
|
|
24
|
+
export const PARTS_ADD_USAGE = 'homie-studio parts add <studio site>/<part id>[@<version>] [--game <id>] [--overwrite] [--no-install]\n --no-install: install nothing and leave package.json as it is; a package the part builds on that this studio does not have is named NOT INSTALLED with the npm command that installs it (the part will not build until then).';
|
|
25
|
+
|
|
26
|
+
export async function partsCommand(root, sub, positional, flags, { log = () => {}, fetch, npm } = {}) {
|
|
27
|
+
const arg = positional[2];
|
|
28
|
+
const str = (k) => (typeof flags.get(k) === 'string' ? flags.get(k) : null);
|
|
29
|
+
switch (sub ?? 'find') {
|
|
30
|
+
case 'find': return findParts(root, positional.slice(2).join(' '), { kind: str('kind'), tag: str('tag'), license: str('license'), builds: str('builds'), fetch, hub: str('hub') });
|
|
31
|
+
case 'new': return newPart(root, arg, { kind: str('kind') ?? 'mechanic', name: str('name'), from: str('from'), paths: positional.slice(3) });
|
|
32
|
+
case 'check': return checkParts(root, arg ?? null, { write: true });
|
|
33
|
+
case 'share': return arg ? sharePart(root, arg, true) : { ok: false, command: 'parts share', why: 'name the part to share' };
|
|
34
|
+
case 'unshare': return arg ? sharePart(root, arg, false) : { ok: false, command: 'parts unshare', why: 'name the part to stop sharing' };
|
|
35
|
+
case 'add': return arg ? addPart(root, arg, { fetch, ...(npm ? { npm } : {}), install: !flags.has('no-install'), say: log, game: str('game'), overwrite: flags.has('overwrite') }) : { ok: false, command: 'parts add', why: `name the part: "<studio site>/<part id>"\n${PARTS_ADD_USAGE}` };
|
|
36
|
+
default: return { ok: false, command: 'parts', why: `"${sub}" is not a parts command: ${PARTS_VERBS.join(', ')}` };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ------------------------------------------------------------------ plain lines */
|
|
41
|
+
|
|
42
|
+
const costWords = (c) => { const b = []; if (Number.isFinite(c?.bytes)) b.push(`${Math.max(1, Math.round(c.bytes / 1024))} KB`); if (Number.isFinite(c?.triangles)) b.push(`${c.triangles} triangles`); if (Number.isFinite(c?.drawCalls)) b.push(`${c.drawCalls} draws`); if (Number.isFinite(c?.textureMB)) b.push(`${c.textureMB} MB textures`); return b.join(', '); };
|
|
43
|
+
const buildsWords = (r) => [...Object.keys(r?.packages && typeof r.packages === 'object' ? r.packages : {}), ...(Array.isArray(r?.parts) ? r.parts.map((x) => `the part ${x}`) : [])].join(', ');
|
|
44
|
+
const issueLines = (issues) => issues.map((i) => ` ${i.level === 'conflict' ? 'CANNOT BE COMBINED' : i.level === 'warn' ? 'look' : 'note'} ${i.parts.join(' + ')}: ${i.problem}${i.fix ? `\n ${i.fix}` : ''}`);
|
|
45
|
+
const packageLines = (rows) => rows.filter((p) => p.state !== 'ok').map((p) => (p.state === 'installed' ? ` npm installed ${p.name}@${p.range}, for ${p.by}` : p.state === 'missing' ? ` NOT INSTALLED ${p.name}@${p.range}: ${p.why} npm installs it with: ${p.command}` : ` ${p.state === 'pinned' ? 'PACKAGE DOES NOT FIT' : 'NPM FAILED'}: ${p.why}`));
|
|
46
|
+
|
|
47
|
+
function addLines(r) {
|
|
48
|
+
const L = [];
|
|
49
|
+
if (r.was && r.same) L.push(`${r.ref} ${r.version} is already here and unchanged.`);
|
|
50
|
+
else if (r.was) L.push(`${r.ref}: ${r.was} → ${r.version}. ${[r.changes.changed.length ? `changed: ${r.changes.changed.join(', ')}` : '', r.changes.added.length ? `new: ${r.changes.added.join(', ')}` : '', r.changes.removed.length ? `gone: ${r.changes.removed.join(', ')}` : ''].filter(Boolean).join('; ') || 'the same files'}. Your tuning.json was kept${r.replaced?.length ? `; REPLACED your edited ${r.replaced.join(', ')} as asked` : ''}.`);
|
|
51
|
+
else L.push(`${r.name} ${r.version} (${r.kind}) from ${r.host} is in ${r.dir}: ${r.files} files, every one checked against its SHA-256. It is this studio's to tune now.`);
|
|
52
|
+
if (r.from?.name || r.from?.game) L.push(` It came out of ${r.from.name ?? r.from.game}${r.from.studio ? ` by ${r.from.studio}` : ''}.`);
|
|
53
|
+
L.push(` Licence: ${r.license ?? 'NONE NAMED'} (asks ${r.asks ?? 'unknown'})${r.attribution ? `; credit: ${r.attribution}` : ''}`);
|
|
54
|
+
if (r.credits?.length) L.push(` Credited in ${r.credits.join(', ')}.`);
|
|
55
|
+
else if (!r.game) L.push(' It is credited in a game\'s credits when that game imports it (the build does it).');
|
|
56
|
+
if (r.import) L.push(` Use it: import … from '${r.import}'`);
|
|
57
|
+
L.push(...packageLines(r.packages ?? []));
|
|
58
|
+
for (const n of r.needs ?? []) L.push(` It needs another part, ${n.ref}: ${n.add}`);
|
|
59
|
+
L.push(...issueLines(r.licences ?? []));
|
|
60
|
+
return L;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function partsLines(r) {
|
|
64
|
+
const L = [];
|
|
65
|
+
switch (r.command) {
|
|
66
|
+
case 'parts find': {
|
|
67
|
+
if (!r.hub.ok) L.push(r.hub.why, '');
|
|
68
|
+
if (!r.results.length) L.push(r.hub.ok ? `No shared part matches${r.query ? ` "${r.query}"` : ''} (${r.searched.hub} in the catalogue, ${r.searched.here} in this studio). Write it; if it turns out reusable, it can become a part.` : 'Nothing in this studio matches.');
|
|
69
|
+
for (const p of r.results) {
|
|
70
|
+
L.push(`${p.name} (${p.kind}) ${p.version ?? ''} · ${p.license ?? 'no licence named'} · ${p.where === 'hub' ? p.ref : p.where === 'here' ? `${p.ref} (already here)` : `${p.id} (this studio's own${p.share ? ', shared' : ', private'})`}`);
|
|
71
|
+
if (p.summary) L.push(` ${p.summary}`);
|
|
72
|
+
if (p.from?.name || p.from?.game) L.push(` from ${p.from.name ?? p.from.game}${p.from.studio ? ` by ${p.from.studio}` : ''}`);
|
|
73
|
+
const bits = [costWords(p.cost) && `costs ${costWords(p.cost)}`, buildsWords(p.requires) && `builds on ${buildsWords(p.requires)}`, p.rig && `rig ${p.rig}`, p.netplay && `in a room: ${p.netplay}`].filter(Boolean);
|
|
74
|
+
if (bits.length) L.push(` ${bits.join(' · ')}`);
|
|
75
|
+
L.push(` ${p.say}`);
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case 'parts new':
|
|
80
|
+
L.push(`parts/${r.id} is a new ${r.kind} part (private).${r.lifted ? ` Lifted out of games/${r.from}: ${r.lifted.moved.length} module(s) moved${r.lifted.copied.length ? `, ${r.lifted.copied.length} other file(s) copied` : ''}.` : ''}`, ...r.next.map((n) => ` ${n}`));
|
|
81
|
+
break;
|
|
82
|
+
case 'parts check':
|
|
83
|
+
for (const row of r.rows) {
|
|
84
|
+
L.push(`${row.id}${row.version ? ` ${row.version}` : ''}: ${row.ok ? 'reads fine' : 'NOT A PART YET'}${row.share ? ', shared' : row.shareable ? ', could be shared' : ', private'}`);
|
|
85
|
+
for (const p of row.problems) L.push(` ${p.level === 'refuse' ? 'FIX ' : 'look'} ${p.field}: ${p.problem}${p.fix ? `\n ${p.fix}` : ''}`);
|
|
86
|
+
if (!row.shareable) { L.push(' Before it can be shared:'); for (const p of row.sharing) L.push(` ${p.field}: ${p.problem}${p.fix ? `\n ${p.fix}` : ''}`); }
|
|
87
|
+
}
|
|
88
|
+
for (const b of r.brought) L.push(`${b.ref}${b.version ? ` ${b.version}` : ''}: ${b.ok ? (b.edited.length ? `edited here (${b.edited.join(', ')}); a newer version will not replace them without being told to` : 'as fetched') : b.problem ?? `missing ${b.missing.join(', ')}`}${b.fix ? `\n ${b.fix}` : ''}`);
|
|
89
|
+
if (!r.rows.length && !r.brought.length) L.push('No parts here yet.');
|
|
90
|
+
break;
|
|
91
|
+
case 'parts share':
|
|
92
|
+
L.push(`parts/${r.id} ${r.version} is shared under ${r.license} (asks ${r.asks}).`, r.effect);
|
|
93
|
+
break;
|
|
94
|
+
case 'parts unshare':
|
|
95
|
+
L.push(`parts/${r.id} is private.`, r.effect);
|
|
96
|
+
break;
|
|
97
|
+
case 'parts add':
|
|
98
|
+
L.push(...addLines(r));
|
|
99
|
+
break;
|
|
100
|
+
default:
|
|
101
|
+
L.push(JSON.stringify(r));
|
|
102
|
+
}
|
|
103
|
+
return L;
|
|
104
|
+
}
|