@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/build.mjs
CHANGED
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
* catalogue the Worker serves (games.json).
|
|
4
4
|
*
|
|
5
5
|
* site/dist/games/<id>/index.html the game's page (the Worker adds HOMIE_NET)
|
|
6
|
-
* site/dist/games/<id>/assets/main
|
|
6
|
+
* site/dist/games/<id>/assets/main-<HASH>.js its bundle (esbuild; @homie-rocks/studio/netplay inlined), named by
|
|
7
|
+
* its content, so a browser or an edge that kept the last build's bundle is
|
|
8
|
+
* never asked for this one under the same name; the built index.html names it
|
|
9
|
+
* site/dist/games/<id>/assets/chunk-<HASH>.js whatever the game loads later with `await import('./x')`: one file
|
|
10
|
+
* each, fetched when asked for, from beside the bundle
|
|
11
|
+
* site/dist/games/<id>/assets/main.js the address tools knew before bundles had hashes: one line that imports
|
|
12
|
+
* the hashed bundle (bundleOf() says the real file)
|
|
13
|
+
* site/dist/_site/build.json what this build made: per game its content hash, its bundle and chunks,
|
|
14
|
+
* and whether it changed since the build before (what a deploy reads)
|
|
7
15
|
* site/dist/games/<id>/... everything in games/<id>/public/
|
|
8
|
-
* site/dist/games/<id>/assets.json its assets' licences and, for those a remix may carry, their address and SHA-256
|
|
9
|
-
* (games/<id>/assets/manifest.json; lib/asset-manifest.mjs servedAssets)
|
|
10
16
|
* site/dist/games/<id>/agents.json the AI guides' vocabulary (games/<id>/agents.json, checked; NETPLAY.md
|
|
11
17
|
* section 18): the only goals and lines an AI in its rooms has
|
|
12
18
|
* site/dist/games.json { studio, games[], songs[], videos[], posts[], site, shop } from studio.json,
|
|
@@ -36,17 +42,19 @@ import { spawnSync } from 'node:child_process';
|
|
|
36
42
|
import { createHash } from 'node:crypto';
|
|
37
43
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
38
44
|
import { createRequire } from 'node:module';
|
|
39
|
-
import { join, relative } from 'node:path';
|
|
45
|
+
import { basename, join, relative } from 'node:path';
|
|
40
46
|
import { buildCodexPages } from './codex.mjs';
|
|
41
|
-
import {
|
|
47
|
+
import { BUDGET_FIELDS, BUDGET_TIERS, loaderBudget } from './asset-check.mjs';
|
|
42
48
|
import { buildMedia } from './media.mjs';
|
|
43
49
|
import { buildSiteFiles, isoDate, landingOf, readPosts, readTheme } from './site.mjs';
|
|
44
50
|
import { checkJsonLd } from './schema-check.mjs';
|
|
45
51
|
import { SCHEMA_REFUSED } from '../worker/schema.mjs';
|
|
46
52
|
import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
|
|
47
|
-
import { SEAT_MAX } from '../worker/seats.mjs';
|
|
53
|
+
import { SEAT_MAX, netplayRow } from '../worker/seats.mjs';
|
|
48
54
|
import { STUDIO_VERSION } from './version.mjs';
|
|
49
|
-
import { licenseOf,
|
|
55
|
+
import { basedOnRow, licenseOf, retiredKeys } from '../worker/license.mjs';
|
|
56
|
+
import { openStage, swapIn } from './stage.mjs';
|
|
57
|
+
import { typecheck } from './typecheck.mjs';
|
|
50
58
|
import { SERVER_LIMITS, serverOf } from '../worker/servers.mjs';
|
|
51
59
|
import { chatProblems } from '../worker/chat.mjs';
|
|
52
60
|
import { screenChatProblems } from '../worker/chat-page.mjs';
|
|
@@ -54,8 +62,9 @@ import { vocabularyOf } from '../worker/brain.mjs';
|
|
|
54
62
|
import { shopForBuild } from './shop.mjs';
|
|
55
63
|
import { audienceOf } from '../worker/shop-rules.mjs';
|
|
56
64
|
import { loungeConfig, loungeProblems } from '../worker/lounge-store.mjs';
|
|
65
|
+
// GAME PARTS (parts/PARTS.md): the three call-outs below are all the build knows of them.
|
|
66
|
+
import { buildParts, partsPlugin } from './parts-build.mjs';
|
|
57
67
|
|
|
58
|
-
const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
|
|
59
68
|
/** Never copied into a static game's served folder. */
|
|
60
69
|
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md', 'lab.json', 'codex']);
|
|
61
70
|
const LOADERS = { '.png': 'file', '.jpg': 'file', '.jpeg': 'file', '.gif': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.m4a': 'file', '.glb': 'file', '.gltf': 'file', '.bin': 'file', '.hdr': 'file', '.svg': 'file', '.json': 'json', '.woff2': 'file', '.ttf': 'file' };
|
|
@@ -84,12 +93,6 @@ function dirBytes(dir) {
|
|
|
84
93
|
for (const e of readdirSync(dir, { withFileTypes: true })) n += e.isDirectory() ? dirBytes(join(dir, e.name)) : statSync(join(dir, e.name)).size;
|
|
85
94
|
return n;
|
|
86
95
|
}
|
|
87
|
-
const TEXT = /\.(ts|tsx|js|mjs|jsx|json|html|css|md|txt|svg|glsl|wgsl|frag|vert)$/i;
|
|
88
|
-
/**
|
|
89
|
-
* Text files of a game folder, capped (2 MB total, 512 KB each): what `homie-studio game remix` takes; with who made
|
|
90
|
-
* it (`credit`: the studio and the game; the live site adds the page, worker/index.mjs) and its licence (game.json
|
|
91
|
-
* "license", worker/license.mjs).
|
|
92
|
-
*/
|
|
93
96
|
/** From games/<id>/style.json: the light colour as paper, the dark as text, and the accent; null without one. */
|
|
94
97
|
function uiOf(g) {
|
|
95
98
|
let pal = null;
|
|
@@ -102,27 +105,6 @@ function uiOf(g) {
|
|
|
102
105
|
return { paper: light ? ink : bg, text: light ? bg : ink, ...(hex(pal?.accent) ? { hot: hex(pal.accent) } : {}) };
|
|
103
106
|
}
|
|
104
107
|
|
|
105
|
-
export function sourceOf(dir, id, { studio = null, game = null, license } = {}) {
|
|
106
|
-
const files = {};
|
|
107
|
-
let total = 0;
|
|
108
|
-
const walk = (rel) => {
|
|
109
|
-
for (const entry of readdirSync(join(dir, rel), { withFileTypes: true })) {
|
|
110
|
-
// The codex (CODEX.md, and codex/: the art-direction decisions and the style board) is the owner's, never shared.
|
|
111
|
-
if (entry.name.startsWith('.') || SOURCE_SKIP.has(entry.name) || (!rel && (entry.name === 'CODEX.md' || entry.name === 'codex'))) continue;
|
|
112
|
-
const path = rel ? `${rel}/${entry.name}` : entry.name;
|
|
113
|
-
if (entry.isDirectory()) walk(path);
|
|
114
|
-
else if (TEXT.test(entry.name)) {
|
|
115
|
-
const text = readFileSync(join(dir, path), 'utf8');
|
|
116
|
-
if (text.length > 512 * 1024 || total + text.length > 2 * 1024 * 1024) continue;
|
|
117
|
-
files[path] = text;
|
|
118
|
-
total += text.length;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
walk('');
|
|
123
|
-
return { v: 1, kind: 'homie-game-source', id, credit: { studio, game: game ?? id }, license: licenseOf(license), files };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
108
|
/*
|
|
127
109
|
* WHAT THIS BUILD IS, for the site's manifest: the commit and branch (Cloudflare's Workers Builds names them in
|
|
128
110
|
* WORKERS_CI_COMMIT_SHA and WORKERS_CI_BRANCH; elsewhere git is asked), when it was built, and the marks of the
|
|
@@ -287,14 +269,75 @@ export function seatsFor(g, net = netplayOf(g)) {
|
|
|
287
269
|
* bundle itself is byte for byte the one a plain build makes (an external map adds no comment to it); main.js.sha256
|
|
288
270
|
* says which bundle the map belongs to, so a map left over from an older build is never used.
|
|
289
271
|
*/
|
|
290
|
-
function keepMap(root, id, out, metafile) {
|
|
272
|
+
function keepMap(root, id, out, metafile, bundle, maps) {
|
|
291
273
|
const dir = join(root, '.studio', 'maps', id);
|
|
292
274
|
rmSync(dir, { recursive: true, force: true });
|
|
293
275
|
mkdirSync(dir, { recursive: true });
|
|
294
|
-
|
|
295
|
-
|
|
276
|
+
// The bundle's map keeps the name the perf tools know (main.js.map) whatever hash the bundle has.
|
|
277
|
+
for (const m of maps) writeFileSync(join(dir, m.entry ? 'main.js.map' : m.name), m.text);
|
|
296
278
|
writeFileSync(join(dir, 'meta.json'), JSON.stringify(metafile ?? {}));
|
|
297
|
-
writeFileSync(join(dir, 'main.js.sha256'), `${createHash('sha256').update(readFileSync(join(out,
|
|
279
|
+
writeFileSync(join(dir, 'main.js.sha256'), `${createHash('sha256').update(readFileSync(join(out, bundle))).digest('hex')}\n`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A built game's real bundle, as a path in its folder (`assets/main-K3J2H1AB.js`): what its index.html loads. The
|
|
284
|
+
* build writes it to bundle.json beside the page; a game built before bundles had hashes (or by its own build) has
|
|
285
|
+
* assets/main.js itself. Null when the folder has neither.
|
|
286
|
+
*/
|
|
287
|
+
export function bundleOf(dir) {
|
|
288
|
+
const named = readJson(join(dir, 'bundle.json'))?.bundle;
|
|
289
|
+
if (typeof named === 'string' && /^assets\/[A-Za-z0-9._-]+\.js$/.test(named) && existsSync(join(dir, named))) return named;
|
|
290
|
+
return existsSync(join(dir, 'assets', 'main.js')) ? 'assets/main.js' : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* One digest of a built game's files (everything in its folder but _landing/, the landing page's own pictures): the
|
|
295
|
+
* same sixteen characters `homie-studio perf` names a build by. Two builds with the same digest serve the same game.
|
|
296
|
+
*/
|
|
297
|
+
export function gameDigest(dir) {
|
|
298
|
+
if (!existsSync(dir)) return null;
|
|
299
|
+
const files = [];
|
|
300
|
+
const walk = (rel) => {
|
|
301
|
+
for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
|
|
302
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
303
|
+
if (e.isDirectory()) { if (r !== '_landing') walk(r); } else files.push(r);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
walk('');
|
|
307
|
+
const h = createHash('sha256');
|
|
308
|
+
for (const f of files.sort()) h.update(`${f}\0${createHash('sha256').update(readFileSync(join(dir, f))).digest('hex')}\n`);
|
|
309
|
+
return h.digest('hex').slice(0, 16);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* game.json `"assets": { "budgets": { "triangles": 8000, "bytes": 1500000 } }`: what ONE model may cost before
|
|
314
|
+
* `createModels()` says so in the console during development (assets/assets.ts; the defaults, 1,500 triangles and
|
|
315
|
+
* 300 KB, are a small prop's, and a 3D game's hero or its level is neither). `texturePx` and `materials` are taken
|
|
316
|
+
* the same way. Null when the game sets none; a field that is not a positive number is left out, with a warning.
|
|
317
|
+
*
|
|
318
|
+
* The key has ONE reader (lib/asset-check.mjs gameBudgets, which `assets check` and `assets add` hold a model to):
|
|
319
|
+
* this is that reading, as the loader can use it. The loader knows no tiers, so a game that sets a budget a tier
|
|
320
|
+
* ({ "hero": { "triangles": 12000 } }) gets the loosest one, and a number past a hard cap is held to the cap here
|
|
321
|
+
* as it is there, said in the build's log. What the loader warns about is then never something the check passes.
|
|
322
|
+
*/
|
|
323
|
+
export function modelBudgetsOf(g, log = () => {}) {
|
|
324
|
+
const raw = g.assets && typeof g.assets === 'object' ? g.assets.budgets : undefined;
|
|
325
|
+
if (raw === undefined) return null;
|
|
326
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { log(`warning: games/${g.id}/game.json assets.budgets is an object ({ "triangles": 8000, "bytes": 1500000 }); left out`); return null; }
|
|
327
|
+
const whole = (v) => { const n = Math.floor(Number(v)); return Number.isFinite(n) && n >= 1 ? n : null; };
|
|
328
|
+
const said = (where, o) => { for (const k of [...BUDGET_FIELDS, 'materials']) if (o[k] !== undefined && whole(o[k]) === null) log(`warning: games/${g.id}/game.json assets.budgets.${where}${k} is a whole number above zero; left out`); };
|
|
329
|
+
said('', raw);
|
|
330
|
+
for (const k of Object.keys(raw)) {
|
|
331
|
+
if (BUDGET_FIELDS.includes(k) || k === 'materials') continue;
|
|
332
|
+
// A budget for one tier (hero, npc, prop, signature, kit), as `assets check` reads it.
|
|
333
|
+
if (BUDGET_TIERS.includes(k) && raw[k] && typeof raw[k] === 'object' && !Array.isArray(raw[k])) said(`${k}.`, raw[k]);
|
|
334
|
+
else log(`warning: games/${g.id}/game.json assets.budgets.${k} is not one of triangles, bytes, texturePx, materials${BUDGET_TIERS.includes(k) ? ' (a tier is an object: { "triangles": … })' : `, or a tier (${BUDGET_TIERS.join(', ')})`}; left out`);
|
|
335
|
+
}
|
|
336
|
+
const read = loaderBudget(g);
|
|
337
|
+
for (const c of read?.capped ?? []) log(`warning: games/${g.id}/game.json assets.budgets asks ${c.asked} ${c.field} for a model; ${c.cap} is the most any model may have (assets check holds the same), so ${c.cap} it is`);
|
|
338
|
+
const out = { ...(read?.budget ?? {}) };
|
|
339
|
+
if (whole(raw.materials) !== null) out.materials = whole(raw.materials);
|
|
340
|
+
return Object.keys(out).length ? out : null;
|
|
298
341
|
}
|
|
299
342
|
|
|
300
343
|
/** esbuild, as the studio has it installed (the version its package.json pins). */
|
|
@@ -303,32 +346,72 @@ export async function studioEsbuild(root) {
|
|
|
303
346
|
try { return require('esbuild'); } catch { return import('esbuild'); }
|
|
304
347
|
}
|
|
305
348
|
|
|
349
|
+
/** In a built page, the bundle's old address (./assets/main.js, in a src or an href) becomes its hashed one. */
|
|
350
|
+
function pointAtBundle(html, bundle) {
|
|
351
|
+
return html.replace(/((?:src|href)\s*=\s*)(["'])((?:\.\/|\/)?)assets\/main\.js((?:[?#][^"']*)?)\2/gi, (m, attr, q, lead, tail) => `${attr}${q}${lead}${bundle}${tail}${q}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
306
354
|
/**
|
|
307
355
|
* One game's own files into `out` (emptied first): its bundle, static copy or own build's output, its index.html and
|
|
308
356
|
* its public/ folder. What `build` serves at /games/<id>/, and what the Game Lab (lib/lab.mjs) builds New and Today
|
|
309
357
|
* with. `sourcemap` is esbuild's (the lab keeps a linked map beside its builds); `maps` keeps the site build's map in
|
|
310
|
-
* .studio/maps/<id>/. Returns { mode, warnings, metafile } (metafile: the bundle's inputs, null for
|
|
358
|
+
* .studio/maps/<id>/. Returns { mode, warnings, metafile, bundle, chunks } (metafile: the bundle's inputs, null for
|
|
359
|
+
* other modes; bundle: its path in `out`, null when the game has none; chunks: what it loads later).
|
|
360
|
+
*
|
|
361
|
+
* The bundle is ES modules with code splitting: every `await import('./later')` in the game becomes a chunk of its
|
|
362
|
+
* own (assets/chunk-<HASH>.js) that the browser fetches when the game asks, from beside the bundle, wherever the
|
|
363
|
+
* game is served (/games/<id>/ on the site, /<id>/__game/ in its frame, the lab). A game with no dynamic import is
|
|
364
|
+
* one file, as before. `hashed` (the site's build) names the bundle by its content too: see the top of this file.
|
|
311
365
|
*/
|
|
312
|
-
export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {} } = {}) {
|
|
366
|
+
export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false } = {}) {
|
|
313
367
|
rmSync(out, { recursive: true, force: true });
|
|
314
368
|
mkdirSync(join(out, 'assets'), { recursive: true });
|
|
315
369
|
const mode = g.build?.mode ?? 'bundle';
|
|
316
370
|
let warnings = 0;
|
|
317
371
|
let metafile = null;
|
|
372
|
+
let bundled = null;
|
|
373
|
+
let chunks = [];
|
|
374
|
+
const budgets = modelBudgetsOf(g, log);
|
|
318
375
|
const bundle = async (entryRel) => {
|
|
319
376
|
const entry = join(g.dir, entryRel);
|
|
320
377
|
if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
|
|
321
|
-
const
|
|
322
|
-
entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: sourcemap ??
|
|
323
|
-
|
|
378
|
+
const options = {
|
|
379
|
+
entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: sourcemap ?? false,
|
|
380
|
+
outdir: join(out, 'assets'), splitting: true, entryNames: hashed ? 'main-[hash]' : 'main', chunkNames: 'chunk-[hash]',
|
|
381
|
+
absWorkingDir: root, logLevel: 'silent', metafile: true,
|
|
324
382
|
loader: LOADERS, assetNames: '[name]-[hash]',
|
|
325
|
-
|
|
383
|
+
// game.json assets.budgets, read by createModels() (assets/assets.ts): a constant in the bundle, never a fetch.
|
|
384
|
+
define: { __HOMIE_MODEL_BUDGETS__: JSON.stringify(budgets ?? {}) },
|
|
385
|
+
// GAME PARTS: `@parts/<host>/<id>` and `@parts/<id>` resolve to the part's entry, and the game is credited.
|
|
386
|
+
plugins: [partsPlugin(root, g.id)],
|
|
387
|
+
};
|
|
388
|
+
const failed = (error) => {
|
|
326
389
|
const first = error.errors?.[0];
|
|
327
390
|
throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
|
|
328
|
-
}
|
|
391
|
+
};
|
|
392
|
+
const result = await esbuild.build(options).catch(failed);
|
|
329
393
|
warnings += result.warnings.length;
|
|
330
394
|
metafile = result.metafile;
|
|
331
|
-
|
|
395
|
+
const outputs = Object.entries(result.metafile.outputs).filter(([k]) => k.endsWith('.js'));
|
|
396
|
+
const main = outputs.find(([, v]) => v.entryPoint)?.[0];
|
|
397
|
+
if (!main) throw new Error(`games/${g.id} did not build: esbuild wrote no bundle for ${entryRel}`);
|
|
398
|
+
bundled = `assets/${basename(main)}`;
|
|
399
|
+
chunks = outputs.filter(([k]) => k !== main).map(([k]) => `assets/${basename(k)}`).sort();
|
|
400
|
+
if (maps) {
|
|
401
|
+
// The map comes from a second pass that writes nothing. esbuild's name hash covers a file's map as well as its
|
|
402
|
+
// code, so a build made WITH maps would name the bundle (and its chunks) differently from a plain build of the
|
|
403
|
+
// same source, and `build --maps` would no longer be the build a deploy ships. The second pass's code differs
|
|
404
|
+
// from the first's only in those names, which are all the same length, so its map fits the shipped bundle
|
|
405
|
+
// position for position.
|
|
406
|
+
const mapped = await esbuild.build({ ...options, sourcemap: 'external', write: false, metafile: true }).catch(failed);
|
|
407
|
+
const entryOut = Object.entries(mapped.metafile.outputs).find(([k, v]) => k.endsWith('.js') && v.entryPoint)?.[0];
|
|
408
|
+
keepMap(root, g.id, out, result.metafile, bundled, mapped.outputFiles.filter((f) => f.path.endsWith('.js.map')).map((f) => ({ name: basename(f.path), text: f.text, entry: Boolean(entryOut) && basename(f.path) === `${basename(entryOut)}.map` })));
|
|
409
|
+
}
|
|
410
|
+
if (hashed) {
|
|
411
|
+
// The address tools and older pages knew: it imports the real bundle, so both are one module, run once.
|
|
412
|
+
writeFileSync(join(out, 'assets', 'main.js'), `import"./${basename(main)}";\n`);
|
|
413
|
+
writeFileSync(join(out, 'bundle.json'), `${JSON.stringify({ v: 1, bundle: bundled, chunks })}\n`);
|
|
414
|
+
}
|
|
332
415
|
};
|
|
333
416
|
if (mode === 'static') {
|
|
334
417
|
copyStatic(g.dir, out);
|
|
@@ -336,6 +419,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
336
419
|
if (g.entry) await bundle(g.entry);
|
|
337
420
|
const html = readFileSync(join(out, 'index.html'), 'utf8');
|
|
338
421
|
if (!/homie-port\.js/.test(html)) log(`warning: games/${g.id}/index.html does not load ./homie-port.js (the port toolkit); add <script src="./homie-port.js"></script> first in <head>`);
|
|
422
|
+
if (hashed && bundled) writeFileSync(join(out, 'index.html'), pointAtBundle(html, bundled));
|
|
339
423
|
} else if (mode === 'command') {
|
|
340
424
|
const command = String(g.build.command ?? 'npm run build');
|
|
341
425
|
const res = spawnSync(command, { cwd: g.dir, shell: true, encoding: 'utf8', timeout: 10 * 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
@@ -348,29 +432,61 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
348
432
|
await bundle(g.entry ?? 'src/main.ts');
|
|
349
433
|
const html = join(g.dir, 'index.html');
|
|
350
434
|
if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
|
|
351
|
-
|
|
435
|
+
const text = readFileSync(html, 'utf8');
|
|
436
|
+
writeFileSync(join(out, 'index.html'), hashed ? pointAtBundle(text, bundled) : text);
|
|
352
437
|
}
|
|
353
438
|
if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
|
|
354
439
|
if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
|
|
355
|
-
return { mode, warnings, metafile };
|
|
440
|
+
return { mode, warnings, metafile, bundle: bundled, chunks };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* studio.json `"site": { "order": ["<id>", …] }`: the order every list of games is in (Home, the Games page, the
|
|
445
|
+
* cards, the feeds for crawlers and /.well-known/homie-studio.json, which all read the catalogue's order). The games
|
|
446
|
+
* it names come first, as it names them; every other game follows by id, as all of them did before.
|
|
447
|
+
*/
|
|
448
|
+
export function orderGames(games, order, log = () => {}) {
|
|
449
|
+
if (order === undefined) return games;
|
|
450
|
+
if (!Array.isArray(order) || order.some((x) => typeof x !== 'string')) { log('warning: studio.json site.order is a list of game ids (["newest-game", "older-game"]); left out'); return games; }
|
|
451
|
+
const ids = new Set(games.map((g) => g.id));
|
|
452
|
+
const want = [...new Set(order)];
|
|
453
|
+
for (const id of want) if (!ids.has(id)) log(`warning: studio.json site.order names "${String(id).slice(0, 40)}", which is not one of this studio's games; skipped`);
|
|
454
|
+
const at = new Map(want.filter((id) => ids.has(id)).map((id, i) => [id, i]));
|
|
455
|
+
return [...games].sort((a, b) => (at.get(a.id) ?? Infinity) - (at.get(b.id) ?? Infinity) || a.id.localeCompare(b.id));
|
|
356
456
|
}
|
|
357
457
|
|
|
358
|
-
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false } = {}) {
|
|
458
|
+
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false } = {}) {
|
|
359
459
|
const esbuild = await studioEsbuild(root);
|
|
360
460
|
const studio = readStudio(root);
|
|
361
461
|
// The shop first (shop/SHOP.md): a shop.json that breaks the kit's rules stops the build before anything is built.
|
|
362
462
|
const shop = shopForBuild(root, { log });
|
|
363
|
-
const
|
|
463
|
+
const live = join(root, 'site', 'dist');
|
|
364
464
|
const games = listGames(root).filter((g) => !only || g.id === only);
|
|
365
465
|
if (only && !games.length) throw new Error(`no game "${only}" in games/`);
|
|
366
|
-
|
|
367
|
-
|
|
466
|
+
// `--types`: the games' TypeScript is checked first (esbuild only strips types, it never reads them), and a type
|
|
467
|
+
// error stops the build before anything is built.
|
|
468
|
+
const typed = types ? typecheck(root, games, { log }) : null;
|
|
469
|
+
// What the build before this one made, to say which games changed.
|
|
470
|
+
const before = readJson(join(live, '_site', 'build.json'))?.games ?? {};
|
|
471
|
+
// Everything is built in a folder of its own and put in place only when all of it is there (lib/stage.mjs): a
|
|
472
|
+
// game that does not build leaves site/dist as it was, never a site without that game. A one-game build starts
|
|
473
|
+
// from the site as it is.
|
|
474
|
+
const dist = openStage(root, { from: only ? live : null });
|
|
475
|
+
try {
|
|
476
|
+
return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps });
|
|
477
|
+
} finally {
|
|
478
|
+
rmSync(dist, { recursive: true, force: true });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps }) {
|
|
368
483
|
const built = [];
|
|
484
|
+
const retired = [];
|
|
369
485
|
const cache = {};
|
|
370
486
|
for (const g of games) {
|
|
371
487
|
const out = join(dist, 'games', g.id);
|
|
372
488
|
const started = Date.now();
|
|
373
|
-
const { mode, warnings } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log });
|
|
489
|
+
const { mode, warnings, bundle, chunks } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true });
|
|
374
490
|
// The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
|
|
375
491
|
vocabFor(g, out);
|
|
376
492
|
// Room chat (NETPLAY.md section 19): game.json "chat", checked here (its quick lines pass the chat floor too).
|
|
@@ -380,19 +496,31 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
380
496
|
if (g.decide !== undefined && typeof g.decide !== 'boolean') throw new Error(`games/${g.id}/game.json: "decide" is true (the host may ask the studio's decision model, within the AI brains' day) or false`);
|
|
381
497
|
// Where chat sits on the screen (game.json "screen": { "chat" }, 0.24.5): a wrong field is the default on the page.
|
|
382
498
|
for (const p of screenChatProblems(g.screen?.chat)) log(`warning: games/${g.id}/game.json: ${p}; the play page uses the default there`);
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
const bytes = existsSync(main) ? statSync(main).size : dirBytes(out);
|
|
499
|
+
// A game.json written for an older toolkit may still carry the retired settings. They are ignored, never an
|
|
500
|
+
// error (a studio that upgrades must still build); what they were is said once, after the games (below).
|
|
501
|
+
const old = retiredKeys(g);
|
|
502
|
+
if (old.length) retired.push(`games/${g.id}/game.json ${old.join(', ')}`);
|
|
503
|
+
const bytes = bundle ? statSync(join(out, bundle)).size : dirBytes(out);
|
|
504
|
+
const later = chunks.reduce((n, c) => n + statSync(join(out, c)).size, 0);
|
|
390
505
|
const seats = seatsFor(g, netplayOf(g, out));
|
|
391
506
|
if (seats.asked > SEAT_MAX) log(`warning: games/${g.id} asks for ${seats.asked} players; a room holds at most ${SEAT_MAX}, so its rooms have ${SEAT_MAX} seats`);
|
|
392
|
-
built.push({
|
|
393
|
-
|
|
507
|
+
built.push({
|
|
508
|
+
id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max,
|
|
509
|
+
...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
|
|
510
|
+
});
|
|
511
|
+
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} ${chunks.length === 1 ? 'chunk' : 'chunks'} loaded later, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
|
|
394
512
|
}
|
|
395
513
|
const all = listGames(root);
|
|
514
|
+
// No game is handed over whole any more (remix was retired; worker/license.mjs). A one-game build starts from the
|
|
515
|
+
// site as it is, which an older toolkit may have built: what that wrote for remixers (a game's whole source, and
|
|
516
|
+
// its assets' addresses) is taken out of every game's folder here. Only those two files as that toolkit wrote them,
|
|
517
|
+
// told by their own `kind`: a game's own public/assets.json is the game's, and stays.
|
|
518
|
+
for (const g of all) {
|
|
519
|
+
for (const [f, kind] of [['source.json', 'homie-game-source'], ['assets.json', 'homie-game-assets']]) {
|
|
520
|
+
const file = join(dist, 'games', g.id, f);
|
|
521
|
+
if (existsSync(file) && statSync(file).size <= 8 * 1024 * 1024 && readJson(file)?.kind === kind) rmSync(file, { force: true });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
396
524
|
// Songs and videos (music/ and videos/ manifests): rebuilt with every full build; a one-game build keeps them.
|
|
397
525
|
const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
|
|
398
526
|
let media = null;
|
|
@@ -402,11 +530,17 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
402
530
|
}
|
|
403
531
|
// The site around the games (site/SITE.md): the look, each game's landing, posts, and what site/ overrides.
|
|
404
532
|
const theme = readTheme(root, { log });
|
|
405
|
-
const
|
|
533
|
+
const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
|
|
534
|
+
// studio.json site.order: the catalogue's order is every listing's order.
|
|
535
|
+
const shown = orderGames(all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))), s.order, log);
|
|
536
|
+
// What each game's build is (its landing's own pictures are not part of it): the digest the manifest names.
|
|
537
|
+
const builds = {};
|
|
406
538
|
const rows = shown.map((g) => {
|
|
407
539
|
// The seats come from the game's netplay manifest (NETPLAY.md §3): what the Worker gives every room of it.
|
|
408
540
|
const net = netplayOf(g, join(dist, 'games', g.id));
|
|
409
541
|
const { min, max } = seatsFor(g, net);
|
|
542
|
+
const netRow = netplayRow(net);
|
|
543
|
+
for (const p of netRow.problems) log(`warning: games/${g.id}/game.json: ${p}`);
|
|
410
544
|
const seeds = serverSeeds(g, log);
|
|
411
545
|
// The game's own palette (style.json, its art direction): the play page's buttons wear its paper and ink, so the
|
|
412
546
|
// page's pills and the game's own HUD are one UI.
|
|
@@ -416,10 +550,18 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
416
550
|
const genre = genreOf(g, log);
|
|
417
551
|
const dates = gameDates(root, g, log);
|
|
418
552
|
const extras = schemaExtras(g.schema, { where: `games/${g.id}/game.json`, types: ['VideoGame', 'WebApplication'], log });
|
|
553
|
+
// The landing first: it may put the game's cover into the build, which is part of what the digest names.
|
|
554
|
+
const landing = landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log });
|
|
555
|
+
const dir = join(dist, 'games', g.id);
|
|
556
|
+
const bundle = bundleOf(dir);
|
|
557
|
+
builds[g.id] = { hash: gameDigest(dir), ...(bundle ? { bundle } : {}) };
|
|
419
558
|
return {
|
|
420
559
|
id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
|
|
421
560
|
...(ui ? { ui } : {}),
|
|
422
561
|
roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
|
|
562
|
+
// game.json "netplay": { "version", "stallMs", "params" } (NETPLAY.md sections 22 to 24): the game's revision (a
|
|
563
|
+
// room runs one build at a time), its own host stall time, and the address's switches its frame is handed.
|
|
564
|
+
...(netRow.row ? { netplay: netRow.row } : {}),
|
|
423
565
|
...(g.screen ? { screen: g.screen } : {}),
|
|
424
566
|
// game.json "saves": true — player accounts and cloud saves (saves/SAVES.md): the play shell answers the game's
|
|
425
567
|
// saves calls, and the site's nav and the game's landing offer a player account.
|
|
@@ -440,22 +582,28 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
440
582
|
// 0.17.0: the game has a vocabulary for its AI guides (agents.json), so they can talk once the owner says so.
|
|
441
583
|
...(existsSync(join(dist, 'games', g.id, 'agents.json')) ? { vocab: true } : {}),
|
|
442
584
|
...(g.agents && typeof g.agents === 'object' && (g.agents.vote === 'game' || g.agents.vote === false) ? { agents: { vote: g.agents.vote } } : {}),
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
585
|
+
// The licence the game names for itself (an SPDX id, worker/license.mjs), when it names one; and, for a game
|
|
586
|
+
// that was made from another studio's while that was possible (game.json `remixOf`), the credit it owes the
|
|
587
|
+
// original, which its landing and credits keep showing.
|
|
588
|
+
...(licenseOf(g.license) ? { license: licenseOf(g.license) } : {}),
|
|
589
|
+
...(basedOnRow(g.remixOf) ? { basedOn: basedOnRow(g.remixOf) } : {}),
|
|
446
590
|
...(genre ? { genre } : {}),
|
|
447
591
|
...(dates ? { dates } : {}),
|
|
448
592
|
...(extras ? { schema: extras } : {}),
|
|
449
|
-
landing
|
|
593
|
+
landing,
|
|
594
|
+
// What is built: its digest (the same one `perf` names a build by) and the bundle its page loads. The live
|
|
595
|
+
// site says them in /.well-known/homie-studio.json, so "is the live game this build?" is one comparison.
|
|
596
|
+
built: builds[g.id],
|
|
450
597
|
};
|
|
451
598
|
});
|
|
452
599
|
const { posts, skipped: postsSkipped } = readPosts(root, { games: rows, songs: media.songs, videos: media.videos, log });
|
|
453
600
|
const site = buildSiteFiles(root, dist, { gameIds: rows.map((g) => g.id), log });
|
|
454
601
|
// Each game's Game Codex (games/<id>/CODEX.md), as the owner's private page at /_studio/codex/<id>/ (never listed).
|
|
455
602
|
const codexes = buildCodexPages(root, dist, games.map((g) => g.id), { log });
|
|
603
|
+
// GAME PARTS: every packed version of every SHARED part into dist/parts/, and its index; a private part is never copied.
|
|
604
|
+
const partsBuilt = buildParts(root, dist, { studio, log });
|
|
456
605
|
mkdirSync(join(dist, '_site'), { recursive: true });
|
|
457
606
|
writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
|
|
458
|
-
const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
|
|
459
607
|
const studioExtras = schemaExtras(s.schema, { where: 'studio.json site', types: 'Organization', log });
|
|
460
608
|
// The Lounge (0.29.0): studio.json "lounge", checked; off when a game already has /lounge/.
|
|
461
609
|
for (const p of loungeProblems(studio, rows, chatProblems)) log(`warning: studio.json: ${p}`);
|
|
@@ -498,10 +646,29 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
498
646
|
...(shop ? { shop } : {}),
|
|
499
647
|
};
|
|
500
648
|
writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
|
|
649
|
+
// What this build made, for whatever runs next (a deploy says which games it changes; a script checks the live
|
|
650
|
+
// site against it): per game its digest, its bundle and chunks, and whether it differs from the build before.
|
|
651
|
+
const changedOf = (id) => (!before[id]?.hash ? 'new' : before[id].hash === builds[id].hash ? 'unchanged' : 'changed');
|
|
652
|
+
const report = {};
|
|
653
|
+
for (const g of rows) {
|
|
654
|
+
const dir = join(dist, 'games', g.id);
|
|
655
|
+
const named = readJson(join(dir, 'bundle.json'));
|
|
656
|
+
report[g.id] = { ...builds[g.id], ...(Array.isArray(named?.chunks) && named.chunks.length ? { chunks: named.chunks } : {}), changed: changedOf(g.id) };
|
|
657
|
+
}
|
|
658
|
+
writeFileSync(join(dist, '_site', 'build.json'), `${JSON.stringify({ v: 1, at: catalogue.studio.build.at, commit: catalogue.studio.build.commit, games: report }, null, 2)}\n`);
|
|
659
|
+
for (const b of built) Object.assign(b, { hash: builds[b.id]?.hash ?? null, changed: report[b.id]?.changed ?? 'new' });
|
|
660
|
+
// A deploy builds first and prints its own result, not the build's: so the build a deploy is about to ship says
|
|
661
|
+
// here which games it changes and which build each is (`homie-studio build` prints the same from its result).
|
|
662
|
+
if (deploy) for (const b of built) log(` ${b.id}: ${b.changed}${b.changed === 'new' ? '' : ' since the last build here'}, build ${b.hash}`);
|
|
663
|
+
// All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
|
|
664
|
+
const swapped = swapIn(dist, live);
|
|
501
665
|
return {
|
|
502
|
-
ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
|
|
666
|
+
ok: true, command: 'build', dist: live, games: built, catalogue: catalogue.games.map((g) => g.id),
|
|
667
|
+
builds: report, swapped, ...(typed ? { types: typed } : {}),
|
|
668
|
+
// One plain note for the whole build, however many games and keys it is about (never one a key).
|
|
669
|
+
...(retired.length ? { retired: `Remix was retired, and games now build on each other through parts (pieces a studio shares; ask for a part, or see parts/PARTS.md). These settings no longer do anything and can be deleted: ${retired.join('; ')}. No game's source is served whole; a game made from another keeps its credit.` } : {}),
|
|
503
670
|
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped, mediaNotes: media.notes ?? [],
|
|
504
|
-
posts: posts.map((p) => p.slug), postsSkipped, codexes, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
|
|
505
|
-
landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length)
|
|
671
|
+
posts: posts.map((p) => p.slug), postsSkipped, codexes, parts: partsBuilt, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
|
|
672
|
+
landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length) })),
|
|
506
673
|
};
|
|
507
674
|
}
|