@volter/play 0.5.211 → 0.5.212

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.
@@ -0,0 +1,514 @@
1
+ /**
2
+ * A STD GAME'S DEV SERVER, AS PLAY REACHES IT (volter-ai/std, design/circle-1.md, "Devices"). A project is a std game
3
+ * when it has `std.json` and depends on `@volter-ai/std-dev`. Play hosts its players as the game's own dev pages, each
4
+ * a device of the dev server (`src/std-game.ts`); this half is the session's side of that:
5
+ *
6
+ * - `GET /__editor/play/std` — `{ game, goal, targets }`: whether the open project is a std game, the goal its bot
7
+ * plays, and the engines it ships to besides the web (std's targets: each engine whose package it depends on).
8
+ * - `POST /__editor/play/std/export` `{ target, add? }` — the game exported: `web` built by `std build` (Vite's, into
9
+ * `dist/`), an engine by `std export --target <engine>` after `std target add <engine>`, which makes it one of the
10
+ * game's targets where `add` asks (an engine the game doesn't target is refused, 409, otherwise) and completes a
11
+ * target half added; each by the game's own std, in its folder, one export a project at a time (409), answered as it
12
+ * goes, one JSON object a line (`application/x-ndjson`): each `{ line }` std prints, then `{ end: { ok, added, out }
13
+ * }` (whether the target was added, and where the export is). The request gone, the session ending or 30 minutes
14
+ * passing ends std's whole process tree.
15
+ * - `POST /__editor/play/std/start` — `{ url }`: the game's dev server, started where none runs (`npm run dev --
16
+ * --no-open`, in the project's folder, owned by this session and ended with it), found by the record std's dev server
17
+ * writes in Vite's cacheDir (`node_modules/.vite/std-dev.json`: its address, its process and its endpoints' token).
18
+ * - `GET /__editor/play/std/devices` and `POST /__editor/play/std/devices/<id>/control` — std's own endpoints
19
+ * (`/__std/devices`, `/__std/devices/<id>/control`), asked with the record's token, which stays in this process.
20
+ * - `GET /__editor/play/std/goals` — `{ goals }`: the goals of the game's bot, its `autopilot/index.ts` program's
21
+ * (`autopilot({ goals })`, std-driver's `program`), read by Node in the game's folder as std reads the game's
22
+ * `autopilot/checks.ts` (std-dev's play-checks.ts: Node's own type stripping), each read a process of its own so an
23
+ * edited program is read afresh.
24
+ *
25
+ * - `GET /__editor/play/std/level` — `{ blenderHalf }`: the level export's Blender half as the game installed it
26
+ * (std-dev's `blender/blend-to-scene.py`), which the page runs in the document's Blender (`../src/std-level.ts`).
27
+ * - `POST /__editor/play/std/level` `{ glb }` — `{ code, output }`: its Node half, `std level <glb> --description <its
28
+ * .level.json>` in the game's folder, by the game's own std (std-dev's `bin/std.js`, under the session's Node), for a
29
+ * level's glb or a prefab's (`prefabs/<name>/<name>.glb`, whose component std writes as `<Name>.tsx`).
30
+ *
31
+ * Every route is the machine owner's alone (`requireLocalOwner`), and a mutating one takes the editor's own origin
32
+ * (the router's check on `/__editor`); a body is the JSON the router has parsed.
33
+ * - `GET /__editor/play/std/pages` — each page of the dev server's newest run folder (`build/runs/<stamp>/`) by its
34
+ * id, with the slot its address named (its trace's first line): how Play tells its own players' pages apart.
35
+ * - `GET /__editor/play/std/trace?page=<page id>&from=<byte>` — `{ lines, next }`: the page's trace (`<page>.jsonl`
36
+ * in that folder) from a byte on, whole lines only.
37
+ * - `POST /__editor/play/std/play` `{ goal, bots? }` — std's own play of the game's bots to the goal's end
38
+ * (`/__std/play`, std-dev's std-endpoints.ts) on the headless device, one bot unless asked, recorded in the run's
39
+ * folder and checked by the goal's checks (`autopilot/checks.ts`): answered as it goes, one JSON object a line
40
+ * (`application/x-ndjson`), each `{ line }` std says, then `{ end: { ok } }`. Its pages are std's, headless, so it
41
+ * plays whether or not the editor is seen.
42
+ * - `GET /__editor/play/std/capture?file=<page id>-capture-<n>.png` — a page's captured frame, the PNG std's receiver
43
+ * wrote in that folder for a `capture` control.
44
+ * - `GET /__editor/play/std/errors?server=<pid>&from=<line>` — `{ lines, next, server }`: the error output (stderr) of
45
+ * the dev server this session started, from a line on (every line, where `server` names another), or with no `from`
46
+ * none and where it ends: the room's own exceptions are Colyseus's to log there, never a page's trace.
47
+ */
48
+ import { spawn, type ChildProcess } from 'node:child_process';
49
+ import { existsSync, readFileSync } from 'node:fs';
50
+ import { open, readdir, readFile } from 'node:fs/promises';
51
+ import type { IncomingMessage, ServerResponse } from 'node:http';
52
+ import { join } from 'node:path';
53
+ import { StringDecoder } from 'node:string_decoder';
54
+ import { pathToFileURL } from 'node:url';
55
+ import { stripVTControlCharacters } from 'node:util';
56
+ import { npmProcess } from '@volter/sdk/npm-process';
57
+ import { signalProcessTree } from '@volter/sdk/process-tree';
58
+ import type { ProjectServingServices } from '@volter/sdk/session/project-serving';
59
+ import type { Plugin } from 'vite';
60
+
61
+ const STD_ROUTE = '/__editor/play/std';
62
+
63
+ /** How long a started dev server has to write its record: a first start optimizes the game's dependencies. */
64
+ const START_MS = 120_000;
65
+ const LOOK_MS = 250;
66
+ /** The most of a trace one read carries. */
67
+ const TRACE_READ_BYTES = 256 * 1024;
68
+ /** The most of a trace's first line read for its head (std's `trace` line: its session, its game, its slot). */
69
+ const TRACE_HEAD_BYTES = 4096;
70
+ /** A page's id as std names it (recording/client page-trace.ts `PAGE_ID`), its trace's file name. */
71
+ const PAGE_ID = /^[0-9a-f]{32}(-rerun)?$/;
72
+ /** A page's captured frame as std's receiver names it (trace-receiver.ts: `<page>-capture-<n>.png`). */
73
+ const CAPTURE_FILE = /^[0-9a-f]{32}(-rerun)?-capture-\d+\.png$/;
74
+
75
+ /** std-dev as the game installed it, and its level export's Blender half. */
76
+ const stdDev = (root: string): string => join(root, 'node_modules', '@volter-ai', 'std-dev');
77
+ const blenderHalfOf = (root: string): string => join(stdDev(root), 'blender', 'blend-to-scene.py');
78
+
79
+ /** A level's glb or a prefab's, by its path from the game's folder: under `levels/`, or a prefab's own
80
+ * (`prefabs/<name>/<name>.glb`, std's), with no empty, `.` or `..` segment. */
81
+ const isLevelGlb = (path: string): boolean =>
82
+ (/^levels\/.+\.glb$/.test(path) || /^prefabs\/([^/]+)\/\1\.glb$/.test(path))
83
+ && !path.includes('\\') && path.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
84
+
85
+ /** The level export's Node half: its exit code and everything it printed, in order. */
86
+ function runStdLevel(root: string, glb: string): Promise<{ readonly code: number | null; readonly output: string }> {
87
+ return new Promise((resolve) => {
88
+ const run = spawn(process.execPath, [join(stdDev(root), 'bin', 'std.js'), 'level', glb, '--description', glb.replace(/\.glb$/, '.level.json')], { cwd: root, windowsHide: true });
89
+ let output = '';
90
+ run.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); });
91
+ run.stderr.on('data', (chunk: Buffer) => { output += chunk.toString(); });
92
+ run.on('error', (error) => resolve({ code: null, output: `${output}${error.message}` }));
93
+ run.on('close', (code) => resolve({ code, output }));
94
+ });
95
+ }
96
+
97
+ /** How long the read of the bot's goals may take: Node loading the program and the driver it imports. */
98
+ const GOALS_MS = 10_000;
99
+ /** The bot program's module, as std's convention names it (std-dev's page-modules.ts `BOT_ENTRY`, `/autopilot/index`). */
100
+ const BOT_ENTRIES = ['index.ts', 'index.js', 'index.mjs'];
101
+
102
+ /** Prints the goals of the program the module at `process.argv[1]` exports as `program`, one JSON line. */
103
+ const READ_GOALS = [
104
+ 'const program = (await import(process.argv[1])).program;',
105
+ "if (typeof program?.goals !== 'object' || program.goals === null) throw new Error('autopilot/index exports no program with goals (autopilot({ goals }))');",
106
+ 'console.log(JSON.stringify(Object.keys(program.goals)));',
107
+ ].join('\n');
108
+
109
+ /** The goals of the game's bot: its `autopilot/` program's, in their order; none where the game has no bot. */
110
+ function botGoals(root: string): Promise<{ readonly status: number; readonly body: unknown }> {
111
+ const entry = BOT_ENTRIES.map((name) => join(root, 'autopilot', name)).find((file) => existsSync(file));
112
+ if (entry === undefined) return Promise.resolve({ status: 200, body: { goals: [] } });
113
+ return new Promise((resolve) => {
114
+ const read = spawn(process.execPath, ['--input-type=module', '--eval', READ_GOALS, pathToFileURL(entry).href],
115
+ { cwd: root, windowsHide: true, timeout: GOALS_MS });
116
+ let output = '';
117
+ let said = '';
118
+ read.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); });
119
+ read.stderr.on('data', (chunk: Buffer) => { said += chunk.toString(); });
120
+ read.on('error', (error) => resolve({ status: 500, body: { error: `The game's goals could not be read: ${error.message}` } }));
121
+ read.on('close', (code) => {
122
+ let goals: unknown = null;
123
+ try { if (code === 0) goals = JSON.parse(output.trim().split('\n').at(-1) ?? '[]'); } catch { /* not the line READ_GOALS prints */ }
124
+ resolve(Array.isArray(goals) && goals.every((goal) => typeof goal === 'string')
125
+ ? { status: 200, body: { goals } }
126
+ : { status: 500, body: { error: `The game's goals could not be read from ${entry} (exit ${String(code)}): ${said.trim() || output.trim()}` } });
127
+ });
128
+ });
129
+ }
130
+
131
+ /** The level route's answer: the Blender half's source (GET), or the Node half's run on a level's glb (POST). */
132
+ async function levelAnswer(root: string, method: string | undefined, body: unknown): Promise<{ readonly status: number; readonly body: unknown }> {
133
+ if (!stdGame(root).game || !existsSync(blenderHalfOf(root)))
134
+ return { status: 404, body: { error: 'The project is not a std game with @volter-ai/std-dev installed: no std.json, or no blender/blend-to-scene.py in its std-dev.' } };
135
+ if (method === 'GET') return { status: 200, body: { blenderHalf: await readFile(blenderHalfOf(root), 'utf8') } };
136
+ const glb = (body as { glb?: unknown } | undefined)?.glb;
137
+ if (typeof glb !== 'string' || !isLevelGlb(glb))
138
+ return { status: 400, body: { error: `A level's glb is a path under levels/ ending .glb, a prefab's prefabs/<name>/<name>.glb; it was given ${JSON.stringify(glb)}.` } };
139
+ return { status: 200, body: await runStdLevel(root, glb) };
140
+ }
141
+
142
+ /** std's record of a running dev server (std-dev's running-server.ts `RunningServer`). */
143
+ interface RunningServer {
144
+ readonly url: string;
145
+ readonly pid: number;
146
+ readonly token: string;
147
+ }
148
+
149
+ /** Each engine a std game may ship to, and its package: the game's target where it depends on it (std's own reading,
150
+ * std-dev's engines.ts `ENGINES` and `enginesOf`). */
151
+ const ENGINE_PACKAGES = { roblox: '@volter-ai/std-roblox', unreal: '@volter-ai/std-unreal' } as const;
152
+ type Engine = keyof typeof ENGINE_PACKAGES;
153
+ const ENGINE_LABELS: Readonly<Record<Engine, string>> = { roblox: 'Roblox', unreal: 'Unreal' };
154
+ const isEngine = (value: unknown): value is Engine => typeof value === 'string' && Object.hasOwn(ENGINE_PACKAGES, value);
155
+
156
+ /** Whether the project in `root` is a std game (`std.json`, and `@volter-ai/std-dev` among its packages), the goal its
157
+ * bot plays (the one its `play` script names, `std play … --goal <goal>`, null where it names none), and its targets. */
158
+ function stdGame(root: string): { readonly game: boolean; readonly goal: string | null; readonly targets: readonly Engine[] } {
159
+ if (!existsSync(join(root, 'std.json'))) return { game: false, goal: null, targets: [] };
160
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as Record<string, Record<string, string> | undefined>;
161
+ const named = (name: string): boolean => manifest.dependencies?.[name] !== undefined || manifest.devDependencies?.[name] !== undefined;
162
+ const targets = (Object.keys(ENGINE_PACKAGES) as Engine[]).filter((engine) => named(ENGINE_PACKAGES[engine]));
163
+ return { game: named('@volter-ai/std-dev'), goal: /--goal[ =](\S+)/.exec(manifest.scripts?.play ?? '')?.[1] ?? null, targets };
164
+ }
165
+
166
+ /** How long an export may take before the route ends it: a Roblox export uploads the game's models first. */
167
+ const EXPORT_MS = 30 * 60_000;
168
+ /** Where each export is, from the game's folder. */
169
+ const EXPORTED: Readonly<Record<'web' | Engine, string>> = { web: 'dist', roblox: 'build/roblox', unreal: 'build/unreal/StdGame' };
170
+
171
+ /** The most of one line std prints that an export passes on (a progress bar's line can run long). */
172
+ const EXPORT_LINE_MOST = 2000;
173
+ /** Each project's export while it runs: its abort, which ends std's process tree, as the session's end does too. */
174
+ const exporting = new Map<string, AbortController>();
175
+
176
+ /** Every running export ended, as the session ends: std's process trees with them. */
177
+ function endExports(): void {
178
+ for (const ended of exporting.values()) ended.abort();
179
+ exporting.clear();
180
+ }
181
+
182
+ /** The game's own std run in its folder, each line it prints told as it comes; its exit code, null where it could not
183
+ * run or was ended. It runs in a process group of its own (detached, as the dev server does), so ending it ends what
184
+ * it started: pnpm, Rojo, an engine's tools. */
185
+ function runStd(root: string, args: readonly string[], line: (text: string) => void, ended: AbortSignal): Promise<number | null> {
186
+ return new Promise((resolve) => {
187
+ const run = spawn(process.execPath, [join(stdDev(root), 'bin', 'std.js'), ...args],
188
+ { cwd: root, windowsHide: true, detached: process.platform !== 'win32' });
189
+ const lines = (): ((chunk: Buffer | null) => void) => {
190
+ const decoder = new StringDecoder('utf8');
191
+ let held = '';
192
+ return (chunk) => {
193
+ held += chunk === null ? decoder.end() : decoder.write(chunk);
194
+ const parts = held.split(/\r\n|\r|\n/);
195
+ held = chunk === null ? '' : parts.pop() ?? '';
196
+ if (held.length > EXPORT_LINE_MOST) { parts.push(held); held = ''; }
197
+ for (const part of parts) if (part.trim() !== '') line(stripVTControlCharacters(part).slice(0, EXPORT_LINE_MOST));
198
+ };
199
+ };
200
+ const out = lines();
201
+ const err = lines();
202
+ run.stdout.on('data', out);
203
+ run.stdout.on('end', () => out(null));
204
+ run.stderr.on('data', err);
205
+ run.stderr.on('end', () => err(null));
206
+ const end = (): void => { if (run.pid !== undefined && run.exitCode === null) signalProcessTree(run.pid, 'SIGTERM'); };
207
+ ended.addEventListener('abort', end, { once: true });
208
+ run.on('error', (error) => { line(error.message); resolve(null); });
209
+ run.on('close', (code) => { ended.removeEventListener('abort', end); resolve(code); });
210
+ });
211
+ }
212
+
213
+ /** THE GAME EXPORTED to `target`: the web, or an engine, made one of the game's targets first where `add` asks. */
214
+ async function exportGame(root: string, body: unknown, response: ServerResponse): Promise<void> {
215
+ const asked = body as { target?: unknown; add?: unknown } | undefined;
216
+ const target = asked?.target;
217
+ if ((target !== 'web' && !isEngine(target)) || (asked?.add !== undefined && typeof asked.add !== 'boolean'))
218
+ return answer(response, 400, { error: `An export is { target, add? }: web, roblox or unreal, and whether to add an engine as the game's target; it was given ${JSON.stringify(body)}.` });
219
+ const game = stdGame(root);
220
+ if (!game.game) return answer(response, 404, { error: 'The project is not a std game: no std.json, or no @volter-ai/std-dev among its packages.' });
221
+ const added = target !== 'web' && !game.targets.includes(target);
222
+ if (added && asked?.add !== true)
223
+ return answer(response, 409, { error: `${ENGINE_LABELS[target]} isn't a target of this game: Export's "Add ${ENGINE_LABELS[target]} and export" adds it (std target add ${target}).` });
224
+ if (exporting.has(root)) return answer(response, 409, { error: 'An export of this game is running: wait for it to end.' });
225
+ const ended = new AbortController();
226
+ exporting.set(root, ended);
227
+ response.statusCode = 200;
228
+ response.setHeader('content-type', 'application/x-ndjson');
229
+ const said = (message: unknown): void => { if (!response.writableEnded && !response.destroyed) response.write(`${JSON.stringify(message)}\n`); };
230
+ response.on('close', () => ended.abort());
231
+ const limit = setTimeout(() => { said({ line: `Play ended the export: it ran ${EXPORT_MS / 60_000} minutes.` }); ended.abort(); }, EXPORT_MS);
232
+ // (an engine's `target add` every time: it adds the target where asked, completes one half added, and is quiet where
233
+ // the target is whole)
234
+ const steps = target === 'web' ? [['build']] : [['target', 'add', target], ['export', '--target', target]];
235
+ let ok = true;
236
+ try {
237
+ for (const step of steps) {
238
+ if (ended.signal.aborted) { ok = false; break; }
239
+ said({ line: `std ${step.join(' ')}` });
240
+ if ((await runStd(root, step, (line) => said({ line }), ended.signal)) !== 0) { ok = false; break; }
241
+ }
242
+ } finally {
243
+ clearTimeout(limit);
244
+ if (exporting.get(root) === ended) exporting.delete(root);
245
+ }
246
+ said({ end: { ok, added: added && stdGame(root).targets.includes(target), out: EXPORTED[target] } });
247
+ response.end();
248
+ }
249
+
250
+ const alive = (pid: number): boolean => {
251
+ try { process.kill(pid, 0); return true; } catch { return false; }
252
+ };
253
+
254
+ /** The game's running dev server by its record, or null where none runs (no record, or its process gone). */
255
+ async function runningServer(root: string): Promise<RunningServer | null> {
256
+ const text = await readFile(join(root, 'node_modules', '.vite', 'std-dev.json'), 'utf8').catch(() => null);
257
+ if (text === null) return null;
258
+ const record = JSON.parse(text) as RunningServer;
259
+ return alive(record.pid) ? record : null;
260
+ }
261
+
262
+ /** The most of a dev server's error output kept, in lines. */
263
+ const ERRORS_KEPT = 500;
264
+
265
+ /** The dev servers this session started, by project, with the last of what each said, why its launch failed, and its
266
+ * error output (stderr, where Colyseus logs a room's exceptions and Vite its errors and warnings) as lines, the first
267
+ * kept one being line `dropped`. */
268
+ interface OwnedServer {
269
+ readonly child: ChildProcess;
270
+ said: string;
271
+ failed: string | null;
272
+ readonly errors: string[];
273
+ dropped: number;
274
+ partial: string;
275
+ readonly decoder: StringDecoder;
276
+ }
277
+
278
+ /** The most of a line kept before it ends (a progress line redrawn with `\r` never does). */
279
+ const LINE_MOST = 4000;
280
+ const owned = new Map<string, OwnedServer>();
281
+
282
+ /** A chunk of a dev server's stderr taken into its lines, whole lines only (`null`, its output's end: the last one
283
+ * too), terminal escapes left out. */
284
+ function heardError(server: OwnedServer, chunk: Buffer | null): void {
285
+ const text = chunk === null ? server.decoder.end() : server.decoder.write(chunk);
286
+ const lines = stripVTControlCharacters(server.partial + text).split(/\r?\n/);
287
+ server.partial = chunk === null ? '' : (lines.pop() ?? '').slice(-LINE_MOST);
288
+ server.errors.push(...lines.filter((line, at) => chunk !== null || at < lines.length - 1 || line !== ''));
289
+ const over = server.errors.length - ERRORS_KEPT;
290
+ if (over > 0) {
291
+ server.errors.splice(0, over);
292
+ server.dropped += over;
293
+ }
294
+ }
295
+
296
+ /** The session's dev server's error output from line `from` of the server `server` (its process) on, every line of
297
+ * another server's, or none and where it ends (`from` null: a run's mark); and the server and the line after them.
298
+ * None where this session didn't start the game's dev server (one already running is its own terminal's). */
299
+ function errorsFrom(root: string, server: number | null, from: number | null): {
300
+ readonly lines: readonly string[]; readonly next: number; readonly server: number | null;
301
+ } {
302
+ const mine = owned.get(root);
303
+ const pid = mine?.child.pid ?? null;
304
+ if (!mine || pid === null) return { lines: [], next: 0, server: null };
305
+ const next = mine.dropped + mine.errors.length;
306
+ if (from === null) return { lines: [], next, server: pid };
307
+ const start = server === pid ? from : 0;
308
+ return { lines: mine.errors.slice(Math.max(0, start - mine.dropped)), next, server: pid };
309
+ }
310
+
311
+ /** Ends the dev servers this session started, each `npm run dev` with everything it started. */
312
+ function endOwned(): void {
313
+ for (const { child } of owned.values()) if (child.pid !== undefined && child.exitCode === null) signalProcessTree(child.pid, 'SIGTERM');
314
+ owned.clear();
315
+ }
316
+
317
+ /** The game's dev server, started where none runs, once it has written its record. */
318
+ async function startedServer(root: string): Promise<RunningServer> {
319
+ const running = await runningServer(root);
320
+ if (running) return running;
321
+ let mine = owned.get(root);
322
+ if (!mine || mine.child.exitCode !== null || mine.failed !== null) {
323
+ const npm = npmProcess(['run', 'dev', '--', '--no-open'], root);
324
+ // (a group of its own on POSIX, so its whole tree ends with it: `signalProcessTree`)
325
+ const child = spawn(npm.program, npm.args, { cwd: root, detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
326
+ mine = { child, said: '', failed: null, errors: [], dropped: 0, partial: '', decoder: new StringDecoder('utf8') };
327
+ const kept = mine;
328
+ const hear = (chunk: Buffer): void => { kept.said = (kept.said + chunk.toString('utf8')).slice(-4000); };
329
+ child.stdout?.on('data', hear);
330
+ child.stderr?.on('data', hear);
331
+ child.stderr?.on('data', (chunk: Buffer) => heardError(kept, chunk));
332
+ child.stderr?.on('end', () => heardError(kept, null));
333
+ child.on('error', (error) => { kept.failed = error.message; });
334
+ owned.set(root, mine);
335
+ }
336
+ const bound = Date.now() + START_MS;
337
+ for (;;) {
338
+ const record = await runningServer(root);
339
+ if (record) return record;
340
+ if (mine.failed !== null) throw new Error(`The game's dev server (npm run dev) could not be started: ${mine.failed}`);
341
+ if (mine.child.exitCode !== null) throw new Error(`The game's dev server (npm run dev) ended with code ${mine.child.exitCode}:\n${mine.said.trim()}`);
342
+ if (Date.now() > bound) throw new Error(`The game's dev server (npm run dev) wrote no record in ${START_MS / 1000} s:\n${mine.said.trim()}`);
343
+ await new Promise((done) => setTimeout(done, LOOK_MS));
344
+ }
345
+ }
346
+
347
+ /** The dev server's newest run folder (`build/runs/<stamp>/`), where its pages' traces are; null before it has one. */
348
+ async function newestRun(root: string): Promise<string | null> {
349
+ const runs = join(root, 'build', 'runs');
350
+ const newest = (await readdir(runs).catch(() => [])).sort().at(-1);
351
+ return newest === undefined ? null : join(runs, newest);
352
+ }
353
+
354
+ /** Each page of the newest run whose trace has begun, by its id: the slot its address named (its trace's first
355
+ * line, std's `trace` head). */
356
+ async function pageSlots(root: string): Promise<Record<string, string>> {
357
+ const run = await newestRun(root);
358
+ if (run === null) return {};
359
+ const slots: Record<string, string> = {};
360
+ for (const name of await readdir(run)) {
361
+ const page = name.replace(/\.jsonl$/, '');
362
+ if (page === name || !PAGE_ID.test(page)) continue;
363
+ const handle = await open(join(run, name), 'r');
364
+ try {
365
+ const buffer = Buffer.alloc(TRACE_HEAD_BYTES);
366
+ const { bytesRead } = await handle.read(buffer, 0, TRACE_HEAD_BYTES, 0);
367
+ const first = buffer.subarray(0, bytesRead).toString('utf8').split('\n')[0] ?? '';
368
+ if (!first.endsWith('}')) continue;
369
+ const head = JSON.parse(first) as { k?: unknown; slot?: unknown };
370
+ if (head.k === 'trace' && typeof head.slot === 'string') slots[page] = head.slot;
371
+ } finally {
372
+ await handle.close();
373
+ }
374
+ }
375
+ return slots;
376
+ }
377
+
378
+ /** The newest run's trace of the page `page`: its lines from byte `from`, whole lines only. */
379
+ async function traceFrom(root: string, page: string, from: number): Promise<{ readonly lines: readonly string[]; readonly next: number }> {
380
+ const run = await newestRun(root);
381
+ if (run === null || !existsSync(join(run, `${page}.jsonl`))) return { lines: [], next: from };
382
+ const handle = await open(join(run, `${page}.jsonl`), 'r');
383
+ try {
384
+ const buffer = Buffer.alloc(TRACE_READ_BYTES);
385
+ const { bytesRead } = await handle.read(buffer, 0, TRACE_READ_BYTES, from);
386
+ const text = buffer.subarray(0, bytesRead).toString('utf8');
387
+ const end = text.lastIndexOf('\n') + 1;
388
+ return { lines: text.slice(0, end).split('\n').filter(Boolean), next: from + Buffer.byteLength(text.slice(0, end)) };
389
+ } finally {
390
+ await handle.close();
391
+ }
392
+ }
393
+
394
+ /** How long std's play of a goal may take before the route ends it: std's own goal limit is its bots'. */
395
+ const PLAY_MS = 15 * 60_000;
396
+
397
+ /**
398
+ * STD'S PLAY OF A GOAL (`/__std/play`, its WebSocket behind the dev server's token: the play asked, then `{ line }`
399
+ * messages, then `{ end: { ok } }`), its messages passed on as they come, one JSON line each.
400
+ */
401
+ async function playGoal(root: string, body: unknown, response: ServerResponse): Promise<void> {
402
+ const asked = body as { goal?: unknown; bots?: unknown } | undefined;
403
+ if (typeof asked?.goal !== 'string' || asked.goal === '' || (asked.bots !== undefined && !(Number.isInteger(asked.bots) && Number(asked.bots) >= 1)))
404
+ return answer(response, 400, { error: `A play is { goal, bots? }: a goal of the game's bot, and a whole number of bots; it was given ${JSON.stringify(body)}.` });
405
+ const running = await runningServer(root);
406
+ if (!running) return answer(response, 409, { error: "The game's dev server is not running: press Play." });
407
+ response.statusCode = 200;
408
+ response.setHeader('content-type', 'application/x-ndjson');
409
+ const said = (message: unknown): void => { response.write(`${JSON.stringify(message)}\n`); };
410
+ const socket = new WebSocket(`${running.url.replace(/^http/, 'ws').replace(/\/$/, '')}/__std/play?token=${running.token}`);
411
+ let ended = false;
412
+ const end = (ok: boolean, why?: string): void => {
413
+ if (ended) return;
414
+ ended = true;
415
+ clearTimeout(limit);
416
+ if (why !== undefined) said({ line: why });
417
+ said({ end: { ok } });
418
+ response.end();
419
+ if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close();
420
+ };
421
+ const limit = setTimeout(() => end(false, `Play ended std's play of ${asked.goal}: it ran ${PLAY_MS / 60_000} minutes.`), PLAY_MS);
422
+ socket.addEventListener('open', () => socket.send(JSON.stringify({ goal: asked.goal, bots: asked.bots ?? 1, device: 'headless' })));
423
+ socket.addEventListener('message', (event) => {
424
+ const message = JSON.parse(String(event.data)) as { line?: unknown; end?: { ok?: unknown } };
425
+ if (message.end !== undefined) end(message.end.ok === true);
426
+ else said(message);
427
+ });
428
+ socket.addEventListener('error', () => end(false, "std's play could not be reached on the game's dev server."));
429
+ socket.addEventListener('close', () => end(false, "std's play closed before it said how it ended."));
430
+ response.on('close', () => { if (!ended) { ended = true; clearTimeout(limit); socket.close(); } });
431
+ }
432
+
433
+ /** A request's JSON body, as the editor's router parsed it before any serving plugin is reached. */
434
+ const bodyOf = (request: IncomingMessage): unknown => (request as IncomingMessage & { body?: unknown }).body;
435
+
436
+ function answer(response: ServerResponse, status: number, value: unknown): void {
437
+ response.statusCode = status;
438
+ response.setHeader('content-type', 'application/json');
439
+ response.end(JSON.stringify(value));
440
+ }
441
+
442
+ /** std's endpoint on the running dev server, its answer passed on as it came. */
443
+ async function askStd(root: string, path: string, init?: RequestInit): Promise<{ readonly status: number; readonly body: unknown }> {
444
+ const running = await runningServer(root);
445
+ if (!running) return { status: 409, body: { error: "The game's dev server is not running: press Play." } };
446
+ const response = await fetch(`${running.url}${path}?token=${running.token}`, init);
447
+ return { status: response.status, body: await response.json() };
448
+ }
449
+
450
+ export function stdDevPlugin(services: Pick<ProjectServingServices, 'currentProjectRoot' | 'requireLocalOwner'>): Plugin {
451
+ return {
452
+ name: 'volter-play-std-dev',
453
+ configureServer(server) {
454
+ server.httpServer?.once('close', endOwned);
455
+ process.once('exit', endOwned);
456
+ server.httpServer?.once('close', endExports);
457
+ process.once('exit', endExports);
458
+ server.middlewares.use(STD_ROUTE, (request, response, next) => {
459
+ const root = services.currentProjectRoot();
460
+ if (!root) return next();
461
+ // (owner-local: it starts the game's dev server and speaks to its pages with the server's token)
462
+ if (!services.requireLocalOwner(request, response)) return;
463
+ const url = new URL(request.url ?? '/', 'http://session');
464
+ // (a captured frame is the one answer that is not JSON: the PNG itself)
465
+ const captured = url.pathname === '/capture' && request.method === 'GET' ? url.searchParams.get('file') ?? '' : null;
466
+ if (captured !== null) {
467
+ void newestRun(root).then(async (run) => {
468
+ if (run === null || !CAPTURE_FILE.test(captured) || !existsSync(join(run, captured)))
469
+ return answer(response, 404, { error: `No captured frame ${JSON.stringify(captured)} in the dev server's newest run.` });
470
+ response.statusCode = 200;
471
+ response.setHeader('content-type', 'image/png');
472
+ response.end(await readFile(join(run, captured)));
473
+ }).catch((error: unknown) => answer(response, 500, { error: error instanceof Error ? error.message : String(error) }));
474
+ return;
475
+ }
476
+ if (url.pathname === '/export' && request.method === 'POST') {
477
+ void exportGame(root, bodyOf(request), response).catch((error: unknown) => {
478
+ if (!response.headersSent) answer(response, 500, { error: error instanceof Error ? error.message : String(error) });
479
+ else response.end();
480
+ });
481
+ return;
482
+ }
483
+ if (url.pathname === '/play' && request.method === 'POST') {
484
+ void playGoal(root, bodyOf(request), response).catch((error: unknown) => {
485
+ if (!response.headersSent) answer(response, 500, { error: error instanceof Error ? error.message : String(error) });
486
+ else response.end();
487
+ });
488
+ return;
489
+ }
490
+ const control = /^\/devices\/([^/]+)\/control$/.exec(url.pathname)?.[1];
491
+ const asked: Promise<{ readonly status: number; readonly body: unknown }> =
492
+ url.pathname === '/' && request.method === 'GET' ? Promise.resolve({ status: 200, body: stdGame(root) })
493
+ : url.pathname === '/start' && request.method === 'POST' ? startedServer(root).then(({ url: at }) => ({ status: 200, body: { url: at } }))
494
+ : url.pathname === '/devices' && request.method === 'GET' ? askStd(root, '/__std/devices')
495
+ : control !== undefined && request.method === 'POST'
496
+ ? askStd(root, `/__std/devices/${encodeURIComponent(decodeURIComponent(control))}/control`,
497
+ { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(bodyOf(request)) })
498
+ : url.pathname === '/level' && (request.method === 'GET' || request.method === 'POST')
499
+ ? levelAnswer(root, request.method, bodyOf(request))
500
+ : url.pathname === '/goals' && request.method === 'GET' ? botGoals(root)
501
+ : url.pathname === '/pages' && request.method === 'GET' ? pageSlots(root).then((body) => ({ status: 200, body }))
502
+ : url.pathname === '/trace' && request.method === 'GET' && PAGE_ID.test(url.searchParams.get('page') ?? '')
503
+ ? traceFrom(root, url.searchParams.get('page')!, Number(url.searchParams.get('from') ?? 0)).then((body) => ({ status: 200, body }))
504
+ : url.pathname === '/errors' && request.method === 'GET'
505
+ ? Promise.resolve({ status: 200, body: errorsFrom(root,
506
+ url.searchParams.has('server') ? Number(url.searchParams.get('server')) : null,
507
+ url.searchParams.has('from') ? Number(url.searchParams.get('from')) || 0 : null) })
508
+ : Promise.resolve({ status: 404, body: { error: `${STD_ROUTE}${url.pathname} is not a route of Play's` } });
509
+ asked.then(({ status, body }) => answer(response, status, body),
510
+ (error: unknown) => answer(response, 500, { error: error instanceof Error ? error.message : String(error) }));
511
+ });
512
+ },
513
+ };
514
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * THE SCENE VIEW'S RUNTIME, served from the std game's own module graph (`./std-scene.ts`, the id
3
+ * `\0volter-std-scene-runtime`): its imports resolve as the game's, so the fiber root, react-three-rapier and std's
4
+ * effects are the game's own instances, the very ones the level's components use. A level's module (`./std-scene.ts`'s
5
+ * `sceneModule`) names its files and hands them here. It is a browser module, not part of play's page bundle, because
6
+ * play's page carries none of the game's libraries.
7
+ */
8
+ import { Component, createElement as h } from 'react';
9
+ import * as THREE from 'three';
10
+ import { advance, createPortal, createRoot, extend, useFrame } from '@react-three/fiber';
11
+ import { Physics } from '@react-three/rapier';
12
+ import { EffectPass, LUT3DEffect } from 'postprocessing';
13
+ import { LUTCubeLoader } from 'three/addons/loaders/LUTCubeLoader.js';
14
+ import { ViewShaperEffect } from '@volter-ai/std/level/client';
15
+
16
+ // (a root made by createRoot knows no three.js element until it is told, as <Canvas> tells its own)
17
+ extend(THREE);
18
+
19
+ /**
20
+ * THE TREES' LOADS STAY THE GAME'S. The level's components load through fiber's `useLoader`, whose loaders and cache are
21
+ * fiber's own, shared by every fiber root on the page; nothing here changes them. A file the level's modules import by
22
+ * URL (`?url`: the glb, the sky, the terrain) carries the game's content version (`./std-scene.ts`), so a new version
23
+ * is a new URL and a new cache entry. A URL the game's code passes as a plain string, and a glTF's external files, keep
24
+ * their URL: they show a change on a page reload (`docs/adr/std-scene-view.md`, decision 5).
25
+ */
26
+
27
+ /** A frame subscriber of priority 1 takes the render over from fiber, so the stage's own draw is the only one. */
28
+ function StageDraws() {
29
+ useFrame(() => {}, 1);
30
+ return null;
31
+ }
32
+
33
+ /** What the level throws as it renders or loads, the view's to show. */
34
+ class Failed extends Component {
35
+ state = { error: null };
36
+ static getDerivedStateFromError(error) { return { error }; }
37
+ componentDidCatch(error) { this.props.onError(error); }
38
+ render() { return this.state.error === null ? this.props.children : null; }
39
+ }
40
+
41
+ /**
42
+ * The stage's renderer as fiber holds it. Fiber's unmount releases a renderer it was given (9.8.2's `releaseRenderer`:
43
+ * for a renderer it did not make, it still calls `forceContextLoss`); the stage's renderer and its context are the
44
+ * stage's, so fiber holds it through this view, whose `forceContextLoss` and `dispose` leave it to the stage. Every
45
+ * other member is the renderer's own.
46
+ */
47
+ function lent(renderer) {
48
+ return new Proxy(renderer, {
49
+ get(target, key) {
50
+ if (key === 'forceContextLoss' || key === 'dispose') return () => {};
51
+ const value = Reflect.get(target, key, target);
52
+ return typeof value === 'function' ? value.bind(target) : value;
53
+ },
54
+ set: (target, key, value) => Reflect.set(target, key, value, target),
55
+ });
56
+ }
57
+
58
+ /**
59
+ * A fiber root for `level` on the stage's renderer and camera, its children portalled into the document: the level's
60
+ * environment into `world`, the document's own scene, whose sky, environment light and fog the stage draws by, and
61
+ * `<Physics paused debug={overlays}><Level /></Physics>` into `root`. Fiber keeps a scene of its own, as its unmount
62
+ * disposes what that scene holds. It draws nothing until `draw` is asked; `advance` runs a frame of the level's own
63
+ * `useFrame`s (the stage draws the picture); `unmount` ends it.
64
+ */
65
+ export async function mountScene(level, { renderer, world, camera, root, onError }) {
66
+ const { Level, Environment, canvas } = level;
67
+ const fiber = createRoot(renderer.domElement);
68
+ const size = renderer.getSize(new THREE.Vector2());
69
+ const { width, height } = renderer.domElement.style;
70
+ try {
71
+ await fiber.configure({ gl: lent(renderer), camera, frameloop: 'never', shadows: canvas.shadows ?? false, flat: true,
72
+ dpr: renderer.getPixelRatio(), size: { width: size.x, height: size.y, top: 0, left: 0 } });
73
+ } catch (error) {
74
+ fiber.unmount();
75
+ throw error;
76
+ }
77
+ // (fiber sizes the canvas's style in pixels; the stage sizes it by its box)
78
+ Object.assign(renderer.domElement.style, { width, height });
79
+ let store = null;
80
+ const draw = (shown) => {
81
+ store = fiber.render(h(Failed, { onError }, h(StageDraws),
82
+ Environment === null ? null : createPortal(h(Environment), world),
83
+ createPortal(h(Physics, { paused: true, debug: shown }, h(Level)), root)));
84
+ };
85
+ return {
86
+ draw,
87
+ // (this root's frame alone: another view's level is its own root)
88
+ advance: (seconds) => { if (store !== null) advance(seconds, true, store.getState()); },
89
+ unmount: () => { fiber.unmount(); },
90
+ };
91
+ }
92
+
93
+ /**
94
+ * A level's view transform as the stage's display transform: std's `ViewShaperEffect`, then postprocessing's
95
+ * `LUT3DEffect` on the LUT std baked, read by three's `LUTCubeLoader` as std reads it. Its `dispose` frees the pass, its
96
+ * effects and the LUT.
97
+ */
98
+ export async function viewTransform({ lut: url, min, max, exposure }) {
99
+ const lut = (await new LUTCubeLoader().loadAsync(url)).texture3D;
100
+ const shaper = new ViewShaperEffect(min, max, exposure);
101
+ const look = new LUT3DEffect(lut, { tetrahedralInterpolation: true });
102
+ look.inputColorSpace = THREE.LinearSRGBColorSpace;
103
+ const pass = new EffectPass(new THREE.PerspectiveCamera(), shaper, look);
104
+ let initialized = false;
105
+ return {
106
+ render(renderer, input, output) {
107
+ if (!initialized) {
108
+ pass.initialize(renderer, false, THREE.HalfFloatType);
109
+ initialized = true;
110
+ }
111
+ pass.renderToScreen = output === null;
112
+ pass.render(renderer, input, output, 0, false);
113
+ },
114
+ dispose() {
115
+ pass.dispose();
116
+ lut.dispose();
117
+ },
118
+ };
119
+ }