@homie-rocks/studio 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/lab.mjs ADDED
@@ -0,0 +1,546 @@
1
+ /**
2
+ * THE GAME LAB (`homie-studio lab <game>`): a page on this computer that plays one short take of a game in two builds
3
+ * side by side, New (the working tree) and Today (the last commit, or any ref), on one clock, with the same seed and
4
+ * the same presses, so a change to how a jump, a hit or a drift FEELS can be seen, scrubbed, slowed down, measured
5
+ * and tuned before it is kept.
6
+ *
7
+ * homie-studio lab <game> [--port 8790] [--today HEAD|<ref>] the lab's server, in the foreground (--stop stops it)
8
+ * homie-studio lab check <game> [--take <name>] [...] the same take played headless: numbers and pictures
9
+ * homie-studio lab set <game> <name>=<value> ... write tunables into games/<game>/tunables.json
10
+ *
11
+ * What it serves (127.0.0.1 only):
12
+ * /<game>/ the lab page (lab/page/)
13
+ * /<game>/new/... New: the game built from the working tree, rebuilt when a file in games/<game>/ changes
14
+ * /<game>/today/... Today: the game built from a checkout of the ref (HEAD by default), cached per commit
15
+ * /_lab/... the page's own files, the harness (lab/harness.js) and the take format (lab/take.js)
16
+ * /_lab/api/<game>/state both builds, the takes (games/<game>/lab.json), the tunables (tunables.json) of each
17
+ * /_lab/api/<game>/events server-sent events: a build finished (New rebuilt after an edit)
18
+ * POST /_lab/api/<game>/tunables { values }: written into tunables.json (one tunable a line)
19
+ * POST /_lab/api/<game>/take { name, take }: a recorded take, written into lab.json
20
+ * Each game's page gets the harness as its first script. Writes come only from the lab page itself (same origin).
21
+ *
22
+ * Builds live in .studio/lab/<game>/ (git-ignored): new/, today-<commit>/ (the last three), and checkout/, where git
23
+ * checks out the ref's games/<game>/ and whatever else New's bundle reads from the studio (a shared folder), and
24
+ * nothing else. The checkout is git's own `git worktree add --no-checkout`, removed by `lab --stop`.
25
+ */
26
+ import { spawnSync } from 'node:child_process';
27
+ import { createServer } from 'node:http';
28
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, watch, writeFileSync } from 'node:fs';
29
+ import { extname, join, normalize, relative, resolve, sep } from 'node:path';
30
+ import { buildGameFiles, studioEsbuild } from './build.mjs';
31
+ import { GAME_ID, PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
32
+ import { STUDIO_VERSION } from './version.mjs';
33
+ import { formatLabFile, formatTunables, readLabFile, checkTake, setTunables, tunableValue, TAKE_NAME } from '../lab/take.js';
34
+
35
+ export const LAB_PORT = 8790;
36
+ const LAB_FILES = join(PACKAGE_ROOT, 'lab');
37
+ const TYPES = {
38
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
39
+ '.json': 'application/json; charset=utf-8', '.map': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg',
40
+ '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon', '.wasm': 'application/wasm', '.mp3': 'audio/mpeg',
41
+ '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.glb': 'model/gltf-binary', '.gltf': 'model/gltf+json', '.bin': 'application/octet-stream',
42
+ '.hdr': 'application/octet-stream', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.txt': 'text/plain; charset=utf-8', '.mp4': 'video/mp4', '.webm': 'video/webm',
43
+ };
44
+
45
+ export const labDir = (root, id) => join(root, '.studio', 'lab', id);
46
+ const serverFile = (root) => join(root, '.studio', 'lab', 'server.json');
47
+ const git = (cwd, args, opts = {}) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024, ...opts });
48
+ const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
49
+
50
+ /* ------------------------------------------------------------------ the game's own lab files */
51
+
52
+ /** games/<id>/lab.json, checked (lab/take.js): { takes, default, problems, exists }. */
53
+ export function readTakes(dir) {
54
+ const file = join(dir, 'lab.json');
55
+ if (!existsSync(file)) return { takes: {}, default: null, problems: [], exists: false };
56
+ let json;
57
+ try { json = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { return { takes: {}, default: null, problems: [`lab.json is not JSON: ${error.message}`], exists: true }; }
58
+ return { ...readLabFile(json), exists: true };
59
+ }
60
+
61
+ /** games/<id>/tunables.json, or null. */
62
+ export function readTunables(dir) {
63
+ const file = join(dir, 'tunables.json');
64
+ if (!existsSync(file)) return null;
65
+ const json = readJson(file);
66
+ if (!json || typeof json !== 'object' || Array.isArray(json)) throw new Error(`${relative(process.cwd(), file) || file} is not a JSON object of tunables`);
67
+ return json;
68
+ }
69
+
70
+ /** `homie-studio lab set <game> name=value ...`: values written into tunables.json, one tunable a line. */
71
+ export function labSet(root, id, pairs) {
72
+ const g = gameOf(root, id);
73
+ const spec = readTunables(g.dir);
74
+ if (!spec) return { ok: false, command: 'lab set', why: `games/${g.id} has no tunables.json yet: the lab skill says how a game gets one (lab.tunables)` };
75
+ const values = {};
76
+ for (const p of pairs) {
77
+ const m = /^([A-Za-z_$][\w$]{0,47})=(.+)$/.exec(String(p));
78
+ if (!m) return { ok: false, command: 'lab set', why: `"${p}" is not name=value` };
79
+ values[m[1]] = m[2];
80
+ }
81
+ if (!Object.keys(values).length) return { ok: false, command: 'lab set', why: 'usage: homie-studio lab set <game> <name>=<value> ...' };
82
+ const r = setTunables(spec, values);
83
+ if (r.refused.length) return { ok: false, command: 'lab set', why: r.refused.join('; ') };
84
+ writeFileSync(join(g.dir, 'tunables.json'), formatTunables(r.spec));
85
+ return { ok: true, command: 'lab set', game: g.id, file: `games/${g.id}/tunables.json`, changed: r.changed };
86
+ }
87
+
88
+ function gameOf(root, id) {
89
+ const games = listGames(root);
90
+ const pick = id ?? (games.length === 1 ? games[0].id : null);
91
+ if (!pick) throw new Error(games.length ? `name the game: ${games.map((g) => g.id).join(', ')}` : 'there is no game in games/ yet');
92
+ if (!GAME_ID.test(String(pick))) throw new Error(`"${pick}" is not a game id`);
93
+ const g = games.find((x) => x.id === pick);
94
+ if (!g) throw new Error(`no game "${pick}" in games/ (${games.map((x) => x.id).join(', ') || 'none yet'})`);
95
+ return g;
96
+ }
97
+
98
+ /* ------------------------------------------------------------------ Today: a checkout of the ref */
99
+
100
+ /** The commit a ref names in the studio's repository, and its one-line subject; null when there is none. */
101
+ export function commitOf(root, ref = 'HEAD') {
102
+ if (!/^[\w./@{}^~-]{1,100}$/.test(String(ref)) || String(ref).startsWith('-')) return null;
103
+ const r = git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
104
+ if (r.status !== 0) return null;
105
+ const sha = r.stdout.trim();
106
+ const subject = git(root, ['log', '-1', '--format=%s', sha]).stdout?.trim() ?? '';
107
+ return { sha, short: sha.slice(0, 7), ref: String(ref), subject: subject.slice(0, 100) };
108
+ }
109
+
110
+ /** Whether games/<id>/ differs from HEAD (staged, changed or new files). */
111
+ export function dirtyOf(root, id) {
112
+ const r = git(root, ['status', '--porcelain', '--', `games/${id}`]);
113
+ return r.status === 0 ? r.stdout.trim().split('\n').filter(Boolean).length : null;
114
+ }
115
+
116
+ /** The paths of `sha` that New's bundle read from outside games/<id>/ (a shared folder), plus the game's own. */
117
+ function pathsFor(root, id, sha, metafile) {
118
+ const want = new Set([`games/${id}`]);
119
+ for (const input of Object.keys(metafile?.inputs ?? {})) {
120
+ const rel = input.split(sep).join('/');
121
+ if (rel.startsWith('node_modules/') || rel.startsWith('../') || rel.startsWith('/') || rel.startsWith(`games/${id}/`) || rel.includes('/node_modules/')) continue;
122
+ want.add(rel);
123
+ }
124
+ for (const f of ['package.json', 'studio.json']) want.add(f);
125
+ const r = git(root, ['ls-tree', '-r', '--name-only', sha, '--', ...want]);
126
+ const present = new Set((r.stdout ?? '').split('\n').filter(Boolean));
127
+ return [...want].filter((p) => present.has(p) || [...present].some((x) => x.startsWith(`${p}/`)));
128
+ }
129
+
130
+ /** git's own checkout of `sha` with only `paths` in it, at .studio/lab/<id>/checkout. */
131
+ function checkout(root, id, sha, paths) {
132
+ const dir = join(labDir(root, id), 'checkout');
133
+ const stamp = join(labDir(root, id), 'checkout.json');
134
+ const had = readJson(stamp);
135
+ if (had?.sha === sha && JSON.stringify(had.paths) === JSON.stringify(paths) && existsSync(join(dir, 'games', id))) return dir;
136
+ removeCheckout(root, dir);
137
+ mkdirSync(labDir(root, id), { recursive: true });
138
+ const add = git(root, ['worktree', 'add', '--no-checkout', '--detach', dir, sha]);
139
+ if (add.status !== 0) throw new Error(`git could not check out ${sha.slice(0, 7)}: ${(add.stderr || add.stdout).trim().split('\n').pop()}`);
140
+ const co = git(dir, ['checkout', sha, '--', ...paths]);
141
+ if (co.status !== 0) throw new Error(`git could not check out games/${id} at ${sha.slice(0, 7)}: ${(co.stderr || co.stdout).trim().split('\n').pop()}`);
142
+ // A game with its own build (game.json build.mode "command") builds with its own node_modules: the working tree's.
143
+ const nm = join(root, 'games', id, 'node_modules');
144
+ if (existsSync(nm) && existsSync(join(dir, 'games', id)) && !existsSync(join(dir, 'games', id, 'node_modules'))) { try { symlinkSync(nm, join(dir, 'games', id, 'node_modules'), 'dir'); } catch { /* builds without */ } }
145
+ writeFileSync(stamp, `${JSON.stringify({ sha, paths, at: new Date().toISOString() })}\n`);
146
+ return dir;
147
+ }
148
+
149
+ function removeCheckout(root, dir) {
150
+ if (existsSync(dir)) {
151
+ const r = git(root, ['worktree', 'remove', '--force', dir]);
152
+ if (r.status !== 0) rmSync(dir, { recursive: true, force: true });
153
+ }
154
+ git(root, ['worktree', 'prune']);
155
+ }
156
+
157
+ /* ------------------------------------------------------------------ the two builds */
158
+
159
+ class Builds {
160
+ constructor(root, id, { today = 'HEAD', log = () => {} } = {}) {
161
+ this.root = root;
162
+ this.id = id;
163
+ this.todayRef = today;
164
+ this.log = log;
165
+ this.new = { ok: false, building: false, error: null };
166
+ this.today = { ok: false, building: false, error: null };
167
+ this.listeners = new Set();
168
+ this.watcher = null;
169
+ this.timer = null;
170
+ this.esbuild = null;
171
+ }
172
+
173
+ emit(event) { for (const l of this.listeners) { try { l(event); } catch { /* a closed stream */ } } }
174
+
175
+ async buildNew() {
176
+ const g = gameOf(this.root, this.id);
177
+ this.esbuild ??= await studioEsbuild(this.root);
178
+ const out = join(labDir(this.root, this.id), 'new');
179
+ const t0 = Date.now();
180
+ this.new = { ...this.new, building: true };
181
+ try {
182
+ const r = await buildGameFiles(this.esbuild, this.root, g, out, { sourcemap: 'linked', log: this.log });
183
+ this.new = { ok: true, building: false, error: null, dir: out, at: new Date().toISOString(), ms: Date.now() - t0, bytes: sizeOf(join(out, 'assets', 'main.js')), metafile: r.metafile, mode: r.mode, dirty: dirtyOf(this.root, this.id) };
184
+ } catch (error) {
185
+ this.new = { ok: false, building: false, error: error.message, dir: out, at: new Date().toISOString() };
186
+ }
187
+ this.emit({ k: 'build', pane: 'new', ok: this.new.ok, error: this.new.error, at: this.new.at });
188
+ return this.new;
189
+ }
190
+
191
+ async buildToday(ref = this.todayRef) {
192
+ this.todayRef = ref;
193
+ const c = commitOf(this.root, ref);
194
+ if (!c) { this.today = { ok: false, building: false, error: null, none: existsSync(join(this.root, '.git')) ? `no commit "${ref}" in this studio's repository` : 'this studio is not a git repository, so there is no Today to compare with' }; return this.today; }
195
+ const out = join(labDir(this.root, this.id), `today-${c.sha.slice(0, 12)}`);
196
+ const has = git(this.root, ['cat-file', '-e', `${c.sha}:games/${this.id}/game.json`]);
197
+ if (has.status !== 0) { this.today = { ok: false, building: false, error: null, commit: c, none: `games/${this.id} is not in ${c.short} yet: commit it, and Today is that commit` }; return this.today; }
198
+ const stamp = readJson(join(out, '.lab-build.json'));
199
+ if (stamp?.sha === c.sha && stamp.version === STUDIO_VERSION && existsSync(join(out, 'index.html'))) {
200
+ this.today = { ok: true, building: false, error: null, dir: out, commit: c, at: stamp.at, bytes: sizeOf(join(out, 'assets', 'main.js')), tunables: stamp.tunables ?? null };
201
+ return this.today;
202
+ }
203
+ this.today = { ...this.today, building: true, commit: c };
204
+ const t0 = Date.now();
205
+ try {
206
+ if (!this.new.metafile) await this.buildNew();
207
+ const paths = pathsFor(this.root, this.id, c.sha, this.new.metafile);
208
+ const dir = checkout(this.root, this.id, c.sha, paths);
209
+ const g = listGames(dir).find((x) => x.id === this.id);
210
+ if (!g) throw new Error(`games/${this.id}/game.json is not in ${c.short}`);
211
+ this.esbuild ??= await studioEsbuild(this.root);
212
+ await buildGameFiles(this.esbuild, dir, g, out, { sourcemap: 'linked', log: this.log });
213
+ let tunables = null;
214
+ try { tunables = readTunables(g.dir); } catch { tunables = null; }
215
+ const at = new Date().toISOString();
216
+ writeFileSync(join(out, '.lab-build.json'), `${JSON.stringify({ sha: c.sha, version: STUDIO_VERSION, at, tunables })}\n`);
217
+ this.today = { ok: true, building: false, error: null, dir: out, commit: c, at, ms: Date.now() - t0, bytes: sizeOf(join(out, 'assets', 'main.js')), tunables };
218
+ prune(labDir(this.root, this.id), out);
219
+ } catch (error) {
220
+ this.today = { ok: false, building: false, error: error.message, commit: c, at: new Date().toISOString() };
221
+ }
222
+ this.emit({ k: 'build', pane: 'today', ok: this.today.ok, error: this.today.error, at: this.today.at });
223
+ return this.today;
224
+ }
225
+
226
+ /** Rebuild New a moment after the last change in games/<id>/ (an editor saves in bursts). */
227
+ watch() {
228
+ if (this.watcher) return;
229
+ // A commit changes Today: look every 2 s (one cheap `git rev-parse`).
230
+ this.refTimer = setInterval(() => { void this.followRef(); }, 2000);
231
+ this.refTimer.unref?.();
232
+ const dir = join(this.root, 'games', this.id);
233
+ try {
234
+ this.watcher = watch(dir, { recursive: true }, (_e, file) => {
235
+ const f = String(file ?? '');
236
+ if (/(^|[\\/])(\.port|node_modules|dist|\.git)([\\/]|$)/.test(f) || f.endsWith('lab.json') || f.endsWith('CODEX.md') || /\.(md|log)$/.test(f)) return;
237
+ clearTimeout(this.timer);
238
+ this.timer = setTimeout(() => { void this.buildNew().then((n) => this.log(n.ok ? `New rebuilt (${f})` : `New did not build: ${n.error}`)); }, 220);
239
+ });
240
+ } catch (error) { this.log(`not watching games/${this.id} for changes (${error.message}); the lab's Rebuild button rebuilds New`); }
241
+ }
242
+
243
+ /** Today follows its ref: a new commit (HEAD moved, a branch was switched) rebuilds it, and the page is told. */
244
+ async followRef() {
245
+ if (this.today.building) return;
246
+ const c = commitOf(this.root, this.todayRef);
247
+ const had = this.today.commit?.sha ?? null;
248
+ if ((c?.sha ?? null) === had && !this.today.error) return;
249
+ if (!c && !had) return;
250
+ await this.buildToday();
251
+ this.new.dirty = dirtyOf(this.root, this.id);
252
+ this.log(this.today.ok ? `Today is now ${this.today.commit.short} "${this.today.commit.subject}"` : `Today: ${this.today.error ?? this.today.none}`);
253
+ }
254
+
255
+ close() { clearTimeout(this.timer); clearInterval(this.refTimer); this.watcher?.close(); this.watcher = null; }
256
+ }
257
+
258
+ function prune(dir, keep) {
259
+ const builds = readdirSync(dir).filter((n) => n.startsWith('today-')).map((n) => ({ n, at: statSync(join(dir, n)).mtimeMs })).sort((a, b) => b.at - a.at);
260
+ for (const b of builds.slice(3)) if (join(dir, b.n) !== keep) rmSync(join(dir, b.n), { recursive: true, force: true });
261
+ }
262
+ function sizeOf(p) { try { return statSync(p).size; } catch { return null; } }
263
+
264
+ /* ------------------------------------------------------------------ the server */
265
+
266
+ /**
267
+ * The lab's server for one studio: every game's lab, on demand. Resolves once listening, with { url, port, close }.
268
+ * `today` is the ref Today is built from (HEAD by default; the page can ask for another with ?today=<ref>).
269
+ */
270
+ export async function startLabServer(root, { port = LAB_PORT, today = 'HEAD', log = () => {}, watchFiles = true, host = '127.0.0.1' } = {}) {
271
+ const labs = new Map();
272
+ const labFor = async (id) => {
273
+ let b = labs.get(id);
274
+ if (!b) {
275
+ gameOf(root, id);
276
+ b = new Builds(root, id, { today, log });
277
+ labs.set(id, b);
278
+ b.ready = (async () => { await b.buildNew(); await b.buildToday(); if (watchFiles) b.watch(); })();
279
+ }
280
+ await b.ready;
281
+ return b;
282
+ };
283
+ let origin = '';
284
+ const server = createServer(async (req, res) => {
285
+ try {
286
+ await handle(req, res);
287
+ } catch (error) {
288
+ send(res, 500, { ok: false, why: error instanceof Error ? error.message : String(error) });
289
+ }
290
+ });
291
+
292
+ async function handle(req, res) {
293
+ const url = new URL(req.url ?? '/', origin);
294
+ const path = decodeURIComponent(url.pathname);
295
+ if (req.method === 'POST') return post(req, res, path);
296
+ if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, { ok: false, why: 'GET or POST' });
297
+ if (path === '/' || path === '/index.html') return page(res, indexHtml(root));
298
+ if (path === '/_lab/health') return send(res, 200, { ok: true, studio: readStudio(root).name ?? null, version: STUDIO_VERSION, games: [...labs.keys()] });
299
+ if (path.startsWith('/_lab/api/')) {
300
+ const [, , , id, what] = path.split('/');
301
+ const b = await labFor(id);
302
+ if (what === 'state') {
303
+ const ref = url.searchParams.get('today');
304
+ if (ref && ref !== b.todayRef) await b.buildToday(ref);
305
+ // The ref moved (a commit), or the last build failed (a fix may have landed meanwhile): Today again.
306
+ else await b.followRef();
307
+ if (b.new.error && !b.new.building && Date.now() - Date.parse(b.new.at ?? 0) > 3000) await b.buildNew();
308
+ return send(res, 200, stateOf(root, b));
309
+ }
310
+ if (what === 'events') return events(req, res, b);
311
+ return send(res, 404, { ok: false, why: 'no such lab call' });
312
+ }
313
+ if (path.startsWith('/_lab/')) {
314
+ const name = path.slice('/_lab/'.length);
315
+ const file = name === 'harness.js' || name === 'take.js' ? join(LAB_FILES, name) : join(LAB_FILES, 'page', name);
316
+ if (!within(LAB_FILES, file) || !existsSync(file) || !statSync(file).isFile()) return send(res, 404, { ok: false, why: 'not found' });
317
+ return file200(res, file);
318
+ }
319
+ const m = /^\/([a-z0-9][a-z0-9-]{0,39})(\/(?:(new|today)(\/.*)?)?)?$/.exec(path);
320
+ if (!m) return send(res, 404, { ok: false, why: 'not found' });
321
+ const id = m[1];
322
+ if (!m[2]) { res.writeHead(302, { location: `/${id}/${url.search}` }); return res.end(); }
323
+ if (!m[3]) {
324
+ if (!listGames(root).some((g) => g.id === id)) return send(res, 404, { ok: false, why: `no game "${id}"` });
325
+ return page(res, readFileSync(join(LAB_FILES, 'page', 'index.html'), 'utf8'));
326
+ }
327
+ const b = await labFor(id);
328
+ const pane = m[3] === 'new' ? b.new : b.today;
329
+ if (!pane.ok || !pane.dir) return page(res, `<!doctype html><meta charset="utf-8"><body style="margin:0;background:#0b0e17;color:#8a93ad;font:600 13px ui-monospace,monospace;display:grid;place-items:center;height:100vh;text-align:center;padding:24px;box-sizing:border-box">${esc(pane.error ?? pane.none ?? 'building…')}</body>`, 200);
330
+ let rest = m[4] ?? '/';
331
+ if (rest.endsWith('/')) rest += 'index.html';
332
+ const file = normalize(join(pane.dir, rest));
333
+ if (!within(pane.dir, file) || !existsSync(file) || !statSync(file).isFile()) return send(res, 404, { ok: false, why: 'not found' });
334
+ if (rest === '/index.html') return page(res, withHarness(readFileSync(file, 'utf8')));
335
+ return file200(res, file);
336
+ }
337
+
338
+ async function post(req, res, path) {
339
+ // Only the lab page itself may write: same origin, JSON, small.
340
+ const from = req.headers.origin;
341
+ if (from !== origin && from !== origin.replace('127.0.0.1', 'localhost')) return send(res, 403, { ok: false, why: 'only the lab page may write' });
342
+ if (!/^application\/json\b/.test(String(req.headers['content-type'] ?? ''))) return send(res, 415, { ok: false, why: 'JSON only' });
343
+ const body = await readBody(req, 512 * 1024);
344
+ if (body === null) return send(res, 413, { ok: false, why: 'too big' });
345
+ let json;
346
+ try { json = JSON.parse(body); } catch { return send(res, 400, { ok: false, why: 'not JSON' }); }
347
+ const [, , , id, what] = path.split('/');
348
+ const b = await labFor(id);
349
+ const dir = gameOf(root, id).dir;
350
+ if (what === 'tunables') {
351
+ const spec = readTunables(dir);
352
+ if (!spec) return send(res, 400, { ok: false, why: `games/${id} has no tunables.json` });
353
+ const r = setTunables(spec, json.values ?? {});
354
+ if (r.refused.length) return send(res, 400, { ok: false, why: r.refused.join('; ') });
355
+ writeFileSync(join(dir, 'tunables.json'), formatTunables(r.spec));
356
+ log(`kept in games/${id}/tunables.json: ${r.changed.map((c) => `${c.name} ${c.from} -> ${c.to}`).join(', ') || 'nothing changed'}`);
357
+ // New is rebuilt with the kept values (the watcher would too; this answers once it is).
358
+ await b.buildNew();
359
+ return send(res, 200, { ok: true, changed: r.changed, file: `games/${id}/tunables.json` });
360
+ }
361
+ if (what === 'take') {
362
+ const name = String(json.name ?? '');
363
+ if (!TAKE_NAME.test(name)) return send(res, 400, { ok: false, why: 'a take name is lowercase letters, digits and hyphens' });
364
+ const problems = checkTake(json.take, name);
365
+ if (problems.length) return send(res, 400, { ok: false, why: problems.slice(0, 4).join('; ') });
366
+ const file = join(dir, 'lab.json');
367
+ const cur = readJson(file) ?? { v: 1, default: name, takes: {} };
368
+ cur.takes = { ...(cur.takes ?? {}), [name]: json.take };
369
+ if (!cur.default || json.default === true) cur.default = name;
370
+ writeFileSync(file, formatLabFile(cur));
371
+ log(`take "${name}" written into games/${id}/lab.json (${json.take.inputs?.length ?? 0} inputs, ${json.take.seconds} s)`);
372
+ return send(res, 200, { ok: true, file: `games/${id}/lab.json`, name });
373
+ }
374
+ if (what === 'rebuild') { await b.buildNew(); await b.buildToday(json.today ?? b.todayRef); return send(res, 200, stateOf(root, b)); }
375
+ return send(res, 404, { ok: false, why: 'no such lab call' });
376
+ }
377
+
378
+ function events(req, res, b) {
379
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
380
+ res.write(': the lab\n\n');
381
+ const l = (e) => res.write(`data: ${JSON.stringify(e)}\n\n`);
382
+ b.listeners.add(l);
383
+ const ping = setInterval(() => res.write(': ping\n\n'), 15_000);
384
+ req.on('close', () => { clearInterval(ping); b.listeners.delete(l); });
385
+ }
386
+
387
+ const at = await listen(server, port, host);
388
+ origin = `http://${host}:${at}`;
389
+ return {
390
+ url: origin,
391
+ port: at,
392
+ labs,
393
+ async close() {
394
+ for (const b of labs.values()) b.close();
395
+ server.closeAllConnections?.();
396
+ await new Promise((r) => server.close(r));
397
+ },
398
+ };
399
+ }
400
+
401
+ function listen(server, port, host) {
402
+ return new Promise((resolveP, reject) => {
403
+ const any = Number(port) === 0;
404
+ let p = any ? 0 : Number(port) || LAB_PORT;
405
+ const tries = any ? 1 : 20;
406
+ let n = 0;
407
+ const attempt = () => {
408
+ server.once('error', (error) => {
409
+ if (error.code === 'EADDRINUSE' && ++n < tries) { p += 1; attempt(); } else reject(error);
410
+ });
411
+ server.listen(p, host, () => resolveP(server.address().port));
412
+ };
413
+ attempt();
414
+ });
415
+ }
416
+
417
+ function stateOf(root, b) {
418
+ const g = gameOf(root, b.id);
419
+ if (b.new.ok) b.new.dirty = dirtyOf(root, b.id);
420
+ const takes = readTakes(g.dir);
421
+ let tunables = null;
422
+ let tunablesError = null;
423
+ try { tunables = readTunables(g.dir); } catch (error) { tunablesError = error.message; }
424
+ const pane = (p) => ({ ok: p.ok, building: p.building, error: p.error ?? null, none: p.none ?? null, at: p.at ?? null, ms: p.ms ?? null, bytes: p.bytes ?? null });
425
+ const todayValues = b.today.tunables ? Object.fromEntries(Object.entries(b.today.tunables).map(([k, s]) => [k, tunableValue(s)])) : null;
426
+ return {
427
+ ok: true,
428
+ version: STUDIO_VERSION,
429
+ studio: readStudio(root).name ?? null,
430
+ game: { id: g.id, name: g.name ?? g.id },
431
+ new: { ...pane(b.new), dirty: b.new.dirty ?? null, mode: b.new.mode ?? null },
432
+ today: { ...pane(b.today), ref: b.todayRef, commit: b.today.commit ?? null },
433
+ takes: { default: takes.default, takes: takes.takes, problems: takes.problems, file: `games/${g.id}/lab.json`, exists: takes.exists },
434
+ tunables: { file: `games/${g.id}/tunables.json`, spec: tunables, today: todayValues, error: tunablesError },
435
+ };
436
+ }
437
+
438
+ /** The harness first in <head>, before any of the game's scripts. */
439
+ export function withHarness(html) {
440
+ const tag = '<script src="/_lab/harness.js"></script>';
441
+ if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (h) => `${h}\n${tag}`);
442
+ if (/<html[^>]*>/i.test(html)) return html.replace(/<html[^>]*>/i, (h) => `${h}\n<head>${tag}</head>`);
443
+ return `${tag}\n${html}`;
444
+ }
445
+
446
+ function indexHtml(root) {
447
+ const games = listGames(root);
448
+ const rows = games.map((g) => `<li><a href="/${g.id}/">${esc(g.name ?? g.id)}</a> <small>${esc(g.id)}${existsSync(join(g.dir, 'lab.json')) ? ' · takes' : ''}${existsSync(join(g.dir, 'tunables.json')) ? ' · tunables' : ''}</small></li>`).join('');
449
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Game Lab</title><link rel="stylesheet" href="/_lab/lab.css"></head><body class="index"><main><p class="brand"><span class="mark"></span>GAME LAB</p><h1>${esc(readStudio(root).name ?? 'This studio')}</h1><ul class="games">${rows || '<li>No game yet.</li>'}</ul></main></body></html>`;
450
+ }
451
+
452
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
453
+ function within(base, p) { const b = resolve(base); const x = resolve(p); return x === b || x.startsWith(b.endsWith(sep) ? b : `${b}${sep}`); }
454
+ function send(res, code, body) {
455
+ if (res.headersSent) { try { res.end(); } catch { /* gone */ } return; }
456
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
457
+ res.end(`${JSON.stringify(body)}\n`);
458
+ }
459
+ function page(res, html, code = 200) {
460
+ res.writeHead(code, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
461
+ res.end(html);
462
+ }
463
+ function file200(res, file) {
464
+ res.writeHead(200, { 'content-type': TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
465
+ res.end(readFileSync(file));
466
+ }
467
+ function readBody(req, max) {
468
+ return new Promise((done) => {
469
+ let n = 0;
470
+ const parts = [];
471
+ req.on('data', (c) => { n += c.length; if (n > max) { done(null); req.destroy(); } else parts.push(c); });
472
+ req.on('end', () => done(Buffer.concat(parts).toString('utf8')));
473
+ req.on('error', () => done(null));
474
+ });
475
+ }
476
+
477
+ /* ------------------------------------------------------------------ the command */
478
+
479
+ /** The lab server already running for this studio (its address), or null. */
480
+ export async function runningLab(root) {
481
+ const rec = readJson(serverFile(root));
482
+ if (!rec?.port) return null;
483
+ try { process.kill(rec.pid, 0); } catch { return null; }
484
+ try {
485
+ const r = await fetch(`http://127.0.0.1:${rec.port}/_lab/health`, { signal: AbortSignal.timeout(1500) });
486
+ if (r.ok) return { url: `http://127.0.0.1:${rec.port}`, port: rec.port, pid: rec.pid };
487
+ } catch { /* not answering */ }
488
+ return null;
489
+ }
490
+
491
+ /**
492
+ * `homie-studio lab <game>`: the server in the foreground until stopped, and the link. One server serves every game of
493
+ * the studio; a second `lab` while one runs gives that one's link.
494
+ */
495
+ export async function labServe(root, id, { port = LAB_PORT, today = 'HEAD', log = () => {}, take = null } = {}) {
496
+ const g = gameOf(root, id);
497
+ const suffix = `${take ? `?take=${encodeURIComponent(take)}` : ''}${today !== 'HEAD' ? `${take ? '&' : '?'}today=${encodeURIComponent(today)}` : ''}`;
498
+ const already = await runningLab(root);
499
+ if (already) return { ok: true, command: 'lab', already: true, url: `${already.url}/${g.id}/${suffix}`, game: g.id };
500
+ const s = await startLabServer(root, { port, today, log });
501
+ mkdirSync(join(root, '.studio', 'lab'), { recursive: true });
502
+ writeFileSync(serverFile(root), `${JSON.stringify({ pid: process.pid, port: s.port, at: new Date().toISOString() })}\n`);
503
+ log(`Game Lab: ${s.url}/${g.id}/${suffix} (New: the working tree; Today: ${today}). Stop it with: npx --no-install homie-studio lab --stop`);
504
+ // Both builds now, so the first page load is quick.
505
+ const t0 = Date.now();
506
+ await fetch(`${s.url}/_lab/api/${g.id}/state`).then((r) => r.json()).then((st) => {
507
+ log(`New ${st.new.ok ? `built (${Math.round((st.new.bytes ?? 0) / 1024)} KB)` : `did not build: ${st.new.error}`}; Today ${st.today.ok ? `is ${st.today.commit?.short} "${st.today.commit?.subject}"` : st.today.error ? `did not build: ${st.today.error}` : `: ${st.today.none}`} (${Date.now() - t0} ms)`);
508
+ }).catch(() => {});
509
+ await new Promise((done) => {
510
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.once(signal, done);
511
+ });
512
+ await s.close();
513
+ rmSync(serverFile(root), { force: true });
514
+ return { ok: true, command: 'lab', stopped: true, url: `${s.url}/${g.id}/` };
515
+ }
516
+
517
+ /** `homie-studio lab --stop`: this studio's lab server (only it), and the checkouts it made. */
518
+ export async function labStop(root) {
519
+ const rec = readJson(serverFile(root));
520
+ const stopped = [];
521
+ if (rec?.pid && rec.pid !== process.pid) {
522
+ let ours = false;
523
+ try {
524
+ process.kill(rec.pid, 0);
525
+ const ps = spawnSync('ps', ['-p', String(rec.pid), '-o', 'command='], { encoding: 'utf8' });
526
+ ours = ps.status !== 0 ? process.platform === 'win32' : /homie-studio|lab/.test(ps.stdout);
527
+ } catch { ours = false; }
528
+ if (ours) {
529
+ try { process.kill(rec.pid, 'SIGTERM'); stopped.push(rec.pid); } catch { /* gone */ }
530
+ const until = Date.now() + 5000;
531
+ while (Date.now() < until) { try { process.kill(rec.pid, 0); await new Promise((r) => setTimeout(r, 150)); } catch { break; } }
532
+ }
533
+ }
534
+ rmSync(serverFile(root), { force: true });
535
+ // The checkouts Today was built from: git forgets them, and their folders go (the builds stay, for next time).
536
+ const base = join(root, '.studio', 'lab');
537
+ const removed = [];
538
+ if (existsSync(base)) {
539
+ for (const id of readdirSync(base)) {
540
+ const dir = join(base, id, 'checkout');
541
+ if (existsSync(dir)) { removeCheckout(root, dir); rmSync(join(base, id, 'checkout.json'), { force: true }); removed.push(`.studio/lab/${id}/checkout`); }
542
+ }
543
+ }
544
+ return { ok: true, command: 'lab stop', stopped, removed };
545
+ }
546
+