@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,539 @@
1
+ // The dev room server — the half of "you can run your own code on the server"
2
+ // that was never built.
3
+ //
4
+ // `joystack publish` has always assembled a per-game Worker from a creator's
5
+ // primitives. `joystack dev` never did: it picked its room server by looking for a
6
+ // `partykit.json`, a file nothing in the creator CLI writes, so it always fell
7
+ // through to the engine's stock room server (whose `extraPrimitives()` returns
8
+ // null). The creator's primitive was skipped with an `unknown primitive` warning
9
+ // swallowed in partykit's stderr, while the client predicted it anyway. The
10
+ // mechanic worked in production and not on the creator's machine — which is the
11
+ // worst way round, because they only discover it after shipping.
12
+ //
13
+ // So: when a game has primitives, we generate a partykit project for it and
14
+ // point dev at that instead.
15
+ //
16
+ // WHY WE BUNDLE IT OURSELVES. A creator's primitive imports engine helpers the
17
+ // way everything else in a game does — `import { runEffect } from
18
+ // '/shared/ui/room/primitives/effects.js'`. That leading `/shared/` is a
19
+ // dev-server alias, not a path on disk, and partykit's bundler has never heard
20
+ // of it. So we esbuild the room server here with a plugin that resolves
21
+ // `/shared/*` to the installed engine, and hand partykit one self-contained
22
+ // module. (The publish path solves the same problem differently: it marks those
23
+ // imports EXTERNAL and rewrites them to the release's pre-bundled
24
+ // `rooms-runtime.js`, because the publish endpoint is a Worker with no bundler.
25
+ // Same seam, two deliveries.)
26
+ import { spawnSync } from 'node:child_process';
27
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
28
+ import { dirname, isAbsolute, join, relative } from 'node:path';
29
+ import { pathToFileURL } from 'node:url';
30
+ import {
31
+ assertNoOverrideCycle,
32
+ isFrameworkV2,
33
+ isServerBacked,
34
+ renderBarrel,
35
+ renderBrowserRegistry,
36
+ scanPrimitives,
37
+ } from './primitives.mjs';
38
+ import { shadowResolvePlugin } from './shadow-resolve.mjs';
39
+ import { DOOR_FILTER, canonicalSpecifier, stateDir } from './legacy-names.mjs';
40
+
41
+ // Build output, not source: gitignored by the scaffold and skipped by publish's
42
+ // file collection. The creator never opens it.
43
+ export const DEV_ROOM_SERVER_DIR = '.joystack/room-server';
44
+
45
+ // The generated BROWSER registry lands here, and `joystack dev` mounts it at
46
+ // `/shared/` AHEAD of both the creator's own overrides/ and the engine — so the
47
+ // client's primitive registry is `{ ...engine, ...the game's }`.
48
+ //
49
+ // It cannot live in the creator's `overrides/shared/` for two reasons:
50
+ // `index.js` there is RESERVED (a hand-written one would silently unregister
51
+ // every engine primitive), and a build artifact has no business in their repo.
52
+ // At publish the same bytes are injected straight into the upload, so neither
53
+ // path ever touches their git.
54
+ export const DEV_OVERRIDES_DIR = '.joystack/overrides/shared';
55
+
56
+ // Resolve the `/shared/...` dev alias to the installed engine. Everything a
57
+ // primitive imports through it — and the room server itself — comes from the
58
+ // exact engine version this game is pinned to.
59
+ function engineAliasPlugin(sharedDir) {
60
+ return {
61
+ name: 'joystack-shared-alias',
62
+ setup(build) {
63
+ build.onResolve({ filter: /^\/shared\// }, (args) => ({
64
+ path: join(sharedDir, args.path.slice('/shared/'.length)),
65
+ }));
66
+ // Workers-runtime built-ins are provided at execution; never bundle them.
67
+ build.onResolve({ filter: /^cloudflare:/ }, (args) => ({ path: args.path, external: true }));
68
+ },
69
+ };
70
+ }
71
+
72
+ // Entity components take the same trip as vendored primitives: scanned from
73
+ // the game tree, bundled, handed to the host — through the sibling
74
+ // `extraEntityComponents()` seam. The room bundle carries each component's
75
+ // MANIFEST (so channel gates are trusted — a world spec arrives from a
76
+ // client and may not define them) and its SIM entry: `server/sim.js` when
77
+ // the component has one, else `shared/sim.js` (predicted sims live in
78
+ // shared/ so the browser can run them too; server/ is for genuinely
79
+ // server-only adjudication and may import shared/ itself).
80
+ //
81
+ // The scan implementation lives in the ENGINE (shared/ui/room/entity/) at
82
+ // the same relative path in the repo and the built artifact. An engine too
83
+ // old to carry it means no entity components exist for that game — skip.
84
+ export async function scanEntityComponents(projectDir, sharedDir) {
85
+ const scanPath = join(sharedDir, 'ui', 'room', 'entity', 'scan.js');
86
+ if (!existsSync(scanPath)) return [];
87
+ const { scanGame } = await import(pathToFileURL(scanPath).href);
88
+ const { components, errors } = scanGame(projectDir);
89
+ if (errors.length) {
90
+ throw new Error(`this game's entity components cannot build a room server:\n - ${errors.join('\n - ')}`);
91
+ }
92
+ const posix = (p) => relative(projectDir, p).split('\\').join('/');
93
+ return [...components.values()].map((c) => {
94
+ const serverSim = join(c.dir, 'server', 'sim.js');
95
+ const sharedSim = join(c.dir, 'shared', 'sim.js');
96
+ const code = existsSync(serverSim) ? serverSim : existsSync(sharedSim) ? sharedSim : null;
97
+ return {
98
+ name: c.name,
99
+ manifestRel: posix(c.manifestPath),
100
+ codeRel: code ? posix(code) : null,
101
+ };
102
+ });
103
+ }
104
+
105
+ export function renderEntityComponents(components) {
106
+ if (!components.length) return 'export const extraEntityComponents = null;';
107
+ const lines = [];
108
+ const manifests = [];
109
+ const code = [];
110
+ components.forEach((c, i) => {
111
+ lines.push(`import entityManifest${i} from './${c.manifestRel}';`);
112
+ manifests.push(`entityManifest${i}`);
113
+ if (c.codeRel) {
114
+ lines.push(`import * as entityCode${i} from './${c.codeRel}';`);
115
+ code.push(` '${c.name}': entityCode${i},`);
116
+ }
117
+ });
118
+ return [
119
+ ...lines,
120
+ 'export const extraEntityComponents = {',
121
+ ` manifests: [${manifests.join(', ')}],`,
122
+ ' code: {',
123
+ ...code,
124
+ ' },',
125
+ '};',
126
+ ].join('\n');
127
+ }
128
+
129
+ // ── framework v2 ─────────────────────────────────────────────────────────
130
+ //
131
+ // A v2 game (package.json `joystack.framework === 'v2'`) authors entities + traits
132
+ // + a world.js; there is no overrides/ or components/ folder. It runs on v1's
133
+ // runtime by LOWERING to v1 artifacts. That lowering is a real compiler with a
134
+ // filesystem+AST front end (expand + the analyzer), so it runs HERE, in node,
135
+ // at build time — never in the workerd room. What ships to the room is the
136
+ // serializable SKELETON (names + schedule, no closures); the room re-runs the
137
+ // game's own closures and zips them back onto it, then lowers. See the engine's
138
+ // shared/framework/README.md ("build/ vs runtime/").
139
+ //
140
+ // So the generated room module has two halves: the SKELETON as an embedded JSON
141
+ // literal (computed here), and an import of the game's world + entity modules so
142
+ // the room can re-run their closures. At module load it calls assembleGraph +
143
+ // lowerGraph (both filesystem-free `runtime/` modules) to rebuild the lowered
144
+ // trio, and hands it to the host through the `frameworkV2()` seam JoystackRoom reads.
145
+ // The generated v2 room module source. `skeleton` is emitSkeleton()'s output;
146
+ // its `kinds` carry each entity's export name + game-root-relative module, in
147
+ // the order the room must re-run them (assembleGraph zips row-for-row by this
148
+ // order). The world root is always `./world.js` (the v2 one-world-per-game
149
+ // convention). Imports are aliased K0/K1/… so a kind name can never collide
150
+ // with a keyword or another binding.
151
+ // The shared front matter both v2 room modules emit: import the framework
152
+ // runtime (assemble + lower) and the game's world + kinds, embed the shipped
153
+ // skeleton, and rebuild the lowered v1 trio at module load. The two callers add
154
+ // only how the trio is EXPOSED — dev subclasses JoystackRoom and returns it from
155
+ // frameworkV2(); publish exports it as `frameworkV2Bundle` for the endpoint's
156
+ // generated entry to wrap. Kept as one function so the kind-aliasing, the
157
+ // `./world.js` root convention, and the assemble→lower call can never drift
158
+ // between the dev and publish builds.
159
+ // A relative path with forward slashes whichever host produced it.
160
+ const posixPath = (p) => p.split('\\').join('/');
161
+
162
+ // The first line of every generated room module, dev and publish alike.
163
+ //
164
+ // Installing deterministic transcendental math happens by import side effect,
165
+ // so the guarantee is about ORDER: any module evaluated ahead of it can capture
166
+ // a native `Math.sin` (or precompute a table with one) and go on using it for
167
+ // the life of the process. A room whose math differs by one bit from the
168
+ // browser's diverges from every replay of its ticks and every predicted tick,
169
+ // hundreds of ticks after the difference is introduced.
170
+ //
171
+ // It is emitted by each caller rather than by the shared preamble so that
172
+ // "first" is true of the emitted text itself. The dev entry would otherwise
173
+ // inherit the install through `/shared/ui/room/server.js`, which is correct
174
+ // today only because that file's own first import is this one — a guarantee
175
+ // living in a different module, one reorder away from silently ending.
176
+ const DETERMINISTIC_MATH_MODULE = 'ui/room/deterministic-math.js';
177
+ const INSTALL_DETERMINISTIC_MATH = `import '/shared/${DETERMINISTIC_MATH_MODULE}';`;
178
+
179
+ // …but only against an engine that HAS it.
180
+ //
181
+ // The CLI and the engine are separate releases, and a game pins the engine
182
+ // exactly while taking the CLI through a caret range — so an ordinary `npm
183
+ // install` routinely produces a new CLI driving an older engine. `/shared/…`
184
+ // specifiers are resolved against that pinned engine's directory and bundled,
185
+ // not left external, so emitting this import unconditionally makes esbuild fail
186
+ // to read a file that release does not contain, and BOTH `joystack dev` and
187
+ // `joystack publish` die at the bundle step for a game that was building fine.
188
+ //
189
+ // Feature-detecting the file is the whole fix: an older engine simply generates
190
+ // the room it generated before. It also degrades in the right direction — such
191
+ // a room runs on native math, which is what it was already doing.
192
+ function installDeterministicMath(sharedDir) {
193
+ if (!sharedDir) return []; // no engine to interrogate: emit nothing rather than a guess
194
+ return existsSync(join(sharedDir, DETERMINISTIC_MATH_MODULE)) ? [INSTALL_DETERMINISTIC_MATH] : [];
195
+ }
196
+
197
+ function renderV2Preamble(raw) {
198
+ // Kind modules are game-relative paths the engine relativized on the
199
+ // creator's machine. An engine whose framework build predates the
200
+ // forward-slash fix emits them with the host separator, so on Windows they
201
+ // arrive as `entities\saucer.js`. Dropped raw into a string literal, `\s` is
202
+ // an escape sequence and esbuild looks for `./entitiessaucer.js`. The CLI and
203
+ // the engine ship separately, so a fixed CLI on an unfixed engine is a real
204
+ // pairing; import specifiers and the URLs the client's kind loader builds
205
+ // from the embedded skeleton are always forward-slash, so normalize both.
206
+ const skeleton = { ...raw, kinds: raw.kinds.map((k) => ({ ...k, module: posixPath(k.module) })) };
207
+ const imports = skeleton.kinds.map(
208
+ (k, i) => `import { ${k.name} as K${i} } from './${k.module}';`,
209
+ );
210
+ const kindList = skeleton.kinds.map((_, i) => `K${i}`).join(', ');
211
+ return [
212
+ "import { assembleGraph } from '/shared/framework/runtime/assemble.js';",
213
+ "import { lowerGraph } from '/shared/framework/runtime/lower.js';",
214
+ "import World from './world.js';",
215
+ ...imports,
216
+ '',
217
+ `const SKELETON = ${JSON.stringify(skeleton)};`,
218
+ '',
219
+ '// Re-run the game closures in the room and zip them onto the shipped',
220
+ '// skeleton, then lower to v1 artifacts. Runs once, at module load.',
221
+ `const GRAPH = assembleGraph(SKELETON, { world: World, kinds: [${kindList}] });`,
222
+ 'const TRIO = lowerGraph(GRAPH, SKELETON.schedule);',
223
+ ];
224
+ }
225
+
226
+ export function renderV2Entry(skeleton, { sharedDir } = {}) {
227
+ return [
228
+ ...installDeterministicMath(sharedDir),
229
+ "import JoystackRoom from '/shared/ui/room/server.js';",
230
+ ...renderV2Preamble(skeleton),
231
+ 'const BUNDLE = { ...TRIO, config: GRAPH.config };',
232
+ '',
233
+ 'export default class GameRoom extends JoystackRoom {',
234
+ ' frameworkV2() { return BUNDLE; }',
235
+ '}',
236
+ '',
237
+ ].join('\n');
238
+ }
239
+
240
+ // Alias the `joystack` doors a v2 game's world/entities/traits import from to the
241
+ // engine's runtime modules — resolved to the exact engine version this game
242
+ // pins, same discipline as the `/shared/` alias. Only the SERVER-valid doors are
243
+ // here: `joystack` (the authoring verbs) and `joystack/traits` (the composable traits).
244
+ // The browser-only doors (`joystack/client`, `joystack/renderers/*`) are deliberately
245
+ // absent — server code that imports one should fail to resolve LOUDLY, not bundle
246
+ // DOM/renderer code into the room worker. This is one of five door-resolution
247
+ // contexts (the others: shared/framework/build/expand.js, packages/cli/lib/
248
+ // inject.mjs, packages/cli/lib/joystack-node-doors.mjs, and
249
+ // backend/api/src/lib/inject.js); the full import surface is
250
+ // shared/docs/api.md. A browser door reaching here is regression-tested in
251
+ // packages/cli/lib/room-server-v2.test.mjs.
252
+ const V2_SERVER_DOORS = {
253
+ joystack: 'joystack.js',
254
+ 'joystack/traits': 'traits.js',
255
+ };
256
+ function joystackAliasPlugin(sharedDir) {
257
+ return {
258
+ name: 'joystack-framework-alias',
259
+ setup(build) {
260
+ build.onResolve({ filter: DOOR_FILTER }, (args) => {
261
+ const rel = V2_SERVER_DOORS[canonicalSpecifier(args.path)];
262
+ // A browser-only or unknown door is refused HERE, by name. Leaving it to
263
+ // esbuild stopped working once a game carried a real node_modules/joystack
264
+ // (joystack-node-doors.mjs): the specifier resolves now, so esbuild would
265
+ // either bundle a stub or fail on that package's own imports, and either
266
+ // way the reader would never see which door was wrong.
267
+ if (!rel) {
268
+ return {
269
+ errors: [{
270
+ text: `\`${args.path}\` cannot be used in room code — it is a browser door. `
271
+ + `The room may import ${Object.keys(V2_SERVER_DOORS).map((d) => `\`${d}\``).join(' and ')}; `
272
+ + 'everything else runs on the page. See shared/docs/api.md.',
273
+ }],
274
+ };
275
+ }
276
+ return { path: join(sharedDir, 'framework', 'runtime', rel) };
277
+ });
278
+ },
279
+ };
280
+ }
281
+
282
+ // Build the v2 room server: expand + lower the game HERE (node), emit the
283
+ // skeleton, codegen the room module, esbuild it for workerd. Returns the same
284
+ // `{ cwd, inputs }` shape as the primitive path so dev watches the right files.
285
+ export async function buildV2RoomServer({ projectDir, engine, esbuildImpl }) {
286
+ // The skeleton is emitted in a FRESH child process, never in-process. `joystack
287
+ // dev` is long-lived and rebuilds on every save; the gate loads the game via
288
+ // import(), so an in-process re-run would read Node's cached (pre-edit)
289
+ // modules and emit a STALE skeleton while esbuild bundles the fresh code — the
290
+ // room's embedded skeleton and its re-run code then disagree and the room
291
+ // crashes on reload (the "assemble: kind X re-ran N ... but the skeleton has
292
+ // M" error). A child starts with an empty module cache, so the skeleton always
293
+ // matches the code on disk. (The publish path builds once per process, so it
294
+ // stays in-process — see buildV2PublishModule.)
295
+ const gateRun = join(engine.sharedDir, 'framework', 'build', 'gate-run.mjs');
296
+ const res = spawnSync(process.execPath, [gateRun, projectDir, '--skeleton'], {
297
+ encoding: 'utf8',
298
+ maxBuffer: 64 * 1024 * 1024,
299
+ });
300
+ if (res.status !== 0) {
301
+ throw new Error(`this v2 game does not build:\n${(res.stderr || res.stdout || '(see .joystack/diagnostics.json)').trim()}`);
302
+ }
303
+ const skeleton = JSON.parse(res.stdout);
304
+
305
+ const esbuild = esbuildImpl ?? (await import('esbuild'));
306
+ const outDir = join(stateDir(projectDir), 'room-server');
307
+ mkdirSync(outDir, { recursive: true });
308
+
309
+ const result = await esbuild.build({
310
+ stdin: {
311
+ contents: renderV2Entry(skeleton, { sharedDir: engine.sharedDir }),
312
+ resolveDir: projectDir,
313
+ sourcefile: 'joystack-room-server.js',
314
+ loader: 'js',
315
+ },
316
+ outfile: join(outDir, 'server.js'),
317
+ bundle: true,
318
+ format: 'esm',
319
+ platform: 'browser',
320
+ target: 'esnext',
321
+ legalComments: 'none',
322
+ conditions: ['workerd', 'worker'],
323
+ loader: { '.wasm': 'binary' },
324
+ metafile: true,
325
+ absWorkingDir: projectDir,
326
+ plugins: [engineAliasPlugin(engine.sharedDir), joystackAliasPlugin(engine.sharedDir)],
327
+ });
328
+
329
+ writeFileSync(
330
+ join(outDir, 'partykit.json'),
331
+ JSON.stringify(
332
+ {
333
+ $schema: 'https://www.partykit.io/schema.json',
334
+ name: 'joystack-dev',
335
+ main: 'server.js',
336
+ compatibilityDate: '2024-09-23',
337
+ },
338
+ null,
339
+ 2,
340
+ ) + '\n',
341
+ );
342
+
343
+ const inputs = Object.keys(result.metafile?.inputs ?? {})
344
+ .filter((p) => p !== 'joystack-room-server.js' && !p.startsWith('<'))
345
+ .map((p) => (isAbsolute(p) ? p : join(projectDir, p)));
346
+
347
+ // The CLIENT needs the SAME skeleton the room embeds: the serve path injects it
348
+ // as `globalThis.__JOYSTACK_SKELETON__` before game.js runs, so startGame() can
349
+ // re-run the game's closures and rebuild the trio in the browser. Hand it up.
350
+ return { cwd: outDir, inputs, skeleton };
351
+ }
352
+
353
+ // The PUBLISH counterpart of renderV2Entry. Publish and dev diverge in one way:
354
+ // dev bundles the WHOLE room (JoystackRoom included) into one self-contained module
355
+ // partykit runs directly; publish uploads a THREE-module worker (a generated
356
+ // entry + the release's pre-bundled rooms-runtime.js + this game module), so
357
+ // this module must NOT bundle JoystackRoom — the release runtime provides it, and
358
+ // re-bundling the creator's local copy would drift from the pinned release and
359
+ // duplicate partyserver. So it imports only the framework runtime (assemble +
360
+ // lower) and the game's own world + kinds, computes the lowered v1 trio at load,
361
+ // and EXPORTS it as `frameworkV2Bundle`. The generated entry
362
+ // (renderV2GameServerEntry, builder side) imports that export and wraps it with
363
+ // the release's JoystackRoom.
364
+ export function renderV2RoomModule(skeleton, { sharedDir } = {}) {
365
+ return [
366
+ ...installDeterministicMath(sharedDir),
367
+ ...renderV2Preamble(skeleton),
368
+ 'export const frameworkV2Bundle = { ...TRIO, config: GRAPH.config };',
369
+ '',
370
+ ].join('\n');
371
+ }
372
+
373
+ // Build the v2 game module the publish endpoint uploads: run the gate (expand +
374
+ // lower + checks) HERE in node, emit the skeleton, codegen the room module, and
375
+ // esbuild it into ONE self-contained module (JoystackRoom NOT bundled — see
376
+ // renderV2RoomModule). Returns the bundled source text plus the skeleton (the
377
+ // client needs the SAME skeleton, injected as globalThis.__JOYSTACK_SKELETON__).
378
+ // `write: false` — the bytes go into the publish upload, never to disk.
379
+ export async function buildV2PublishModule({ projectDir, engine, esbuildImpl }) {
380
+ const buildUrl = (rel) => pathToFileURL(join(engine.sharedDir, 'framework', rel)).href;
381
+ const { runGate } = await import(buildUrl('build/gate.js'));
382
+ const { emitSkeleton } = await import(buildUrl('build/emit.js'));
383
+
384
+ const { ok, graph, report } = await runGate(projectDir, { write: false });
385
+ if (!ok) {
386
+ const errs = (report?.errors ?? []).map((e) => ` - ${e.message ?? JSON.stringify(e)}`).join('\n');
387
+ throw new Error(`this v2 game does not build:\n${errs || ' (see .joystack/diagnostics.json)'}`);
388
+ }
389
+ const skeleton = emitSkeleton(graph);
390
+
391
+ const esbuild = esbuildImpl ?? (await import('esbuild'));
392
+ const result = await esbuild.build({
393
+ stdin: {
394
+ contents: renderV2RoomModule(skeleton, { sharedDir: engine.sharedDir }),
395
+ resolveDir: projectDir,
396
+ sourcefile: 'joystack-v2-room.js',
397
+ loader: 'js',
398
+ },
399
+ bundle: true,
400
+ write: false,
401
+ format: 'esm',
402
+ platform: 'browser',
403
+ target: 'esnext',
404
+ legalComments: 'none',
405
+ conditions: ['workerd', 'worker'],
406
+ loader: { '.wasm': 'binary' },
407
+ absWorkingDir: projectDir,
408
+ plugins: [engineAliasPlugin(engine.sharedDir), joystackAliasPlugin(engine.sharedDir)],
409
+ });
410
+ return { source: result.outputFiles[0].text, skeleton };
411
+ }
412
+
413
+ // The room the game actually gets: the engine's room class, subclassed to hand
414
+ // the host the creator's primitives through the `extraPrimitives()` seam the
415
+ // engine already reads (server.js). Identical in shape to what the publish
416
+ // endpoint generates for the per-game Worker — one contract, two builders.
417
+ export function renderV1Entry(primitives, entityComponents, { sharedDir } = {}) {
418
+ return [
419
+ // Before the barrel and before the entity components — both of which emit
420
+ // imports of the CREATOR's own simulation code, which is exactly the code
421
+ // that must not be evaluated on native transcendental math.
422
+ ...installDeterministicMath(sharedDir),
423
+ renderBarrel(primitives),
424
+ renderEntityComponents(entityComponents),
425
+ "import JoystackRoom from '/shared/ui/room/server.js';",
426
+ '',
427
+ 'export default class GameRoom extends JoystackRoom {',
428
+ ' extraPrimitives() { return extraPrimitives; }',
429
+ ' extraEntityComponents() { return extraEntityComponents; }',
430
+ '}',
431
+ '',
432
+ ].join('\n');
433
+ }
434
+
435
+ // → `{ cwd, inputs }`: the cwd to run partykit in — the engine's stock room
436
+ // server when the game has no primitives of its own, a freshly-built one when
437
+ // it does — plus the absolute paths of every file bundled into it. The room
438
+ // bundle is frozen at build time, so `inputs` is what dev watches to know the
439
+ // running room no longer matches the files on disk.
440
+ //
441
+ // Throws if the primitives dir is malformed (e.g. a hand-written index.js) —
442
+ // dev must not quietly serve a room that is missing the creator's code.
443
+ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
444
+ // A v2 game runs its own (lowered) code on the server, so it gets its own room
445
+ // server — but built by the framework compiler, not the primitive scanner.
446
+ if (isFrameworkV2(projectDir)) {
447
+ return buildV2RoomServer({ projectDir, engine, esbuildImpl });
448
+ }
449
+ const primitives = scanPrimitives(projectDir); // throws on a reserved index.js
450
+ const entityComponents = await scanEntityComponents(projectDir, engine.sharedDir); // throws on a malformed component
451
+ if (!primitives.length && !entityComponents.length) {
452
+ // Not server-backed (any more). A PREVIOUS run may have generated a room
453
+ // server and a registry shadow; both must go, or dev keeps mounting a stale
454
+ // `.joystack/overrides/shared/.../index.js` that imports a primitive the creator
455
+ // has since deleted — an ES-import 404 that stops the whole room library
456
+ // loading. The mount is unconditional (dev.mjs), so the cleanup has to be too.
457
+ rmSync(join(stateDir(projectDir), 'room-server'), { recursive: true, force: true });
458
+ rmSync(join(stateDir(projectDir), 'overrides', 'shared'), { recursive: true, force: true });
459
+ return { cwd: engine.roomServerDir, inputs: [] };
460
+ }
461
+ assertNoOverrideCycle(primitives, projectDir, engine.sharedDir);
462
+
463
+ const esbuild = esbuildImpl ?? (await import('esbuild'));
464
+ const outDir = join(stateDir(projectDir), 'room-server');
465
+ mkdirSync(outDir, { recursive: true });
466
+
467
+ const result = await esbuild.build({
468
+ // No entry file on disk: the barrel + subclass are fed straight in, resolved
469
+ // against the game root, so `./overrides/...` means what it says and no
470
+ // machine-specific absolute path can leak into the output.
471
+ stdin: {
472
+ contents: renderV1Entry(primitives, entityComponents, { sharedDir: engine.sharedDir }),
473
+ resolveDir: projectDir,
474
+ sourcefile: 'joystack-room-server.js',
475
+ loader: 'js',
476
+ },
477
+ outfile: join(outDir, 'server.js'),
478
+ bundle: true,
479
+ format: 'esm',
480
+ // workerd is browser-shaped, and so is the engine's own room runtime build.
481
+ platform: 'browser',
482
+ target: 'esnext',
483
+ legalComments: 'none',
484
+ conditions: ['workerd', 'worker'],
485
+ loader: { '.wasm': 'binary' },
486
+ metafile: true,
487
+ absWorkingDir: projectDir,
488
+ plugins: [
489
+ // Shadowing, for the bundler: an override's relative imports fall through
490
+ // to the engine exactly as they do in the browser. This is what lets an
491
+ // override subclass the primitive it overrides (`import { PlayerBody }
492
+ // from './engine.js'`) — it cannot import its own path.
493
+ shadowResolvePlugin({ projectDir, sharedDir: engine.sharedDir }),
494
+ engineAliasPlugin(engine.sharedDir),
495
+ ],
496
+ });
497
+
498
+ writeFileSync(
499
+ join(outDir, 'partykit.json'),
500
+ JSON.stringify(
501
+ {
502
+ $schema: 'https://www.partykit.io/schema.json',
503
+ name: 'joystack-dev',
504
+ main: 'server.js',
505
+ compatibilityDate: '2024-09-23',
506
+ },
507
+ null,
508
+ 2,
509
+ ) + '\n',
510
+ );
511
+
512
+ // The BROWSER half. Building the room server fixes the SERVER; without this
513
+ // the client would still predict with the ENGINE's class of the same name,
514
+ // disagree with the server every tick, and rubber-band. Served by dev at
515
+ // `/shared/ui/room/primitives/index.js`, shadowing the engine's. Its imports
516
+ // are ABSOLUTE `/shared/...` (see renderBrowserRegistry) so they resolve the
517
+ // same in dev (static mount fall-through) and in production (the import map):
518
+ // /shared/ui/room/primitives/engine.js → not shadowed → the engine's list
519
+ // /shared/ui/room/primitives/beacon.js → shadowed → the game's primitive
520
+ if (primitives.length) {
521
+ const registryDir = join(stateDir(projectDir), 'overrides', 'shared', 'ui/room/primitives');
522
+ mkdirSync(registryDir, { recursive: true });
523
+ writeFileSync(join(registryDir, 'index.js'), renderBrowserRegistry(primitives));
524
+ } else {
525
+ // Entity components alone need no primitive-registry shadow — and a stale
526
+ // one from a removed primitive must not stay mounted.
527
+ rmSync(join(stateDir(projectDir), 'overrides', 'shared'), { recursive: true, force: true });
528
+ }
529
+
530
+ // Metafile inputs are relative to absWorkingDir (the game root); the stdin
531
+ // entry (keyed by its `sourcefile` name, no file on disk) is excluded.
532
+ const inputs = Object.keys(result.metafile?.inputs ?? {})
533
+ .filter((p) => p !== 'joystack-room-server.js' && !p.startsWith('<'))
534
+ .map((p) => (isAbsolute(p) ? p : join(projectDir, p)));
535
+
536
+ return { cwd: outDir, inputs };
537
+ }
538
+
539
+ export { isServerBacked };