@joystack/cli 0.0.0-stage → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +738 -0
  2. package/LICENSE +202 -0
  3. package/README.md +21 -2
  4. package/bin/joystack.mjs +300 -0
  5. package/lib/agent-session.mjs +52 -0
  6. package/lib/agent-surface.mjs +333 -0
  7. package/lib/assets-json.mjs +205 -0
  8. package/lib/assets-page.mjs +184 -0
  9. package/lib/assets-sheet.mjs +365 -0
  10. package/lib/build-id.mjs +130 -0
  11. package/lib/bundle-primitives.mjs +123 -0
  12. package/lib/changelog.mjs +193 -0
  13. package/lib/config.mjs +56 -0
  14. package/lib/create.mjs +241 -0
  15. package/lib/dev.mjs +435 -0
  16. package/lib/engine.mjs +198 -0
  17. package/lib/feedback-sync.mjs +231 -0
  18. package/lib/feedback.mjs +209 -0
  19. package/lib/freeze.mjs +108 -0
  20. package/lib/game-files.mjs +99 -0
  21. package/lib/history.mjs +612 -0
  22. package/lib/inbox-cmd.mjs +57 -0
  23. package/lib/inject.mjs +250 -0
  24. package/lib/joystack-node-doors.mjs +230 -0
  25. package/lib/lane.mjs +141 -0
  26. package/lib/legacy-names.mjs +246 -0
  27. package/lib/lint.mjs +292 -0
  28. package/lib/login.mjs +134 -0
  29. package/lib/model-bake-cli.mjs +17 -0
  30. package/lib/model-bake.mjs +869 -0
  31. package/lib/npm.mjs +38 -0
  32. package/lib/ports.mjs +234 -0
  33. package/lib/primitives.mjs +393 -0
  34. package/lib/printable.mjs +12 -0
  35. package/lib/process-tree.mjs +30 -0
  36. package/lib/project.mjs +84 -0
  37. package/lib/publish.mjs +179 -0
  38. package/lib/replay-cmd.mjs +426 -0
  39. package/lib/replay-export.mjs +1379 -0
  40. package/lib/replay-list.mjs +308 -0
  41. package/lib/replay-profile.mjs +467 -0
  42. package/lib/replay-store.mjs +719 -0
  43. package/lib/room-reload.mjs +168 -0
  44. package/lib/room-server.mjs +539 -0
  45. package/lib/self-update.mjs +162 -0
  46. package/lib/shadow-resolve.mjs +85 -0
  47. package/lib/share.mjs +149 -0
  48. package/lib/smoke-gpu-preload.mjs +91 -0
  49. package/lib/source-path.mjs +33 -0
  50. package/lib/static-server.mjs +1238 -0
  51. package/lib/test-cmd.mjs +697 -0
  52. package/lib/test-policy.mjs +157 -0
  53. package/lib/test-reporter.mjs +177 -0
  54. package/lib/test-selection-cli.mjs +18 -0
  55. package/lib/update.mjs +299 -0
  56. package/package.json +27 -4
