@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
package/lib/npm.mjs ADDED
@@ -0,0 +1,38 @@
1
+ // The one way the CLI shells out to npm.
2
+ //
3
+ // Why this module exists: on Windows npm is not a binary, it's an `npm.cmd`
4
+ // shim, and Node cannot launch a .bat/.cmd from execFile/spawn. A bare
5
+ // execFileSync('npm', …) dies with ENOENT — which is exactly how `joystack
6
+ // create` failed for our first Windows creator (2026-07-11).
7
+ //
8
+ // The obvious fix is a trap: renaming to 'npm.cmd' does NOT work either.
9
+ // Since the CVE-2024-27980 fix (Node >= 18.20.2 / 20.12.2 / 21.7.3) Node
10
+ // refuses to spawn a .bat/.cmd without `shell: true`, so the rename just
11
+ // trades ENOENT for EINVAL. And raw `shell: true` is its own trap: cmd.exe
12
+ // re-parses the argv we hand it, so an unquoted path with a space in it
13
+ // (ensureEngine passes an absolute tarball path, and Windows home dirs are
14
+ // routinely "C:\Users\John Smith\…") silently breaks.
15
+ //
16
+ // cross-spawn does the whole dance: detects the shim, routes it through
17
+ // cmd.exe, escapes the args. It's the standard fix and it's what npm's own
18
+ // tooling uses.
19
+ import spawn from 'cross-spawn';
20
+
21
+ // Callers treat a throw as "npm failed" (create aborts the scaffold,
22
+ // ensureEngine falls back). cross-spawn's sync API mirrors spawnSync — it
23
+ // RETURNS a non-zero status rather than throwing — so re-throw here, or a
24
+ // failed install would sail past every call site as success.
25
+ export function runNpm(args, opts = {}) {
26
+ const res = spawn.sync('npm', args, { stdio: 'pipe', ...opts });
27
+ if (res.error) throw res.error;
28
+ if (res.status !== 0) {
29
+ const err = new Error(
30
+ `npm ${args.join(' ')} failed (exit ${res.status})` +
31
+ (res.stderr?.length ? `\n${res.stderr.toString().trim()}` : ''),
32
+ );
33
+ err.status = res.status;
34
+ err.stderr = res.stderr;
35
+ throw err;
36
+ }
37
+ return res;
38
+ }
package/lib/ports.mjs ADDED
@@ -0,0 +1,234 @@
1
+ // Port conventions + takeover.
2
+ //
3
+ // Canonical ports: static :8000, partykit :1999. The engine's browser client
4
+ // derives the partykit port from location.port (1999 + (static − 8000)), so a
5
+ // non-default --port shifts both together and everything keeps lining up.
6
+ //
7
+ // Takeover: `joystack dev` must "just work" — a stale dev session or orphaned
8
+ // workerd holding a port means the user gets stale code with no recourse, so
9
+ // the listener is TERMed (then KILLed) instead of skipped.
10
+ //
11
+ // …but ONLY when it is our own. `portInUse()` is a bare TCP connect, so taking
12
+ // over whatever answers would kill another checkout's dev stack (a second copy
13
+ // of the same game, another game) and steal its ports. So a port has an OWNER,
14
+ // and the answer decides:
15
+ //
16
+ // free → take it
17
+ // our own dev server → take it OVER (the stale-code case above)
18
+ // another project's, or a
19
+ // foreign app's → LEAVE IT and step to the next block
20
+ //
21
+ // Ownership is asked, not inferred: the static server answers `/__joystack/whoami`
22
+ // with its project dir (static-server.mjs). No registry file to go stale, and
23
+ // nothing to clean up after a crash — if it answers, it's alive.
24
+ import { execFileSync } from 'node:child_process';
25
+ import net from 'node:net';
26
+ import { networkInterfaces } from 'node:os';
27
+
28
+ export const STATIC_PORT_BASE = 8000;
29
+ export const MP_PORT_BASE = 1999;
30
+
31
+ // Ports a BROWSER refuses to connect to (ERR_UNSAFE_PORT), from Chromium's
32
+ // net/base/port_util.cc `kRestrictedPorts` (Firefox bans the same set). The
33
+ // mp port is derived from the static port, so a block landing either on one of
34
+ // these silently breaks the browser connection — while curl and a
35
+ // headless probe connect fine, so every server-side check looks healthy. The
36
+ // one that bites in the normal range is 2049 (NFS): static 8050 → mp 2049. The
37
+ // full set keeps it robust if the derivation range ever shifts. Reported by a
38
+ // creator (feedback fb_c690e35cff30511e).
39
+ export const RESTRICTED_PORTS = new Set([
40
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79,
41
+ 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137,
42
+ 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532,
43
+ 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723,
44
+ 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697,
45
+ 10080,
46
+ ]);
47
+
48
+ export function isRestricted(port) {
49
+ return RESTRICTED_PORTS.has(port);
50
+ }
51
+
52
+ /** True if a static base derives a block where static or mp is browser-restricted. */
53
+ export function blockIsRestricted(staticPort) {
54
+ const p = portsFor(staticPort);
55
+ return isRestricted(p.static) || isRestricted(p.mp);
56
+ }
57
+
58
+ /** The first static port ≥ `staticPort` whose whole derived block is browser-reachable. */
59
+ export function nextBrowserSafe(staticPort) {
60
+ let p = staticPort;
61
+ while (blockIsRestricted(p)) p += 1;
62
+ return p;
63
+ }
64
+
65
+ // Ports move in BLOCKS: the browser derives mp from location.port, so the
66
+ // whole stack shifts together. 8000/1999 → 8010/2009 → …
67
+ export const BLOCK_STRIDE = 10;
68
+ export const WHOAMI_PATH = '/__joystack/whoami';
69
+
70
+ export function portsFor(staticPort = STATIC_PORT_BASE) {
71
+ const offset = staticPort - STATIC_PORT_BASE;
72
+ return { static: staticPort, mp: MP_PORT_BASE + offset };
73
+ }
74
+
75
+ // Who is on this port? → { projectDir, pid } if one of our dev servers answers,
76
+ // null if the port is silent, or if something that isn't us holds it (a foreign
77
+ // app owes us no answer — and either way the verdict is "not mine, don't touch").
78
+ export function whoIsOn(port, timeoutMs = 400) {
79
+ return new Promise((resolve) => {
80
+ const req = net.connect({ port, host: '127.0.0.1' });
81
+ let body = '';
82
+ const done = (v) => {
83
+ req.destroy();
84
+ resolve(v);
85
+ };
86
+ req.setTimeout(timeoutMs, () => done(null));
87
+ req.on('error', () => done(null)); // nothing listening
88
+ req.on('connect', () => {
89
+ req.write(`GET ${WHOAMI_PATH} HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n`);
90
+ });
91
+ req.on('data', (chunk) => {
92
+ body += chunk;
93
+ const i = body.indexOf('\r\n\r\n');
94
+ if (i === -1) return; // headers still arriving
95
+ if (!/^HTTP\/1\.[01] 200/.test(body)) return done(null); // answered, but not ours
96
+ try {
97
+ const parsed = JSON.parse(body.slice(i + 4));
98
+ done(parsed.projectDir ? parsed : null);
99
+ } catch {
100
+ /* body may still be arriving; wait for more, or for the timeout */
101
+ }
102
+ });
103
+ req.on('end', () => done(null));
104
+ });
105
+ }
106
+
107
+ // Pick the port block this dev stack should bind.
108
+ //
109
+ // `requested` (an explicit --port) is obeyed as given, takeover included: you
110
+ // named the port, you get the port. Explicit beats clever — it's the AUTO path
111
+ // that has to be safe, because that's the one nobody thought about.
112
+ // `base` exists so the tests can run in a private port range: a suite that
113
+ // probes :8000 for real would fight (and report on) whatever dev servers the
114
+ // developer has running — which is how this file's own tests first "failed".
115
+ export async function resolvePorts({
116
+ projectDir,
117
+ requested = null,
118
+ blocks = 24,
119
+ base = STATIC_PORT_BASE,
120
+ } = {}) {
121
+ if (requested != null) {
122
+ return { ports: portsFor(requested), tookOver: true, steppedAside: [] };
123
+ }
124
+ const steppedAside = [];
125
+ for (let i = 0; i < blocks; i++) {
126
+ const ports = portsFor(base + i * BLOCK_STRIDE);
127
+ // A block whose static or mp port lands on a browser-restricted port is
128
+ // unusable no matter how free it is — the browser refuses the connection
129
+ // (ERR_UNSAFE_PORT) even though curl and server checks pass. Skip it before
130
+ // probing, so auto-allocation only ever hands the creator a browser-
131
+ // reachable stack. (feedback fb_c690e35cff30511e — mp 2049/NFS on base 8050.)
132
+ if (blockIsRestricted(base + i * BLOCK_STRIDE)) {
133
+ steppedAside.push({ port: ports.static, projectDir: null, reason: 'browser-restricted' });
134
+ continue;
135
+ }
136
+ const owner = (await portInUse(ports.static)) ? await whoIsOn(ports.static) : undefined;
137
+
138
+ if (owner === undefined) {
139
+ // The game port is free — but a block only isolates if the WHOLE stack
140
+ // fits in it. Half a stack here and half somewhere else is worse than
141
+ // moving on, so a contaminated block is skipped entire.
142
+ if (await portInUse(ports.mp)) {
143
+ steppedAside.push({ port: ports.static, projectDir: null });
144
+ continue;
145
+ }
146
+ return { ports, tookOver: false, steppedAside };
147
+ }
148
+ if (owner && owner.projectDir === projectDir) {
149
+ return { ports, tookOver: true, steppedAside }; // our own stale server
150
+ }
151
+ steppedAside.push({ port: ports.static, projectDir: owner?.projectDir ?? null });
152
+ }
153
+ throw new Error(
154
+ `No free port block after trying ${blocks}. Close a dev server, or pass --port explicitly.`,
155
+ );
156
+ }
157
+
158
+ export function portInUse(port) {
159
+ return new Promise((resolve) => {
160
+ const sock = net.connect({ port, host: '127.0.0.1' });
161
+ const done = (v) => {
162
+ sock.destroy();
163
+ resolve(v);
164
+ };
165
+ sock.setTimeout(300, () => done(false));
166
+ sock.on('connect', () => done(true));
167
+ sock.on('error', () => done(false));
168
+ });
169
+ }
170
+
171
+ function findPids(port) {
172
+ const pids = new Set();
173
+ try {
174
+ if (process.platform === 'win32') {
175
+ const out = execFileSync('netstat', ['-ano'], { encoding: 'utf8', timeout: 5000 });
176
+ const re = new RegExp(String.raw`\s\S+:${port}\s+\S+\s+LISTENING\s+(\d+)`, 'i');
177
+ for (const line of out.split('\n')) {
178
+ const m = re.exec(line);
179
+ if (m) pids.add(Number(m[1]));
180
+ }
181
+ } else {
182
+ const out = execFileSync('lsof', ['-ti', `:${port}`, '-sTCP:LISTEN'], {
183
+ encoding: 'utf8',
184
+ timeout: 5000,
185
+ });
186
+ for (const line of out.split('\n')) if (/^\d+$/.test(line.trim())) pids.add(Number(line.trim()));
187
+ }
188
+ } catch {
189
+ // lsof exits 1 when nothing listens — that's "no pids".
190
+ }
191
+ return pids;
192
+ }
193
+
194
+ function killPid(pid, force) {
195
+ try {
196
+ if (process.platform === 'win32') {
197
+ execFileSync('taskkill', [...(force ? ['/F'] : []), '/PID', String(pid)], { timeout: 5000 });
198
+ } else {
199
+ process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
200
+ }
201
+ } catch {
202
+ // Already gone.
203
+ }
204
+ }
205
+
206
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
207
+
208
+ export async function killPort(port) {
209
+ let pids = findPids(port);
210
+ if (!pids.size) return true;
211
+ for (const p of pids) killPid(p, false);
212
+ for (let i = 0; i < 10; i++) {
213
+ await sleep(200);
214
+ if (!findPids(port).size) return true;
215
+ }
216
+ for (const p of findPids(port)) killPid(p, true);
217
+ for (let i = 0; i < 10; i++) {
218
+ await sleep(200);
219
+ if (!findPids(port).size) return true;
220
+ }
221
+ return false;
222
+ }
223
+
224
+ export function lanIp() {
225
+ // First non-internal private IPv4 — the address a phone on the same wifi
226
+ // uses to reach this machine. Null when offline / no private interface.
227
+ for (const addrs of Object.values(networkInterfaces())) {
228
+ for (const a of addrs ?? []) {
229
+ if (a.family !== 'IPv4' || a.internal) continue;
230
+ if (/^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a.address)) return a.address;
231
+ }
232
+ }
233
+ return null;
234
+ }
@@ -0,0 +1,393 @@
1
+ // The game's own server primitives — discovery, and the registry the CLI
2
+ // generates from them.
3
+ //
4
+ // THE ONE RULE
5
+ //
6
+ // A file at `overrides/shared/<engine path>` IS that engine file, for this
7
+ // game. And `shared/ui/room/primitives/**` is the only part of it the SERVER
8
+ // runs; everything else under `overrides/shared/` is browser-only.
9
+ //
10
+ // One mechanism, one folder and one mental model serve both the browser and the
11
+ // server — there is no separate server-only folder. Overriding and adding are
12
+ // one action too: the engine has a `player-body.js` and does not have a
13
+ // `gravity-well.js`, so both are the same act — put a file at the path. The creator never has to
14
+ // know which one they're doing.
15
+ //
16
+ // FILENAME IS THE TYPE NAME. `gravity-well.js` → `{ type: 'gravity-well' }` in a
17
+ // world spec. A folder with an `index.js` works too. There is no barrel to
18
+ // maintain and no `extraPrimitives` argument to remember: the registry is
19
+ // generated from the directory, for both sides, from this one scan, so the
20
+ // client predicts with the same class the server runs. (A client predicting
21
+ // with the engine's class while the server ran the creator's would disagree
22
+ // every tick and rubber-band.)
23
+ //
24
+ // `index.js` in the primitives dir is RESERVED — it IS the generated registry.
25
+ // A hand-written one would shadow the engine's and silently unregister every
26
+ // engine primitive, so it is rejected loudly instead.
27
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { gameConfig } from './legacy-names.mjs';
30
+
31
+ // The one path that is server code. Mirrors the engine's own layout, because
32
+ // that is the whole point — this is `shared/ui/room/primitives/`, for this game.
33
+ export const PRIMITIVES_DIR = 'overrides/shared/ui/room/primitives';
34
+
35
+ // The two files the CLI GENERATES for a game with primitives. Neither is ever
36
+ // hand-written, neither is in the creator's git.
37
+ //
38
+ // SERVER_BARREL — the `extraPrimitives` map the room's GameRoom subclass
39
+ // hands to the host. Bundled into the per-game Worker at
40
+ // publish; esbuilt into the dev room server by `joystack dev`.
41
+ // It also IS the publish endpoint's server-backed detector.
42
+ //
43
+ // BROWSER_REGISTRY — a shadow of the engine's primitive registry, merged:
44
+ // `{ ...engine, ...the game's }`. It is what lets the CLIENT
45
+ // predict with the game's own classes without a
46
+ // `createRoom({ extraPrimitives })` argument. It shadows
47
+ // index.js, which is a one-line re-export of engine.js
48
+ // precisely so this merge is possible (a shadow cannot import
49
+ // the file it replaces).
50
+ export const SERVER_BARREL = '_joystack/primitives.js';
51
+ export const BROWSER_REGISTRY = `${PRIMITIVES_DIR}/index.js`;
52
+
53
+ // A framework-v2 game's publish upload carries its lowered room MODULE here (in
54
+ // place of a primitives barrel): the CLI-bundled framework runtime that computes
55
+ // the v1 trio at load and exports it as `frameworkV2Bundle`. Its presence is
56
+ // what makes the publish endpoint pick the v2 worker entry. MUST match
57
+ // ROOM_MODULE_PATH in backend/api/src/lib/game-server.js.
58
+ export const SERVER_V2_ROOM = '_joystack/v2-room.js';
59
+
60
+ // Emitted by the engine build (packages/engine/build.mjs): the DERIVED set of
61
+ // `/shared/...` modules a game may NOT override — the room-runtime bundle closure
62
+ // minus catalogued primitives minus the browser-registry seam. The build does the
63
+ // derivation once; this guard and the publish endpoint (resolvePublish) both read
64
+ // the same file, so there is no second copy to drift.
65
+ export const SERVER_RESERVED_MANIFEST = 'room-server/server-reserved.json';
66
+
67
+ // A primitive's source is `<type>.js` or `<type>/index.js`. Anything else next
68
+ // to it — tests, a README, a helper module a primitive imports — is not one.
69
+ const IS_TEST = /\.(test|smoke)\.(js|mjs)$/;
70
+
71
+ // `export default class Foo {}` / `export default Foo` / `export { Foo as default }`.
72
+ const HAS_DEFAULT_EXPORT = /^\s*export\s+default\b|^\s*export\s*\{[^}]*\bas\s+default\b/m;
73
+
74
+ function isPrimitiveFile(name) {
75
+ return name.endsWith('.js') && !IS_TEST.test(name) && name !== 'index.js';
76
+ }
77
+
78
+ // → [{ type, module }], sorted by type. `module` is the game-root-relative path
79
+ // to the class file, always POSIX-separated (it goes into generated source).
80
+ //
81
+ // Sorted because the output is bundled and content-hashed at publish, and a
82
+ // release is immutable: an unstable order would mean an unstable hash for
83
+ // bytes that never changed.
84
+ export function scanPrimitives(projectDir) {
85
+ const root = join(projectDir, PRIMITIVES_DIR);
86
+ if (!existsSync(root)) return [];
87
+
88
+ const found = [];
89
+ for (const name of readdirSync(root)) {
90
+ const abs = join(root, name);
91
+
92
+ if (statSync(abs).isDirectory()) {
93
+ if (existsSync(join(abs, 'index.js'))) {
94
+ found.push({ type: name, module: `${PRIMITIVES_DIR}/${name}/index.js` });
95
+ }
96
+ continue;
97
+ }
98
+
99
+ if (name === 'index.js') {
100
+ throw new Error(
101
+ `${PRIMITIVES_DIR}/index.js is reserved — it is the primitive registry, and Joystack generates it for you.\n` +
102
+ 'Delete it. Every .js file in that folder is registered automatically, using its filename as the type name\n' +
103
+ "(so `gravity-well.js` is `{ type: 'gravity-well' }` in your world spec).",
104
+ );
105
+ }
106
+
107
+ if (isPrimitiveFile(name)) {
108
+ // Every top-level .js here IS a primitive — the filename is the type name,
109
+ // so there is nowhere for a plain helper module to hide. Catch it now, in
110
+ // the creator's own words, rather than as an esbuild error about a missing
111
+ // default export in a barrel they never wrote and cannot see.
112
+ if (!HAS_DEFAULT_EXPORT.test(readFileSync(abs, 'utf8'))) {
113
+ throw new Error(
114
+ `${PRIMITIVES_DIR}/${name} has no default export, so it cannot be a primitive.\n` +
115
+ 'A primitive is `export default class { ... }`, and its filename is its type name.\n' +
116
+ `If ${name} is a HELPER your primitive imports, put it in a lib/ subfolder here —\n` +
117
+ ` ${PRIMITIVES_DIR}/lib/${name}\n` +
118
+ `and import it as './lib/${name}'. The scanner skips lib/, and a primitive runs on BOTH\n` +
119
+ 'sides — bundled into the room AND served to the browser to predict with — so its helper\n' +
120
+ 'has to sit under this /shared/ tree to be reachable from both. Anywhere else in your game\n' +
121
+ 'resolves for the room but 404s in the browser.',
122
+ );
123
+ }
124
+ found.push({ type: name.slice(0, -3), module: `${PRIMITIVES_DIR}/${name}` });
125
+ }
126
+ }
127
+
128
+ found.sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0));
129
+ return found;
130
+ }
131
+
132
+ // A framework-v2 game (package.json `joystack.framework === 'v2'`) authors
133
+ // entities + traits + a world.js and lowers to v1 artifacts at build time.
134
+ // It always runs its own (lowered) code on the server, so it is server-backed
135
+ // by definition — even with no overrides/ or components/ folder. The room
136
+ // server for it is built by the framework compiler (buildV2RoomServer), not
137
+ // the primitive scanner.
138
+ export function isFrameworkV2(projectDir) {
139
+ const pkgPath = join(projectDir, 'package.json');
140
+ if (!existsSync(pkgPath)) return false;
141
+ try {
142
+ return gameConfig(JSON.parse(readFileSync(pkgPath, 'utf8'))).framework === 'v2';
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ // Does this game run its own code on the server? The ONE predicate — `dev` and
149
+ // `publish` both ask it, so a game can never be server-backed in one and not the
150
+ // other. (If they asked different questions, a creator's primitive could run in
151
+ // production and silently not run in dev.)
152
+ //
153
+ // TWO trees make a game server-backed: vendored primitives (overrides/) and
154
+ // entity components (components/, or entity-local ones under entities/). The
155
+ // entity half is a cheap folder check, not a scan — a malformed component
156
+ // still makes the game server-backed; the build is where it fails loudly.
157
+ export function isServerBacked(projectDir) {
158
+ if (isFrameworkV2(projectDir)) return true;
159
+ if (scanPrimitives(projectDir).length > 0) return true;
160
+ if (existsSync(join(projectDir, 'components'))) return true;
161
+ const entitiesDir = join(projectDir, 'entities');
162
+ if (!existsSync(entitiesDir)) return false;
163
+ return readdirSync(entitiesDir).some((name) => {
164
+ try { return existsSync(join(entitiesDir, name, 'components')); } catch { return false; }
165
+ });
166
+ }
167
+
168
+ // VQ7M2K: a framework-v2 game is SINGLE-PLAYER unless it declares
169
+ // config({ multiplayer: true }). A single-player game runs its authoritative sim
170
+ // in-process (the LocalRoom transport) with no wire — so dev stands up no partykit
171
+ // room and publish deploys no per-game room worker. The multiplayer flag lives in
172
+ // the built skeleton's config (emitSkeleton → graph.config, default false); a game
173
+ // with no skeleton (a v1 primitive/entity game) is never single-player in this
174
+ // sense and keeps its room. Callers pass the skeleton they already built.
175
+ export function isSinglePlayerV2({ projectDir, skeleton }) {
176
+ return isFrameworkV2(projectDir) && skeleton?.config?.multiplayer !== true;
177
+ }
178
+
179
+ // The one cycle shadowing makes possible.
180
+ //
181
+ // A NEW primitive may subclass an engine one — that is the RECOMMENDED way to
182
+ // change a primitive's behaviour, because you keep taking engine updates to the
183
+ // base and write only what differs:
184
+ //
185
+ // // overrides/shared/ui/room/primitives/terrain-walk.js ← a NEW name
186
+ // import { PlayerBody } from './engine.js';
187
+ // export default class TerrainWalk extends PlayerBody { … }
188
+ //
189
+ // A file that OVERRIDES `player-body` cannot do the same, and the reason is not
190
+ // obvious: `engine.js` imports `./player-body.js`, and that specifier now resolves
191
+ // to the game's own file. engine.js → your file → engine.js. In the browser that
192
+ // surfaces as "Cannot access 'PlayerBody' before initialization" at boot, which
193
+ // tells the creator nothing about what they actually did wrong.
194
+ //
195
+ // So: overriding a primitive in place is a FORK — self-contained, no engine.js.
196
+ // Want to subclass? Give it a new name. That is what this error says.
197
+ export function assertNoOverrideCycle(primitives, projectDir, sharedDir) {
198
+ const engineDir = join(sharedDir, 'ui', 'room', 'primitives');
199
+ for (const p of primitives) {
200
+ const shadowsEngine =
201
+ existsSync(join(engineDir, `${p.type}.js`)) || existsSync(join(engineDir, p.type, 'index.js'));
202
+ if (!shadowsEngine) continue;
203
+ if (!IMPORTS_ENGINE_JS.test(readFileSync(join(projectDir, p.module), 'utf8'))) continue;
204
+
205
+ throw new Error(
206
+ `${p.module} overrides the engine's '${p.type}' primitive AND imports './engine.js' — that is a cycle.\n` +
207
+ `engine.js imports './${p.type}.js', which is now YOUR file, which imports engine.js.\n` +
208
+ '\n' +
209
+ 'Two ways out:\n' +
210
+ ` • Subclass instead of overriding — rename your file (e.g. my-${p.type}.js), keep the\n` +
211
+ ` './engine.js' import, and use { type: 'my-${p.type}' } in your world spec. You keep\n` +
212
+ ' taking engine updates to the base class. This is usually what you want.\n' +
213
+ ` • Or keep the name and make it a FORK — drop the './engine.js' import and write the\n` +
214
+ ` primitive self-contained. It stops receiving engine updates, like any override.`,
215
+ );
216
+ }
217
+ }
218
+
219
+ // A real `import … from './engine.js'` STATEMENT — line-anchored, and not inside
220
+ // a comment.
221
+ //
222
+ // A bare /from ['"]\.\/engine\.js['"]/ would match the sentence "…import the base
223
+ // from './engine.js'" in a code COMMENT and refuse to build a primitive that is
224
+ // doing nothing wrong. A guard that fires on prose is worse than no guard: it
225
+ // cries wolf, and the next person turns it off.
226
+ const IMPORTS_ENGINE_JS = /^(?!\s*(?:\/\/|\*|\/\*))\s*import\b[^\n]*?\bfrom\s*['"]\.\/engine\.js['"]/m;
227
+
228
+ // The generated registry, as source. Imported by the dev room server and bundled
229
+ // into the per-game worker at publish; both feed it to `GameRoom.extraPrimitives()`.
230
+ //
231
+ // Paths are relative to the GAME ROOT — the barrel is written to `.joystack/` but
232
+ // esbuild is given the game root as its resolve base, so `./overrides/...`
233
+ // resolves the same in dev and at publish.
234
+ export function renderBarrel(primitives) {
235
+ const lines = [
236
+ '// GENERATED by the Joystack CLI from ' + PRIMITIVES_DIR + '/ — do not edit, do not commit.',
237
+ '//',
238
+ '// Every .js file in that folder is a server primitive, registered under its',
239
+ '// filename. Add one by adding a file; there is nothing else to wire up.',
240
+ '',
241
+ ];
242
+ primitives.forEach((p, i) => {
243
+ lines.push(`import P${i} from './${p.module}';`);
244
+ });
245
+ lines.push('');
246
+ lines.push('export const extraPrimitives = {');
247
+ primitives.forEach((p, i) => {
248
+ lines.push(` '${p.type}': P${i},`);
249
+ });
250
+ lines.push('};');
251
+ lines.push('');
252
+ return lines.join('\n');
253
+ }
254
+
255
+ // The BROWSER half: a shadow of the engine's primitive registry, merged.
256
+ //
257
+ // This is what lets the CLIENT predict with the game's own classes. Without it,
258
+ // the server runs the creator's primitive while the client predicts with the
259
+ // ENGINE's class of the same name — they disagree every tick and the game
260
+ // rubber-bands. The creator never has to wire this up (no
261
+ // `createRoom({ extraPrimitives })`), because the platform generates it.
262
+ //
263
+ // It is written to `overrides/shared/ui/room/primitives/index.js`, which shadows
264
+ // the engine's `index.js` — a deliberate one-line re-export of `engine.js` that
265
+ // exists ONLY so this merge is possible. A shadow cannot import the file it
266
+ // replaces (that's a cycle), so it reaches the engine's list through `engine.js`,
267
+ // which nothing shadows.
268
+ //
269
+ // The served path this registry (and the engine's) lives at — `PRIMITIVES_DIR`
270
+ // with the game-repo `overrides/` prefix stripped. Imports below are keyed to
271
+ // THIS, absolutely.
272
+ const SERVED_PRIMITIVES_DIR = `/${PRIMITIVES_DIR.replace(/^overrides\//, '')}`;
273
+
274
+ // Every import here is ABSOLUTE `/shared/...`, NOT relative — and that is
275
+ // load-bearing. This file is a shared module served content-addressed: in
276
+ // production it lands at `/_shared/<hash>/shared/ui/room/primitives/index.js`
277
+ // and `/shared/...` specifiers resolve through the game's import map. A RELATIVE
278
+ // `./engine.js` would instead resolve against that hashed URL — same hash prefix
279
+ // → the SAME (index.js) bytes come back, whose `default` export doesn't exist →
280
+ // the client predictor never loads and the game boots broken in prod while
281
+ // working in dev (where `/shared/` is served live and relative happens to work).
282
+ // So the generated file is born absolute, like every other shared module.
283
+ // '/shared/ui/room/primitives/engine.js' → not shadowed → the engine's list
284
+ // '/shared/ui/room/primitives/beacon.js' → shadowed → the game's primitive
285
+ export function renderBrowserRegistry(primitives) {
286
+ const lines = [
287
+ '// GENERATED by the Joystack CLI from ' + PRIMITIVES_DIR + '/ — do not edit, do not commit.',
288
+ '//',
289
+ "// This shadows the engine's primitive registry and merges your primitives",
290
+ '// into it, so the client predicts your movement with YOUR code — the same',
291
+ '// code the room is running. There is nothing to wire up in your game.',
292
+ '',
293
+ `import { PRIMITIVES as ENGINE } from '${SERVED_PRIMITIVES_DIR}/engine.js';`,
294
+ ];
295
+ primitives.forEach((p, i) => {
296
+ // Sibling paths INSIDE the shadowed folder — strip the repo-relative prefix.
297
+ const rel = p.module.slice(PRIMITIVES_DIR.length + 1);
298
+ lines.push(`import P${i} from '${SERVED_PRIMITIVES_DIR}/${rel}';`);
299
+ });
300
+ lines.push('');
301
+ lines.push('export const PRIMITIVES = {');
302
+ // ...ENGINE FIRST: a game primitive sharing an engine primitive's name must
303
+ // WIN. Reverse these and an override would be silently clobbered by the engine.
304
+ lines.push(' ...ENGINE,');
305
+ primitives.forEach((p, i) => {
306
+ lines.push(` '${p.type}': P${i},`);
307
+ });
308
+ lines.push('};');
309
+ lines.push('');
310
+ return lines.join('\n');
311
+ }
312
+
313
+ // ── The silent no-op, made loud ──────────────────────────────────────────────
314
+ //
315
+ // Shadowing is a BROWSER mechanism: the dev server and the published import map
316
+ // serve the game's copy of `/shared/<path>` instead of the engine's. The room
317
+ // SERVER never resolves `/shared/...` at all — it runs `rooms-runtime.js`, which
318
+ // esbuild bundled at engine-build time, long before this game existed.
319
+ //
320
+ // So shadowing a file that is inside that bundle gets you a SPLIT BRAIN: your
321
+ // browser runs your copy, the room keeps running the engine's, the two disagree
322
+ // every tick, and nothing anywhere says why. Before this guard the entire class
323
+ // was accepted in silence — the file uploaded, served, and simply never ran on
324
+ // the server.
325
+ //
326
+ // The one exception is a primitive CLASS. That is the supported case, and the
327
+ // reason it works is that the CLI does NOT rely on shadowing to get it there: it
328
+ // bundles the class into the game's own room server and injects it through
329
+ // `extraPrimitives`, so both sides run the same code.
330
+ //
331
+ // WHY THE LIST IS DERIVED, NOT WRITTEN
332
+ //
333
+ // The closure is deep and it moves: server.js → runtime.js → host-core.js →
334
+ // tags.js, body-integration.js, wallet-credit.js, room-identity.js,
335
+ // cannon-physics.js … and `/shared/ui/jump/jump.js`, which nobody would ever
336
+ // have guessed belongs on a hand-written list. A list that is 90% right is worse
337
+ // than none: the 10% it misses is a guard that silently stopped guarding. So the
338
+ // engine build emits the exact set (metafile closure minus catalogued primitives
339
+ // minus the browser-registry seam) and we read it — the same file the publish
340
+ // endpoint reads, so the CLI's answer and the server's cannot diverge.
341
+ function reservedModules(engine) {
342
+ const manifest = join(engine.dir, SERVER_RESERVED_MANIFEST);
343
+ // A game pinned to an engine released before this guard existed has no
344
+ // manifest. That engine also has no override support worth guarding, so skip
345
+ // the check rather than refusing to build.
346
+ if (!existsSync(manifest)) return null;
347
+ try {
348
+ return new Set(JSON.parse(readFileSync(manifest, 'utf8')));
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+
354
+ // Walk the game's authored overrides — `overrides/shared/**` — yielding the
355
+ // engine path each one claims to be (`/shared/ui/room/host-core.js`).
356
+ function* authoredOverrides(projectDir) {
357
+ const root = join(projectDir, 'overrides', 'shared');
358
+ if (!existsSync(root)) return;
359
+ const walk = function* (dir, rel) {
360
+ for (const name of readdirSync(dir)) {
361
+ const abs = join(dir, name);
362
+ const next = rel ? `${rel}/${name}` : name;
363
+ if (statSync(abs).isDirectory()) yield* walk(abs, next);
364
+ else if (name.endsWith('.js') && !IS_TEST.test(name)) yield { abs, shared: `/shared/${next}` };
365
+ }
366
+ };
367
+ yield* walk(root, '');
368
+ }
369
+
370
+ export function assertNoInertOverride({ projectDir, engine }) {
371
+ const reserved = reservedModules(engine);
372
+ if (!reserved) return;
373
+
374
+ for (const { shared } of authoredOverrides(projectDir)) {
375
+ if (!reserved.has(shared)) continue; // overridable (primitive or browser-only)
376
+
377
+ const rel = shared.replace('/shared/', '');
378
+ throw new Error(
379
+ `overrides/shared/${rel} cannot be overridden — the room server runs this file, and it CANNOT take your copy.\n` +
380
+ '\n' +
381
+ 'Your override would reach the browser but not the room: the server runs a runtime that was\n' +
382
+ 'bundled before your game existed. Your client and the room would then be running different\n' +
383
+ 'code, disagreeing every tick, with nothing to tell you why. So this is an error, not a warning.\n' +
384
+ '\n' +
385
+ 'What you CAN override on the server is a primitive — a file in\n' +
386
+ ` ${PRIMITIVES_DIR}/<name>.js\n` +
387
+ 'Those are bundled into your game\'s own room server, so they run on BOTH sides.\n' +
388
+ '\n' +
389
+ 'If you need behaviour this file has, put it in a primitive and use that instead.\n' +
390
+ 'If you think the engine itself should change here, send it with `joystack feedback`.',
391
+ );
392
+ }
393
+ }