@joystack/cli 0.0.0-stage → 0.1.43

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +738 -0
  2. package/LICENSE +202 -0
  3. package/README.md +21 -2
  4. package/bin/joystack.mjs +300 -0
  5. package/lib/agent-session.mjs +52 -0
  6. package/lib/agent-surface.mjs +333 -0
  7. package/lib/assets-json.mjs +205 -0
  8. package/lib/assets-page.mjs +184 -0
  9. package/lib/assets-sheet.mjs +365 -0
  10. package/lib/build-id.mjs +130 -0
  11. package/lib/bundle-primitives.mjs +123 -0
  12. package/lib/changelog.mjs +193 -0
  13. package/lib/config.mjs +56 -0
  14. package/lib/create.mjs +241 -0
  15. package/lib/dev.mjs +435 -0
  16. package/lib/engine.mjs +198 -0
  17. package/lib/feedback-sync.mjs +231 -0
  18. package/lib/feedback.mjs +209 -0
  19. package/lib/freeze.mjs +108 -0
  20. package/lib/game-files.mjs +99 -0
  21. package/lib/history.mjs +612 -0
  22. package/lib/inbox-cmd.mjs +57 -0
  23. package/lib/inject.mjs +250 -0
  24. package/lib/joystack-node-doors.mjs +230 -0
  25. package/lib/lane.mjs +141 -0
  26. package/lib/legacy-names.mjs +246 -0
  27. package/lib/lint.mjs +292 -0
  28. package/lib/login.mjs +134 -0
  29. package/lib/model-bake-cli.mjs +17 -0
  30. package/lib/model-bake.mjs +869 -0
  31. package/lib/npm.mjs +38 -0
  32. package/lib/ports.mjs +234 -0
  33. package/lib/primitives.mjs +393 -0
  34. package/lib/printable.mjs +12 -0
  35. package/lib/process-tree.mjs +30 -0
  36. package/lib/project.mjs +84 -0
  37. package/lib/publish.mjs +179 -0
  38. package/lib/replay-cmd.mjs +426 -0
  39. package/lib/replay-export.mjs +1379 -0
  40. package/lib/replay-list.mjs +308 -0
  41. package/lib/replay-profile.mjs +467 -0
  42. package/lib/replay-store.mjs +719 -0
  43. package/lib/room-reload.mjs +168 -0
  44. package/lib/room-server.mjs +539 -0
  45. package/lib/self-update.mjs +162 -0
  46. package/lib/shadow-resolve.mjs +85 -0
  47. package/lib/share.mjs +149 -0
  48. package/lib/smoke-gpu-preload.mjs +91 -0
  49. package/lib/source-path.mjs +33 -0
  50. package/lib/static-server.mjs +1238 -0
  51. package/lib/test-cmd.mjs +697 -0
  52. package/lib/test-policy.mjs +157 -0
  53. package/lib/test-reporter.mjs +177 -0
  54. package/lib/test-selection-cli.mjs +18 -0
  55. package/lib/update.mjs +299 -0
  56. package/package.json +27 -4