@@ -0,0 +1,162 @@
1
+ // Keep the `joystack` command itself up to date — the third lane.
2
+ //
3
+ // THE BUG THIS EXISTS FOR. A game's skills and its engine ride one artifact, so
4
+ // they can't drift. The `joystack` command does not: it's an npm devDependency
5
+ // pinned in the game's own lockfile, and `npx joystack` runs THAT copy, not npm's
6
+ // latest. `joystack update` used to move only the engine — so a real game (riven)
7
+ // ended up on engine 0.1.14, whose skills tell the agent to run
8
+ // `joystack changelog`, while its CLI was still 0.1.6:
9
+ //
10
+ // $ npx joystack changelog
11
+ // Unknown command: changelog
12
+ //
13
+ // The agent surface is shipped BY the engine and can name any `joystack` command it
14
+ // likes. Nothing kept the CLI in step with it. So `joystack update` must mean
15
+ // "update Joystack" — engine, skills, AND the command — or the next skill that
16
+ // mentions a new command breaks the same way.
17
+ //
18
+ // FAIL SOFT, ALWAYS. The engine update is the important half and it has already
19
+ // happened by the time we get here. A CLI bump that cannot be done (offline, npm
20
+ // down, no write access) must warn and step aside — never take the command down
21
+ // with it. Nothing in here throws.
22
+ import { readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { runNpm } from './npm.mjs';
25
+ import { LEGACY_CLI_PACKAGE } from './legacy-names.mjs';
26
+
27
+ export const CLI_PKG = '@joystack/cli';
28
+
29
+ // What `npx joystack` will run NEXT TIME — the copy on disk in this game, which is
30
+ // not necessarily the copy running right now (`npx @joystack/cli@x update` in
31
+ // a repo pinned to something older runs the newer one but must still fix the
32
+ // repo).
33
+ export function projectCliVersion(projectDir) {
34
+ try {
35
+ const pkg = JSON.parse(
36
+ readFileSync(join(projectDir, 'node_modules', ...CLI_PKG.split('/'), 'package.json'), 'utf8'),
37
+ );
38
+ return typeof pkg.version === 'string' ? pkg.version : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function isOlder(a, b) {
45
+ const pa = a.split('.').map(Number);
46
+ const pb = b.split('.').map(Number);
47
+ for (let i = 0; i < 3; i++) {
48
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0);
49
+ }
50
+ return false;
51
+ }
52
+
53
+ // Whether the game's package.json still names the CLI by its name from before
54
+ // the rename.
55
+ export function dependsOnLegacyCli(projectDir) {
56
+ try {
57
+ const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
58
+ return Boolean(pkg.devDependencies?.[LEGACY_CLI_PACKAGE] ?? pkg.dependencies?.[LEGACY_CLI_PACKAGE]);
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ // A game made with the CLI under its old name: swap that dependency for this
65
+ // CLI. The game's scripts keep working afterwards: this CLI also installs the
66
+ // command name they call. The new CLI is installed at the version npm calls
67
+ // latest, or, when npm cannot say, at the version running now.
68
+ async function switchFromLegacyCli({ projectDir, npm, log }) {
69
+ let to = runningCliVersion();
70
+ try {
71
+ to = JSON.parse(npm(['view', CLI_PKG, 'version', '--json'], { cwd: projectDir }).stdout.toString());
72
+ } catch { /* keep the running version */ }
73
+ const spec = `${CLI_PKG}@^${to}`;
74
+ log('');
75
+ log(` This game used ${LEGACY_CLI_PACKAGE}, the CLI's name before Joystack. Moving it to ${spec}…`);
76
+ try {
77
+ npm(['uninstall', LEGACY_CLI_PACKAGE], { cwd: projectDir });
78
+ npm(['install', '--save-dev', spec], { cwd: projectDir });
79
+ } catch (err) {
80
+ log(` The move did not finish (npm failed). Run these two, then \`npx joystack update\` again:`);
81
+ log(` npm uninstall ${LEGACY_CLI_PACKAGE}`);
82
+ log(` npm install --save-dev ${spec}`);
83
+ return { from: LEGACY_CLI_PACKAGE, to, updated: false, error: err };
84
+ }
85
+ return { from: LEGACY_CLI_PACKAGE, to, updated: true, switched: true };
86
+ }
87
+
88
+ export async function syncCli({ projectDir, npm = runNpm, log = console.log } = {}) {
89
+ if (dependsOnLegacyCli(projectDir)) return switchFromLegacyCli({ projectDir, npm, log });
90
+ const from = projectCliVersion(projectDir);
91
+
92
+ // Nothing installed: `create` and `dev` own that. There is nothing to bring up
93
+ // to date, and force-installing here would be a surprise.
94
+ if (!from) return { from: null, to: null, updated: false, skipped: 'not-installed' };
95
+
96
+ let latest;
97
+ try {
98
+ const out = npm(['view', CLI_PKG, 'version', '--json'], { cwd: projectDir });
99
+ latest = JSON.parse(out.stdout.toString());
100
+ } catch (err) {
101
+ log('');
102
+ log(` Could not check for a newer joystack command (npm did not answer). Your engine is`);
103
+ log(` up to date; the command stayed at ${from}. Try again later, or run:`);
104
+ log(` npm update ${CLI_PKG}`);
105
+ return { from, to: null, updated: false, error: err };
106
+ }
107
+
108
+ // Never downgrade. A local dev build (or a version newer than the registry
109
+ // knows about) is deliberate — leave it alone.
110
+ if (!isOlder(from, latest)) return { from, to: latest, updated: false };
111
+
112
+ try {
113
+ // --save-dev so the DECLARED range moves too. Without it the next plain
114
+ // `npm install` in that repo happily resolves back down to the old pin, and
115
+ // the update silently un-does itself.
116
+ npm(['install', '--save-dev', `${CLI_PKG}@${latest}`], { cwd: projectDir });
117
+ } catch (err) {
118
+ log('');
119
+ log(` The joystack command could not be updated to ${latest} (npm install failed).`);
120
+ log(` Your engine is up to date; the command is still ${from}. Run this to retry:`);
121
+ log(` npm install --save-dev ${CLI_PKG}@${latest}`);
122
+ return { from, to: latest, updated: false, error: err };
123
+ }
124
+
125
+ return { from, to: latest, updated: true };
126
+ }
127
+
128
+ // The version of the CLI EXECUTING right now — which is not always the one the
129
+ // project has installed. `npx joystack` runs the local install (so they match), but
130
+ // `npx @joystack/cli@latest update` runs a newer one against an older repo.
131
+ export function runningCliVersion() {
132
+ try {
133
+ return JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')).version ?? null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ // What `update` prints. Separate so the wording is testable.
140
+ //
141
+ // The caveat — "you're still running the old one" — is TRUE only when the process
142
+ // executing is the copy we just replaced. An earlier version asserted it
143
+ // unconditionally and told a `npx @joystack/cli@latest update` run that it was
144
+ // "still running 0.1.6", which it plainly was not. Caught by reading the output of
145
+ // a real run against riven. Say it only when it's true.
146
+ export function reportCli(log, cli, { running = runningCliVersion() } = {}) {
147
+ if (!cli?.updated) return;
148
+ log('');
149
+ if (cli.switched) {
150
+ log(`✅ This game now uses ${CLI_PKG} ${cli.to} in place of ${cli.from}.`);
151
+ log(' Your package.json scripts keep working as they are.');
152
+ return;
153
+ }
154
+ log(`✅ The joystack command updated: ${cli.from} → ${cli.to}`);
155
+ if (running === cli.from) {
156
+ log(` You're still running ${cli.from} in this process — any new commands work from`);
157
+ log(` your next \`npx joystack\`.`);
158
+ } else {
159
+ log(` New commands are available from your next \`npx joystack\`.`);
160
+ }
161
+ log(` Its own changes are in node_modules/${CLI_PKG}/CHANGELOG.md.`);
162
+ }
@@ -0,0 +1,85 @@
1
+ // Shadowing, for the BUNDLER.
2
+ //
3
+ // `overrides/shared/<engine path>` works in the browser because the dev server
4
+ // (and, in production, the import map) resolves each `/shared/...` request
5
+ // against the game's overrides FIRST and the engine SECOND — per file. A game's
6
+ // override sits at the engine's own URL, so its neighbours resolve to the engine's.
7
+ //
8
+ // esbuild has no such notion. It resolves relative imports against the real
9
+ // filesystem, where the game's overrides folder holds only the one or two files
10
+ // the creator actually wrote. So this:
11
+ //
12
+ // // overrides/shared/ui/room/primitives/player-body.js
13
+ // import { PlayerBody } from './engine.js'; // ← the escape hatch
14
+ //
15
+ // resolves in the browser (engine.js is right there at /shared/ui/room/primitives/)
16
+ // and dies in the bundler ("Could not resolve ./engine.js") — the game's folder
17
+ // has no engine.js, and never will: it's the engine's.
18
+ //
19
+ // That import is not incidental. It is the ONLY way an override can subclass the
20
+ // primitive it overrides — it cannot import its own path, which is itself — and
21
+ // "change one method" is the most common reason to override anything. So the
22
+ // bundler has to learn the same fall-through the servers already do:
23
+ //
24
+ // a relative import from inside overrides/shared/…
25
+ // → the game's file, if it has one
26
+ // → otherwise the engine's file at the mirrored path
27
+ import { existsSync, realpathSync } from 'node:fs';
28
+ import { dirname, join, relative, resolve, sep } from 'node:path';
29
+
30
+ const OVERRIDES_ROOT = join('overrides', 'shared');
31
+
32
+ // esbuild hands us importer paths with symlinks already resolved, while the
33
+ // project dir arrives as the caller spelled it. On macOS that alone is enough to
34
+ // break the comparison below — a temp dir is `/var/…` to us and `/private/var/…`
35
+ // to esbuild — and the fall-through would silently never fire, leaving the
36
+ // creator with "Could not resolve ./engine.js" and no idea why.
37
+ function realOrSelf(p) {
38
+ try {
39
+ return realpathSync(p);
40
+ } catch {
41
+ return p; // doesn't exist yet — compare as given
42
+ }
43
+ }
44
+
45
+ // Is `file` inside the game's overrides/shared/ tree?
46
+ function underOverrides(file, overridesRoot) {
47
+ const rel = relative(overridesRoot, file);
48
+ return rel !== '' && !rel.startsWith('..') && !rel.startsWith(sep);
49
+ }
50
+
51
+ // engineExternal: at PUBLISH the engine is never bundled in — it is marked
52
+ // external and rewritten to the release's pre-bundled `rooms-runtime.js`, so the
53
+ // worker's room code is always ours. Pass the module specifier to rewrite to.
54
+ // In DEV we build the whole room server ourselves, so the engine's real file on
55
+ // disk is exactly what we want.
56
+ export function shadowResolvePlugin({ projectDir, sharedDir, engineExternal = null, onEngineHit = () => {} }) {
57
+ const overridesRoot = realOrSelf(join(projectDir, OVERRIDES_ROOT));
58
+
59
+ return {
60
+ name: 'joystack-shadow-resolve',
61
+ setup(build) {
62
+ build.onResolve({ filter: /^\.{1,2}\// }, (args) => {
63
+ if (!args.importer) return null;
64
+ // Everything below is computed in real-path space, so the overrides-root
65
+ // comparison and the shadow mapping can't disagree about the same file.
66
+ const importer = realOrSelf(args.importer);
67
+ if (!underOverrides(importer, overridesRoot)) return null;
68
+
69
+ const candidate = resolve(dirname(importer), args.path);
70
+
71
+ // The game has this file — its own primitive, its own helper. Use it.
72
+ if (existsSync(candidate)) return { path: candidate };
73
+
74
+ // It doesn't. In the browser this would have fallen through to the
75
+ // engine, so do the same here: map the path back across the shadow.
76
+ const rel = relative(overridesRoot, candidate);
77
+ const engineFile = join(sharedDir, rel);
78
+ if (!existsSync(engineFile)) return null; // let esbuild report it honestly
79
+
80
+ onEngineHit(`/shared/${rel.split(sep).join('/')}`);
81
+ return engineExternal ? { path: engineExternal, external: true } : { path: engineFile };
82
+ });
83
+ },
84
+ };
85
+ }
package/lib/share.mjs ADDED
@@ -0,0 +1,149 @@
1
+ // `joystack dev --share` — a public link to this dev server, for testers.
2
+ //
3
+ // A Cloudflare quick tunnel (`cloudflared tunnel --url`, no account needed)
4
+ // gives a random `https://<words>.trycloudflare.com` name that forwards every
5
+ // request to the dev server's port. A tester opens it on their phone and plays;
6
+ // their recording and their device's performance land in this game's
7
+ // .joystack/replays/ exactly as the creator's own sessions do. The dev server
8
+ // trusts that one hostname (static-server.mjs, trustedHost) and forwards the
9
+ // room's websocket (its upgrade handler), so multiplayer works through the same
10
+ // link.
11
+ //
12
+ // The link lasts as long as `joystack dev` runs. Holding it is the same as being
13
+ // on the creator's wifi: nothing on the dev server is locked away from it.
14
+ import { spawn } from 'node:child_process';
15
+ import { Resolver } from 'node:dns/promises';
16
+ import { readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { withoutAgentSession } from './agent-session.mjs';
19
+
20
+ const LINK_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/g;
21
+ // cloudflared's own API, which it names in the error it logs when it cannot get
22
+ // a tunnel. Never a link.
23
+ const API_HOST = 'api.trycloudflare.com';
24
+
25
+ // Whether the engine a game is pinned to can be shared. The page decides it is
26
+ // a dev page from the flag the dev server writes into it (the engine's
27
+ // dev-host.js, SHARE_FLAG); an engine from before that decides by address
28
+ // alone, so through the link it would record nothing and send a multiplayer
29
+ // page to the production room server.
30
+ export function engineCanShare(sharedDir) {
31
+ try {
32
+ return readFileSync(join(sharedDir, 'ui/room/lib/dev-host.js'), 'utf8').includes('JOYSTACK_DEV_SHARE');
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ // How to get cloudflared, per platform, for the error a missing one produces.
39
+ const INSTALL_HINT = [
40
+ 'cloudflared is not installed. It is the free Cloudflare program that opens the link. Install it, then run `joystack dev --share` again:',
41
+ ' macOS: brew install cloudflared',
42
+ ' Windows: winget install --id Cloudflare.cloudflared',
43
+ ' Linux: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/',
44
+ ].join('\n');
45
+
46
+ // A fresh tunnel's name is registered with DNS a few seconds after cloudflared
47
+ // prints it. A device that looks it up in that gap gets "not found" and may
48
+ // keep that answer cached for minutes, so the link is printed only once a
49
+ // public resolver answers for it.
50
+ function publicResolver() {
51
+ const r = new Resolver();
52
+ r.setServers(['1.1.1.1', '8.8.8.8']);
53
+ return (hostname) => r.resolve4(hostname);
54
+ }
55
+
56
+ /**
57
+ * Open a quick tunnel to `http://localhost:<port>`.
58
+ * `exited` settles when cloudflared stops, for whatever reason, after the link
59
+ * was given; the link is dead from then on.
60
+ * @returns {Promise<{ url: string, hostname: string, resolved: boolean, close: () => void, exited: Promise<void> }>}
61
+ */
62
+ export function openTunnel({
63
+ port,
64
+ spawnImpl = spawn,
65
+ resolveHost = publicResolver(),
66
+ timeoutMs = 45_000,
67
+ dnsTimeoutMs = 30_000,
68
+ pollMs = 1_000,
69
+ } = {}) {
70
+ return new Promise((resolve, reject) => {
71
+ const child = spawnImpl('cloudflared', ['tunnel', '--url', `http://localhost:${port}`, '--no-autoupdate'], {
72
+ stdio: ['ignore', 'pipe', 'pipe'],
73
+ env: withoutAgentSession(process.env),
74
+ });
75
+ const tail = [];
76
+ let settled = false;
77
+ const exited = new Promise((r) => child.on('exit', () => r()));
78
+ const close = () => {
79
+ if (child.killed) return;
80
+ try { child.kill(); } catch { /* already gone */ }
81
+ };
82
+ const fail = (err) => {
83
+ if (settled) return;
84
+ settled = true;
85
+ clearTimeout(timer);
86
+ close();
87
+ reject(err);
88
+ };
89
+ const timer = setTimeout(
90
+ () => fail(new Error(`cloudflared gave no link within ${Math.round(timeoutMs / 1000)}s.${lastWords()}`)),
91
+ timeoutMs,
92
+ );
93
+ const lastWords = () => (tail.length ? `\ncloudflared said:\n ${tail.slice(-5).join('\n ')}` : '');
94
+
95
+ child.on('error', (err) => fail(err?.code === 'ENOENT' ? new Error(INSTALL_HINT) : err));
96
+ child.on('exit', (code, signal) => {
97
+ if (!settled) fail(new Error(`cloudflared exited before giving a link (${signal ?? `code ${code}`}).${lastWords()}`));
98
+ });
99
+
100
+ const onOutput = (chunk) => {
101
+ for (const line of String(chunk).split('\n')) {
102
+ const trimmed = line.trim();
103
+ if (trimmed) tail.push(trimmed);
104
+ if (tail.length > 20) tail.shift();
105
+ for (const [link] of line.matchAll(LINK_RE)) {
106
+ if (!settled && new URL(link).hostname !== API_HOST) found(link);
107
+ }
108
+ }
109
+ };
110
+ child.stdout?.on('data', onOutput);
111
+ child.stderr?.on('data', onOutput);
112
+
113
+ async function found(url) {
114
+ settled = true;
115
+ clearTimeout(timer);
116
+ const hostname = new URL(url).hostname;
117
+ const deadline = Date.now() + dnsTimeoutMs;
118
+ let resolved = false;
119
+ while (!resolved && Date.now() < deadline) {
120
+ try {
121
+ resolved = (await resolveHost(hostname)).length > 0;
122
+ } catch { /* not registered yet */ }
123
+ if (!resolved) await new Promise((r) => setTimeout(r, pollMs));
124
+ }
125
+ resolve({ url, hostname, resolved, close, exited });
126
+ }
127
+ });
128
+ }
129
+
130
+ /**
131
+ * What `joystack dev --share` prints: the link to send, and what sending it gives.
132
+ */
133
+ export function shareBanner({ url, gamePath, resolved, frozen = false }) {
134
+ const lines = [
135
+ `🔗 ${url}${gamePath} (send this to your testers)`,
136
+ '',
137
+ '⚠ Anyone with this link can reach this dev server as if they were on your wifi:',
138
+ ' play the game, read any of the files your game folder serves, and list, keep or',
139
+ ' delete your recordings. Send it only to people you trust. The link stops',
140
+ ' working when you stop `joystack dev`.',
141
+ ' Every session they play is recorded into .joystack/replays/, with their',
142
+ ' device\'s performance, as yours are.',
143
+ ...(frozen
144
+ ? [' The game is frozen as it was when you started: nothing you save reaches', ' them until you restart `joystack dev`, which makes a new link.']
145
+ : [' Saving a file reloads their page too; start with --no-reload to freeze', ' the game for everyone while you edit.']),
146
+ ];
147
+ if (!resolved) lines.splice(1, 0, ' (the link is new: it may take a minute before it opens everywhere)');
148
+ return lines;
149
+ }
@@ -0,0 +1,91 @@
1
+ // Preloaded before every browser smoke via `node --import`. Its whole job is to
2
+ // make headless Chromium render on the real GPU instead of SwiftShader.
3
+ //
4
+ // Why it matters: a headless browser has no GPU, so WebGL falls back to
5
+ // SwiftShader — software rasterization spread across a thread pool sized to the
6
+ // machine's core count. A single 3D-game smoke can pin 8+ cores that way, which
7
+ // on a modest laptop means a frozen machine, not just a slow test. Rendering on
8
+ // the actual GPU (Metal on macOS, D3D11 on Windows, GL or Vulkan on Linux — see
9
+ // `angleBackend` below for which of these is named outright) moves that work
10
+ // off the CPU entirely: measured ~8.5 cores → ~0.1, with pixel-identical output.
11
+ //
12
+ // It runs as a preload so an UNMODIFIED smoke needs no change: it patches the
13
+ // `chromium.launch` of the GAME's own playwright (resolved from cwd, the same
14
+ // instance the smoke imports) to append the flags. Fetch-only smokes that never
15
+ // import playwright, and machines with no usable GPU, both fall through
16
+ // untouched — ANGLE simply falls back to software, so this is never worse than
17
+ // before. Opt out with JOYSTACK_TEST_NO_GPU=1.
18
+ import { createRequire } from 'node:module';
19
+ import { pathToFileURL } from 'node:url';
20
+ import { readFileSync } from 'node:fs';
21
+ import { join, dirname } from 'node:path';
22
+ import { readEnv } from './legacy-names.mjs';
23
+
24
+ // The ANGLE backend to name, or null to let Chromium choose. Windows is named
25
+ // explicitly: headless Chromium there does not reach the GPU through its
26
+ // default backend even with the two GPU flags set, and renders slower than
27
+ // software; naming D3D11 is what engages the GPU (measured on one scene:
28
+ // 5.8 fps → 57 fps). macOS (Metal) and Linux pick a working backend unaided.
29
+ // JOYSTACK_TEST_ANGLE=<backend> overrides on any platform; `default` removes the
30
+ // flag, for a Windows machine whose driver misbehaves under D3D11.
31
+ function angleBackend(platform) {
32
+ const override = readEnv('JOYSTACK_TEST_ANGLE');
33
+ if (override) return override === 'default' ? null : override;
34
+ return platform === 'win32' ? 'd3d11' : null;
35
+ }
36
+
37
+ export function gpuArgs(platform = process.platform) {
38
+ if (readEnv('JOYSTACK_TEST_NO_GPU') === '1') return [];
39
+ const angle = angleBackend(platform);
40
+ return ['--enable-gpu', '--ignore-gpu-blocklist', ...(angle ? [`--use-angle=${angle}`] : [])];
41
+ }
42
+
43
+ // Append `extra` to whatever args a caller already passed to chromium.launch(),
44
+ // preserving theirs. Idempotent enough for our use: called once per smoke process.
45
+ export function patchLaunch(chromium, extra = gpuArgs()) {
46
+ if (!chromium || typeof chromium.launch !== 'function' || extra.length === 0) return false;
47
+ const orig = chromium.launch.bind(chromium);
48
+ chromium.launch = (opts = {}) => orig({ ...opts, args: [...(opts.args || []), ...extra] });
49
+ return true;
50
+ }
51
+
52
+ // Resolve the SAME playwright module object a smoke's `import 'playwright'` gets.
53
+ // The catch: a bare ESM import resolves via the package's "import" export
54
+ // (index.mjs), while createRequire().resolve() returns the "require" main
55
+ // (index.js) — a DIFFERENT module instance. Patching that one would miss, and the
56
+ // smoke would silently fall back to software with no error. So resolve the
57
+ // package dir, then import its ESM entry explicitly.
58
+ //
59
+ // Resolve from the SMOKE FILE's directory first (process.argv[1] under
60
+ // `node --import <preload> <smoke>`), then cwd. A smoke normally runs from its
61
+ // own game folder (cwd == the smoke's dir), but a runner may invoke it from a
62
+ // different working directory — resolving off the smoke's own location makes the
63
+ // same playwright install resolve either way.
64
+ async function loadGamePlaywright() {
65
+ const bases = [];
66
+ const smoke = process.argv[1];
67
+ if (smoke) bases.push(join(dirname(smoke), 'package.json'));
68
+ bases.push(join(process.cwd(), 'package.json'));
69
+ let lastErr;
70
+ for (const base of bases) {
71
+ try {
72
+ const require = createRequire(base);
73
+ const pkgDir = dirname(require.resolve('playwright')); // .../node_modules/playwright
74
+ const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
75
+ const entry = pkg.exports?.['.']?.import ?? pkg.module ?? pkg.main ?? 'index.js';
76
+ return import(pathToFileURL(join(pkgDir, entry)).href);
77
+ } catch (e) { lastErr = e; }
78
+ }
79
+ throw lastErr;
80
+ }
81
+
82
+ // Self-execute on preload. Kept in try/catch so a smoke without playwright (a
83
+ // fetch-only boot smoke) or a missing install never turns a preload into a crash.
84
+ if (gpuArgs().length > 0) {
85
+ try {
86
+ const pw = await loadGamePlaywright();
87
+ patchLaunch(pw.chromium ?? pw.default?.chromium);
88
+ } catch {
89
+ // No playwright here, or it couldn't be patched — leave the smoke unchanged.
90
+ }
91
+ }
@@ -0,0 +1,33 @@
1
+ // Where a piece of code lives, named the way a creator would look for it:
2
+ // relative to the game folder, or inside the engine's package as
3
+ // `@joystack/engine/shared/…`, which is the same in every install.
4
+ //
5
+ // Paths are compared as the disk sees them, through any link: an engine
6
+ // reached through a symlinked folder (a linked install, `JOYSTACK_ENGINE_DIR`)
7
+ // is still the engine.
8
+ import { realpathSync } from 'node:fs';
9
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
10
+
11
+ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
12
+
13
+ // `path` relative to `root` with `/` separators, or null when it is not inside.
14
+ function within(root, path) {
15
+ const rel = relative(root, path);
16
+ return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel.split(sep).join('/') : null;
17
+ }
18
+
19
+ /**
20
+ * A file's name for people: `@joystack/engine/shared/<path>` inside the
21
+ * engine, `<path>` inside the game, or null when it is in neither.
22
+ *
23
+ * @param {string} path - absolute, or relative to `dir`
24
+ * @param {{ dir?: string|null, sharedDir?: string|null }} where - the game
25
+ * folder, and the engine's shared/ tree
26
+ */
27
+ export function sourcePath(path, { dir = null, sharedDir = null } = {}) {
28
+ if (typeof path !== 'string' || !path) return null;
29
+ const abs = real(dir ? resolve(dir, path) : resolve(path));
30
+ const inEngine = sharedDir ? within(real(sharedDir), abs) : null;
31
+ if (inEngine) return `@joystack/engine/shared/${inEngine}`;
32
+ return dir ? within(real(dir), abs) : null;
33
+ }