@volter/play 0.5.211 → 0.5.213

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