@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/mcp-tools.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* THE LOCAL MCP SERVER'S TOOLS (`homie-studio mcp`, lib/mcp.mjs): the studio toolkit as MCP tools, so the Claude
|
|
3
3
|
* desktop app (through the Homie extension), Claude Code or any MCP client builds games in the SAME chat that shows
|
|
4
4
|
* the cards. Where a tool overlaps the remote Homie MCP (homie.rocks/mcp) it has the same name and the same input
|
|
5
|
-
* shape: studio_scaffold, studio_card, game_make,
|
|
5
|
+
* shape: studio_scaffold, studio_card, game_make, game_port, preview_run, studio_deploy,
|
|
6
6
|
* studio_publish, build_open, build_progress, build_stop. The remote ones say what to run; these run it, here.
|
|
7
7
|
*
|
|
8
8
|
* Long work never holds a tool call: a check, a playtest, a deploy or an npm install starts as a job (lib/jobs.mjs)
|
|
@@ -28,7 +28,10 @@ import { cliJob, findNode, getJob, installJob, installed, jobView, runningJobs,
|
|
|
28
28
|
import { Feed, currentId, publicFeed, readFeed, startProgress } from './progress.mjs';
|
|
29
29
|
import { newStudio, slugify } from './scaffold.mjs';
|
|
30
30
|
import { repoFromUrl, studioRepo } from './repo.mjs';
|
|
31
|
-
import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl
|
|
31
|
+
import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
|
|
32
|
+
import { runningDev } from './dev.mjs';
|
|
33
|
+
import { deployWords } from './deploy-state.mjs';
|
|
34
|
+
import { beforeLine, partsForListing, publishesSoFar } from './directory.mjs';
|
|
32
35
|
import { STUDIO_VERSION } from './version.mjs';
|
|
33
36
|
import { compareVersions, whatsNew, whatsNewLines } from './changelog.mjs';
|
|
34
37
|
import { pinnedVersion } from './upgrade.mjs';
|
|
@@ -37,6 +40,9 @@ import { detectLocalAi } from './local-ai.mjs';
|
|
|
37
40
|
import { LAB_PORT, runningLab } from './lab.mjs';
|
|
38
41
|
import { pictureFor } from './pictures.mjs';
|
|
39
42
|
import { ART_UI, artToolDefs } from './art-tools.mjs';
|
|
43
|
+
// GAME PARTS (parts/PARTS.md): parts_find, part_add, part_new and part_share are lib/parts-tools.mjs.
|
|
44
|
+
import { PARTS_FIRST, partsToolDefs } from './parts-tools.mjs';
|
|
45
|
+
import { partsPlanLines } from './parts-build.mjs';
|
|
40
46
|
import { FEEDBACK_APPS, FEEDBACK_KINDS, FEEDBACK_LIMITS, appOf, cleanNote, draftId, noteBlock, sendNote, withLine } from './feedback.mjs';
|
|
41
47
|
|
|
42
48
|
export const UI = Object.freeze({
|
|
@@ -285,12 +291,9 @@ function coverData(dir) {
|
|
|
285
291
|
return null;
|
|
286
292
|
}
|
|
287
293
|
|
|
294
|
+
/** This studio's dev server when it really runs (lib/dev.mjs: a stale record, or a recycled process id, is not one). */
|
|
288
295
|
function devOf(root) {
|
|
289
|
-
try {
|
|
290
|
-
const rec = JSON.parse(readFileSync(join(workerDir(root), '.wrangler', 'homie-dev.json'), 'utf8'));
|
|
291
|
-
process.kill(rec.pid, 0);
|
|
292
|
-
return { url: `http://127.0.0.1:${rec.port}`, port: rec.port, pid: rec.pid };
|
|
293
|
-
} catch { return null; }
|
|
296
|
+
try { return runningDev(root); } catch { return null; }
|
|
294
297
|
}
|
|
295
298
|
|
|
296
299
|
function published(root, kind) {
|
|
@@ -419,6 +422,10 @@ function buildCard(ctx, root, found) {
|
|
|
419
422
|
feed.error ? `Last error: ${feed.error}` : null,
|
|
420
423
|
...(run?.look ? [`Pictures of the site (file_read shows one): ${run.look.join(', ')}`] : []),
|
|
421
424
|
...jobs.filter((j) => j.state === 'failed').map((j) => `${j.label} failed: ${j.result?.why ?? j.tail.slice(-2).join(' ')}`),
|
|
425
|
+
// A deploy that went through: which games it changed with each one's content hash, and anything it had to say
|
|
426
|
+
// about the studio's domain or the directory (lib/cloudflare.mjs).
|
|
427
|
+
...jobs.filter((j) => j.result?.command === 'deploy' && j.result?.ok && (j.result.games ?? []).some((g) => g.hash)).map((j) => `Deployed: ${j.result.games.map((g) => `${g.id} ${deployWords(g)}`).join('; ')}.`),
|
|
428
|
+
...jobs.filter((j) => j.result?.command === 'deploy').flatMap((j) => (j.result.notes ?? []).map((n) => `Note: ${n}`)),
|
|
422
429
|
feed.state === 'running' ? 'The card on screen follows it live; you need not call this again to watch it.' : null,
|
|
423
430
|
].filter(Boolean).join('\n');
|
|
424
431
|
// A check, playtest or deploy inside a build the person opened (build_open) ends before the build does: say so.
|
|
@@ -857,7 +864,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
857
864
|
},
|
|
858
865
|
{
|
|
859
866
|
name: 'game_make', title: 'Make a multiplayer game',
|
|
860
|
-
description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays.',
|
|
867
|
+
description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays. Before writing one of its systems from scratch, look for a piece of an existing game that does it (parts_find; part_add brings it in), and for the general mechanism in the @homie-rocks/* packages.',
|
|
861
868
|
inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens): its address'), name: str('The game\'s display name'), from: str('Starter id (default gem-rush)'), ...STUDIO_ARG }, required: ['id', 'name'] },
|
|
862
869
|
annotations: { title: 'Make a game', ...RW },
|
|
863
870
|
run: async (a) => {
|
|
@@ -883,24 +890,6 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
883
890
|
return ok(`games/${a.id} is ${a.name}, from the ${a.from ?? 'gem-rush'} starter (${(r.result?.files ?? []).length} files).${got}${install} Change it in games/${a.id}/src/main.ts: file_read it once (a long file in two or three reads), decide every change, and make them in ONE file_edit with an edits list (never a call per change: twenty single edits use up a turn before the game is checked); then build and preview_run; check proves two browsers finish a round.`, { kind: 'game', ...r.result });
|
|
884
891
|
},
|
|
885
892
|
},
|
|
886
|
-
{
|
|
887
|
-
name: 'game_remix', title: 'Remix a shared game',
|
|
888
|
-
description: 'Bring a game another studio shared in the Homie directory into this studio as a new game of its own. The original stays credited: game.json remixOf says "Remix of <game> by <studio>" with a link back, shown on the new game\'s landing and in its credits. A game whose owner\'s licence says no remix is refused.',
|
|
889
|
-
inputSchema: { type: 'object', properties: { game: str('The directory game: "<studio site>/<game id>" or its play URL'), id: str('The new game id in this studio'), name: str('Optional: its new name'), ...STUDIO_ARG }, required: ['game', 'id'] },
|
|
890
|
-
annotations: { title: 'Remix a game', ...RW, openWorldHint: true },
|
|
891
|
-
run: async (a) => {
|
|
892
|
-
const root = ctx.root(a.studio);
|
|
893
|
-
let site; let gameId;
|
|
894
|
-
try { const u = new URL(String(a.game ?? '')); site = u.origin; gameId = u.pathname.split('/').filter(Boolean)[0]; } catch { return fail('game is "<studio site>/<game id>" or its play URL'); }
|
|
895
|
-
if (!/^https:\/\//.test(site) || !GAME_ID.test(String(gameId ?? '')) || !GAME_ID.test(String(a.id ?? ''))) return fail('game is "https://<studio site>/<game id>", and id a new lowercase id');
|
|
896
|
-
const source = `${site}/games/${gameId}/source.json`;
|
|
897
|
-
const r = await cli(ctx, root, `game remix ${a.id}`, ['game', 'remix', source, '--id', a.id, ...(a.name ? ['--name', String(a.name).slice(0, 60)] : [])]);
|
|
898
|
-
if (!r.ended) return stillRunning(r.job, 'The remix');
|
|
899
|
-
if (r.job.code !== 0) return fail(`Not remixed: ${whyOf(r.job)}`);
|
|
900
|
-
const credit = r.result?.credit ? ` Its game.json credits the original: "${r.result.credit}"${r.result.page ? ` (${r.result.page})` : ''}, on its landing and in its credits; keep it.` : '';
|
|
901
|
-
return ok(`games/${a.id} is a remix of ${site}/${gameId} (${(r.result?.files ?? []).length} files).${credit}`, { kind: 'game', ...r.result });
|
|
902
|
-
},
|
|
903
|
-
},
|
|
904
893
|
{
|
|
905
894
|
name: 'game_port', title: 'Make an existing game multiplayer',
|
|
906
895
|
description: 'Read an existing single-player web game on this computer and grade how hard making it multiplayer will be (easy, medium, hard, not a fit, with its risks); with id, also bring it into the studio as games/<id>/ for the port. studio_guide { "topic": "port" } has the whole job; check (and studio_run ["port","check",…]) proves it.',
|
|
@@ -928,7 +917,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
928
917
|
},
|
|
929
918
|
{
|
|
930
919
|
name: 'game_plan', title: 'Plan a game (its Game Codex)',
|
|
931
|
-
description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and get the plan interview to run with the person: two or three questions a message, each with options and your pick, about game type and genre, style, devices, players and rooms, art and film, music and sound, and scope. A game is planned before it is made: with no game <id> yet, this starts its folder with only the codex. Fill CODEX.md from their answers in one call (file_write of the whole file, or one file_edit with an edits list), then game_codex shows it.',
|
|
920
|
+
description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and get the plan interview to run with the person: two or three questions a message, each with options and your pick, about game type and genre, style, devices, players and rooms, art and film, music and sound, and scope. A game is planned before it is made: with no game <id> yet, this starts its folder with only the codex. Fill CODEX.md from their answers in one call (file_write of the whole file, or one file_edit with an edits list), then game_codex shows it. While planning, run `parts find` (the parts_find tool) for each system the game needs, so the plan starts from pieces of existing games and the @homie-rocks/* packages rather than from nothing; the codex\'s Built from section records what was found.',
|
|
932
921
|
inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens)'), name: str('Its name, when the game is not made yet'), ...STUDIO_ARG }, required: ['id'] },
|
|
933
922
|
annotations: { title: 'Plan a game', ...RW },
|
|
934
923
|
run: async (a) => {
|
|
@@ -943,6 +932,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
943
932
|
return ok([
|
|
944
933
|
made ? `Wrote games/${a.id}/CODEX.md (every section${made.planned ? '; the game is planned here before it is made' : ''}).` : `games/${a.id}/CODEX.md is there already: change it, never replace it.`,
|
|
945
934
|
'Now the interview: two or three questions a message, each with concrete options and your pick, so "yes" is an answer. Say back what you heard in one line before the next. Stop after three or four rounds; "just build it" means fill the rest with your own choices and list them under Open questions. Then fill CODEX.md in one call (file_read it once; then file_write the whole file, or one file_edit whose edits list has every section) and show it with game_codex.',
|
|
935
|
+
'', PARTS_FIRST,
|
|
946
936
|
...(guide ? ['', guide] : []),
|
|
947
937
|
].join('\n'), { kind: 'plan', id: a.id, file: `games/${a.id}/CODEX.md`, created: Boolean(made) });
|
|
948
938
|
},
|
|
@@ -977,7 +967,8 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
977
967
|
if (!r.ended) return stillRunning(r.job, 'The build');
|
|
978
968
|
if (r.job.code !== 0) return fail(`The build failed: ${whyOf(r.job)}`);
|
|
979
969
|
const g = r.result?.games ?? [];
|
|
980
|
-
|
|
970
|
+
// Each game's build hash, in the words the terminal prints: the hash a deploy reports and the live site answers with.
|
|
971
|
+
return ok(`Built ${g.length ? g.map((x) => `${x.id} (${Math.round(x.bytes / 1024)} KB${x.hash ? `, ${x.changed ?? 'new'}${x.changed && x.changed !== 'new' ? ' since the last build here' : ''}, build ${x.hash}` : ''})`).join(', ') : 'the home page (no game yet)'}.${devOf(root) ? ' Reload the preview to see it.' : ''}`, { kind: 'built', ...r.result });
|
|
981
972
|
},
|
|
982
973
|
},
|
|
983
974
|
{
|
|
@@ -1008,7 +999,9 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1008
999
|
run: async (a) => {
|
|
1009
1000
|
const root = ctx.root(a.studio);
|
|
1010
1001
|
const r = await cli(ctx, root, 'dev --stop', ['dev', '--stop'], { wait: 20_000 });
|
|
1011
|
-
|
|
1002
|
+
// What a server stopped another way left behind is cleaned up and said (lib/dev.mjs).
|
|
1003
|
+
const note = r.result?.why && (r.result?.stale || r.result?.stopped?.length) ? ` ${r.result.why[0].toUpperCase()}${r.result.why.slice(1)}.` : '';
|
|
1004
|
+
return ok(`${r.result?.stopped?.length ? 'Stopped the studio\'s site here.' : 'It was not running.'}${note}`, { kind: 'preview', stopped: r.result?.stopped ?? [], ...(r.result?.stale ? { stale: true } : {}) });
|
|
1012
1005
|
},
|
|
1013
1006
|
},
|
|
1014
1007
|
{
|
|
@@ -1102,7 +1095,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1102
1095
|
if (!r.ended) return stillRunning(r.job, 'The deploy plan');
|
|
1103
1096
|
if (r.job.code !== 0) return fail(whyOf(r.job));
|
|
1104
1097
|
const p = r.result;
|
|
1105
|
-
return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
|
|
1098
|
+
return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, ...(p.parts ? partsPlanLines(p.parts) : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
|
|
1106
1099
|
}
|
|
1107
1100
|
const studio = readStudio(root);
|
|
1108
1101
|
return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy'] }] });
|
|
@@ -1126,15 +1119,22 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1126
1119
|
},
|
|
1127
1120
|
{
|
|
1128
1121
|
name: 'studio_publish', title: 'List the studio in the Homie directory',
|
|
1129
|
-
description: 'List a deployed studio\'s games, songs and videos in the homie.rocks directory, with their Play links (at most 12 games per studio in the beta).',
|
|
1130
|
-
inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), ...STUDIO_ARG } },
|
|
1122
|
+
description: 'List a deployed studio\'s games, songs and videos in the homie.rocks directory, with their Play links (at most 12 games per studio in the beta). The beta also caps how many times a studio may publish in a day: the answer says how many are left when the directory gives the number, and before: true only says what this computer knows (how many it has sent today, what the directory last said was left) without publishing.',
|
|
1123
|
+
inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), before: { type: 'boolean', description: 'Only say how many publishes this computer has sent today and what the directory last said was left; publish nothing' }, ...STUDIO_ARG } },
|
|
1131
1124
|
annotations: { title: 'List in the directory', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1132
1125
|
run: async (a) => {
|
|
1133
1126
|
const root = ctx.root(a.studio);
|
|
1127
|
+
// What this computer knows of the day's cap, before anything is sent (lib/directory.mjs).
|
|
1128
|
+
const soFar = beforeLine(publishesSoFar(root));
|
|
1129
|
+
// The parts in the games a listing shows, in the deploy plan's own lines (licences, credits, and plainly when
|
|
1130
|
+
// two cannot be combined): said before the studio is listed, and again with the answer.
|
|
1131
|
+
const partsNow = partsForListing(root);
|
|
1132
|
+
const partsSaid = partsPlanLines(partsNow, { at: 'publish' });
|
|
1133
|
+
if (a.before === true) return ok([`${soFar.replace(/^Publishing to the directory: /, 'Not published. ')} A publish from another computer or from the Homie connector is not counted here.`, ...partsSaid].join('\n'), { kind: 'publish-count', ...publishesSoFar(root), ...(partsSaid.length ? { parts: partsNow } : {}) });
|
|
1134
1134
|
const r = await cli(ctx, root, 'publish', ['publish', ...(a.site ? ['--site', String(a.site)] : [])]);
|
|
1135
1135
|
if (!r.ended) return stillRunning(r.job, 'Listing');
|
|
1136
|
-
if (r.job.code !== 0) return fail(`Not listed: ${whyOf(r.job)}
|
|
1137
|
-
return ok(`Listed: ${r.result?.studioPage ?? r.result?.directory ?? ''}\n${(r.result?.games ?? []).map((g) => ` ${g.name}: ${g.play}`).join('\n')}`, { kind: 'publish', ...r.result });
|
|
1136
|
+
if (r.job.code !== 0) return fail(`Not listed: ${whyOf(r.job)}`, r.result?.publishes ? { kind: 'publish', ok: false, needs: r.result.needs ?? null, publishes: r.result.publishes } : undefined);
|
|
1137
|
+
return ok(`Listed: ${r.result?.studioPage ?? r.result?.directory ?? ''}\n${(r.result?.games ?? []).map((g) => ` ${g.name}: ${g.play}`).join('\n')}${r.result?.publishes?.line ? `\n${r.result.publishes.line}` : ''}${partsSaid.length ? `\n${partsSaid.join('\n')}` : ''}`, { kind: 'publish', ...r.result });
|
|
1138
1138
|
},
|
|
1139
1139
|
},
|
|
1140
1140
|
{
|
|
@@ -1184,7 +1184,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1184
1184
|
},
|
|
1185
1185
|
{
|
|
1186
1186
|
name: 'studio_run', title: 'Run a studio command',
|
|
1187
|
-
description: 'Any other homie-studio command in the studio, by its words (the guides name them): e.g. ["progress","stage","plan","done","--note","…"], ["progress","spend","0.40","--what","a cover"], ["look"], ["stats"], ["codex","link","<id>"], ["port","check","<id>","--url","…"], ["upgrade"], ["storage","add"]. Runs the studio\'s own pinned toolkit; long commands hand back a job. ["upgrade"] runs with THIS Homie\'s toolkit when the studio pins an older one (that is how a studio moves up to it): it says what is new since the studio\'s version and what would change, and changes nothing; ["upgrade","--apply"] only after the person agrees, then studio_install.',
|
|
1187
|
+
description: 'Any other homie-studio command in the studio, by its words (the guides name them): e.g. ["progress","stage","plan","done","--note","…"], ["progress","spend","0.40","--what","a cover"], ["look"], ["stats"], ["codex","link","<id>"], ["port","check","<id>","--url","…"], ["shoot","<id>","--preview"], ["build","--types"], ["assets","use","<id>","<asset>","unused"], ["upgrade"], ["storage","add"]. Runs the studio\'s own pinned toolkit; long commands hand back a job. ["upgrade"] runs with THIS Homie\'s toolkit when the studio pins an older one (that is how a studio moves up to it): it says what is new since the studio\'s version and what would change, and changes nothing; ["upgrade","--apply"] only after the person agrees, then studio_install.',
|
|
1188
1188
|
inputSchema: { type: 'object', properties: { args: { type: 'array', items: { type: 'string' }, description: 'The command\'s words after `homie-studio`' }, ...STUDIO_ARG }, required: ['args'] },
|
|
1189
1189
|
annotations: { title: 'Run a studio command', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1190
1190
|
run: async (a) => {
|
|
@@ -1361,6 +1361,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1361
1361
|
];
|
|
1362
1362
|
// Art direction, the cast, the starter library, generated props, checks, the lineup and the rights (lib/art-tools.mjs).
|
|
1363
1363
|
tools.push(...artToolDefs(ctx, { ok, fail, cli, stillRunning, whyOf, needsInstall, ui, str, STUDIO_ARG, RO, RW, pictureFor, findNode, startJob, waitJob }));
|
|
1364
|
+
tools.push(...partsToolDefs(ctx, { ok, fail, str, STUDIO_ARG, RO, RW }));
|
|
1364
1365
|
const topics = guideTopics(ctx);
|
|
1365
1366
|
if (topics.length) {
|
|
1366
1367
|
tools.push({
|
package/lib/mcp.mjs
CHANGED
|
@@ -38,7 +38,7 @@ A new studio follows one checklist, in order and never ahead. Show it in your fi
|
|
|
38
38
|
1. The studio (studio_scaffold): it has NO game; its home page says "First game coming soon".
|
|
39
39
|
2. See a working game (game_demo): a live game on Homie Arcade with its Play link, nothing copied in. Copy a starter in (game_make) only if they ask.
|
|
40
40
|
3. One small change from one sentence of theirs: to the copied game, or to the studio's home (site/theme.json colours, a tagline in studio.json, a first post in posts/). Then build and preview_run, and they reload.
|
|
41
|
-
4. Plan their game (game_plan): a short interview, two or three questions a message with options and your pick; then fill games/<id>/CODEX.md and show it (game_codex).
|
|
41
|
+
4. Plan their game (game_plan): a short interview, two or three questions a message with options and your pick; then fill games/<id>/CODEX.md and show it (game_codex). Look for pieces first: the @homie-rocks/* packages for general mechanisms, and parts_find for pieces other studios shared (part_add brings one in).
|
|
42
42
|
5. Build it: build_open, then game_make under the planned id (the codex stays), file edits (few and large: one file_edit carries every change to a file in its edits list, never a call per change), build, preview_run, check. The card follows every build.
|
|
43
43
|
6. Playtest it (playtest), then put it online: studio_deploy with plan: true first (say what it creates and costs, free), cloudflare_login when not signed in (they approve once in their browser), studio_deploy, then studio_publish.
|
|
44
44
|
If they ask for everything at once, show the list, make your own choices for steps 2 to 4 in one line each, and go on.
|
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();
|