@joystack/cli 0.1.53 → 0.1.55

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 CHANGED
@@ -14,6 +14,58 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.55] - 2026-10-22
18
+
19
+ ### Added
20
+
21
+ - `joystack assets <id> --export` and `joystack assets --export <file.js>`
22
+ write an asset as files to `.joystack/exports/`: a model as `.glb` and a
23
+ picture, a sound as `.wav`, a 2D asset as `.png` (`--set`, `--tag`,
24
+ `--out`, `--stage`). Needs engine 0.2.48 or later.
25
+ - `joystack shot <script.mjs>` runs a short Playwright script of your
26
+ agent's against the running game and writes the pictures it takes with
27
+ `shot(name)` to `.joystack/shots/`. The script is handed a page already
28
+ open on the game at a phone's size, under a player name of its own, and the
29
+ world handle `openPage` gives a smoke; it is checked before anything starts,
30
+ the page opens only once the dev server has finished updating after an edit,
31
+ each step (a click, a `game.waitFor`) waits at most 5 seconds, the run stops,
32
+ saying so, if the game updates under it, and it fails if the game throws
33
+ while it runs. In a multiplayer game the page joins the game's one room,
34
+ beside anyone playing it.
35
+
36
+ ### Changed
37
+
38
+ - In the Joystack maker, `joystack dev` says the game is ready to play on
39
+ your page and that there is no link to give, and the dev server's log
40
+ lists no addresses, so the agent no longer hands you one you cannot open.
41
+ - `joystack dev` names every file of your game and the engine by its
42
+ content, so a browser keeps what has not changed between loads: opening
43
+ the game again after an edit downloads only the files the edit touched.
44
+
45
+ ### Fixed
46
+
47
+ - Under `joystack dev`, each of your entity files runs once: it used to run
48
+ twice, once for your game's imports and once for the engine's.
49
+ - In Safari, a game page `joystack dev` reloads after an edit downloads only
50
+ the files the edit touched: it used to download every file again.
51
+ - When your game does not build, the page `joystack dev` serves in its place
52
+ now shows the error's own message, not the line of code that threw it, and
53
+ answers with status 500, so a check that fetches the game sees it failed.
54
+ - `joystack test` no longer fails with `ENOENT` in `.joystack/frozen/` when
55
+ `joystack dev` is running: looking for your tests, it never opens
56
+ `.joystack/`, `node_modules/` or `overrides/`.
57
+
58
+ ## [0.1.54] - 2026-10-21
59
+
60
+ ### Changed
61
+
62
+ - Every page of yours `joystack dev` serves gets the dev tools, replays
63
+ (`?replay=`) and staged entities, whatever its address, including the
64
+ maker's live link; a tester's share link gets none of them.
65
+ - `joystack publish --dev-tools` says the review build has the dev tools on,
66
+ and an old recording's sweep message points at **Keep** in Sessions, in
67
+ the dev tools.
68
+
17
69
  ## [0.1.53] - 2026-10-20
18
70
 
19
71
  ### Added
package/bin/joystack.mjs CHANGED
@@ -38,7 +38,9 @@ Usage:
38
38
  joystack dev --no-reload ...frozen as it is now: saves reach nobody until you restart it
39
39
  joystack assets [<id>] Browse and drive your models, sounds and assets on their own
40
40
  joystack assets <id> --sheet Draw a view sheet of a 3D asset to .joystack/sheets/ (--help: options)
41
+ joystack assets <id> --export Export an asset as files: a model as .glb and a picture, a sound as .wav (--help: options)
41
42
  joystack assets --json [<id>…] Print each asset's parameters, measured stats and flags as JSON
43
+ joystack shot <script.mjs> Run a short Playwright script on the running game; its shot(name) calls write pictures to .joystack/shots/ (--help)
42
44
  joystack lane <name> Open an isolated copy of the game to experiment in, safely
43
45
  joystack land <name> Put a lane's work into the game, then remove the lane
44
46
  joystack test [aspect] Run targeted checks (no aspect: conservative full run)
