@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.
- package/CHANGELOG.md +108 -1
- package/README.md +349 -23
- 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 +204 -124
- 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 +203 -10
- 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 +172 -9
- package/lib/doctor.mjs +12 -11
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +42 -37
- 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 +720 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +240 -0
- package/lib/scaffold.mjs +83 -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 +552 -16
- package/netplay/netplay.ts +950 -43
- package/package.json +10 -1
- package/parts/PARTS.md +169 -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 +329 -40
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +7 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +41 -12
- 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 +50 -11
- 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 +50 -11
- 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 +156 -29
- package/worker/parts.mjs +275 -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/net.mjs
CHANGED
|
@@ -76,6 +76,14 @@ export function restartWithProxy({ argv = process.argv, env = process.env, execA
|
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The error codes that mean "this computer could not look the name up" (the resolver's, never the site's). ONE list:
|
|
81
|
+
* every command that works against a site reads it through `whyFailed`, and the plugin's own preflight
|
|
82
|
+
* (skills/playtest/scripts/lib/preflight.mjs, which cannot import this file: it runs against whatever studio is
|
|
83
|
+
* installed) keeps the same list, held equal by a test that feeds both the same errors.
|
|
84
|
+
*/
|
|
85
|
+
export const DNS_CODES = ['ENOTFOUND', 'EAI_AGAIN', 'EAI_NODATA', 'EAI_NONAME', 'ESERVFAIL'];
|
|
86
|
+
|
|
79
87
|
const hostOf = (url) => { try { return new URL(String(url)).host; } catch { return String(url); } };
|
|
80
88
|
const oneLine = (text, max = 200) => String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
81
89
|
|
|
@@ -104,7 +112,9 @@ export function whyFailed(error, url, { env = process.env, execArgv = process.ex
|
|
|
104
112
|
if (refused) return { why: `the network proxy (${proxy?.variable ?? 'HTTPS_PROXY'}) answered ${refused[1]} when asked to connect to ${host}`, code: `PROXY_${refused[1]}` };
|
|
105
113
|
const via = proxy ? (used ? ` (through the proxy in ${proxy.variable})` : ` (directly: this machine's proxy in ${proxy.variable} was not used${canUseProxy() ? '' : `, because Node.js ${process.versions.node} cannot use it; Node.js 22.21 or newer can`})`) : '';
|
|
106
114
|
if (code === 'TIMEOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'ETIMEDOUT') return { why: `no answer from ${host} in time${via}`, code: code ?? 'TIMEOUT' };
|
|
107
|
-
|
|
115
|
+
// A name this process could not look up is this computer's network, never the site's fault: `preflight` says so
|
|
116
|
+
// to callers that would otherwise read "did not answer" as a broken site or game (see `reachSite`).
|
|
117
|
+
if (DNS_CODES.includes(code)) return { why: `could not look up ${host} (${code})${via}`, code, preflight: 'dns' };
|
|
108
118
|
if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') return { why: `could not connect to ${host} (${code})${via}`, code };
|
|
109
119
|
if (/CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/i.test(code ?? '') || /certificate/i.test(words)) {
|
|
110
120
|
return { why: `the TLS certificate ${host} presented is not trusted here (${code ?? words})${via}; when a proxy inspects traffic, its CA belongs in NODE_EXTRA_CA_CERTS`, code: code ?? 'CERT' };
|
|
@@ -132,6 +142,75 @@ export async function request(url, init = {}, { timeout = 10_000, fetchFn = glob
|
|
|
132
142
|
} catch (error) { return { ok: false, ...whyFailed(error, url) }; }
|
|
133
143
|
let body = null;
|
|
134
144
|
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
|
135
|
-
if (!res.ok || body?.ok === false) return { ok: false, body, ...whyRefused(res, body, text, url) };
|
|
145
|
+
if (!res.ok || body?.ok === false) return { ok: false, body, headers: res.headers, ...whyRefused(res, body, text, url) };
|
|
136
146
|
return { ok: true, status: res.status, body, headers: res.headers };
|
|
137
147
|
}
|
|
148
|
+
|
|
149
|
+
/** Whether an address is this computer's own (a dev site): never a network question. */
|
|
150
|
+
export function isLoopback(url) {
|
|
151
|
+
try { const h = new URL(String(url)).hostname.toLowerCase(); return LOOPBACK.includes(h) || h.endsWith('.localhost'); } catch { return false; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/*
|
|
155
|
+
* NODE'S DNS IS NOT THE BROWSER'S. On one computer the public studio opened in Chrome while Node's fetch() could
|
|
156
|
+
* not look its hostname up at all (a browser may use its own secure DNS, a VPN's or a cached answer; Node asks the
|
|
157
|
+
* system resolver and nothing else). A check against the public address stopped before it opened a game, and what
|
|
158
|
+
* it printed read as the site, or the game, being down.
|
|
159
|
+
*
|
|
160
|
+
* `reachSite` is the one preflight for a command that is about to work against a site: it asks once, and a failure
|
|
161
|
+
* says what kind it is.
|
|
162
|
+
* { ok: true, status } the site answered (any status is an answer)
|
|
163
|
+
* { ok: false, preflight: 'dns', why, instead, ... } THIS COMPUTER could not look the name up: a network
|
|
164
|
+
* preflight failure, with local testing offered instead
|
|
165
|
+
* { ok: false, preflight: 'network', why, instead, ... } no connection, a timeout, a certificate, a proxy refusal
|
|
166
|
+
* { ok: false, preflight: 'site', status, why } it answered, and the answer is an error (5xx)
|
|
167
|
+
* It never says a game is broken: it has not opened one.
|
|
168
|
+
*/
|
|
169
|
+
export const LOCAL_INSTEAD = 'To test without this computer\'s network in the way: run the site here (`npx --no-install homie-studio dev`, as a background task) and give the command --url http://127.0.0.1:8787 (or leave --url out where the command finds the dev site itself).';
|
|
170
|
+
|
|
171
|
+
export async function reachSite(url, { fetchFn = globalThis.fetch, timeout = 10_000, path = '/', env = process.env, execArgv = process.execArgv } = {}) {
|
|
172
|
+
const base = String(url ?? '').replace(/\/+$/, '');
|
|
173
|
+
const host = hostOf(base);
|
|
174
|
+
let res;
|
|
175
|
+
try { res = await fetchFn(`${base}${path}`, { method: 'GET', redirect: 'manual', headers: { accept: '*/*' }, signal: AbortSignal.timeout(timeout) }); } catch (error) {
|
|
176
|
+
const w = whyFailed(error, base, { env, execArgv });
|
|
177
|
+
if (isLoopback(base)) return { ok: false, preflight: 'local', code: w.code ?? null, why: `${base} does not answer (${w.code ?? 'no connection'}): the site is not running here. Start it (npx --no-install homie-studio dev, as a background task) or check the port.` };
|
|
178
|
+
if (w.preflight === 'dns') {
|
|
179
|
+
return {
|
|
180
|
+
ok: false, preflight: 'dns', code: w.code, needs: 'network-preflight',
|
|
181
|
+
why: `network preflight failed, before any page or game was opened: this computer's Node.js ${w.why}. A browser on the same computer may still open ${host} (browsers can use their own DNS), so this says nothing about the site or the game: it is this computer's name lookup.`,
|
|
182
|
+
instead: LOCAL_INSTEAD,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { ok: false, preflight: 'network', code: w.code ?? null, needs: w.needs ?? 'network-preflight', why: `network preflight failed, before any page or game was opened: ${w.why}. That is the connection from this computer to ${host}, not a result about the game.`, instead: LOCAL_INSTEAD };
|
|
186
|
+
}
|
|
187
|
+
try { await res.body?.cancel?.(); } catch { /* nothing to drop */ }
|
|
188
|
+
if (res.status >= 500) return { ok: false, preflight: 'site', status: res.status, why: `${host} answered ${res.status}: the site itself is failing (not this computer's network).` };
|
|
189
|
+
return { ok: true, status: res.status };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* WHAT A COMMAND SAYS WHEN `reachSite` DID NOT FIND THE PLAY PAGE: one classification and one sentence for `check`,
|
|
194
|
+
* `perf`, `shoot` and `port check` (and, word for word in its DNS case, the playtest and perf skills' scripts), so
|
|
195
|
+
* the same failure is never "the site does not answer" from one command and "network preflight" from the next.
|
|
196
|
+
*
|
|
197
|
+
* null the page answered: go on
|
|
198
|
+
* preflight 'local' this computer's own address has nothing listening: start the site
|
|
199
|
+
* preflight 'site' it answered, with an error (a 5xx, or a 4xx: a wrong address or game id)
|
|
200
|
+
* preflight 'dns' | 'network' BLOCKED: this computer could not ask. Nothing was measured, nothing is said
|
|
201
|
+
* about the site or the game, and local testing is offered instead.
|
|
202
|
+
*
|
|
203
|
+
* A command that only drives a browser (check, shoot) may still try on a BLOCKED one, because a browser can open a
|
|
204
|
+
* site this process cannot look up; if the browser then fails too, this is what it reports.
|
|
205
|
+
*/
|
|
206
|
+
export function siteRefusal(reach, { command, play }) {
|
|
207
|
+
if (reach?.ok) {
|
|
208
|
+
return reach.status >= 400 ? { ok: false, command, preflight: 'site', status: reach.status, why: `${play} answered ${reach.status}: start the site (npm run dev, as a background task) or check the address and the game's id` } : null;
|
|
209
|
+
}
|
|
210
|
+
if (reach?.preflight === 'local') return { ok: false, command, preflight: 'local', why: `${play} does not answer: start the site (npm run dev, as a background task that outlives this command) or check the address` };
|
|
211
|
+
if (reach?.preflight === 'site') return { ok: false, command, preflight: 'site', status: reach.status ?? null, why: reach.why };
|
|
212
|
+
return {
|
|
213
|
+
ok: false, command, blocked: true, verdict: 'BLOCKED', preflight: reach?.preflight ?? 'network', ...(reach?.code ? { code: reach.code } : {}), needs: reach?.needs ?? 'network-preflight', instead: reach?.instead ?? LOCAL_INSTEAD,
|
|
214
|
+
why: `BLOCKED ${reach?.why ?? 'network preflight failed, before any page or game was opened.'} Nothing was measured. ${reach?.instead ?? LOCAL_INSTEAD}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE NODE.JS THIS TOOLKIT NEEDS, SAID BEFORE ANYTHING ELSE RUNS.
|
|
3
|
+
*
|
|
4
|
+
* `@homie-rocks/studio` needs Node.js 22 or newer (package.json `engines`), and npm only warns about that. On an
|
|
5
|
+
* older Node the commands did not say so: `homie-studio dev` under Node 20 ended with no word at all, and the person
|
|
6
|
+
* was left to guess. So the CLI imports this file FIRST. An ES module's imports are evaluated in order, so this
|
|
7
|
+
* runs before any other file of the toolkit does, and on a Node that is too old the command ends here, with the
|
|
8
|
+
* version it found, the version it needs and how to get it.
|
|
9
|
+
*
|
|
10
|
+
* This file is read by the old Node it complains about, so it stays plain: no syntax or built-in newer than Node 14.
|
|
11
|
+
* (A file the old Node cannot even parse fails before any code runs; nothing in this package's own code is known to
|
|
12
|
+
* do that on Node 18 or 20, and if one ever does, Node prints its own syntax error, which is not silent either.)
|
|
13
|
+
*/
|
|
14
|
+
export const NODE_MIN = 22;
|
|
15
|
+
|
|
16
|
+
/** What is wrong with this Node.js version for the toolkit, as one sentence, or null when it is new enough. */
|
|
17
|
+
export function nodeProblem(version) {
|
|
18
|
+
var found = String(version === undefined ? process.versions.node : version);
|
|
19
|
+
var major = Number(found.split('.')[0]);
|
|
20
|
+
if (major >= NODE_MIN) return null;
|
|
21
|
+
return 'homie-studio needs Node.js ' + NODE_MIN + ' or newer, and this is Node.js ' + (found || 'of an unknown version') + '. '
|
|
22
|
+
+ 'Install Node.js ' + NODE_MIN + ' (https://nodejs.org, or `nvm install ' + NODE_MIN + '` / `fnm install ' + NODE_MIN + '`), '
|
|
23
|
+
+ 'make it the one this terminal runs (`node --version` says which), then run the command again. Nothing was started or changed.';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* End the command now when this Node.js is too old. `--json` gets the same refusal as JSON on stdout, like every
|
|
28
|
+
* other refusal of the CLI; a person gets one line on stderr. Exit status 1 either way.
|
|
29
|
+
*/
|
|
30
|
+
export function requireNode(version, argv) {
|
|
31
|
+
var why = nodeProblem(version);
|
|
32
|
+
if (!why) return;
|
|
33
|
+
var args = argv === undefined ? process.argv.slice(2) : argv;
|
|
34
|
+
if (args.indexOf('--json') >= 0) process.stdout.write(JSON.stringify({ ok: false, needs: 'node', why: why }, null, 2) + '\n');
|
|
35
|
+
else process.stderr.write('homie-studio: ' + why + '\n');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// HOMIE_STUDIO_NODE: the toolkit's own tests say which version to judge (no machine here has an old Node to run).
|
|
40
|
+
requireNode(process.env.HOMIE_STUDIO_NODE || undefined);
|
package/lib/office.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* room_kick, room_announce, room_close, game_launch_state)
|
|
9
9
|
* homie-studio office announce "<text>" [--game <id>] [--room <code>] [--seconds 30]
|
|
10
10
|
* homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
|
|
11
|
-
* homie-studio office launch <game> private|invite|public [--
|
|
11
|
+
* homie-studio office launch <game> private|invite|public [--max <n>|game]
|
|
12
12
|
* homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
|
|
13
13
|
* homie-studio office mute <game> <room> <seat number | name> [--minutes 10] [--off]
|
|
14
14
|
* homie-studio office close <game> <room> [--minutes 10] [--reopen]
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* THE PROOF OF OWNERSHIP IS THE STUDIO'S OWN CLOUDFLARE LOGIN, as for the stats: a key is minted here and only its
|
|
18
18
|
* SHA-256 goes into the studio's D1. Looking, announcing and inviting happen at once. Kick, mute, closing a room and
|
|
19
|
-
* a game's launch state
|
|
19
|
+
* a game's launch state or room size are only ASKED for here: the answer is a one-time sign-in link
|
|
20
20
|
* that opens the ask in the owner's own browser, where one tap does it (the AI that ran this cannot).
|
|
21
21
|
*/
|
|
22
22
|
import { readStudio } from './studio.mjs';
|
|
@@ -113,11 +113,11 @@ export async function officeInvite(root, game, { url, label, uses, count, days,
|
|
|
113
113
|
return { ok: true, command: 'office invite', game, launch: r.launch, invites: r.invites, note: server || r.launch === 'invite' ? null : `${game} is ${r.launch} now: invites let people in once it is an invite-only beta (homie-studio office launch ${game} invite).` };
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
export async function officeLaunch(root, game, state, { url,
|
|
116
|
+
export async function officeLaunch(root, game, state, { url, max } = {}) {
|
|
117
117
|
if (!GAME.test(String(game ?? '')) || (state !== undefined && state !== null && !['private', 'invite', 'public'].includes(state))) {
|
|
118
|
-
return { ok: false, command: 'office launch', why: 'usage: homie-studio office launch <game> private|invite|public [--
|
|
118
|
+
return { ok: false, command: 'office launch', why: 'usage: homie-studio office launch <game> private|invite|public [--max <n>|game]' };
|
|
119
119
|
}
|
|
120
|
-
const body = { game, ...(state ? { launch: state } : {}), ...(
|
|
120
|
+
const body = { game, ...(state ? { launch: state } : {}), ...(max !== undefined ? { maxPlayers: max === 'game' ? null : Number(max) } : {}) };
|
|
121
121
|
const r = await withKey(root, url, (call) => call('/_studio/api/game', body));
|
|
122
122
|
return askedFor(root, url, r, 'office launch');
|
|
123
123
|
}
|
|
@@ -181,7 +181,7 @@ export function officeLines(r) {
|
|
|
181
181
|
const now = r.now ?? Date.now();
|
|
182
182
|
const mins = (ms) => `${Math.max(0, Math.round(ms / 60000))} min`;
|
|
183
183
|
for (const g of r.games ?? []) {
|
|
184
|
-
lines.push(` ${g.name} (${g.id}): ${g.launch}${g.launch === 'public' ? '' : ' (not listed)'},
|
|
184
|
+
lines.push(` ${g.name} (${g.id}): ${g.launch}${g.launch === 'public' ? '' : ' (not listed)'}, rooms of ${g.maxPlayers}${g.maxSet ? ` (the game's own: ${g.seats})` : ''}`);
|
|
185
185
|
for (const i of (g.invites ?? []).filter((x) => x.open)) lines.push(` invite ${i.code}${i.label ? ` (${i.label})` : ''}: ${i.uses}${i.maxUses ? ` of ${i.maxUses}` : ''} used · ${i.link}`);
|
|
186
186
|
if (!g.rooms.length) lines.push(' no live rooms');
|
|
187
187
|
for (const rm of g.rooms) {
|
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
|
+
}
|