@homie-rocks/studio 0.30.2 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -1
- package/README.md +349 -23
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +204 -124
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +203 -10
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +172 -9
- package/lib/doctor.mjs +12 -11
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +42 -37
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +720 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +240 -0
- package/lib/scaffold.mjs +83 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +552 -16
- package/netplay/netplay.ts +950 -43
- package/package.json +10 -1
- package/parts/PARTS.md +169 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +329 -40
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +7 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +41 -12
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +50 -11
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +50 -11
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +156 -29
- package/worker/parts.mjs +275 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
package/lib/cloudflare.mjs
CHANGED
|
@@ -19,17 +19,28 @@
|
|
|
19
19
|
* It REFUSES to touch a Worker, database or bucket of that name that this
|
|
20
20
|
* studio did not create (studio.json `cloudflare.created` is the record), so it
|
|
21
21
|
* can never overwrite someone's existing site. `deploy --plan` says all of this
|
|
22
|
-
* before anything happens and
|
|
22
|
+
* before anything happens and changes nothing.
|
|
23
|
+
*
|
|
24
|
+
* A studio on a CUSTOM DOMAIN shares that domain with whatever else its owner
|
|
25
|
+
* runs there. The plan and the deploy read the domain's Worker routes (a read,
|
|
26
|
+
* with Wrangler's own sign-in) and warn when another site's catch-all or
|
|
27
|
+
* wildcard covers the studio's hostname: that route is never the studio's to
|
|
28
|
+
* edit or remove (lib/routes.mjs, zoneCheck below).
|
|
23
29
|
*/
|
|
24
30
|
import { spawn, spawnSync } from 'node:child_process';
|
|
25
31
|
import { createHash } from 'node:crypto';
|
|
26
32
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
33
|
import { join } from 'node:path';
|
|
34
|
+
import { cloudflareAuth } from './brain-probe.mjs';
|
|
28
35
|
import { build } from './build.mjs';
|
|
36
|
+
import { builtGames, compareDeploy, deployWords, lastDeploy, lastDeployRecord, lockDeploy, recordDeploy } from './deploy-state.mjs';
|
|
37
|
+
import { listedHere, publish } from './directory.mjs';
|
|
29
38
|
import { MAX_ASSET_BYTES, R2_COST, mediaPlan, r2OverOf, recordMove, sha256File, sizeOf } from './media.mjs';
|
|
39
|
+
import { whyFailed } from './net.mjs';
|
|
30
40
|
import { repoOf } from './repo.mjs';
|
|
41
|
+
import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
|
|
31
42
|
import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
|
|
32
|
-
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
43
|
+
import { LOCAL_STATE, configPath, domainOrigin, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
33
44
|
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
34
45
|
|
|
35
46
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
@@ -90,12 +101,66 @@ export function explainCloudflare(out, accountId = null) {
|
|
|
90
101
|
if (/maximum number of (D1 )?databases|database limit|too many databases/i.test(text)) {
|
|
91
102
|
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
103
|
}
|
|
104
|
+
// A route in wrangler.jsonc that another Worker of the zone already holds. Wrangler refuses, and so does the
|
|
105
|
+
// studio: that route is not the studio's to take (lib/routes.mjs).
|
|
106
|
+
if (/assigned to another worker|already assigned to routes/i.test(text)) {
|
|
107
|
+
const routes = [...text.matchAll(/^\s*-\s+(\S+)\s*$/gm)].map((m) => m[1]);
|
|
108
|
+
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.` };
|
|
109
|
+
}
|
|
93
110
|
if (/\b1027\b|exceeded .*daily request limit/i.test(text)) {
|
|
94
111
|
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
112
|
}
|
|
96
113
|
return null;
|
|
97
114
|
}
|
|
98
115
|
|
|
116
|
+
/** The studio's custom hostname ("play.example.com"), or null for a studio on its workers.dev address. */
|
|
117
|
+
export function customHost(studio) {
|
|
118
|
+
const cf = studio?.cloudflare ?? {};
|
|
119
|
+
const origin = domainOrigin(cf.domain) ?? (cf.url && !isWorkersDev(cf.url) ? domainOrigin(cf.url) : null);
|
|
120
|
+
return origin ? new URL(origin).hostname : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* For a studio on a custom domain: what the domain's other Worker routes mean for it (lib/routes.mjs zoneFinding),
|
|
125
|
+
* read from Cloudflare with the sign-in Wrangler already has (lib/brain-probe.mjs cloudflareAuth: a token in the
|
|
126
|
+
* environment, else `wrangler auth token`). Null for a studio with no custom domain, which is asked nothing. It
|
|
127
|
+
* only reads; a list that cannot be read comes back as state 'unmeasured' with the reason. `config` stands in for
|
|
128
|
+
* wrangler.jsonc (a deploy that is about to add a route), `fetchFn` for the network in tests.
|
|
129
|
+
*/
|
|
130
|
+
export async function zoneCheck(root, { studio = readStudio(root), config = readConfig(root), accountId = null, fetchFn = null } = {}) {
|
|
131
|
+
const hostname = customHost(studio);
|
|
132
|
+
if (!hostname) return null;
|
|
133
|
+
const worker = studio.cloudflare?.worker ?? null;
|
|
134
|
+
// The toolkit's own tests (HOMIE_STUDIO_WARM=0) with no stand-in network ask nobody, as readLiveSite does.
|
|
135
|
+
if (!fetchFn && process.env.HOMIE_STUDIO_WARM === '0') return zoneFinding({ hostname, worker, config, zone: { read: false, why: 'not asked' } });
|
|
136
|
+
const bin = wranglerBin(root);
|
|
137
|
+
let headers = null;
|
|
138
|
+
try {
|
|
139
|
+
const w = bin ? runner(root, accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {}) : null;
|
|
140
|
+
headers = await cloudflareAuth({
|
|
141
|
+
env: { ...process.env, ...projectsCloudflareEnv(root) }, bin, cwd: root,
|
|
142
|
+
exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; },
|
|
143
|
+
});
|
|
144
|
+
} catch { headers = null; }
|
|
145
|
+
const zone = await readZoneRoutes({ hostname, config, headers, ...(fetchFn ? { fetchFn } : {}) });
|
|
146
|
+
return zoneFinding({ hostname, worker, config, zone });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A zone finding as a deploy's result and its plan carry it: the state, the routes that are not the studio's, the one line. */
|
|
150
|
+
function zoneView(z) {
|
|
151
|
+
return {
|
|
152
|
+
state: z.state, host: z.host, domain: z.zone, foreign: z.foreign.map((r) => ({ pattern: r.pattern, worker: r.script, kind: r.kind })),
|
|
153
|
+
...(z.state === 'foreign' || z.state === 'unmeasured' ? { route: z.route, line: z.line } : {}),
|
|
154
|
+
...(z.warning ? { warning: z.warning } : {}), ...(z.why ? { why: z.why } : {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `deploy --plan` for a studio on a custom domain: the plan, and what the domain's other routes mean for it. */
|
|
159
|
+
export async function zonePlan(root, opts = {}) {
|
|
160
|
+
const z = await zoneCheck(root, opts);
|
|
161
|
+
return z ? zoneView(z) : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
99
164
|
/** What `deploy` will create and what it costs, from studio.json alone. It calls nothing. */
|
|
100
165
|
export function deployPlan(root) {
|
|
101
166
|
const studio = readStudio(root);
|
|
@@ -127,10 +192,29 @@ export function deployPlan(root) {
|
|
|
127
192
|
};
|
|
128
193
|
}
|
|
129
194
|
|
|
130
|
-
|
|
195
|
+
/**
|
|
196
|
+
* `homie-studio deploy`. One at a time for a studio: it holds the studio's deploy lock (lib/deploy-state.mjs) from
|
|
197
|
+
* before it reads anything until it ends, however it ends. `fetchFn` is the toolkit's own tests' stand-in network.
|
|
198
|
+
*/
|
|
199
|
+
export async function deploy(root, opts = {}) {
|
|
200
|
+
const lock = lockDeploy(root);
|
|
201
|
+
if (!lock.ok) return { ok: false, command: 'deploy', needs: lock.needs, why: lock.why };
|
|
202
|
+
// A deploy that is killed still lets go (a kill -9 cannot; the next deploy sees its process is gone).
|
|
203
|
+
const onExit = () => lock.release();
|
|
204
|
+
process.once('exit', onExit);
|
|
205
|
+
try { return await deployLocked(root, opts); } finally { process.off('exit', onExit); lock.release(); }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRoute = false } = {}) {
|
|
131
209
|
const studio = readStudio(root);
|
|
132
210
|
const cf = { r2: null, created: [], ...studio.cloudflare };
|
|
133
211
|
const created = new Set(cf.created ?? []);
|
|
212
|
+
// A wildcard or catch-all route in the studio's own config would hand other hostnames of the zone to this Worker:
|
|
213
|
+
// refused before Cloudflare is asked anything (lib/routes.mjs). Read once here; every rewrite below keeps the
|
|
214
|
+
// studio's own routes exactly as its owner wrote them.
|
|
215
|
+
const wide = wideRouteRefusal(readConfig(root), studio);
|
|
216
|
+
if (wide) return { ok: false, command: 'deploy', ...wide };
|
|
217
|
+
let routes = keptRoutes(root, studio);
|
|
134
218
|
const who = whoami(root);
|
|
135
219
|
if (!who) {
|
|
136
220
|
if (cf.auth === 'stripe-projects') {
|
|
@@ -157,6 +241,25 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
157
241
|
};
|
|
158
242
|
const steps = [];
|
|
159
243
|
const step = (what, extra = {}) => { steps.push({ what, ...extra }); log(what); };
|
|
244
|
+
const notes = [];
|
|
245
|
+
|
|
246
|
+
// A studio on a custom domain: is another site's catch-all or wildcard route on the same domain covering its
|
|
247
|
+
// hostname? Read (never written) and said before Wrangler changes anything: the repair that suggests itself,
|
|
248
|
+
// editing or removing that route, takes the other site down (lib/routes.mjs). `--own-route` adds the one line
|
|
249
|
+
// that is the studio's to add, and only when the domain's routes were read and show it is needed.
|
|
250
|
+
let zone = await zoneCheck(root, { studio, accountId, fetchFn });
|
|
251
|
+
if (zone) {
|
|
252
|
+
if (ownRoute && zone.state === 'foreign') {
|
|
253
|
+
routes = [...(routes ?? []), zone.route];
|
|
254
|
+
step(`added the studio's own exact-host route to "routes" in wrangler.jsonc: ${zone.line}. It covers ${zone.host} only, and every deploy keeps it; the other site's route was not touched.`, { route: zone.route });
|
|
255
|
+
const config = readConfig(root) ?? {};
|
|
256
|
+
zone = await zoneCheck(root, { studio, accountId, fetchFn, config: { ...config, routes: [...routesOf(config), zone.route] } });
|
|
257
|
+
} else if (ownRoute) {
|
|
258
|
+
step(`--own-route added nothing: ${zone.state === 'unmeasured' ? 'the domain\'s routes could not be read, so the domain\'s name in Cloudflare is not known for sure' : zone.state === 'taken' ? `${zone.host} is routed to another Worker, which only the person can change` : zone.state === 'own-route' ? 'the studio\'s own exact-host route is already there' : `no other route covers ${zone.host}`}.`);
|
|
259
|
+
}
|
|
260
|
+
if (zone.warning) { notes.push(zone.warning); step(`warning: ${zone.warning}`, { needs: 'cloudflare-routes', ...(zone.state === 'foreign' ? { route: zone.route } : {}) }); }
|
|
261
|
+
else if (zone.why) step(zone.why);
|
|
262
|
+
} else if (ownRoute) step('--own-route added nothing: this studio has no custom domain (studio.json cloudflare.domain).');
|
|
160
263
|
|
|
161
264
|
// The first deploy says what it is about to create, and what it costs, before it creates anything: the person
|
|
162
265
|
// reads it in the transcript whether or not the AI repeated it.
|
|
@@ -187,6 +290,14 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
187
290
|
// A new studio goes live with its own Home ("First game coming soon") before it has a game.
|
|
188
291
|
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
292
|
for (const n of b.mediaNotes ?? []) step(`note: ${n.file}: ${n.why}`);
|
|
293
|
+
if (b.retired) step(`note: ${b.retired}`);
|
|
294
|
+
// Which games this deploy changes, against what the last deploy from this computer put live, each with the
|
|
295
|
+
// build's own hash for it (lib/deploy-state.mjs: one hash vocabulary). The record is written only once Wrangler has deployed.
|
|
296
|
+
const builtNow = builtGames(root, b.catalogue);
|
|
297
|
+
const delta = compareDeploy(builtNow, lastDeploy(root), lastDeployRecord(root));
|
|
298
|
+
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 });
|
|
299
|
+
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');
|
|
300
|
+
for (const id of delta.removed) step(`${id}: no longer in the build; it leaves the site with this deploy`);
|
|
190
301
|
|
|
191
302
|
// The Worker: never one this studio did not make.
|
|
192
303
|
if (!created.has(`worker:${cf.worker}`)) {
|
|
@@ -218,14 +329,15 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
218
329
|
const r2 = storage;
|
|
219
330
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
220
331
|
|
|
221
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
|
|
332
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes }));
|
|
333
|
+
if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
|
|
222
334
|
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
223
335
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
224
336
|
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
225
337
|
step('D1 migrations applied');
|
|
226
338
|
// Workers AI (0.17.0): bound only when a server's AI guides think with it.
|
|
227
339
|
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 }));
|
|
340
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes }));
|
|
229
341
|
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
342
|
}
|
|
231
343
|
|
|
@@ -250,11 +362,45 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
250
362
|
// The homie.rocks directory claim: the live site claims itself the first time its manifest is read (0.10.0), so
|
|
251
363
|
// reading it once now is all it takes; nothing is stored by hand.
|
|
252
364
|
const directory = homie || studio.homie?.directory || 'https://homie.rocks';
|
|
365
|
+
recordDeploy(root, builtNow);
|
|
253
366
|
let claim = null;
|
|
254
367
|
if (url) {
|
|
255
|
-
|
|
368
|
+
const read = await readLiveSite(url, fetchFn ? { fetchFn } : {});
|
|
369
|
+
claim = read.claim;
|
|
256
370
|
if (claim) step('the site claimed itself in the directory');
|
|
257
|
-
else
|
|
371
|
+
else if (read.state === 'other' && !isWorkersDev(url)) {
|
|
372
|
+
// The studio's own domain answered as something else: say which route is needed (lib/routes.mjs). The deploy
|
|
373
|
+
// itself went through; the zone's other routes are never touched.
|
|
374
|
+
const s = shadowedDomain(readConfig(root), new URL(url).hostname, read);
|
|
375
|
+
notes.push(s.why);
|
|
376
|
+
step(s.why, s.route ? { needs: 'cloudflare-routes', route: s.route } : {});
|
|
377
|
+
} else if (read.state === 'unreachable') {
|
|
378
|
+
// This computer could not ask (its own name lookup, no connection): a network preflight failure, never a
|
|
379
|
+
// verdict on the site that was just deployed.
|
|
380
|
+
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.`;
|
|
381
|
+
notes.push(said);
|
|
382
|
+
step(said, { preflight: read.preflight ?? 'network' });
|
|
383
|
+
} else step('the directory claim is not there yet (the site claims itself when the directory first reads it)');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// The deploy before this one came from a toolkit that still had remix, and left games open to be taken whole. No
|
|
387
|
+
// game's source is served any more, but the directory keeps its old copy of the studio (and its offer of those
|
|
388
|
+
// games) until it reads the site again, so ask it to, now. ONLY for a studio this computer listed (publish records
|
|
389
|
+
// it): going online never lists a studio, and a deploy never lists one either. Once: see compareDeploy.
|
|
390
|
+
let reread = null;
|
|
391
|
+
if (url && delta.withdrawn.length) {
|
|
392
|
+
const which = delta.withdrawn.join(', ');
|
|
393
|
+
const dir = String(directory).replace(/\/+$/, '');
|
|
394
|
+
if (listedHere(root, { site: url, directory: dir })) {
|
|
395
|
+
const again = await publish(root, { homie: dir, site: url, ...(fetchFn ? { fetchFn } : {}) });
|
|
396
|
+
reread = { ok: again.ok, games: delta.withdrawn, ...(again.ok ? {} : { why: again.why }), ...(again.publishes ? { publishes: again.publishes } : {}) };
|
|
397
|
+
step(again.ok
|
|
398
|
+
? `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})` : ''}`
|
|
399
|
+
: `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`);
|
|
400
|
+
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).`);
|
|
401
|
+
} else {
|
|
402
|
+
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.`);
|
|
403
|
+
}
|
|
258
404
|
}
|
|
259
405
|
|
|
260
406
|
// studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
|
|
@@ -264,7 +410,12 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
264
410
|
return {
|
|
265
411
|
ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
266
412
|
...(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 })
|
|
413
|
+
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 }; }),
|
|
414
|
+
...(delta.removed.length ? { removed: delta.removed } : {}),
|
|
415
|
+
...(routes ? { routes: routes.map((r) => r.pattern) } : {}),
|
|
416
|
+
...(zone ? { zone: zoneView(zone) } : {}),
|
|
417
|
+
...(notes.length ? { notes } : {}),
|
|
418
|
+
...(reread ? { reread } : {}),
|
|
268
419
|
songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
|
|
269
420
|
videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
|
|
270
421
|
claim: Boolean(claim), directory,
|
|
@@ -301,6 +452,44 @@ export function repoVar(root) {
|
|
|
301
452
|
return repo ? ['--var', `HOMIE_REPO:${repo}`] : [];
|
|
302
453
|
}
|
|
303
454
|
|
|
455
|
+
/**
|
|
456
|
+
* The live site's manifest, read back after a deploy, and what the answer was:
|
|
457
|
+
* state 'studio' it answered as a studio; `claim` is its directory claim (null until it has one)
|
|
458
|
+
* state 'other' the address answered, and not as a studio (another Worker's page, a 404 that is not ours):
|
|
459
|
+
* `status`, and `said`, the first words of what it said
|
|
460
|
+
* state 'unreachable' this computer could not ask: `why`, and `preflight` ('dns' for a name it could not look up)
|
|
461
|
+
* state 'skipped' the toolkit's own tests (HOMIE_STUDIO_WARM=0) with no stand-in network
|
|
462
|
+
* Reading it is also what makes a 0.10.0 site claim itself. A fresh deploy can take a few seconds to answer
|
|
463
|
+
* everywhere, so a site that does not yet answer as a studio is asked three times.
|
|
464
|
+
*/
|
|
465
|
+
export async function readLiveSite(site, { tries = 3, wait = 2500, fetchFn = null } = {}) {
|
|
466
|
+
if (!fetchFn && process.env.HOMIE_STUDIO_WARM === '0') return { state: 'skipped', claim: null };
|
|
467
|
+
const ask = fetchFn ?? globalThis.fetch;
|
|
468
|
+
const base = String(site).replace(/\/+$/, '');
|
|
469
|
+
let last = { state: 'unreachable', claim: null, why: 'no answer' };
|
|
470
|
+
for (let i = 0; i < tries; i++) {
|
|
471
|
+
try {
|
|
472
|
+
const res = await ask(`${base}/.well-known/homie-studio.json`, { headers: { accept: 'application/json', 'user-agent': 'homie-studio-deploy' }, signal: AbortSignal.timeout(15_000) });
|
|
473
|
+
const text = await res.text();
|
|
474
|
+
let body = null;
|
|
475
|
+
try { body = JSON.parse(text); } catch { body = null; }
|
|
476
|
+
const studio = res.ok && body && typeof body === 'object' && ('claim' in body || Array.isArray(body.games));
|
|
477
|
+
if (studio) {
|
|
478
|
+
const claim = /^[a-f0-9]{16,128}$/.test(body.claim ?? '') ? body.claim : null;
|
|
479
|
+
if (claim) return { state: 'studio', claim };
|
|
480
|
+
last = { state: 'studio', claim: null };
|
|
481
|
+
} 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) };
|
|
482
|
+
} catch (error) {
|
|
483
|
+
const w = whyFailed(error, base);
|
|
484
|
+
last = { state: 'unreachable', claim: null, why: w.why, code: w.code ?? null, ...(w.preflight ? { preflight: w.preflight } : {}) };
|
|
485
|
+
// A name this computer cannot look up does not start resolving in five seconds: say so now.
|
|
486
|
+
if (w.preflight === 'dns') return last;
|
|
487
|
+
}
|
|
488
|
+
if (i < tries - 1) await new Promise((r) => setTimeout(r, wait));
|
|
489
|
+
}
|
|
490
|
+
return last;
|
|
491
|
+
}
|
|
492
|
+
|
|
304
493
|
/**
|
|
305
494
|
* Read the live site's manifest once, which makes a 0.10.0 site claim itself in its directory. Returns the claim it
|
|
306
495
|
* serves, or null (the directory did not answer, the site is not reachable yet, or it is a Preview). A fresh deploy
|
|
@@ -378,10 +567,13 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
378
567
|
step(claim ? 'the site claimed itself in the directory' : 'no directory claim yet (the site claims itself when the directory first reads it)');
|
|
379
568
|
let games = [];
|
|
380
569
|
try { games = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { games = []; }
|
|
570
|
+
const hashes = builtGames(root, games);
|
|
571
|
+
for (const id of games) if (hashes[id]?.hash) step(`${id}: ${deployWords(hashes[id])}`);
|
|
381
572
|
return {
|
|
382
573
|
ok: true, command: 'deploy', ci: true, url, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
|
|
383
574
|
commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
|
|
384
|
-
|
|
575
|
+
// A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
|
|
576
|
+
games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
|
|
385
577
|
claim: Boolean(claim),
|
|
386
578
|
};
|
|
387
579
|
}
|
|
@@ -429,7 +621,8 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
429
621
|
const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
|
|
430
622
|
writeStudio(root, next);
|
|
431
623
|
if (next.cloudflare.d1Id) {
|
|
432
|
-
|
|
624
|
+
// The studio's own routes stay through this rewrite too (lib/routes.mjs).
|
|
625
|
+
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
626
|
}
|
|
434
627
|
log(`created R2 ${bucket}`);
|
|
435
628
|
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
|
|