@@ -53,7 +55,7 @@ Usage:
53
55
  Publish this game to <slug>.joystack.games, open to anyone with a
54
56
  Joystack account (--slug for an A/B copy, open only to invited
55
57
  accounts unless --open; --dev-tools makes that copy a review build
56
- with Tweaks on)
58
+ with the dev tools on)
57
59
  joystack build-id The id of this game's code as it is now, with its git commit
58
60
  joystack replay list Every recorded session: when, how long, on what device, which build
59
61
  joystack replay export [<id>] Turn a recorded session into tables you can query (Parquet)
@@ -134,6 +136,23 @@ try {
134
136
  process.stdout.write(`${safeJson(out)}\n`);
135
137
  break;
136
138
  }
139
+ // `--export`: the asset as files, made headless (assets-export.mjs).
140
+ if (rest.includes('--export')) {
141
+ const { assetsExport, parseExportArgs, EXPORT_HELP } = await import('../lib/assets-export.mjs');
142
+ if (rest.includes('--help')) {
143
+ console.log(EXPORT_HELP);
144
+ break;
145
+ }
146
+ try {
147
+ await assetsExport({ ...parseExportArgs(rest), startDev: dev });
148
+ } catch (err) {
149
+ // An error here may quote the game's own text, so every line of it
150
+ // is made printable before it reaches the terminal.
151
+ console.error(`joystack assets: ${printableLines(err?.message ?? err)}`);
152
+ process.exit(1);
153
+ }
154
+ process.exit(0);
155
+ }
137
156
  // `--sheet`: draw a view sheet headless and write it to disk, instead
138
157
  // of opening the page (assets-sheet.mjs).
