@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/cloudflare.mjs
CHANGED
|
@@ -26,8 +26,12 @@ import { createHash } from 'node:crypto';
|
|
|
26
26
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { join } from 'node:path';
|
|
28
28
|
import { build } from './build.mjs';
|
|
29
|
+
import { builtGames, compareDeploy, deployWords, lastDeploy, lastDeployRecord, lockDeploy, recordDeploy } from './deploy-state.mjs';
|
|
30
|
+
import { listedHere, publish } from './directory.mjs';
|
|
29
31
|
import { MAX_ASSET_BYTES, R2_COST, mediaPlan, r2OverOf, recordMove, sha256File, sizeOf } from './media.mjs';
|
|
32
|
+
import { whyFailed } from './net.mjs';
|
|
30
33
|
import { repoOf } from './repo.mjs';
|
|
34
|
+
import { keptRoutes, readConfig, shadowedDomain, wideRouteRefusal } from './routes.mjs';
|
|
31
35
|
import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
|
|
32
36
|
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
33
37
|
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
@@ -90,6 +94,12 @@ export function explainCloudflare(out, accountId = null) {
|
|
|
90
94
|
if (/maximum number of (D1 )?databases|database limit|too many databases/i.test(text)) {
|
|
91
95
|
return { needs: 'd1-limit', why: 'This Cloudflare account has used all the D1 databases its plan allows (10 on the free plan). Nothing was deployed. Ask the person which unused database of theirs to remove, or use another account; never delete one yourself.' };
|
|
92
96
|
}
|
|
97
|
+
// A route in wrangler.jsonc that another Worker of the zone already holds. Wrangler refuses, and so does the
|
|
98
|
+
// studio: that route is not the studio's to take (lib/routes.mjs).
|
|
99
|
+
if (/assigned to another worker|already assigned to routes/i.test(text)) {
|
|
100
|
+
const routes = [...text.matchAll(/^\s*-\s+(\S+)\s*$/gm)].map((m) => m[1]);
|
|
101
|
+
return { needs: 'cloudflare-routes', ...(routes.length ? { routes } : {}), why: `A route in wrangler.jsonc${routes.length ? ` (${routes.join(', ')})` : ''} already belongs to another Worker on this Cloudflare zone, so Cloudflare refused the deploy and the live site is unchanged. The studio never takes a route from another Worker. If the route is wider than the studio's own hostname (a wildcard, a catch-all), take it out of wrangler.jsonc and give the studio its own: { "pattern": "<its hostname>/*", "zone_name": "<the domain>" }. If it IS the studio's own hostname, ask the person: only they can unassign it from the other Worker, in the zone's Workers Routes in the Cloudflare dashboard.` };
|
|
102
|
+
}
|
|
93
103
|
if (/\b1027\b|exceeded .*daily request limit/i.test(text)) {
|
|
94
104
|
return { needs: 'free-plan-daily-limit', why: 'This account used its free plan\'s 100,000 Worker requests for today (it resets at 00:00 UTC). Nothing is broken; try again after the reset.' };
|
|
95
105
|
}
|
|
@@ -127,10 +137,29 @@ export function deployPlan(root) {
|
|
|
127
137
|
};
|
|
128
138
|
}
|
|
129
139
|
|
|
130
|
-
|
|
140
|
+
/**
|
|
141
|
+
* `homie-studio deploy`. One at a time for a studio: it holds the studio's deploy lock (lib/deploy-state.mjs) from
|
|
142
|
+
* before it reads anything until it ends, however it ends. `fetchFn` is the toolkit's own tests' stand-in network.
|
|
143
|
+
*/
|
|
144
|
+
export async function deploy(root, opts = {}) {
|
|
145
|
+
const lock = lockDeploy(root);
|
|
146
|
+
if (!lock.ok) return { ok: false, command: 'deploy', needs: lock.needs, why: lock.why };
|
|
147
|
+
// A deploy that is killed still lets go (a kill -9 cannot; the next deploy sees its process is gone).
|
|
148
|
+
const onExit = () => lock.release();
|
|
149
|
+
process.once('exit', onExit);
|
|
150
|
+
try { return await deployLocked(root, opts); } finally { process.off('exit', onExit); lock.release(); }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function deployLocked(root, { log = () => {}, homie, fetchFn = null } = {}) {
|
|
131
154
|
const studio = readStudio(root);
|
|
132
155
|
const cf = { r2: null, created: [], ...studio.cloudflare };
|
|
133
156
|
const created = new Set(cf.created ?? []);
|
|
157
|
+
// A wildcard or catch-all route in the studio's own config would hand other hostnames of the zone to this Worker:
|
|
158
|
+
// refused before Cloudflare is asked anything (lib/routes.mjs). Read once here; every rewrite below keeps the
|
|
159
|
+
// studio's own routes exactly as its owner wrote them.
|
|
160
|
+
const wide = wideRouteRefusal(readConfig(root), studio);
|
|
161
|
+
if (wide) return { ok: false, command: 'deploy', ...wide };
|
|
162
|
+
const routes = keptRoutes(root, studio);
|
|
134
163
|
const who = whoami(root);
|
|
135
164
|
if (!who) {
|
|
136
165
|
if (cf.auth === 'stripe-projects') {
|
|
@@ -187,6 +216,14 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
187
216
|
// A new studio goes live with its own Home ("First game coming soon") before it has a game.
|
|
188
217
|
step(b.catalogue.length || b.songs.length || b.videos.length ? `built ${b.catalogue.length} game(s), ${b.songs.length} song(s), ${b.videos.length} video(s)` : 'built the home page (no game yet: "First game coming soon")');
|
|
189
218
|
for (const n of b.mediaNotes ?? []) step(`note: ${n.file}: ${n.why}`);
|
|
219
|
+
if (b.retired) step(`note: ${b.retired}`);
|
|
220
|
+
// Which games this deploy changes, against what the last deploy from this computer put live, each with the
|
|
221
|
+
// build's own hash for it (lib/deploy-state.mjs: one hash vocabulary). The record is written only once Wrangler has deployed.
|
|
222
|
+
const builtNow = builtGames(root, b.catalogue);
|
|
223
|
+
const delta = compareDeploy(builtNow, lastDeploy(root), lastDeployRecord(root));
|
|
224
|
+
for (const g of delta.games) step(`${g.id}: ${deployWords(delta.first ? { hash: g.hash } : g)}`, { game: g.id, hash: g.hash, change: g.change });
|
|
225
|
+
if (delta.first && delta.games.length) step('no earlier deploy from this computer to compare with: the next one says changed or unchanged for each game');
|
|
226
|
+
for (const id of delta.removed) step(`${id}: no longer in the build; it leaves the site with this deploy`);
|
|
190
227
|
|
|
191
228
|
// The Worker: never one this studio did not make.
|
|
192
229
|
if (!created.has(`worker:${cf.worker}`)) {
|
|
@@ -218,14 +255,15 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
218
255
|
const r2 = storage;
|
|
219
256
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
220
257
|
|
|
221
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
|
|
258
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes }));
|
|
259
|
+
if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
|
|
222
260
|
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
223
261
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
224
262
|
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
225
263
|
step('D1 migrations applied');
|
|
226
264
|
// Workers AI (0.17.0): bound only when a server's AI guides think with it.
|
|
227
265
|
if (needsWorkersAi(root, w, cf.d1)) {
|
|
228
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true }));
|
|
266
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes }));
|
|
229
267
|
step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
|
|
230
268
|
}
|
|
231
269
|
|
|
@@ -250,11 +288,46 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
250
288
|
// The homie.rocks directory claim: the live site claims itself the first time its manifest is read (0.10.0), so
|
|
251
289
|
// reading it once now is all it takes; nothing is stored by hand.
|
|
252
290
|
const directory = homie || studio.homie?.directory || 'https://homie.rocks';
|
|
291
|
+
recordDeploy(root, builtNow);
|
|
253
292
|
let claim = null;
|
|
293
|
+
const notes = [];
|
|
254
294
|
if (url) {
|
|
255
|
-
|
|
295
|
+
const read = await readLiveSite(url, fetchFn ? { fetchFn } : {});
|
|
296
|
+
claim = read.claim;
|
|
256
297
|
if (claim) step('the site claimed itself in the directory');
|
|
257
|
-
else
|
|
298
|
+
else if (read.state === 'other' && !isWorkersDev(url)) {
|
|
299
|
+
// The studio's own domain answered as something else: say which route is needed (lib/routes.mjs). The deploy
|
|
300
|
+
// itself went through; the zone's other routes are never touched.
|
|
301
|
+
const s = shadowedDomain(readConfig(root), new URL(url).hostname, read);
|
|
302
|
+
notes.push(s.why);
|
|
303
|
+
step(s.why, s.route ? { needs: 'cloudflare-routes', route: s.route } : {});
|
|
304
|
+
} else if (read.state === 'unreachable') {
|
|
305
|
+
// This computer could not ask (its own name lookup, no connection): a network preflight failure, never a
|
|
306
|
+
// verdict on the site that was just deployed.
|
|
307
|
+
const said = `deployed, but this computer could not read ${url} back to check it: ${read.why}. ${read.preflight === 'dns' ? 'That is this computer\'s name lookup (a browser here may still open the site), not the deploy.' : 'That is this computer\'s connection, not the deploy.'} The site claims itself when the directory first reads it.`;
|
|
308
|
+
notes.push(said);
|
|
309
|
+
step(said, { preflight: read.preflight ?? 'network' });
|
|
310
|
+
} else step('the directory claim is not there yet (the site claims itself when the directory first reads it)');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// The deploy before this one came from a toolkit that still had remix, and left games open to be taken whole. No
|
|
314
|
+
// game's source is served any more, but the directory keeps its old copy of the studio (and its offer of those
|
|
315
|
+
// games) until it reads the site again, so ask it to, now. ONLY for a studio this computer listed (publish records
|
|
316
|
+
// it): going online never lists a studio, and a deploy never lists one either. Once: see compareDeploy.
|
|
317
|
+
let reread = null;
|
|
318
|
+
if (url && delta.withdrawn.length) {
|
|
319
|
+
const which = delta.withdrawn.join(', ');
|
|
320
|
+
const dir = String(directory).replace(/\/+$/, '');
|
|
321
|
+
if (listedHere(root, { site: url, directory: dir })) {
|
|
322
|
+
const again = await publish(root, { homie: dir, site: url, ...(fetchFn ? { fetchFn } : {}) });
|
|
323
|
+
reread = { ok: again.ok, games: delta.withdrawn, ...(again.ok ? {} : { why: again.why }), ...(again.publishes ? { publishes: again.publishes } : {}) };
|
|
324
|
+
step(again.ok
|
|
325
|
+
? `asked the directory to read the studio again: it still offered the source of ${which}, and no game's source is served whole any more${again.publishes?.line ? ` (${again.publishes.line})` : ''}`
|
|
326
|
+
: `the directory still offers the source of ${which}, which is no longer served, and it did not take the re-read (${String(again.why ?? 'no answer').split('\n')[0]}): it keeps its old copy until the next publish`);
|
|
327
|
+
if (!again.ok) notes.push(`The directory still offers the source of ${which}, which this site no longer serves: publish again later (homie-studio publish).`);
|
|
328
|
+
} else {
|
|
329
|
+
step(`the source of ${which} is no longer served (games build on each other through parts now); this computer has no record of listing the studio in the directory, so the directory was not asked anything (a deploy never lists a studio). If it is listed, publish again and the directory reads the site as it is.`);
|
|
330
|
+
}
|
|
258
331
|
}
|
|
259
332
|
|
|
260
333
|
// studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
|
|
@@ -264,7 +337,11 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
264
337
|
return {
|
|
265
338
|
ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
266
339
|
...(media ? { media: { moved: media.moved ?? [], failed: media.failed ?? [], inR2: media.inR2 ?? null } } : {}),
|
|
267
|
-
games: b.catalogue.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })
|
|
340
|
+
games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: g?.hash ?? null, change: delta.first ? 'first deploy from this computer' : g?.change ?? null }; }),
|
|
341
|
+
...(delta.removed.length ? { removed: delta.removed } : {}),
|
|
342
|
+
...(routes ? { routes: routes.map((r) => r.pattern) } : {}),
|
|
343
|
+
...(notes.length ? { notes } : {}),
|
|
344
|
+
...(reread ? { reread } : {}),
|
|
268
345
|
songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
|
|
269
346
|
videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
|
|
270
347
|
claim: Boolean(claim), directory,
|
|
@@ -301,6 +378,44 @@ export function repoVar(root) {
|
|
|
301
378
|
return repo ? ['--var', `HOMIE_REPO:${repo}`] : [];
|
|
302
379
|
}
|
|
303
380
|
|
|
381
|
+
/**
|
|
382
|
+
* The live site's manifest, read back after a deploy, and what the answer was:
|
|
383
|
+
* state 'studio' it answered as a studio; `claim` is its directory claim (null until it has one)
|
|
384
|
+
* state 'other' the address answered, and not as a studio (another Worker's page, a 404 that is not ours):
|
|
385
|
+
* `status`, and `said`, the first words of what it said
|
|
386
|
+
* state 'unreachable' this computer could not ask: `why`, and `preflight` ('dns' for a name it could not look up)
|
|
387
|
+
* state 'skipped' the toolkit's own tests (HOMIE_STUDIO_WARM=0) with no stand-in network
|
|
388
|
+
* Reading it is also what makes a 0.10.0 site claim itself. A fresh deploy can take a few seconds to answer
|
|
389
|
+
* everywhere, so a site that does not yet answer as a studio is asked three times.
|
|
390
|
+
*/
|
|
391
|
+
export async function readLiveSite(site, { tries = 3, wait = 2500, fetchFn = null } = {}) {
|
|
392
|
+
if (!fetchFn && process.env.HOMIE_STUDIO_WARM === '0') return { state: 'skipped', claim: null };
|
|
393
|
+
const ask = fetchFn ?? globalThis.fetch;
|
|
394
|
+
const base = String(site).replace(/\/+$/, '');
|
|
395
|
+
let last = { state: 'unreachable', claim: null, why: 'no answer' };
|
|
396
|
+
for (let i = 0; i < tries; i++) {
|
|
397
|
+
try {
|
|
398
|
+
const res = await ask(`${base}/.well-known/homie-studio.json`, { headers: { accept: 'application/json', 'user-agent': 'homie-studio-deploy' }, signal: AbortSignal.timeout(15_000) });
|
|
399
|
+
const text = await res.text();
|
|
400
|
+
let body = null;
|
|
401
|
+
try { body = JSON.parse(text); } catch { body = null; }
|
|
402
|
+
const studio = res.ok && body && typeof body === 'object' && ('claim' in body || Array.isArray(body.games));
|
|
403
|
+
if (studio) {
|
|
404
|
+
const claim = /^[a-f0-9]{16,128}$/.test(body.claim ?? '') ? body.claim : null;
|
|
405
|
+
if (claim) return { state: 'studio', claim };
|
|
406
|
+
last = { state: 'studio', claim: null };
|
|
407
|
+
} else last = { state: 'other', claim: null, status: res.status, said: String(body?.error ?? body?.message ?? text ?? '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) };
|
|
408
|
+
} catch (error) {
|
|
409
|
+
const w = whyFailed(error, base);
|
|
410
|
+
last = { state: 'unreachable', claim: null, why: w.why, code: w.code ?? null, ...(w.preflight ? { preflight: w.preflight } : {}) };
|
|
411
|
+
// A name this computer cannot look up does not start resolving in five seconds: say so now.
|
|
412
|
+
if (w.preflight === 'dns') return last;
|
|
413
|
+
}
|
|
414
|
+
if (i < tries - 1) await new Promise((r) => setTimeout(r, wait));
|
|
415
|
+
}
|
|
416
|
+
return last;
|
|
417
|
+
}
|
|
418
|
+
|
|
304
419
|
/**
|
|
305
420
|
* Read the live site's manifest once, which makes a 0.10.0 site claim itself in its directory. Returns the claim it
|
|
306
421
|
* serves, or null (the directory did not answer, the site is not reachable yet, or it is a Preview). A fresh deploy
|
|
@@ -378,10 +493,13 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
378
493
|
step(claim ? 'the site claimed itself in the directory' : 'no directory claim yet (the site claims itself when the directory first reads it)');
|
|
379
494
|
let games = [];
|
|
380
495
|
try { games = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { games = []; }
|
|
496
|
+
const hashes = builtGames(root, games);
|
|
497
|
+
for (const id of games) if (hashes[id]?.hash) step(`${id}: ${deployWords(hashes[id])}`);
|
|
381
498
|
return {
|
|
382
499
|
ok: true, command: 'deploy', ci: true, url, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
|
|
383
500
|
commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
|
|
384
|
-
|
|
501
|
+
// A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
|
|
502
|
+
games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
|
|
385
503
|
claim: Boolean(claim),
|
|
386
504
|
};
|
|
387
505
|
}
|
|
@@ -429,7 +547,8 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
429
547
|
const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
|
|
430
548
|
writeStudio(root, next);
|
|
431
549
|
if (next.cloudflare.d1Id) {
|
|
432
|
-
|
|
550
|
+
// The studio's own routes stay through this rewrite too (lib/routes.mjs).
|
|
551
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root), routes: keptRoutes(root, studio) }));
|
|
433
552
|
}
|
|
434
553
|
log(`created R2 ${bucket}`);
|
|
435
554
|
return { ok: true, command: 'storage add', bucket, account: accountId, cost: R2_COST, next: ['npx --no-install homie-studio media move --dry-run (which songs and videos go to R2: over 1 MiB, or left out of git)', 'npm run deploy (binds the bucket as MEDIA, moves them, checks each by SHA-256, and serves them from R2 at the same addresses; the files stay in this folder)'] };
|
package/lib/codex.mjs
CHANGED
|
@@ -702,6 +702,12 @@ ${game.blurb ?? ''}
|
|
|
702
702
|
|
|
703
703
|
<!-- The theme's mood and tempo, the sounds that matter (pick-up, hit, win), and whether songs come from the free synth or ElevenLabs. -->
|
|
704
704
|
|
|
705
|
+
## Built from
|
|
706
|
+
|
|
707
|
+
<!-- What this game is made of, looked for BEFORE writing (the plan tool says how: \`parts find\` for each system, and the @homie-rocks/* packages). One line each:
|
|
708
|
+
packages (camera, input, audio, effects… from npm); parts from other games (which part, the game and studio it came from, its licence);
|
|
709
|
+
and what was written here from scratch, and why nothing that exists fitted. -->
|
|
710
|
+
|
|
705
711
|
## Milestones
|
|
706
712
|
|
|
707
713
|
${game.planned ? `- [ ] The plan (this codex)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio collision`: a model that can be drawn becomes a model that can be stood on, and stays that way.
|
|
3
|
+
*
|
|
4
|
+
* homie-studio collision bake <game> <model.glb> [--cell 0.25] [--side top|under] [--max-y <m>]
|
|
5
|
+
* homie-studio collision check <game> [<model.glb>]
|
|
6
|
+
*
|
|
7
|
+
* Importing a model records what it looks like and where it came from. Nothing in that says where a figure may walk
|
|
8
|
+
* on it, so the first playable version of a generated landmark is one figures fall through. `bake` reads the
|
|
9
|
+
* model's triangles (every node's transform applied, in metres) and writes two files beside it:
|
|
10
|
+
*
|
|
11
|
+
* <model>.collision.json the height grid (@homie-rocks/heightfield MeshBake), the SHA-256 of the model file and
|
|
12
|
+
* a checksum of its triangles, and three things a person AUTHORS and a re-bake keeps:
|
|
13
|
+
* "proxies" (boxes, cylinders and ramps for what a figure walks around), "mover" (the
|
|
14
|
+
* body that must fit: radius, height, step, arrival radius) and "routes" (ways a bot
|
|
15
|
+
* should be able to walk, as lists of points)
|
|
16
|
+
* <model>.collision.svg the plan at true scale (40 px to the metre, a metre grid, a scale bar): the grid shaded
|
|
17
|
+
* by height, the proxies outlined, the routes drawn, and a mark where a route fails
|
|
18
|
+
*
|
|
19
|
+
* `check` is the gate: the model file is the one that was baked (a changed file is "stale: bake it again", by name),
|
|
20
|
+
* the proxies are well formed, and a body of the mover's size both passes the static route check and walks every
|
|
21
|
+
* route to its end. It answers ok: false with a row for each thing wrong, so it can stand in a build script.
|
|
22
|
+
*
|
|
23
|
+
* The game loads the JSON with `unpackBake` and `bakeField` and calls `bakeStale` against the mesh it loaded.
|
|
24
|
+
*
|
|
25
|
+
* The arithmetic is @homie-rocks/heightfield's, not a copy of it. It is not installed with the toolkit (a pinned
|
|
26
|
+
* version of it that is not on npm yet would stop every new studio's install): the studio's own copy is used, else one
|
|
27
|
+
* beside the toolkit, else the answer says the one install to run, which an AI with a shell runs itself.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
33
|
+
import { pathToFileURL } from 'node:url';
|
|
34
|
+
import { modelTools } from './optimise.mjs';
|
|
35
|
+
import { listGames } from './studio.mjs';
|
|
36
|
+
|
|
37
|
+
/** The body that must fit when a file names none: a person, in metres. */
|
|
38
|
+
export const DEFAULT_MOVER = Object.freeze({ radius: 0.35, height: 1.8, step: 0.3, maxDrop: 0.5, arrival: 1, speed: 4 });
|
|
39
|
+
const PX = 40;
|
|
40
|
+
/** How a walk that did not arrive reads in a sentence after "the bot". */
|
|
41
|
+
const ENDED = { fell: 'fell', blocked: 'was blocked', headroom: 'ran out of headroom', 'wrong level': 'reached the wrong level', timeout: 'ran out of time' };
|
|
42
|
+
|
|
43
|
+
/** @homie-rocks/heightfield's modules, from the studio's own install first, then from beside this toolkit. */
|
|
44
|
+
export async function heightfieldModules(root) {
|
|
45
|
+
const names = ['MeshBake', 'Proxy', 'Levels', 'Route'];
|
|
46
|
+
const from = [];
|
|
47
|
+
if (root) { try { const req = createRequire(join(root, 'package.json')); from.push((n) => import(pathToFileURL(req.resolve(`@homie-rocks/heightfield/${n}.js`)).href)); } catch { /* not a package: the toolkit's own */ } }
|
|
48
|
+
from.push((n) => import(`@homie-rocks/heightfield/${n}.js`));
|
|
49
|
+
for (const load of from) {
|
|
50
|
+
try { const [bake, proxy, levels, route] = await Promise.all(names.map(load)); if (bake.bakeMesh && route.traverse) return { ...bake, ...proxy, ...levels, ...route }; } catch { /* the next place */ }
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const NEEDS = { ok: false, needs: 'heightfield', why: 'collision needs the package @homie-rocks/heightfield (0.2.0 or later, for its MeshBake module) in this studio, and found none', instead: 'npm install --save-exact @homie-rocks/heightfield (in the studio folder; then ask again)' };
|
|
55
|
+
|
|
56
|
+
/** Every triangle of a model, in the scene's own space (each node's transform applied): { positions, indices }. */
|
|
57
|
+
export async function modelTriangles(bytes) {
|
|
58
|
+
const { io } = await modelTools();
|
|
59
|
+
const doc = await io.readBinary(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
|
|
60
|
+
const positions = []; const indices = [];
|
|
61
|
+
const scenes = doc.getRoot().listScenes();
|
|
62
|
+
const scene = doc.getRoot().getDefaultScene() ?? scenes[0];
|
|
63
|
+
if (!scene) return { positions: new Float32Array(0), indices: new Uint32Array(0) };
|
|
64
|
+
scene.traverse((node) => {
|
|
65
|
+
const mesh = node.getMesh();
|
|
66
|
+
if (!mesh) return;
|
|
67
|
+
const m = node.getWorldMatrix();
|
|
68
|
+
for (const prim of mesh.listPrimitives()) {
|
|
69
|
+
// 4 is TRIANGLES. Lines and points are not something to stand on; strips and fans are not what an optimiser writes.
|
|
70
|
+
if (prim.getMode() !== 4) continue;
|
|
71
|
+
const pos = prim.getAttribute('POSITION');
|
|
72
|
+
if (!pos) continue;
|
|
73
|
+
const base = positions.length / 3; const el = [0, 0, 0];
|
|
74
|
+
for (let i = 0; i < pos.getCount(); i += 1) {
|
|
75
|
+
pos.getElement(i, el);
|
|
76
|
+
positions.push(m[0] * el[0] + m[4] * el[1] + m[8] * el[2] + m[12], m[1] * el[0] + m[5] * el[1] + m[9] * el[2] + m[13], m[2] * el[0] + m[6] * el[1] + m[10] * el[2] + m[14]);
|
|
77
|
+
}
|
|
78
|
+
const ix = prim.getIndices();
|
|
79
|
+
if (ix) for (let i = 0; i < ix.getCount(); i += 1) indices.push(base + ix.getScalar(i));
|
|
80
|
+
else for (let i = 0; i < pos.getCount(); i += 1) indices.push(base + i);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return { positions: new Float32Array(positions), indices: new Uint32Array(indices) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The plan at true scale, as SVG text: 40 px to the metre, north (-z) up. `problems` are points to mark.
|
|
90
|
+
* A text file on purpose: it diffs, and a person can open it beside the lineup.
|
|
91
|
+
*/
|
|
92
|
+
export function collisionSvg(bake, { proxies = [], routes = [], problems = [], title = '' } = {}) {
|
|
93
|
+
const { nx, nz, x0, z0, cell, heights, covered } = bake;
|
|
94
|
+
const pad = 1; // metres of margin
|
|
95
|
+
const w = ((nx - 1) * cell + pad * 2) * PX; const h = ((nz - 1) * cell + pad * 2) * PX + 30;
|
|
96
|
+
const sx = (x) => ((x - x0 + pad) * PX).toFixed(1); const sz = (z) => ((z - z0 + pad) * PX).toFixed(1);
|
|
97
|
+
let lo = Infinity; let hi = -Infinity;
|
|
98
|
+
for (let k = 0; k < heights.length; k += 1) if (covered[k]) { lo = Math.min(lo, heights[k]); hi = Math.max(hi, heights[k]); }
|
|
99
|
+
const out = [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w.toFixed(0)} ${h.toFixed(0)}" width="${w.toFixed(0)}" height="${h.toFixed(0)}" font-family="system-ui, sans-serif" font-size="12">`,
|
|
100
|
+
`<title>${esc(title || 'collision plan')}</title>`, `<rect width="100%" height="100%" fill="#101418"/>`];
|
|
101
|
+
// Sixteen shades of height, runs of one shade along a row merged into one rectangle.
|
|
102
|
+
for (let j = 0; j < nz; j += 1) {
|
|
103
|
+
let run = -1; let shade = -1;
|
|
104
|
+
const flush = (i) => { if (run >= 0) { const v = 60 + shade * 12; out.push(`<rect x="${sx(x0 + (run - 0.5) * cell)}" y="${sz(z0 + (j - 0.5) * cell)}" width="${((i - run) * cell * PX).toFixed(1)}" height="${(cell * PX).toFixed(1)}" fill="rgb(${v},${v + 8},${v + 14})"/>`); } run = -1; };
|
|
105
|
+
for (let i = 0; i <= nx; i += 1) {
|
|
106
|
+
const k = j * nx + i;
|
|
107
|
+
const s = i < nx && covered[k] ? Math.min(15, Math.floor(((heights[k] - lo) / Math.max(1e-6, hi - lo)) * 16)) : -1;
|
|
108
|
+
if (s !== shade) { flush(i); shade = s; if (s >= 0) run = i; }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (let x = Math.ceil(x0 - pad); x <= x0 + (nx - 1) * cell + pad; x += 1) out.push(`<line x1="${sx(x)}" y1="0" x2="${sx(x)}" y2="${(h - 30).toFixed(0)}" stroke="#ffffff" stroke-opacity="${x === 0 ? 0.35 : 0.08}"/>`);
|
|
112
|
+
for (let z = Math.ceil(z0 - pad); z <= z0 + (nz - 1) * cell + pad; z += 1) out.push(`<line x1="0" y1="${sz(z)}" x2="${w.toFixed(0)}" y2="${sz(z)}" stroke="#ffffff" stroke-opacity="${z === 0 ? 0.35 : 0.08}"/>`);
|
|
113
|
+
for (const p of proxies) {
|
|
114
|
+
if (p.kind === 'cylinder') { out.push(`<circle cx="${sx(p.x)}" cy="${sz(p.z)}" r="${(p.r * PX).toFixed(1)}" fill="none" stroke="#ff7a59" stroke-width="2"><title>${esc(p.id ?? p.kind)}: ${p.h} m high</title></circle>`); continue; }
|
|
115
|
+
const yaw = p.yaw ?? 0; const c = Math.cos(yaw); const s = Math.sin(yaw);
|
|
116
|
+
const pts = [[-p.hx, -p.hz], [p.hx, -p.hz], [p.hx, p.hz], [-p.hx, p.hz]].map(([ax, az]) => `${sx(p.x + ax * c + az * s)},${sz(p.z - ax * s + az * c)}`).join(' ');
|
|
117
|
+
out.push(`<polygon points="${pts}" fill="none" stroke="#ff7a59" stroke-width="2"${p.kind === 'ramp' ? ' stroke-dasharray="6 3"' : ''}><title>${esc(p.id ?? p.kind)}: ${p.h} m high</title></polygon>`);
|
|
118
|
+
}
|
|
119
|
+
for (const r of routes) out.push(`<polyline points="${(r.path ?? []).map((q) => `${sx(q.x)},${sz(q.z)}`).join(' ')}" fill="none" stroke="#5ec8ff" stroke-width="2"><title>${esc(r.name ?? 'route')}</title></polyline>`);
|
|
120
|
+
for (const q of problems) out.push(`<circle cx="${sx(q.x)}" cy="${sz(q.z)}" r="7" fill="none" stroke="#ff3355" stroke-width="3"><title>${esc(q.why ?? '')}</title></circle>`);
|
|
121
|
+
out.push(`<line x1="20" y1="${(h - 14).toFixed(0)}" x2="${20 + PX}" y2="${(h - 14).toFixed(0)}" stroke="#fff" stroke-width="3"/>`,
|
|
122
|
+
`<text x="${28 + PX}" y="${(h - 10).toFixed(0)}" fill="#fff">1 m · ${esc(title)} · ${cell} m cell · heights ${Number.isFinite(lo) ? `${lo.toFixed(2)} to ${hi.toFixed(2)} m` : 'none'}</text>`, '</svg>');
|
|
123
|
+
return `${out.join('\n')}\n`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const gameDir = (root, id) => listGames(root).find((g) => g.id === id)?.dir ?? null;
|
|
127
|
+
/** Every file under a game's folder whose name passes `want`, sorted (no dot folders, no node_modules). */
|
|
128
|
+
function filesUnder(dir, want) {
|
|
129
|
+
const out = [];
|
|
130
|
+
const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { if (e.name === 'node_modules' || e.name.startsWith('.')) continue; const p = join(d, e.name); if (e.isDirectory()) walk(p); else if (want(e.name)) out.push(p); } };
|
|
131
|
+
walk(dir);
|
|
132
|
+
return out.sort();
|
|
133
|
+
}
|
|
134
|
+
/** A model named on the command line: a path from the game's folder or from here, or a bare file name anywhere in the game. */
|
|
135
|
+
function modelPath(dir, file) {
|
|
136
|
+
const direct = isAbsolute(file) ? file : [join(dir, file), resolve(file)].find((p) => existsSync(p));
|
|
137
|
+
if (direct && existsSync(direct)) return direct;
|
|
138
|
+
return file === basename(file) ? filesUnder(dir, (n) => n === file)[0] ?? null : null;
|
|
139
|
+
}
|
|
140
|
+
const sidecar = (model) => model.replace(/\.(glb|gltf)$/i, '') + '.collision.json';
|
|
141
|
+
const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
142
|
+
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
143
|
+
|
|
144
|
+
/** Check one sidecar against its model: rows of { label, ok, note }, and the points where a route failed. */
|
|
145
|
+
async function checkOne(H, model, file) {
|
|
146
|
+
const rows = []; const marks = [];
|
|
147
|
+
const row = (label, ok, note = '') => rows.push({ label, ok, note });
|
|
148
|
+
const side = readJson(file);
|
|
149
|
+
if (!side || side.v !== 1 || !side.bake) { row('collision file', false, 'not a version 1 collision file: bake it again'); return { rows, marks, side: null, bake: null }; }
|
|
150
|
+
let bake = null;
|
|
151
|
+
try { bake = H.unpackBake(side.bake); } catch (e) { row('height grid', false, e.message); return { rows, marks, side, bake }; }
|
|
152
|
+
if (!existsSync(model)) { row('model', false, `${basename(model)} is not beside its collision file`); return { rows, marks, side, bake }; }
|
|
153
|
+
const bytes = readFileSync(model);
|
|
154
|
+
if (sha256(bytes) === side.sha256) row('model', true, 'the file that was baked');
|
|
155
|
+
else {
|
|
156
|
+
// The file changed. Whether the SURFACE changed is the mesh checksum's question: a re-export with new textures
|
|
157
|
+
// and the same triangles is still the same collider.
|
|
158
|
+
const why = H.bakeStale(bake, await modelTriangles(bytes));
|
|
159
|
+
row('model', !why, why ? `stale: ${why}` : 'the file changed, its triangles did not');
|
|
160
|
+
}
|
|
161
|
+
const proxies = Array.isArray(side.proxies) ? side.proxies : [];
|
|
162
|
+
const bad = H.checkProxies(proxies);
|
|
163
|
+
row('proxies', bad.length === 0, bad.length ? bad.join('; ') : `${proxies.length} authored`);
|
|
164
|
+
const mover = { ...DEFAULT_MOVER, ...(side.mover ?? {}) };
|
|
165
|
+
const world = H.createWorld({ ground: H.bakeField(bake), proxies: bad.length ? [] : proxies });
|
|
166
|
+
const routes = Array.isArray(side.routes) ? side.routes : [];
|
|
167
|
+
if (!routes.length) row('routes', true, 'none authored: add "routes" to the collision file to have a bot walk it');
|
|
168
|
+
for (const r of routes) {
|
|
169
|
+
const name = `route ${r.name ?? routes.indexOf(r) + 1}`;
|
|
170
|
+
const path = Array.isArray(r.path) ? r.path : [];
|
|
171
|
+
if (path.length < 2) { row(name, false, 'a route is { "name", "path": [{ "x", "y", "z" }, …] } with at least two points'); continue; }
|
|
172
|
+
const found = H.checkRoute(world, path, mover);
|
|
173
|
+
const run = H.traverse(world, path, mover);
|
|
174
|
+
for (const p of found) marks.push({ x: p.at.x, z: p.at.z, why: `${name}: ${p.why}` });
|
|
175
|
+
if (!run.ok) marks.push({ x: run.at.x, z: run.at.z, why: `${name}: the bot ${ENDED[run.why] ?? run.why} here` });
|
|
176
|
+
const first = found[0];
|
|
177
|
+
row(name, found.length === 0 && run.ok, found.length === 0 && run.ok ? `walked ${run.metres.toFixed(1)} m in ${run.seconds.toFixed(1)} s with a ${mover.arrival} m arrival radius`
|
|
178
|
+
: [first ? `${first.part} ${first.index}: ${first.why} (at ${first.at.x.toFixed(2)}, ${first.at.z.toFixed(2)})${found.length > 1 ? ` and ${found.length - 1} more` : ''}` : null, run.ok ? null : `the bot ${ENDED[run.why] ?? run.why} at ${run.at.x.toFixed(2)}, ${run.at.z.toFixed(2)} heading for point ${run.index}`].filter(Boolean).join('; '));
|
|
179
|
+
}
|
|
180
|
+
return { rows, marks, side, bake };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `collision bake`: the grid, the checksums and the plan, beside the model. Authored proxies, mover and routes are kept. */
|
|
184
|
+
export async function collisionBake(root, id, file, { cell = 0.25, side = 'top', maxY, minY } = {}) {
|
|
185
|
+
const command = 'collision bake';
|
|
186
|
+
const dir = gameDir(root, id);
|
|
187
|
+
if (!dir) return { ok: false, command, why: `no game ${id} in this studio` };
|
|
188
|
+
const model = file ? modelPath(dir, file) : null;
|
|
189
|
+
if (!model) return { ok: false, command, why: `which model? ${file ? `${file} is not in games/${id}` : `homie-studio collision bake ${id} <model.glb>`}` };
|
|
190
|
+
if (!(Number(cell) > 0)) return { ok: false, command, why: '--cell is metres between posts, a positive number (0.25 suits a walkway)' };
|
|
191
|
+
const H = await heightfieldModules(root);
|
|
192
|
+
if (!H) return { ...NEEDS, command };
|
|
193
|
+
const bytes = readFileSync(model);
|
|
194
|
+
let bake;
|
|
195
|
+
try { bake = H.bakeMesh(await modelTriangles(bytes), { cell: Number(cell), side: side === 'under' ? 'under' : 'top', ...(maxY !== undefined ? { maxY: Number(maxY) } : {}), ...(minY !== undefined ? { minY: Number(minY) } : {}) }); } catch (e) { return { ok: false, command, why: e.message }; }
|
|
196
|
+
const out = sidecar(model);
|
|
197
|
+
const before = readJson(out) ?? {};
|
|
198
|
+
const doc = { v: 1, model: basename(model), sha256: sha256(bytes), bake: H.packBake(bake), proxies: Array.isArray(before.proxies) ? before.proxies : [], mover: { ...DEFAULT_MOVER, ...(before.mover ?? {}) }, routes: Array.isArray(before.routes) ? before.routes : [] };
|
|
199
|
+
writeFileSync(out, `${JSON.stringify(doc)}\n`);
|
|
200
|
+
const checked = await checkOne(H, model, out);
|
|
201
|
+
const svg = out.replace(/\.json$/, '.svg');
|
|
202
|
+
writeFileSync(svg, collisionSvg(bake, { proxies: doc.proxies, routes: doc.routes, problems: checked.marks, title: doc.model }));
|
|
203
|
+
const cover = bake.covered.reduce((n, c) => n + c, 0);
|
|
204
|
+
const rel = (p) => relative(root, p).split('\\').join('/');
|
|
205
|
+
return {
|
|
206
|
+
ok: true, command, game: id, model: rel(model), file: rel(out), plan: rel(svg), cell: bake.cell, posts: [bake.nx, bake.nz], covered: cover, triangles: bake.source.triangles, checksum: bake.source.checksum, rows: checked.rows,
|
|
207
|
+
lines: [`Baked ${rel(model)}: ${bake.nx} x ${bake.nz} posts at ${bake.cell} m (${(bake.nx - 1) * bake.cell} m by ${(bake.nz - 1) * bake.cell} m), ${cover} over the model, from ${bake.source.triangles} triangles.`,
|
|
208
|
+
` ${rel(out)} (authored "proxies", "mover" and "routes" in it are kept by a re-bake)`, ` ${rel(svg)} the plan at true scale, 40 px to the metre`,
|
|
209
|
+
...checked.rows.map((r) => ` ${r.ok ? 'ok ' : 'FAIL'} ${r.label}${r.note ? `: ${r.note}` : ''}`)],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `collision check`: every collision file of a game (or one model's) against its model, its proxies and its routes. */
|
|
214
|
+
export async function collisionCheck(root, id, file) {
|
|
215
|
+
const command = 'collision check';
|
|
216
|
+
const dir = gameDir(root, id);
|
|
217
|
+
if (!dir) return { ok: false, command, why: `no game ${id} in this studio` };
|
|
218
|
+
const H = await heightfieldModules(root);
|
|
219
|
+
if (!H) return { ...NEEDS, command };
|
|
220
|
+
let files;
|
|
221
|
+
if (file) { const model = modelPath(dir, file); if (!model || !existsSync(sidecar(model))) return { ok: false, command, why: `${file} has no collision file: homie-studio collision bake ${id} ${file}` }; files = [sidecar(model)]; }
|
|
222
|
+
else files = filesUnder(dir, (n) => n.endsWith('.collision.json'));
|
|
223
|
+
const rel = (p) => relative(root, p).split('\\').join('/');
|
|
224
|
+
const models = []; const lines = [];
|
|
225
|
+
for (const f of files) {
|
|
226
|
+
const side = readJson(f);
|
|
227
|
+
const model = join(dirname(f), side?.model ?? `${basename(f, '.collision.json')}.glb`);
|
|
228
|
+
const one = await checkOne(H, model, f);
|
|
229
|
+
if (one.bake) writeFileSync(f.replace(/\.json$/, '.svg'), collisionSvg(one.bake, { proxies: one.side?.proxies ?? [], routes: one.side?.routes ?? [], problems: one.marks, title: basename(model) }));
|
|
230
|
+
models.push({ file: rel(f), ok: one.rows.every((r) => r.ok), rows: one.rows });
|
|
231
|
+
lines.push(`${rel(f)}`, ...one.rows.map((r) => ` ${r.ok ? 'ok ' : 'FAIL'} ${r.label}${r.note ? `: ${r.note}` : ''}`));
|
|
232
|
+
}
|
|
233
|
+
if (!files.length) lines.push(`No collision files in games/${id}. homie-studio collision bake ${id} <model.glb> makes one.`);
|
|
234
|
+
const failed = models.filter((m) => !m.ok);
|
|
235
|
+
return { ok: failed.length === 0, command, game: id, models, lines, ...(failed.length ? { why: `${lines.join('\n')}\n${failed.length} of ${models.length} collision file(s) need attention` } : {}) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The CLI's `collision` command (bin/homie-studio.mjs hands it over whole). */
|
|
239
|
+
export async function collisionCommand(root, sub, positional, flags) {
|
|
240
|
+
const id = positional[2] ?? (listGames(root).length === 1 ? listGames(root)[0].id : null);
|
|
241
|
+
const usage = 'homie-studio collision bake <game> <model.glb> [--cell 0.25] [--side top|under] [--max-y <m>] | collision check <game> [<model.glb>]';
|
|
242
|
+
if (!id || !['bake', 'check'].includes(sub)) return { ok: false, command: 'collision', why: usage };
|
|
243
|
+
if (sub === 'check') return collisionCheck(root, id, positional[3]);
|
|
244
|
+
return collisionBake(root, id, positional[3], { cell: flags.get('cell') ?? 0.25, side: flags.get('side') ?? 'top', maxY: flags.get('max-y'), minY: flags.get('min-y') });
|
|
245
|
+
}
|
package/lib/decisions.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Each change bumps the decision's `rev`; every asset records the revisions it was made under (assets/manifest.json
|
|
13
13
|
* `made.under`), so a changed decision makes exactly those assets stale: listed and priced, never remade by itself.
|
|
14
14
|
*
|
|
15
|
-
* The file is private like CODEX.md:
|
|
15
|
+
* The file is private like CODEX.md: a static game's served copy skips the whole codex/ folder.
|
|
16
16
|
* games/<id>/style.json (public, tiny) carries the palette and fonts a game, its landing and its title cards draw with.
|
|
17
17
|
*/
|
|
18
18
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
@@ -207,7 +207,7 @@ export const CATALOGUE = Object.freeze([
|
|
|
207
207
|
['cast.tiers', 'cast', 'What may each asset cost on a phone?', ['style.camera', 'game.devices'], (ctx) => {
|
|
208
208
|
const big = ctx.players > 8;
|
|
209
209
|
const v = { hero: { triangles: big ? 5000 : 8000, texturePx: 1024, kb: 1536, bones: 48 }, npc: { triangles: 3000, texturePx: 512, kb: 600, bones: 32 }, prop: { triangles: 1500, texturePx: 512, kb: 300 }, signature: { triangles: 5000, texturePx: 1024, kb: 800 }, kit: { triangles: 1000, texturePx: 512, kb: 200 }, scene: { drawCalls: 100, triangles: 150_000, textureMB: 48, firstPlayMB: 5 } };
|
|
210
|
-
return { value: v, label: `hero ${v.hero.triangles.toLocaleString('en-US')} · prop 1,500 · scene 150k triangles, 100 draws, 48 MB textures, 5 MB
|
|
210
|
+
return { value: v, label: `hero ${v.hero.triangles.toLocaleString('en-US')} · prop 1,500 · scene 150k triangles, 100 draws, 48 MB textures, 5 MB shipped payload`, why: `phone first${big ? `; rooms of ${ctx.players} make heroes lighter` : ''}`, options: [] };
|
|
211
211
|
}],
|
|
212
212
|
['cast.variation', 'cast', 'How many looks per character from one model?', [], (ctx) => ({ value: { paletteSwaps: Math.min(4, Math.max(2, ctx.players)), partSwaps: false, decals: false }, label: '4 palette swaps of the hero', why: 'team colours without another model', options: [] })],
|
|
213
213
|
|
package/lib/demo.mjs
CHANGED
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
* studio only when the person asks for one: `homie-studio game new <id> --from gem-rush`.
|
|
9
9
|
*
|
|
10
10
|
* It reads the arcade's own public manifest (/.well-known/homie-studio.json) and its live rooms (/api/rooms), so the
|
|
11
|
-
* links are the ones live right now
|
|
11
|
+
* links are the ones live right now. When this session cannot reach the arcade (a sandbox with no network, as Codex
|
|
12
|
+
* runs commands by default; a proxy; the arcade slow), it still answers, with the arcade's standing first pick
|
|
13
|
+
* (DEMO_FALLBACK, built in) and `reached: false`: the person's own browser opens that link, not this session, so it
|
|
14
|
+
* is still a live game to try. Read-only.
|
|
12
15
|
*/
|
|
16
|
+
import { whyFailed } from './net.mjs';
|
|
13
17
|
import { STUDIO_VERSION } from './version.mjs';
|
|
14
18
|
|
|
15
19
|
export const DEMO_STUDIO = 'https://arcade.homie.rocks';
|
|
@@ -34,13 +38,17 @@ async function getJson(fetchFn, url, ms) {
|
|
|
34
38
|
|
|
35
39
|
export async function demoGames({ fetchFn = globalThis.fetch, studio = DEMO_STUDIO, timeoutMs = 6000 } = {}) {
|
|
36
40
|
const site = String(studio).replace(/\/+$/, '');
|
|
37
|
-
let manifest = null; let rooms = null; let why = null;
|
|
41
|
+
let manifest = null; let rooms = null; let why = null; let refused = false;
|
|
38
42
|
try {
|
|
39
43
|
[manifest, rooms] = await Promise.all([
|
|
40
44
|
getJson(fetchFn, `${site}/.well-known/homie-studio.json`, timeoutMs),
|
|
41
45
|
getJson(fetchFn, `${site}/api/rooms`, timeoutMs).catch(() => null),
|
|
42
46
|
]);
|
|
43
|
-
} catch (error) {
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// The arcade answered with an error, or this session never got to it (no network here, a proxy, a timeout).
|
|
49
|
+
refused = /answered \d{3}$/.test(String(error?.message));
|
|
50
|
+
why = refused ? error.message : whyFailed(error, site).why;
|
|
51
|
+
}
|
|
44
52
|
const live = new Map();
|
|
45
53
|
for (const r of Array.isArray(rooms?.rooms) ? rooms.rooms : []) {
|
|
46
54
|
const id = text(r?.game, 40);
|
|
@@ -63,7 +71,11 @@ export async function demoGames({ fetchFn = globalThis.fetch, studio = DEMO_STUD
|
|
|
63
71
|
ok: true, command: 'demo',
|
|
64
72
|
studio: { name: text(manifest?.name, 60) || 'Homie Arcade', site },
|
|
65
73
|
pick, games: games.length ? games : [pick],
|
|
66
|
-
|
|
74
|
+
// Whether this session read the arcade just now. Not reached: the pick is the built-in one, and its link is as good.
|
|
75
|
+
reached: !why,
|
|
76
|
+
...(why ? { note: refused
|
|
77
|
+
? `the arcade did not give its list just now (${why}), so this is its standing first pick`
|
|
78
|
+
: `this session could not reach the arcade just now (${why}), so this is its standing first pick; the Play link opens in the person's own browser all the same` } : {}),
|
|
67
79
|
how: HOW, copy: COPY,
|
|
68
80
|
};
|
|
69
81
|
}
|