@joystack/cli 0.1.54 → 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 +41 -0
- package/bin/joystack.mjs +38 -0
- package/lib/assets-export.mjs +198 -0
- package/lib/assets-json.mjs +1 -1
- package/lib/assets-page.mjs +15 -5
- package/lib/assets-sheet.mjs +25 -17
- package/lib/dev.mjs +15 -10
- package/lib/fingerprint/README.md +16 -0
- package/lib/fingerprint/fingerprint.mjs +195 -0
- package/lib/fingerprint/warm.mjs +60 -0
- package/lib/inject.mjs +64 -148
- package/lib/maker.mjs +16 -2
- package/lib/shot/README.md +75 -0
- package/lib/shot/check.mjs +57 -0
- package/lib/shot/lock.mjs +109 -0
- package/lib/shot/ready.mjs +45 -0
- package/lib/shot/run.mjs +217 -0
- package/lib/shot/shot.mjs +320 -0
- package/lib/static-server.mjs +104 -22
- package/lib/test-cmd.mjs +29 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,47 @@ 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
|
+
|
|
17
58
|
## [0.1.54] - 2026-10-21
|
|
18
59
|
|
|
19
60
|
### Changed
|
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)
|
|
@@ -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');
|
|
@@ -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 `<>]/.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
|
+
}
|
package/lib/assets-json.mjs
CHANGED
|
@@ -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
|
|
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}`);
|
package/lib/assets-page.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
173
|
-
headers: { Host: `127.0.0.1:${port}`,
|
|
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)
|
|
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); });
|
package/lib/assets-sheet.mjs
CHANGED
|
@@ -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.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
|
|
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),
|
|
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)}
|
|
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
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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.
|