139
158
  if (rest.includes('--sheet')) {
@@ -186,6 +205,25 @@ try {
186
205
  process.on('SIGTERM', shutdown);
187
206
  break;
188
207
  }
208
+ case 'shot': {
209
+ // A Playwright script of the agent's own, run on the running game
210
+ // (lib/shot/).
211
+ const { shot: runShot, parseShotArgs, SHOT_HELP } = await import('../lib/shot/shot.mjs');
212
+ if (rest.includes('--help') || !rest.length) {
213
+ console.log(SHOT_HELP);
214
+ break;
215
+ }
216
+ try {
217
+ await runShot({ ...parseShotArgs(rest), startDev: dev });
218
+ } catch (err) {
219
+ // The runner has already said what went wrong; anything else here may
220
+ // quote the game's own text (its build error), so every line of it is
221
+ // made printable before it reaches the terminal.
222
+ if (!err?.reported) console.error(`joystack shot: ${printableLines(err?.message ?? err)}`);
223
+ process.exit(err?.exitCode ?? 1);
224
+ }
225
+ process.exit(0);
226
+ }
189
227
  case 'relay': {
190
228
  const { relayCommand, RELAY_HELP } = await import('../lib/relay/cli.mjs');
191
229
  const dirAt = rest.indexOf('--dir');
@@ -266,7 +304,7 @@ try {
266
304
  case 'replay': {
267
305
  // `replay` is the accessor for a recorded session; `export` is its first
268
306
  // verb. Watching one back needs no command at all — it is `?replay=<id>`
269
- // on the game's URL, or the toolbox's Sessions panel.
307
+ // on the game's URL, or Sessions in the dev tools.
270
308
  const { REPLAY_HELP, replayExport, replayProfile, replayList, parseReplayArgs } = await import('../lib/replay-cmd.mjs');
271
309
  // Parsed in the lib so the argv split is under test — a flag's value must
272
310
  // never slide into the session slot, and --help must be answered before
@@ -0,0 +1,198 @@
1
+ // `joystack assets <id> --export` and `joystack assets --export <file.js>`:
2
+ // one asset as files someone else can open, made by the Assets page headless
3
+ // from the asset as the game builds it (shared/ui/assets/export/).
4
+ //
5
+ // joystack assets car --export .joystack/exports/car.glb + car.png
6
+ // joystack assets car --export --set armor=2 --tag heavy
7
+ // .joystack/exports/car-heavy.glb + .png
8
+ // joystack assets engine-hum --export .joystack/exports/engine-hum.wav
9
+ // joystack assets --export art/car-v2.js .joystack/exports/car-v2.glb + .png
10
+ //
11
+ // The last form is a scratch module: a file in the asset format (the mount
12
+ // and apply a declared asset's module has) that is not declared in assets.js,
13
+ // so a version of a model can be made, exported and thrown away, leaving
14
+ // nothing in the game.
15
+ //
16
+ // In the maker (JOYSTACK_MAKER set), it also prints the line that shows the
17
+ // result in the agent's reply, which is how the person sees it there.
18
+
19
+ import { mkdirSync } from 'node:fs';
20
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
21
+ import { findProject } from './project.mjs';
22
+ import { ASSETS_PAGE_PATH } from './assets-page.mjs';
23
+ import { gpuArgs } from './smoke-gpu-preload.mjs';
24
+ import { engineTooOld, loadDriver, loadPlaywright, parseSet, printable, safeName, stackOrigin, stateFolderIn, writeReplacing } from './assets-sheet.mjs';
25
+ import { makerDraft } from './maker.mjs';
26
+
27
+ export const EXPORT_HELP = `joystack assets <id> --export [--set <k=v,…>] [--tag <name>] [--out <dir>] [--port <n>]
28
+ joystack assets --export <file.js> [--stage <stage>] [--set <k=v,…>] [--tag <name>] [--out <dir>] [--port <n>]
29
+
30
+ Exports one asset as files: a 3D model as <name>.glb and a picture of it
31
+ <name>.png, a sound as <name>.wav, any other asset as <name>.png. Written to
32
+ .joystack/exports/ unless --out names a folder. Says what a .glb could not
33
+ carry (a look drawn by a shader of the asset's own, motion made in code).
34
+
35
+ <file.js> a scratch module: a file in the asset format that is not
36
+ declared in assets.js, exported as it is (its stage is
37
+ three unless --stage names another). For trying versions
38
+ of an asset without adding them to the game.
39
+ --set k=v,… export it at these parameter values. A value outside what
40
+ the asset declares is an error that names the range.
41
+ --tag <name> added to the file names, e.g. --tag v2
42
+ --out <dir> where to write
43
+
44
+ Uses the dev stack already running for this game, or starts one for the
45
+ length of the command. Needs playwright in the game (npm i -D playwright).`;
46
+
47
+ const SWITCHES = new Set(['--export', '--help']);
48
+ const SCRATCH = /\.(m?js)$/i;
49
+ // The id a scratch module has on the Assets page (shared/ui/assets/index.html).
50
+ const SCRATCH_ID = '_scratch';
51
+
52
+ // The arguments after `joystack assets`, read for the export mode.
53
+ export function parseExportArgs(rest = []) {
54
+ const opts = { id: undefined, module: null, stage: null, sets: [], tag: null, out: null, port: null };
55
+ let named;
56
+ for (let i = 0; i < rest.length; i += 1) {
57
+ const a = rest[i];
58
+ const value = () => {
59
+ const v = rest[i + 1];
60
+ if (v == null || v.startsWith('--')) throw new Error(`${a} needs a value.\n\n${EXPORT_HELP}`);
61
+ i += 1;
62
+ return v;
63
+ };
64
+ if (a === '--sheet' || a === '--json') throw new Error(`--export and ${a} are two different outputs; ask for one.\n\n${EXPORT_HELP}`);
65
+ if (a === '--set') opts.sets.push(value());
66
+ else if (a === '--tag') opts.tag = value();
67
+ else if (a === '--out') opts.out = value();
68
+ else if (a === '--stage') opts.stage = value();
69
+ else if (a === '--port') {
70
+ const p = Number(value());
71
+ if (!Number.isInteger(p) || p <= 0 || p > 65535) throw new Error('--port needs a port number');
72
+ opts.port = p;
73
+ } else if (a.startsWith('--')) {
74
+ if (!SWITCHES.has(a)) throw new Error(`joystack assets --export has no option ${a}.\n\n${EXPORT_HELP}`);
75
+ } else if (named === undefined) named = a;
76
+ else throw new Error(`joystack assets --export exports one asset; got "${named}" and "${a}".\n\n${EXPORT_HELP}`);
77
+ }
78
+ if (!named) throw new Error(`Name the asset or the file: joystack assets <id> --export, or joystack assets --export <file.js>\n\n${EXPORT_HELP}`);
79
+ if (SCRATCH.test(named)) opts.module = named;
80
+ else opts.id = named;
81
+ if (opts.stage && !opts.module) throw new Error('--stage is for a scratch module; a declared asset names its own stage in assets.js');
82
+ return { ...opts, values: parseSet(opts.sets) };
83
+ }
84
+
85
+ // A scratch module's path inside the game, with forward slashes, or an error.
86
+ export function scratchPath(projectDir, file, cwd) {
87
+ const full = resolve(cwd, file);
88
+ const rel = relative(projectDir, full);
89
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
90
+ throw new Error(`${file} is not inside this game; a scratch module has to be, so the dev server can serve it`);
91
+ }
92
+ return rel.split(sep).join('/');
93
+ }
94
+
95
+ // The line that shows an exported file in the agent's reply in the maker: a
96
+ // markdown image of the model's .glb (its picture is found beside it), the
97
+ // sound or the picture. The path is written with forward slashes, since a
98
+ // backslash in markdown escapes the character after it.
99
+ export function replyLine(label, path) {
100
+ const alt = String(label).replace(/[[\]\n]/g, ' ').trim() || 'Asset';
101
+ path = path.split(sep).join('/');
102
+ return `![${alt}](${/[\s()<>]/.test(path) ? `<${path}>` : path})`;
103
+ }
104
+
105
+ const lines = (problems) => problems.map((p) => printable(` ✗ ${p.key}=${p.requested}: ${p.key} ${p.reason}`)).join('\n');
106
+
107
+ // Where the Assets page is opened for the export, and the name its files take.
108
+ function pageFor({ projectDir, id, module, stage, cwd }) {
109
+ const query = new URLSearchParams({ _thumbs: '0' });
110
+ if (!module) {
111
+ query.set('_item', id);
112
+ return { query, name: id };
113
+ }
114
+ const path = scratchPath(projectDir, module, cwd);
115
+ query.set('_item', SCRATCH_ID);
116
+ query.set('_module', path);
117
+ if (stage) query.set('_stage', stage);
118
+ return { query, name: basename(path, extname(path)) };
119
+ }
120
+
121
+ // The page's ids hold the asset asked for, or the error that says why not.
122
+ function mustHold(ids, { id, module, errors }) {
123
+ const want = module ? SCRATCH_ID : id;
124
+ if (Array.isArray(ids) && ids.includes(want)) return;
125
+ if (module) throw new Error(`${printable(module)} did not load as an asset${errors.length ? `: ${printable(errors.join('; '))}` : ''}`);
126
+ const declared = Array.isArray(ids) ? ids.filter((x) => x !== SCRATCH_ID).map(printable).join(', ') : '';
127
+ throw new Error(`there is no asset "${printable(id)}" in this game's assets.js. Declared: ${declared || '(none)'}`);
128
+ }
129
+
130
+ // Each exported file written under `base` in `outDir`: { glb?, png?, wav? }.
131
+ function writeFiles(made, outDir, base) {
132
+ const files = {};
133
+ for (const [key, file] of Object.entries(made.files)) {
134
+ files[key] = join(outDir, `${base}.${key}`);
135
+ writeReplacing(files[key], file.bytes);
136
+ }
137
+ return files;
138
+ }
139
+
140
+ // What was written, what a file left out, and in the maker the reply line.
141
+ function report({ files, made, label, projectDir, errors, env, log }) {
142
+ const shown = (p) => (p.startsWith(`${projectDir}${sep}`) ? p.slice(projectDir.length + 1) : p);
143
+ const main = files.glb ?? files.wav ?? files.png;
144
+ log(`\n 📦 ${printable(label)} → ${shown(main)}`);
145
+ if (files.glb && files.png) log(` its picture → ${shown(files.png)}`);
146
+ for (const l of made.left ?? []) log(` left out: ${printable(l)}`);
147
+ if (made.clips?.length) log(` clips: ${made.clips.map(printable).join(', ')}`);
148
+ if (errors.length) log(` ⚠ the page reported: ${printable(errors.join('; '))}`);
149
+ if (makerDraft(env)) log(` To show it in your reply, write: ${replyLine(label, shown(main))}`);
150
+ }
151
+
152
+ // The game's Assets page driver, or the one a test gives, refused when the
153
+ // game's engine is too old to export.
154
+ async function driverFor(projectDir, given) {
155
+ const driver = given ?? await loadDriver(projectDir, 'joystack assets --export');
156
+ if (typeof driver.exportAsset !== 'function') throw engineTooOld('joystack assets --export', projectDir);
157
+ return driver;
158
+ }
159
+
160
+ // A headless browser from the game's own Playwright.
161
+ async function launch(projectDir) {
162
+ const { chromium } = await loadPlaywright(projectDir, 'an asset is exported');
163
+ return chromium.launch({ args: gpuArgs() });
164
+ }
165
+
166
+ // Export one asset. `startDev` starts a dev stack when none is running for
167
+ // the game. `origin`, `browser` and `headless` are for a caller that already
168
+ // has them (a test), as in assetsSheet. `env` decides whether the maker's
169
+ // line is printed.
170
+ export async function assetsExport({
171
+ cwd = process.cwd(), id, module, stage, values = {}, tag, out, port, startDev, log = console.log, origin, browser, headless, env = process.env,
172
+ } = {}) {
173
+ const project = findProject(cwd);
174
+ const { query, name } = pageFor({ projectDir: project.dir, id, module, stage, cwd });
175
+ let started = null;
176
+ if (!origin) ({ origin, started } = await stackOrigin({ cwd, projectDir: project.dir, port, startDev }));
177
+ let ownBrowser = null;
178
+ try {
179
+ const driver = await driverFor(project.dir, headless);
180
+ if (!browser) browser = ownBrowser = await launch(project.dir);
181
+ const insp = await driver.openAssetsPage({ browser, url: `${origin}${ASSETS_PAGE_PATH}?${query}`, viewport: { width: 1280, height: 800 } });
182
+ try {
183
+ mustHold(await insp.ids(), { id, module, errors: insp.errors });
184
+ const made = await driver.exportAsset(insp, { values });
185
+ if (made.problems) throw new Error(`these values would not apply as given, so nothing was exported:\n${lines(made.problems)}`);
186
+ const outDir = out ? resolve(cwd, out) : stateFolderIn(project.dir, 'exports');
187
+ mkdirSync(outDir, { recursive: true });
188
+ const files = writeFiles(made, outDir, `${safeName(name)}${tag ? `-${safeName(tag)}` : ''}`);
189
+ report({ files, made, label: module ? name : made.label, projectDir: project.dir, errors: insp.errors, env, log });
190
+ return { files, kind: made.kind, left: made.left ?? [] };
191
+ } finally {
192
+ await insp.close();
193
+ }
194
+ } finally {
195
+ if (ownBrowser) await ownBrowser.close();
196
+ await started?.stop();
197
+ }
198
+ }
@@ -62,7 +62,7 @@ export function parseJsonArgs(rest = []) {
62
62
  let port = null;
63
63
  for (let i = 0; i < rest.length; i += 1) {
64
64
  const a = rest[i];
65
- if (a === '--sheet') throw new Error(`--json and --sheet are two different outputs; ask for one.\n\n${JSON_HELP}`);
65
+ if (a === '--sheet' || a === '--export') throw new Error(`--json and ${a} are two different outputs; ask for one.\n\n${JSON_HELP}`);
66
66
  if (a === '--port') {
67
67
  const v = rest[i + 1];
68
68
  if (v == null || v.startsWith('--')) throw new Error(`--port needs a value.\n\n${JSON_HELP}`);
@@ -51,8 +51,9 @@ export function hasDeclaredAssets(projectDir) {
51
51
  // argument that is not a flag" turns 8210 into the thing to open — on the one
52
52
  // command whose entire job is opening the right asset.
53
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']);
54
+ // are among them (assets-sheet.mjs), and the `--stage` of `--export`
55
+ // (assets-export.mjs).
56
+ export const VALUED_FLAGS = new Set(['--port', '--set', '--tag', '--out', '--against', '--stage']);
56
57
 
57
58
  export function itemArg(rest = []) {
58
59
  for (let i = 0; i < rest.length; i += 1) {
@@ -167,16 +168,25 @@ export async function openAssets({
167
168
  // is listening or the stack cannot say. Loopback only, with the header the
168
169
  // status route requires.
169
170
  function stackAgent(port, timeoutMs = HEALTH_TIMEOUT_MS) {
171
+ return loopbackJson({ port, path: '/__joystack/relay/status', headers: { 'X-Joystack-Agent-UI': '1' }, timeoutMs })
172
+ .then((body) => body?.agent ?? null);
173
+ }
174
+
175
+ // What a route of a dev stack on this machine answers, as JSON, or null when
176
+ // it answers anything but a 200 with JSON, or nothing in `timeoutMs`. Asked of
177
+ // 127.0.0.1 under its own Host, which is what the stack's same-origin checks
178
+ // accept.
179
+ export function loopbackJson({ port, path, headers = {}, timeoutMs = HEALTH_TIMEOUT_MS }) {
170
180
  return new Promise((resolve) => {
171
181
  const req = http.get({
172
- host: '127.0.0.1', port, path: '/__joystack/relay/status', timeout: timeoutMs,
173
- headers: { Host: `127.0.0.1:${port}`, 'X-Joystack-Agent-UI': '1' },
182
+ host: '127.0.0.1', port, path, timeout: timeoutMs,
183
+ headers: { Host: `127.0.0.1:${port}`, ...headers },
174
184
  }, (res) => {
175
185
  let body = '';
176
186
  res.setEncoding('utf8');
177
187
  res.on('data', (d) => { if (body.length < 65536) body += d; });
178
188
  res.on('end', () => {
179
- try { resolve(res.statusCode === 200 ? JSON.parse(body).agent ?? null : null); } catch { resolve(null); }
189
+ try { resolve(res.statusCode === 200 ? JSON.parse(body) : null); } catch { resolve(null); }
180
190
  });
181
191
  });
182
192
  req.on('timeout', () => { req.destroy(); resolve(null); });
@@ -41,7 +41,7 @@ import { gpuArgs } from './smoke-gpu-preload.mjs';
41
41
  import { printable } from './printable.mjs';
42
42
  import { stateDirName } from './legacy-names.mjs';
43
43
 
44
- export const SHEET_HELP = `joystack assets <id> --sheet [--set <k=v,…>] [--tag <name>] [--out <dir>] [--against <sheet.json>]
44
+ export const SHEET_HELP = `joystack assets <id> --sheet [--set <k=v,…>] [--tag <name>] [--out <dir>] [--against <sheet.json>] [--port <n>]
45
45
 
46
46
  Draws a view sheet of one 3D asset: front, side, back and top at one scale
47
47
  with a ruler, a three-quarter view, the in-game view when assets.js declares
@@ -185,13 +185,16 @@ export function writeReplacing(path, data, { suffix = () => randomBytes(8).toStr
185
185
  }
186
186
  }
187
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) {
188
+ // The default sheets folder, created inside the game.
189
+ export const sheetsDirIn = (projectDir) => stateFolderIn(projectDir, 'sheets');
190
+
191
+ // A folder in the game's state folder (`.joystack/<name>`), created when it
192
+ // is missing. `.joystack` and the folder must each be a real folder, never a
193
+ // link (even one that points inside the game today), and the folder must
194
+ // resolve inside the game once it exists.
195
+ export function stateFolderIn(projectDir, name) {
193
196
  let dir = projectDir;
194
- for (const part of [stateDirName(projectDir), 'sheets']) {
197
+ for (const part of [stateDirName(projectDir), name]) {
195
198
  dir = join(dir, part);
196
199
  const shown = relative(projectDir, dir);
197
200
  let st = null;
@@ -207,7 +210,7 @@ export function sheetsDirIn(projectDir) {
207
210
  const real = realpathSync(dir);
208
211
  const rel = relative(realpathSync(projectDir), real);
209
212
  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`);
213
+ throw new Error(`${stateDirName(projectDir)}/${name} in this game leads outside it (to ${printable(real)}); remove the link, or choose a folder with --out`);
211
214
  }
212
215
  return dir;
213
216
  }
@@ -264,6 +267,19 @@ export async function loadPlaywright(projectDir, why = 'a view sheet is drawn')
264
267
 
265
268
  const lines = (problems) => problems.map((p) => printable(` ✗ ${p.key}=${p.requested}: ${p.key} ${p.reason}`)).join('\n');
266
269
 
270
+ // The origin of the dev stack running for the game, or of one started for the
271
+ // length of a command when none is (`started`, to stop afterwards; null when
272
+ // one was already running). The one started has no room server unless `mp`:
273
+ // a sheet needs none, a picture of a multiplayer game does.
274
+ export async function stackOrigin({ cwd, projectDir, port = null, startDev = null, mp = false }) {
275
+ const stack = await findRunningStack({ projectDir, port });
276
+ if (stack) return { origin: stack.origin, started: null };
277
+ if (!startDev) throw new Error('no dev stack is running for this game');
278
+ const quiet = [];
279
+ const started = await startDev({ cwd, port: port ?? undefined, watch: false, reload: false, noMp: !mp, log: (l) => quiet.push(l) });
280
+ return { origin: new URL(started.url).origin, started };
281
+ }
282
+
267
283
  // Draw one sheet. `startDev` starts a dev stack (dev() in the CLI) when none
268
284
  // is running for the game. `origin`, `browser` and `headless` are for a caller
269
285
  // that already has them (a test): an origin skips looking for a stack, a
@@ -274,18 +290,10 @@ export async function assetsSheet({
274
290
  } = {}) {
275
291
  const earlier = against ? readEarlierSheet(resolve(cwd, against)) : null;
276
292
  let project = null;
277
- let stack = null;
278
293
  let started = null;
279
294
  if (!origin) {
280
295
  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;
296
+ ({ origin, started } = await stackOrigin({ cwd, projectDir: project.dir, port, startDev }));
289
297
  }
290
298
  if (!project && !(headless && browser)) project = findProject(cwd);
291
299
  const base = `${safeName(id)}${tag ? `-${safeName(tag)}` : ''}-sheet`;
package/lib/dev.mjs CHANGED
@@ -83,7 +83,8 @@ export async function waitForRoomServer(port, { timeoutMs = 30_000, intervalMs =
83
83
  // answer, both commands.
84
84
 
85
85
  // `share` opens a public link to the stack for testers (share.mjs); `openShare`
86
- // replaces the tunnel for tests.
86
+ // replaces the tunnel for tests. `announce: false` prints no address: the
87
+ // Joystack maker, which serves the game on the person's own page.
87
88
  export async function dev(opts = {}) {
88
89
  // An interactive `joystack dev` (watch on, the default) also writes
89
90
  // everything it prints to .joystack/dev/last.log, so an agent that started
@@ -121,7 +122,7 @@ export async function dev(opts = {}) {
121
122
  // changes the operating system's events miss (live-versions.mjs); left unset
122
123
  // it is that module's default of one second. Tests pass a shorter one so a
123
124
  // save is acted on sooner.
124
- async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, reload = true, share = false, openShare = openTunnel, log = console.log, agentSession = null, shareIsOwner = false, devLogPath = null, pollMs } = {}, onFailure) {
125
+ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, reload = true, share = false, openShare = openTunnel, log = console.log, agentSession = null, shareIsOwner = false, devLogPath = null, pollMs, announce = true } = {}, onFailure) {
125
126
  const project = findProject(cwd);
126
127
  // Q4 revision: the engine is not an npm dependency — install/refresh it from
127
128
  // the platform's login-gated registry (no-op when the pinned version is in).
@@ -575,24 +576,28 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
575
576
  const ip = lanIp();
576
577
  log('');
577
578
  log('────────────────────────────────────────────────────────────');
578
- log(`✅ ${project.slug} ready. Open:`);
579
- log(` ${url}`);
580
- if (ip) log(` 📱 http://${ip}:${ports.static}/games/${project.slug}/index.html (phone / LAN)`);
579
+ // `announce: false`: the Joystack maker shows the game on the person's own
580
+ // page, so nothing here is an address anyone could open, and an address in
581
+ // this output (which an agent reads in .joystack/dev/last.log) is one it
582
+ // would hand over.
583
+ log(announce ? `✅ ${project.slug} ready. Open:` : `✅ ${project.slug} ready.`);
584
+ if (announce) log(` ${url}`);
585
+ if (announce && ip) log(` 📱 http://${ip}:${ports.static}/games/${project.slug}/index.html (phone / LAN)`);
581
586
  // Only when this game declares assets (inspect.mjs). A line printed on every
582
587
  // single boot is a line nobody reads by the third one, and the tool it points
583
588
  // at is then invisible exactly when it would have helped.
584
- if (hasDeclaredAssets(project.dir)) {
589
+ if (announce && hasDeclaredAssets(project.dir)) {
585
590
  log(` 🔍 ${assetsPageUrl(url)} (your models, sounds and assets, one at a time)`);
586
591
  }
587
592
  if (devLogPath) log(` 📄 ${relative(project.dir, devLogPath).split(sep).join('/')} (everything this server prints, for reading later)`);
588
593
  if (share) {
589
- log('');
590
- log('→ share opening a public link (cloudflared)…');
594
+ if (announce) log('');
595
+ if (announce) log('→ share opening a public link (cloudflared)…');
591
596
  tunnel = await openShare({ port: ports.static }); // a failure stops the stack (see dev)
592
597
  staticServer.setShareHost(tunnel.hostname);
593
- for (const line of shareBanner({ url: tunnel.url, gamePath: `/games/${project.slug}/index.html`, resolved: tunnel.resolved, frozen: live && !reload })) log(line);
598
+ if (announce) for (const line of shareBanner({ url: tunnel.url, gamePath: `/games/${project.slug}/index.html`, resolved: tunnel.resolved, frozen: live && !reload })) log(line);
594
599
  tunnel.exited.then(() => {
595
- if (!stopped) log('⚠ the share link stopped working (cloudflared exited). Restart `joystack dev --share` for a new one.');
600
+ if (!stopped && announce) log('⚠ the share link stopped working (cloudflared exited). Restart `joystack dev --share` for a new one.');
596
601
  });
597
602
  }
598
603
  log('────────────────────────────────────────────────────────────');
@@ -0,0 +1,16 @@
1
+ # fingerprint
2
+
3
+ A file's address from its content, for the dev server (`static-server.mjs`).
4
+
5
+ - **The stamp**: a URL the server writes (a `<script src>`, an import, an
6
+ import-map target, an injected tag) carries `?v=<hash>`, the hash of the
7
+ file's import closure, so an edit anywhere below a module renames it, and a
8
+ restart of the dev server renames nothing.
9
+ - **The headers**: a stamped URL whose `v` is current is kept by the
10
+ browser for good; any other file of the game or the engine is checked with the server (`ETag`, `304`).
11
+ A game's page is never kept.
12
+ - **The start's files**: the stamped closure of a game's start and its
13
+ declared assets, which the maker downloads ahead of Play through the warm
14
+ page (`warm.mjs`, served at `/__joystack/warm/<slug>/`): a page of the dev
15
+ server's own, not a game page, that fetches the list and fetches it again
16
+ on each new version.