@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.
- package/CHANGELOG.md +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- 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
|
+
}
|