@@ -0,0 +1,184 @@
1
+ // `joystack assets` — the dev stack, opened on the Assets page instead of the
2
+ // game.
3
+ //
4
+ // the assets page is a served page under /shared/, not a separate server: the dev
5
+ // server already mounts the engine's shared tree, so there is nothing new to
6
+ // start and nothing to keep in sync. That also means the assets page still works
7
+ // when the game itself is broken, which is when it is most wanted.
8
+ //
9
+ // It finds the game on its own — the page asks the dev server what it is serving
10
+ // (/__joystack/whoami) — so the URL carries no slug.
11
+
12
+ import { spawn } from 'node:child_process';
13
+ import { existsSync, realpathSync } from 'node:fs';
14
+ import http from 'node:http';
15
+ import { join } from 'node:path';
16
+ import { findProject } from './project.mjs';
17
+ import {
18
+ BLOCK_STRIDE, STATIC_PORT_BASE, blockIsRestricted, portInUse, whoIsOn,
19
+ } from './ports.mjs';
20
+ import { readEnv } from './legacy-names.mjs';
21
+
22
+ export const ASSETS_PAGE_PATH = '/shared/ui/assets/';
23
+
24
+ // Built from the dev server's OWN url rather than an assumed localhost:8000: a
25
+ // lane runs on its own port base and 8000 may belong to somebody else's server,
26
+ // and an assets page opened on the wrong port inspects the wrong game — or none.
27
+ export function assetsPageUrl(gameUrl, { item } = {}) {
28
+ const base = new URL(gameUrl);
29
+ const q = item ? `?_item=${encodeURIComponent(item)}` : '';
30
+ return `${base.origin}${ASSETS_PAGE_PATH}${q}`;
31
+ }
32
+
33
+ // Does this game have anything for the assets page to show?
34
+ //
35
+ // The whole answer is `assets.js`, because the list IS the declaration — nothing
36
+ // walks the folder looking for files any more. So this is the same question as
37
+ // "did anyone declare anything", and it is asked for the dev banner: a line that
38
+ // appears on every boot is a line nobody reads by the third one, and the tool it
39
+ // points at is then invisible exactly when it would have helped.
40
+ //
41
+ // The file's CONTENTS are not read. A registry that declares nothing, or one
42
+ // that throws on import, is a case the assets page itself reports far better
43
+ // than a banner line could — and reading it here would mean the dev server
44
+ // evaluating game code to decide how to print a URL.
45
+ export function hasDeclaredAssets(projectDir) {
46
+ return !!projectDir && existsSync(join(projectDir, 'assets.js'));
47
+ }
48
+
49
+ // Flags that take a VALUE, so the value is not mistaken for the asset name.
50
+ // `joystack assets --port 8210` is two argv entries, and reading "the first
51
+ // argument that is not a flag" turns 8210 into the thing to open — on the one
52
+ // command whose entire job is opening the right asset.
53
+ // The `--set`, `--tag`, `--out` and `--against` of `joystack assets <id> --sheet`
54
+ // are among them (assets-sheet.mjs).
55
+ export const VALUED_FLAGS = new Set(['--port', '--set', '--tag', '--out', '--against']);
56
+
57
+ export function itemArg(rest = []) {
58
+ for (let i = 0; i < rest.length; i += 1) {
59
+ const a = rest[i];
60
+ if (a.startsWith('--')) {
61
+ if (VALUED_FLAGS.has(a)) i += 1;
62
+ continue;
63
+ }
64
+ return a;
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ export function openBrowser(url) {
70
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
71
+ try {
72
+ // Detached and fully ignored: a browser that outlives the CLI, and one that
73
+ // cannot hold the dev stack's stdio open if it decides to write to it.
74
+ spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ // ── Reusing a running `joystack dev` ───────────────────────────────────────────
82
+ //
83
+ // `joystack assets` next to a `joystack dev` the creator is already using opens the
84
+ // Assets page on that stack. Starting its own would go through dev()'s port
85
+ // takeover, which treats any server for the same game as stale and kills it,
86
+ // and the page would then be served by a stack nobody is watching. Reusing the
87
+ // running one also keeps the agent session that `joystack dev` was started with,
88
+ // so the page's agent channel reaches the same agent.
89
+ //
90
+ // "Running" means healthy, not merely present: the server answers the
91
+ // ownership probe with this game's folder AND serves the Assets page itself.
92
+ // A server for this game that answers the probe but cannot serve the page (an
93
+ // engine folder removed under it, a stuck request handler) is not reused, and
94
+ // the dev() that `joystack assets` then starts reclaims its port as before.
95
+
96
+ // How long the health probe waits for the page. Generous on purpose: a busy
97
+ // but working server that misses a short deadline would be reclaimed (killed)
98
+ // by the dev() that follows.
99
+ const HEALTH_TIMEOUT_MS = 1500;
100
+
101
+ // Does the server on `port` answer GET `path` with 200? Loopback only, and
102
+ // with a literal Host, which is what the dev server requires of a local
103
+ // request.
104
+ function serves(port, path, timeoutMs) {
105
+ return new Promise((resolve) => {
106
+ const req = http.get({ host: '127.0.0.1', port, path, headers: { Host: `127.0.0.1:${port}` }, timeout: timeoutMs }, (res) => {
107
+ res.resume();
108
+ resolve(res.statusCode === 200);
109
+ });
110
+ req.on('timeout', () => { req.destroy(); resolve(false); });
111
+ req.on('error', () => resolve(false));
112
+ });
113
+ }
114
+
115
+ // One folder reached through different paths (a symlinked temp or home
116
+ // directory) is still one game.
117
+ const real = (dir) => {
118
+ try { return realpathSync(dir); } catch { return dir; }
119
+ };
120
+
121
+ // The running, healthy dev stack serving `projectDir`, as `{ port, origin }`,
122
+ // or null. With `port` (an explicit --port) only that port is looked at;
123
+ // otherwise every block `joystack dev` could have taken, in the order it takes
124
+ // them.
125
+ export async function findRunningStack({
126
+ projectDir, port = null, base = STATIC_PORT_BASE, blocks = 24, timeoutMs = HEALTH_TIMEOUT_MS,
127
+ } = {}) {
128
+ const candidates = port != null
129
+ ? [port]
130
+ : Array.from({ length: blocks }, (_, i) => base + i * BLOCK_STRIDE).filter((p) => !blockIsRestricted(p));
131
+ for (const p of candidates) {
132
+ if (!(await portInUse(p))) continue;
133
+ const owner = await whoIsOn(p, Math.min(timeoutMs, 1000));
134
+ if (!owner?.projectDir || real(owner.projectDir) !== real(projectDir)) continue;
135
+ if (!(await serves(p, ASSETS_PAGE_PATH, timeoutMs))) continue;
136
+ return { port: p, origin: `http://localhost:${p}` };
137
+ }
138
+ return null;
139
+ }
140
+
141
+ // What `joystack assets` runs: reuse the game's running dev stack when there is
142
+ // a healthy one, otherwise start one with `startDev` (dev() in the CLI).
143
+ // Resolves `{ reused, target, handle }`; `handle` is the started stack, null
144
+ // when one was reused (nothing for this process to keep alive or stop).
145
+ export async function openAssets({
146
+ cwd = process.cwd(), port, item, agentSession = null, startDev,
147
+ base = STATIC_PORT_BASE, blocks = 24, env = process.env,
148
+ } = {}) {
149
+ const project = findProject(cwd);
150
+ const fromEnv = Number(readEnv('JOYSTACK_DEV_PORT', env));
151
+ const explicit = port ?? (Number.isInteger(fromEnv) && fromEnv > 0 ? fromEnv : null);
152
+ const live = await findRunningStack({ projectDir: project.dir, port: explicit, base, blocks });
153
+ if (live) {
154
+ // The page's agent channel belongs to the stack, so a reused stack reaches
155
+ // the agent that started IT. When the caller is an agent and the running
156
+ // stack has none, notes sent from the page would only be filed, and the
157
+ // caller is told so rather than left waiting for them.
158
+ const agentUnreachable = !!agentSession && !(await stackAgent(live.port));
159
+ return { reused: true, target: assetsPageUrl(live.origin, { item }), handle: null, agentUnreachable };
160
+ }
161
+ const handle = await startDev({ cwd, port, agentSession });
162
+ return { reused: false, target: assetsPageUrl(handle.url, { item }), handle, agentUnreachable: false };
163
+ }
164
+
165
+ // The agent a running stack would wake (`'Claude Code'`), or null when none
166
+ // is listening or the stack cannot say. Loopback only, with the header the
167
+ // status route requires.
168
+ function stackAgent(port, timeoutMs = HEALTH_TIMEOUT_MS) {
169
+ return new Promise((resolve) => {
170
+ const req = http.get({
171
+ host: '127.0.0.1', port, path: '/__joystack/agent-inbox/status', timeout: timeoutMs,
172
+ headers: { Host: `127.0.0.1:${port}`, 'X-Joystack-Agent-UI': '1' },
173
+ }, (res) => {
174
+ let body = '';
175
+ res.setEncoding('utf8');
176
+ res.on('data', (d) => { if (body.length < 65536) body += d; });
177
+ res.on('end', () => {
178
+ try { resolve(res.statusCode === 200 ? JSON.parse(body).agent ?? null : null); } catch { resolve(null); }
179
+ });
180
+ });
181
+ req.on('timeout', () => { req.destroy(); resolve(null); });
182
+ req.on('error', () => resolve(null));
183
+ });
184
+ }
@@ -0,0 +1,365 @@
1
+ // `joystack assets <id> --sheet` — a view sheet of one 3D asset, drawn by the
2
+ // Assets page in a headless browser and written to disk, for an agent (or a
3
+ // person) to look at without opening anything.
4
+ //
5
+ // The sheet is the page's own (shared/ui/assets/sheet.js, through
6
+ // `window.joystackAssets.sheet()` and headless.mjs `viewSheet`), so the numbers
7
+ // and pictures here are exactly what the page shows. This file only finds a
8
+ // dev stack to open the page on, drives it, and writes the files.
9
+ //
10
+ // joystack assets ship --sheet .joystack/sheets/ship-sheet.png (+ .json)
11
+ // joystack assets ship --sheet --set height=2,hull=#3366cc --tag tall
12
+ // joystack assets ship --sheet --against .joystack/sheets/ship-before-sheet.json
13
+ //
14
+ // ── WHAT IS TRUSTED ─────────────────────────────────────────────────────────
15
+ //
16
+ // Everything on the command line is the creator's own. The asset id and the
17
+ // tag become part of a file name, so they are reduced to safe characters
18
+ // before they touch a path. `--against` names a sheet report written earlier;
19
+ // its PNG is found beside it by name, never from anything inside the report,
20
+ // so the report's contents cannot point the command at another file.
21
+ //
22
+ // A game folder may come from somebody else, and the game's own code runs in
23
+ // the page this command drives. So neither file `--against` reads may be a
24
+ // symbolic link (a planted `before-sheet.png` linked to a private file would
25
+ // otherwise be read and handed to that code), the PNG must be a PNG, each
26
+ // sheet is written to a temporary name and renamed into place (a planted link
27
+ // at the destination is replaced, never written through), and the default
28
+ // folder must really be inside the game. Text that came from the page is
29
+ // stripped of control characters before it reaches the terminal.
30
+
31
+ import { randomBytes } from 'node:crypto';
32
+ import {
33
+ closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeSync,
34
+ } from 'node:fs';
35
+ import { createRequire } from 'node:module';
36
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
37
+ import { pathToFileURL } from 'node:url';
38
+ import { findProject, resolveEngine } from './project.mjs';
39
+ import { findRunningStack, ASSETS_PAGE_PATH } from './assets-page.mjs';
40
+ import { gpuArgs } from './smoke-gpu-preload.mjs';
41
+ import { printable } from './printable.mjs';
42
+ import { stateDirName } from './legacy-names.mjs';
43
+
44
+ export const SHEET_HELP = `joystack assets <id> --sheet [--set <k=v,…>] [--tag <name>] [--out <dir>] [--against <sheet.json>]
45
+
46
+ Draws a view sheet of one 3D asset: front, side, back and top at one scale
47
+ with a ruler, a three-quarter view, the in-game view when assets.js declares
48
+ a camera, the reference images, and the measured stats. Writes
49
+ <dir>/<id>[-<tag>]-sheet.png and the same name as .json (default
50
+ dir: .joystack/sheets/).
51
+
52
+ --set k=v,… draw it at these parameter values. A value outside what the
53
+ asset declares is an error that names the range; nothing is
54
+ drawn at a value it did not ask for.
55
+ --tag <name> added to the file names, e.g. --tag before / --tag after
56
+ --against <f> an earlier sheet's .json: also writes <id>[-<tag>]-sheet-compare.png,
57
+ the two side by side with what moved between them
58
+ --out <dir> where to write
59
+
60
+ Uses the dev stack already running for this game, or starts one for the
61
+ length of the command. Needs playwright in the game (npm i -D playwright).`;
62
+
63
+ // Flags of this mode that take no value. Any other flag is a typo, and is
64
+ // refused rather than skipped: a skipped `--tgs before` would make `before`
65
+ // the asset to draw.
66
+ const SHEET_SWITCHES = new Set(['--sheet', '--no-open', '--help']);
67
+
68
+ // A file-name-safe version of an id or a tag: letters, digits, dot, dash and
69
+ // underscore, never starting with a dot, never empty.
70
+ export function safeName(text) {
71
+ const s = String(text ?? '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^\.+/, '_').slice(0, 80);
72
+ return s || '_';
73
+ }
74
+
75
+ // `k=v,k=v`, possibly given more than once, as { k: 'v' }. Values stay text:
76
+ // the page reads them against the asset's declaration, which is the only
77
+ // thing that knows what type each one is.
78
+ export function parseSet(texts) {
79
+ const out = {};
80
+ for (const text of texts) {
81
+ for (const pair of String(text).split(',')) {
82
+ const at = pair.indexOf('=');
83
+ const key = at > 0 ? pair.slice(0, at).trim() : '';
84
+ if (!key) throw new Error(`--set takes k=v pairs separated by commas; "${pair}" has no name before "="`);
85
+ out[key] = pair.slice(at + 1).trim();
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ // The arguments after `joystack assets`, read for the sheet mode.
92
+ export function parseSheetArgs(rest = []) {
93
+ const opts = { id: undefined, sets: [], tag: null, out: null, against: null, port: null };
94
+ for (let i = 0; i < rest.length; i += 1) {
95
+ const a = rest[i];
96
+ const value = () => {
97
+ const v = rest[i + 1];
98
+ if (v == null || v.startsWith('--')) throw new Error(`${a} needs a value.\n\n${SHEET_HELP}`);
99
+ i += 1;
100
+ return v;
101
+ };
102
+ if (a === '--set') opts.sets.push(value());
103
+ else if (a === '--tag') opts.tag = value();
104
+ else if (a === '--out') opts.out = value();
105
+ else if (a === '--against') opts.against = value();
106
+ else if (a === '--port') {
107
+ const p = Number(value());
108
+ if (!Number.isInteger(p) || p <= 0 || p > 65535) throw new Error('--port needs a port number');
109
+ opts.port = p;
110
+ } else if (a.startsWith('--')) {
111
+ if (!SHEET_SWITCHES.has(a)) throw new Error(`joystack assets --sheet has no option ${a}.\n\n${SHEET_HELP}`);
112
+ } else if (opts.id === undefined) opts.id = a;
113
+ else throw new Error(`joystack assets --sheet draws one asset; got "${opts.id}" and "${a}".\n\n${SHEET_HELP}`);
114
+ }
115
+ if (!opts.id) throw new Error(`Name the asset: joystack assets <id> --sheet\n\n${SHEET_HELP}`);
116
+ return { ...opts, values: parseSet(opts.sets) };
117
+ }
118
+
119
+ // The largest sheet report and sheet picture `--against` will read. A report
120
+ // is a few kilobytes and a sheet a few hundred; anything far larger is not one.
121
+ const REPORT_MAX_BYTES = 2 * 1024 * 1024;
122
+ const PNG_MAX_BYTES = 32 * 1024 * 1024;
123
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
124
+
125
+ // A regular file, or a reason it is not one (missing, a link, a folder…).
126
+ function plainFile(path, what, max) {
127
+ let st;
128
+ try {
129
+ st = lstatSync(path);
130
+ } catch {
131
+ return `${what} does not exist`;
132
+ }
133
+ if (st.isSymbolicLink()) return `${what} is a symbolic link; give the file itself`;
134
+ if (!st.isFile()) return `${what} is not a file`;
135
+ if (st.size > max) return `${what} is too large to be one written by joystack assets --sheet`;
136
+ return null;
137
+ }
138
+
139
+ // An earlier sheet, from the path of its report: { report, png, pngPath }.
140
+ export function readEarlierSheet(path) {
141
+ const jsonPath = resolve(path);
142
+ if (!/\.json$/i.test(jsonPath)) throw new Error(`--against takes the .json written beside a sheet; got ${path}`);
143
+ if (!existsSync(jsonPath)) throw new Error(`--against: no such file ${path}`);
144
+ const notJson = plainFile(jsonPath, path, REPORT_MAX_BYTES);
145
+ if (notJson) throw new Error(`--against: ${notJson}`);
146
+ let report;
147
+ try {
148
+ report = JSON.parse(readFileSync(jsonPath, 'utf8'));
149
+ } catch (err) {
150
+ throw new Error(`--against: ${path} is not valid JSON (${err.message})`);
151
+ }
152
+ if (report?.kind !== 'joystack-asset-sheet') throw new Error(`--against: ${path} is not a sheet report written by joystack assets --sheet`);
153
+ const pngPath = jsonPath.replace(/\.json$/i, '.png');
154
+ if (!existsSync(pngPath)) throw new Error(`--against: the sheet picture ${basename(pngPath)} is missing beside ${basename(jsonPath)}`);
155
+ const notPng = plainFile(pngPath, `the sheet picture ${basename(pngPath)}`, PNG_MAX_BYTES);
156
+ if (notPng) throw new Error(`--against: ${notPng}`);
157
+ const png = readFileSync(pngPath);
158
+ if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error(`--against: ${basename(pngPath)} is not a PNG`);
159
+ return { report, png, pngPath };
160
+ }
161
+
162
+ // Written beside its destination under a temporary name, then renamed over
163
+ // it: a rename replaces whatever is at the destination, a link included,
164
+ // where a write would follow the link to its target.
165
+ //
166
+ // The temporary name is random and is created exclusively ('wx'), so a link
167
+ // or file planted at it beforehand makes the write fail instead of being
168
+ // written through. A temporary file this call created is removed if anything
169
+ // after its creation fails; one it did not create is never touched.
170
+ // `suffix` names the temporary file and exists for tests.
171
+ export function writeReplacing(path, data, { suffix = () => randomBytes(8).toString('hex') } = {}) {
172
+ const tmp = `${path}.${suffix()}.tmp`;
173
+ const fd = openSync(tmp, 'wx', 0o644);
174
+ try {
175
+ try {
176
+ const bytes = typeof data === 'string' ? Buffer.from(data) : data;
177
+ for (let at = 0; at < bytes.length;) at += writeSync(fd, bytes, at, bytes.length - at);
178
+ } finally {
179
+ closeSync(fd);
180
+ }
181
+ renameSync(tmp, path);
182
+ } catch (err) {
183
+ try { unlinkSync(tmp); } catch { /* already gone */ }
184
+ throw err;
185
+ }
186
+ }
187
+
188
+ // The default sheets folder, created inside the game. `.joystack` and
189
+ // `.joystack/sheets` must each be a real folder, never a link (even one that
190
+ // points inside the game today), and the folder must resolve inside the game
191
+ // once it exists.
192
+ export function sheetsDirIn(projectDir) {
193
+ let dir = projectDir;
194
+ for (const part of [stateDirName(projectDir), 'sheets']) {
195
+ dir = join(dir, part);
196
+ const shown = relative(projectDir, dir);
197
+ let st = null;
198
+ try {
199
+ st = lstatSync(dir);
200
+ } catch {
201
+ mkdirSync(dir);
202
+ st = lstatSync(dir);
203
+ }
204
+ if (st.isSymbolicLink()) throw new Error(`${shown} in this game is a symbolic link; remove it, or choose a folder with --out`);
205
+ if (!st.isDirectory()) throw new Error(`${shown} in this game is not a folder; remove it, or choose a folder with --out`);
206
+ }
207
+ const real = realpathSync(dir);
208
+ const rel = relative(realpathSync(projectDir), real);
209
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
210
+ throw new Error(`${stateDirName(projectDir)}/sheets in this game leads outside it (to ${printable(real)}); remove the link, or choose a folder with --out`);
211
+ }
212
+ return dir;
213
+ }
214
+
215
+ // The newest engine release whose Assets page driver (shared/ui/assets/
216
+ // headless.mjs) lacks what `joystack assets --json` and `--sheet` call: the
217
+ // list of declared ids, one asset's record, and the view sheet. The CLI is
218
+ // installed separately from a game's engine, so it can meet one this old.
219
+ export const ENGINE_WITHOUT_ASSET_DRIVER = '0.2.32';
220
+
221
+ // The error for an engine whose page driver lacks what `what` needs, naming
222
+ // the game's installed engine version when it can be read.
223
+ export function engineTooOld(what, projectDir) {
224
+ let version = null;
225
+ try {
226
+ version = resolveEngine(projectDir).version;
227
+ } catch { /* no engine installed, or no package.json to read */ }
228
+ const has = version ? ` (this game has ${printable(version)})` : '';
229
+ return new Error(`${what} needs an engine newer than ${ENGINE_WITHOUT_ASSET_DRIVER}${has}. Run \`joystack update\`, then try again.`);
230
+ }
231
+
232
+ // The game's Assets page driver. An engine with no headless.mjs at all is
233
+ // older than every engine that has these commands.
234
+ export async function loadDriver(projectDir, what) {
235
+ const path = join(resolveEngine(projectDir).sharedDir, 'ui', 'assets', 'headless.mjs');
236
+ if (!existsSync(path)) throw engineTooOld(what, projectDir);
237
+ return import(pathToFileURL(path).href);
238
+ }
239
+
240
+ // A number the page measured, for printing: the page's code can hand back
241
+ // anything, so whatever is not a finite number prints as "?".
242
+ const num = (v, digits) => {
243
+ const n = Number(v);
244
+ if (!Number.isFinite(n)) return '?';
245
+ return digits == null ? Math.round(n).toLocaleString('en-US') : n.toFixed(digits);
246
+ };
247
+
248
+ // printable() lives in printable.mjs; exported here too for the callers of this module.
249
+ export { printable };
250
+
251
+ // Playwright from the game's own install, the same one its smokes use.
252
+ // `why` finishes the sentence "… is measured in a headless browser".
253
+ export async function loadPlaywright(projectDir, why = 'a view sheet is drawn') {
254
+ try {
255
+ const req = createRequire(join(projectDir, 'package.json'));
256
+ const pkgDir = dirname(req.resolve('playwright/package.json'));
257
+ const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
258
+ const entry = typeof pkg.exports?.['.'] === 'object' ? pkg.exports['.'].import ?? pkg.exports['.'].default : null;
259
+ return await import(pathToFileURL(join(pkgDir, entry ?? pkg.main ?? 'index.js')).href);
260
+ } catch {
261
+ throw new Error(`${why} in a headless browser, and this game has no playwright. Run: npm i -D playwright && npx playwright install chromium`);
262
+ }
263
+ }
264
+
265
+ const lines = (problems) => problems.map((p) => printable(` ✗ ${p.key}=${p.requested}: ${p.key} ${p.reason}`)).join('\n');
266
+
267
+ // Draw one sheet. `startDev` starts a dev stack (dev() in the CLI) when none
268
+ // is running for the game. `origin`, `browser` and `headless` are for a caller
269
+ // that already has them (a test): an origin skips looking for a stack, a
270
+ // browser is used and left open, and `headless` is the page driver's module.
271
+ export async function assetsSheet({
272
+ cwd = process.cwd(), id, values = {}, tag = null, out = null, against = null, port = null,
273
+ startDev = null, log = console.log, origin = null, browser = null, headless = null,
274
+ } = {}) {
275
+ const earlier = against ? readEarlierSheet(resolve(cwd, against)) : null;
276
+ let project = null;
277
+ let stack = null;
278
+ let started = null;
279
+ if (!origin) {
280
+ project = findProject(cwd);
281
+ stack = await findRunningStack({ projectDir: project.dir, port });
282
+ if (!stack) {
283
+ if (!startDev) throw new Error('no dev stack is running for this game');
284
+ const quiet = [];
285
+ started = await startDev({ cwd, port: port ?? undefined, watch: false, reload: false, noMp: true, log: (l) => quiet.push(l) });
286
+ stack = { origin: new URL(started.url).origin };
287
+ }
288
+ origin = stack.origin;
289
+ }
290
+ if (!project && !(headless && browser)) project = findProject(cwd);
291
+ const base = `${safeName(id)}${tag ? `-${safeName(tag)}` : ''}-sheet`;
292
+
293
+ let ownBrowser = null;
294
+ try {
295
+ const driver = headless ?? await loadDriver(project.dir, 'joystack assets --sheet');
296
+ if (typeof driver.viewSheet !== 'function') throw engineTooOld('joystack assets --sheet', project?.dir ?? cwd);
297
+ if (!browser) {
298
+ const { chromium } = await loadPlaywright(project.dir);
299
+ ownBrowser = await chromium.launch({ args: gpuArgs() });
300
+ browser = ownBrowser;
301
+ }
302
+ const url = `${origin}${ASSETS_PAGE_PATH}?_item=${encodeURIComponent(id)}&_thumbs=0`;
303
+ const insp = await driver.openAssetsPage({ browser, url, viewport: { width: 1280, height: 800 } });
304
+ try {
305
+ if (typeof insp.ids !== 'function') throw engineTooOld('joystack assets --sheet', project?.dir ?? cwd);
306
+ const ids = await insp.ids();
307
+ if (!Array.isArray(ids) || !ids.includes(id)) {
308
+ const declared = Array.isArray(ids) ? ids.map(printable).join(', ') : '';
309
+ throw new Error(`there is no asset "${printable(id)}" in this game's assets.js. Declared: ${declared || '(none)'}`);
310
+ }
311
+ const made = await driver.viewSheet(insp, {
312
+ values,
313
+ against: earlier ? { report: earlier.report, png: earlier.png } : null,
314
+ });
315
+ if (made.problems) {
316
+ throw new Error(`these values would not apply as given, so no sheet was drawn:\n${lines(made.problems)}`);
317
+ }
318
+ let outDir;
319
+ if (out) {
320
+ outDir = resolve(cwd, out);
321
+ mkdirSync(outDir, { recursive: true });
322
+ } else {
323
+ outDir = sheetsDirIn(project?.dir ?? cwd);
324
+ }
325
+ const png = join(outDir, `${base}.png`);
326
+ const json = join(outDir, `${base}.json`);
327
+ writeReplacing(png, made.png);
328
+ writeReplacing(json, `${JSON.stringify(made.report, null, 2)}\n`);
329
+ const files = { png, json };
330
+ if (made.comparison) {
331
+ files.compare = join(outDir, `${base}-compare.png`);
332
+ writeReplacing(files.compare, made.comparison.png);
333
+ }
334
+ const r = made.report;
335
+ const missing = (Array.isArray(r.refs) ? r.refs : []).filter((x) => !x?.ok);
336
+ const shown = (p) => (isAbsolute(p) && project && p.startsWith(project.dir) ? p.slice(project.dir.length + 1) : p);
337
+ log(`\n 🗂 ${printable(r.label)} (${printable(r.id)}) → ${shown(files.png)}`);
338
+ log(` report → ${shown(files.json)}`);
339
+ if (files.compare) log(` before/after → ${shown(files.compare)}`);
340
+ const s = r.stats;
341
+ log(` ${num(s?.triangles)} tris · ${num(s?.size?.x, 2)} × ${num(s?.size?.y, 2)} × ${num(s?.size?.z, 2)} u`);
342
+ if (r.inGame) {
343
+ const b = r.inGame.box ?? {};
344
+ const d = r.inGame.drawn;
345
+ const screen = `${num(r.inGame.camera?.screen?.width)} × ${num(r.inGame.camera?.screen?.height)} screen`;
346
+ log(b.behind
347
+ ? ' in game: the camera is inside the model at the declared distance'
348
+ : ` in game: ${d ? `drawn ${num(d.width)} × ${num(d.height)} px, ` : ''}bounding box ${num(b.width)} × ${num(b.height)} px on a ${screen}${b.fits ? '' : ' (does not fit)'}`);
349
+ } else {
350
+ log(' in game: no camera declared in assets.js');
351
+ }
352
+ const empty = Object.entries(r.views ?? {}).filter(([, v]) => v?.empty).map(([k]) => k);
353
+ if (empty.length) log(` empty views: ${empty.map(printable).join(', ')}`);
354
+ if (made.comparison) for (const line of made.comparison.changes) log(` ${printable(line)}`);
355
+ for (const m of missing) log(` ✗ reference ${printable(m.src)}: ${printable(m.error)}`);
356
+ if (insp.errors.length) log(` ⚠ the page reported: ${printable(insp.errors.join('; '))}`);
357
+ return { files, report: r, missingRefs: missing };
358
+ } finally {
359
+ await insp.close();
360
+ }
361
+ } finally {
362
+ if (ownBrowser) await ownBrowser.close();
363
+ await started?.stop();
364
+ }
365
+ }