@joystack/cli 0.1.45 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,29 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.46] - 2026-10-09
18
+
19
+ ### Changed
20
+
21
+ - `joystack dev` serves one version of your game at a time. Once your files
22
+ stop changing, it copies them, and the page, every file the page loads
23
+ afterwards, and the multiplayer room all come from that copy. A running page
24
+ can no longer meet files that changed under it, such as an entity that was
25
+ deleted or a layout that no longer matches the code.
26
+ - While a new version is being prepared, the page shows "Updating…". If the
27
+ game does not build, the page says what is wrong in one line instead of
28
+ showing the previous game, and updates again on your next save. The full
29
+ error is printed in the terminal.
30
+ - The multiplayer room reloads only when the code it runs has changed, and
31
+ in place rather than by restarting the server. An edit to the page alone
32
+ keeps the room and its state.
33
+ - A save reaches the open page sooner: `joystack dev` hears about the change
34
+ from the system instead of checking every 400 ms, a save that touches
35
+ nothing the game's code imports (a page, a style, an image) skips the
36
+ build, and a new version copies only the files that changed.
37
+ - `GET /__joystack/dev` on the dev server reports whether it is up to date
38
+ and, after a failed build, the error. Your agent can read it after an edit.
39
+
17
40
  ## [0.1.45] - 2026-10-09
18
41
 
19
42
  ### Changed
package/lib/dev.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // `joystack dev` — the standalone two-service dev stack:
2
2
  //
3
- // static :8000 game folder at /games/<slug>/, /shared/ → engine bundle,
4
- // head injection + auto-reload
3
+ // static :8000 a version of the game at /games/<slug>/, /shared/ → engine
4
+ // bundle, head injection + auto-reload (live-versions.mjs)
5
5
  // mp :1999 local partykit running the BUNDLE's room-server code
6
6
  //
7
7
  // Multiplayer runs from minute one — the platform is not optional (the Next
@@ -9,9 +9,10 @@
9
9
  import { spawn } from 'node:child_process';
10
10
  import { terminateChildTree } from './process-tree.mjs';
11
11
  import { withoutAgentSession } from './agent-session.mjs';
12
- import { existsSync, readFileSync } from 'node:fs';
12
+ import { createHash } from 'node:crypto';
13
+ import { copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
13
14
  import { tmpdir } from 'node:os';
14
- import { join, dirname, basename } from 'node:path';
15
+ import { join, dirname, basename, isAbsolute, relative, sep } from 'node:path';
15
16
  import { findProject } from './project.mjs';
16
17
  import { ensureEngine } from './engine.mjs';
17
18
  import { createStaticServer } from './static-server.mjs';
@@ -19,11 +20,15 @@ import { pruneOnStart } from './replay-store.mjs';
19
20
  import { resolvePorts, portInUse, killPort, lanIp, isRestricted, nextBrowserSafe } from './ports.mjs';
20
21
  import { assertNoInertOverride, isFrameworkV2, isSinglePlayerV2, scanPrimitives } from './primitives.mjs';
21
22
  import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
22
- import { createFileWatcher, createRoomReloader } from './room-reload.mjs';
23
+ import { createLiveVersions, snapshotFiles } from './live-versions.mjs';
23
24
  import { hasDeclaredAssets, assetsPageUrl } from './assets-page.mjs';
24
25
  import { openTunnel, shareBanner, engineCanShare } from './share.mjs';
25
- import { freezeGame } from './freeze.mjs';
26
- import { readEnv, stateDir } from './legacy-names.mjs';
26
+ import { freezeGame, frozenFiles } from './freeze.mjs';
27
+ import { STATE_DIR_NAMES, readEnv, stateDir } from './legacy-names.mjs';
28
+
29
+ // The route a room under `joystack dev` answers with the name of the build it
30
+ // runs (installRoom in startDev).
31
+ const ROOM_BUILD_PATH = '/__joystack/room-build';
27
32
 
28
33
  // Resolve the partykit CLI entry from OUR dependencies (the game never
29
34
  // declares partykit; it rides @joystack/cli). partykit's `exports` map hides
@@ -121,14 +126,97 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
121
126
  // be watching one. The caps and the keep marker live in replay-store.mjs.
122
127
  pruneOnStart({ projectDir: project.dir, log });
123
128
 
124
- // `reload: false` (`joystack dev --no-reload`) freezes the game as it is now:
125
- // pages are served, and the room is built, from a copy taken here, after the
126
- // bake so baked models come with it, and nothing is watched. Recordings still
127
- // land in the real folder. See freeze.mjs for why a copy is needed.
128
- const frozen = reload ? null : freezeGame(project.dir);
129
- if (frozen) onFailure(frozen.remove);
130
- if (frozen) watch = false;
131
- const codeDir = frozen?.dir ?? project.dir;
129
+ // `watch` (an interactive `joystack dev`) serves versions of the game: copies
130
+ // taken as the files settle, each with the room and the entity layout built
131
+ // from it (live-versions.mjs). `reload: false` (`--no-reload`) starts with
132
+ // auto-reload off. A stack started without `watch` (`joystack test`, the
133
+ // asset tools) runs once against the folder itself.
134
+ const live = watch;
135
+ let nextVersion = 1;
136
+ const makeVersion = async ({ files, previous = null, bake = true }) => {
137
+ if (bake) await ensureBaked({ dir: project.dir, log });
138
+ const copy = freezeGame(project.dir, { files, from: previous });
139
+ try {
140
+ const reused = previous && reusableRoom(previous, files) ? reuseRoom(previous.room, copy.dir) : null;
141
+ return { id: nextVersion++, dir: copy.dir, build: copy.build, remove: copy.remove, room: reused ?? await buildRoom(copy.dir) };
142
+ } catch (err) {
143
+ // Errors name the copy's files, under either spelling of its path; the
144
+ // person reading them edits the game's.
145
+ const named = (text) => [[realpathSync(copy.dir), realpathSync(project.dir)], [copy.dir, project.dir]]
146
+ .reduce((t, [from, to]) => t.split(from).join(to), String(text));
147
+ err.message = named(err.message);
148
+ copy.remove();
149
+ throw err;
150
+ }
151
+ };
152
+ // The inert-override check runs before the build: an override of, say,
153
+ // `primitives/effects.js` is a room-runtime file that merely happens to live
154
+ // in the primitives folder, and the scan would report it as "a primitive
155
+ // with no default export", which is true and useless.
156
+ //
157
+ // `key` names what the room runs: the bundle and the entity layout. The
158
+ // bundle names its source files in comments, by paths that run through each
159
+ // copy's own folder, so those are left out of it. `inputs` are the files the
160
+ // build read, written from the game folder.
161
+ const buildRoom = async (dir) => {
162
+ assertNoInertOverride({ projectDir: dir, engine });
163
+ const built = await buildDevRoomServer({ projectDir: dir, engine });
164
+ const bundle = built.cwd === engine.roomServerDir ? 'engine' : readFileSync(join(built.cwd, 'server.js'), 'utf8').replace(/frozen[/\\][^/\\]+[/\\]/g, '');
165
+ const key = createHash('sha256').update(bundle).update('\0').update(JSON.stringify(built.skeleton ?? null)).digest('hex');
166
+ const inputs = new Set((built.inputs ?? []).map((p) => relative(dir, p).split(sep).join('/')));
167
+ return { ...built, key, inputs, v2: isFrameworkV2(dir) };
168
+ };
169
+ // A framework-v2 game's room and layout are built from the world file and
170
+ // what it imports, and nothing else in the folder: a save that touches none
171
+ // of those (a page, a style, an image, a file nothing imports) keeps the
172
+ // previous build. The engine's files, package.json and overrides/ always
173
+ // count, since they change how the build itself runs.
174
+ const reusableRoom = (previous, files) => {
175
+ if (!previous.room.v2) return false;
176
+ const changed = new Set();
177
+ for (const [k, v] of files) if (previous.files.get(k) !== v) changed.add(k);
178
+ for (const k of previous.files.keys()) if (!files.has(k)) changed.add(k);
179
+ for (const path of changed) {
180
+ const rel = relative(project.dir, path).split(sep).join('/');
181
+ if (rel.startsWith('..') || isAbsolute(rel)) return false;
182
+ if (rel === 'package.json' || rel.startsWith('overrides/') || previous.room.inputs.has(rel)) return false;
183
+ }
184
+ return true;
185
+ };
186
+ // The previous build, carried into a new copy: the files the build wrote
187
+ // into its room-server folder are linked, so the copy holds everything its
188
+ // version runs on. The folders in there are the room server's own, made
189
+ // while it runs, and stay behind.
190
+ const reuseRoom = (room, dir) => {
191
+ if (room.cwd === engine.roomServerDir) return room;
192
+ const to = join(stateDir(dir), 'room-server');
193
+ mkdirSync(to, { recursive: true });
194
+ for (const name of readdirSync(room.cwd)) {
195
+ const from = join(room.cwd, name);
196
+ if (!lstatSync(from).isFile()) continue;
197
+ try {
198
+ linkSync(from, join(to, name));
199
+ } catch {
200
+ copyFileSync(from, join(to, name));
201
+ }
202
+ }
203
+ return { ...room, cwd: to };
204
+ };
205
+ // What a version is made from: the game's files, and the engine's, which the
206
+ // room bundle includes. Engine files are served as they are, not copied.
207
+ const sourceFiles = () => {
208
+ const files = frozenFiles(project.dir);
209
+ for (const [k, v] of snapshotFiles([engine.sharedDir], (name) => name.startsWith('.') || name === 'node_modules')) files.set(k, v);
210
+ return files;
211
+ };
212
+ let first = null;
213
+ if (live) {
214
+ const files = sourceFiles();
215
+ first = await makeVersion({ files, bake: false });
216
+ first.files = files;
217
+ onFailure(first.remove);
218
+ }
219
+ const codeDir = first?.dir ?? project.dir;
132
220
 
133
221
  // Which ports, and may we take them? An explicit --port is obeyed as given
134
222
  // (takeover included). With no flag we step around anyone else's stack —
@@ -162,29 +250,35 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
162
250
  // taking over ports, and because this generates the merged browser registry
163
251
  // the static server is about to mount. Building it after would leave a window
164
252
  // where the page loads and predicts with the engine's classes instead.
165
- //
166
- // The inert-override check runs FIRST, before the scan. An override of, say,
167
- // `primitives/effects.js` is a room-runtime file that merely happens to live in
168
- // the primitives folder — scanned first, it would be reported as "a primitive
169
- // with no default export", which is true and useless. Checked first, the
170
- // creator is told the actual problem: the server cannot take this file.
171
- assertNoInertOverride({ projectDir: codeDir, engine });
172
253
  const serverPrimitives = scanPrimitives(codeDir);
173
- const { cwd: roomServerCwd, inputs: roomInputs, skeleton: roomSkeleton = null } = await buildDevRoomServer({ projectDir: codeDir, engine });
254
+ const { cwd: roomServerCwd, skeleton: roomSkeleton = null } = first?.room ?? await buildRoom(codeDir);
174
255
  const ownServer = roomServerCwd !== engine.roomServerDir;
175
256
  // A single-player framework-v2 game (no config({ multiplayer: true })) runs its
176
257
  // authoritative sim in-process via LocalRoom, so no room server is started.
177
258
  const singlePlayer = isSinglePlayerV2({ projectDir: codeDir, skeleton: roomSkeleton });
178
259
  const runsRoom = !(noMp || singlePlayer);
179
260
  let tunnel = null;
261
+ // Moves the multiplayer room onto a version (set below when a room runs).
262
+ let moveRoom = async () => {};
180
263
 
181
264
  let stopped = false;
265
+ let versions = null;
266
+ // What the room runs (a version's room.key). It can be an older version's
267
+ // than the page's: a version whose room build is the same keeps the room
268
+ // running, with its state.
269
+ let roomKey = first?.room.key ?? null;
270
+ // Where the room runs from under `joystack dev`: a folder of its own that each
271
+ // version's room build is written into, so the running room server reloads
272
+ // it in place rather than being restarted (moveRoom).
273
+ const liveRoomDir = join(stateDir(project.dir), 'room-live');
182
274
  const stop = () => {
183
275
  stopped = true;
276
+ versions?.close();
184
277
  tunnel?.close();
185
278
  for (const s of servers) s.close();
186
279
  for (const c of children) terminateChildTree(c);
187
- frozen?.remove();
280
+ versions?.current.remove();
281
+ first?.remove();
188
282
  };
189
283
  onFailure(stop);
190
284
 
@@ -207,23 +301,25 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
207
301
  // (primitives/index.js) the creator is not allowed to write themselves.
208
302
  // 2. THE CREATOR'S (overrides/shared) — everything they wrote.
209
303
  // Then the engine. First mount that HAS the file wins (static-server.mjs).
210
- const generatedOverridesDir = join(stateDir(codeDir), 'overrides', 'shared');
211
- const overridesDir = join(codeDir, 'overrides', 'shared');
304
+ const mountsFor = (dir) => [
305
+ { url: `/games/${project.slug}/`, dir },
306
+ { url: '/shared/', dir: join(stateDir(dir), 'overrides', 'shared') },
307
+ { url: '/shared/', dir: join(dir, 'overrides', 'shared') },
308
+ { url: '/shared/', dir: engine.sharedDir },
309
+ ];
212
310
  const staticServer = createStaticServer({
213
311
  slug: project.slug,
214
312
  projectDir: project.dir, // answers /__joystack/whoami — see ports.mjs
215
313
  log, // agent-inbox report lines ride the dev logger
216
314
  engineVersion: engine.version ?? null, // stamped on agent-inbox reports
217
315
  agentSession, // woken by agent-inbox reports sent from this machine
218
- mounts: [
219
- { url: `/games/${project.slug}/`, dir: codeDir },
220
- { url: '/shared/', dir: generatedOverridesDir },
221
- { url: '/shared/', dir: overridesDir },
222
- { url: '/shared/', dir: engine.sharedDir },
223
- ],
224
- watchDirs: watch ? [codeDir, engine.sharedDir] : [],
225
- injectReload: watch,
226
- ...(frozen ? { fixedBuild: frozen.build } : {}),
316
+ mounts: mountsFor(codeDir),
317
+ ...(first ? { fixedBuild: first.build } : {}),
318
+ versions: live ? {
319
+ status: () => versions?.status ?? { state: 'ready', auto: reload, pending: false, error: null },
320
+ reload: () => versions?.reload(),
321
+ setAuto: (on) => versions?.setAuto(on),
322
+ } : null,
227
323
  // Present only for a framework-v2 game — injects the joystack import map + the
228
324
  // lowered skeleton into the entry HTML so startGame() boots in the browser.
229
325
  skeleton: roomSkeleton,
@@ -233,7 +329,8 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
233
329
  });
234
330
  await staticServer.listen(ports.static);
235
331
  servers.push(staticServer);
236
- log(`→ static :${ports.static} (serving ${frozen ? `a copy of ${project.dir} as it is now` : project.dir}, engine ${engine.version}, auto-reload ${watch ? 'on' : 'off'})`);
332
+ if (first) staticServer.setGame({ id: first.id, mounts: mountsFor(first.dir), skeleton: roomSkeleton, build: first.build });
333
+ log(`→ static :${ports.static} (serving ${live ? `${project.dir} as it is when its files settle` : project.dir}, engine ${engine.version}, auto-reload ${live && reload ? 'on' : 'off'})`);
237
334
 
238
335
  // ─── multiplayer (partykit on the bundle's room code) ───
239
336
  // A single-player game's client never opens a socket, so standing up partykit
@@ -245,29 +342,6 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
245
342
  ? `→ multiplayer :${ports.mp} (skipped — single-player; add config({ multiplayer: true }) to enable)`
246
343
  : `→ multiplayer :${ports.mp} (skipped — NO_MP=1)`,
247
344
  );
248
- // No room worker, but the client still assembles its graph against the injected
249
- // skeleton — so a STRUCTURAL edit (new entity/trait) must rebuild the skeleton
250
- // and push it to the static server, or the page reloads onto a stale skeleton
251
- // and assembleGraph throws ("re-ran N ticks but the skeleton has M"). The full
252
- // partykit reloader below does this as a side effect; single-player needs a
253
- // stripped-down version with no child to respawn. (NO_MP on a real multiplayer
254
- // game has no client to serve a skeleton to, so it needs none.)
255
- if (singlePlayer && watch) {
256
- const skelWatcher = createFileWatcher({
257
- files: roomInputs,
258
- dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(codeDir, d)),
259
- onChange: async () => {
260
- try {
261
- const built = await buildDevRoomServer({ projectDir: codeDir, engine });
262
- staticServer.setSkeleton(built.skeleton ?? null);
263
- skelWatcher.setFiles(built.inputs);
264
- } catch (err) {
265
- log(`[skeleton] rebuild failed — fix the error and save again: ${err.message}`);
266
- }
267
- },
268
- });
269
- servers.push({ close: () => skelWatcher.close() });
270
- }
271
345
  } else {
272
346
  if (tookOver && (await portInUse(ports.mp))) {
273
347
  log(`→ multiplayer :${ports.mp} (our stale server — reclaiming)`);
@@ -328,7 +402,80 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
328
402
  await new Promise((r) => setTimeout(r, 100));
329
403
  }
330
404
  };
331
- const pk = spawnMp(roomServerCwd);
405
+ // The room server's entry under `joystack dev`: the version's bundle, plus a
406
+ // route that answers with a name for this build, which is how a reload in
407
+ // place is known to have finished.
408
+ let roomBuilds = 0;
409
+ const installRoom = (room) => {
410
+ const build = `${process.pid}-${++roomBuilds}`;
411
+ mkdirSync(liveRoomDir, { recursive: true });
412
+ // A file the room server has open cannot be replaced on Windows until it
413
+ // lets go, so a file is written under a new name and renamed into place,
414
+ // and when the rename is refused, written in place, a few times over if
415
+ // need be. A half-written file only fails a rebuild that the next write
416
+ // replaces; the build name below says when the right one is running.
417
+ const write = (name, body) => {
418
+ const tmp = join(liveRoomDir, `${name}.tmp`);
419
+ writeFileSync(tmp, body);
420
+ for (let i = 0; ; i++) {
421
+ try {
422
+ renameSync(tmp, join(liveRoomDir, name));
423
+ return;
424
+ } catch (err) {
425
+ if (i >= 20 || !['EPERM', 'EBUSY', 'EACCES'].includes(err.code)) throw err;
426
+ try {
427
+ writeFileSync(join(liveRoomDir, name), body);
428
+ rmSync(tmp, { force: true });
429
+ return;
430
+ } catch { /* still held */ }
431
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
432
+ }
433
+ }
434
+ };
435
+ // Each build's bundle under a name of its own, so the one the server
436
+ // runs is never written over; earlier ones go once a later one runs (pruneRoom).
437
+ const server = `server-${build}.js`;
438
+ writeFileSync(join(liveRoomDir, server), readFileSync(join(room.cwd, 'server.js')));
439
+ const config = JSON.parse(readFileSync(join(room.cwd, 'partykit.json'), 'utf8'));
440
+ write('partykit.json', `${JSON.stringify({ ...config, main: 'main.js' }, null, 2)}\n`);
441
+ // Written last: the room server rebuilds from it, and a rebuild that
442
+ // carries this build's name carries this build's bundle.
443
+ write('main.js', [
444
+ `import Room from './${server}';`,
445
+ 'export default class extends Room {',
446
+ ' static onFetch(req, ...rest) {',
447
+ ` if (new URL(req.url).pathname === ${JSON.stringify(ROOM_BUILD_PATH)}) return new Response(${JSON.stringify(build)});`,
448
+ " return super.onFetch ? super.onFetch(req, ...rest) : new Response('Not found', { status: 404 });",
449
+ ' }',
450
+ '}',
451
+ '',
452
+ ].join('\n'));
453
+ return build;
454
+ };
455
+ // The bundles of builds before `build`, once the server runs `build`.
456
+ const pruneRoom = (build) => {
457
+ for (const name of readdirSync(liveRoomDir)) {
458
+ if (!/^server-.+\.js$/.test(name) || name === `server-${build}.js`) continue;
459
+ try {
460
+ rmSync(join(liveRoomDir, name), { force: true });
461
+ } catch { /* still open: the next prune takes it */ }
462
+ }
463
+ };
464
+ const roomServes = async (build, timeoutMs) => {
465
+ const deadline = Date.now() + timeoutMs;
466
+ while (Date.now() < deadline && !stopped) {
467
+ try {
468
+ const res = await fetch(`http://127.0.0.1:${ports.mp}${ROOM_BUILD_PATH}`, { signal: AbortSignal.timeout(1000) });
469
+ if ((await res.text()) === build) return true;
470
+ } catch { /* reloading */ }
471
+ await new Promise((r) => setTimeout(r, 20));
472
+ }
473
+ return false;
474
+ };
475
+ const ownRoom = (room) => room.cwd !== engine.roomServerDir;
476
+ let runsOwn = live && ownServer;
477
+ if (live) rmSync(liveRoomDir, { recursive: true, force: true });
478
+ let pk = spawnMp(runsOwn ? (installRoom(first.room), liveRoomDir) : roomServerCwd);
332
479
  // A game's own room carries one of three kinds of server code — creator
333
480
  // primitives (named), a framework-v2 lowered bundle, or v1 entity
334
481
  // components — and only the first has a list to print. Naming the kind
@@ -350,59 +497,55 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
350
497
  if (room === 'exited') throw new Error(`the multiplayer server (partykit on :${ports.mp}) stopped while starting — its errors are printed above with a [partykit] prefix`);
351
498
  if (room === 'timeout') log(`[partykit] not answering on :${ports.mp} after 30s — the game will keep trying to connect`);
352
499
 
353
- // The room bundle is frozen at build time, so the static watcher's page
354
- // reload is only half the contract: an edit to anything the room runs on
355
- // (entity data, a room override, a server sim) must also rebuild the room
356
- // and restart its child, or the creator playtests against pre-edit
357
- // adjudication with everything looking live — a failure `joystack test` is
358
- // structurally blind to, because it boots fresh stacks.
359
- //
360
- // The watch set is the bundle's own input list (covers every file the room
361
- // actually imports, wherever it lives) plus the room-shaped game trees for
362
- // files that don't exist yet — a first primitive, a new entity, a new room
363
- // override. overrides/ is deliberately scoped to its room subtree: editing
364
- // a purely client-side override must not restart the room and reset its
365
- // state mid-playtest.
366
- let roomWatcher = null;
367
- let roomCwd = roomServerCwd;
368
- const reloader = createRoomReloader({
369
- build: () => buildDevRoomServer({ projectDir: codeDir, engine }),
370
- spawnChild: spawnMp,
371
- killChild: killMp,
372
- log,
373
- onRebuilt: (built) => {
374
- roomWatcher?.setFiles(built.inputs);
375
- // Push the rebuilt skeleton to the static server so the client injects
376
- // the SAME lowered graph the room now embeds — otherwise a v2 hot-reload
377
- // leaves the page on the startup skeleton and assembleGraph throws on the
378
- // next load (client "re-ran N ticks but the skeleton has M").
379
- staticServer.setSkeleton(built.skeleton ?? null);
380
- staticServer.roomRestarted();
381
- // A rebuild can flip which server the game runs on (first primitive
382
- // added, last one removed) — say so, or the startup line misleads for
383
- // the rest of the session.
384
- if (built.cwd !== roomCwd) {
385
- roomCwd = built.cwd;
386
- log(
387
- `→ multiplayer :${ports.mp} (now on ${roomCwd === engine.roomServerDir ? "the engine's room server" : "this game's OWN server"})`,
388
- );
500
+ // A new version moves the room only when what the room runs changed: an
501
+ // edit to a page-only file keeps the room and its state. A room of the
502
+ // game's own is reloaded in place by the running room server, which takes
503
+ // a fraction of a restart; moving between the engine's room and the
504
+ // game's own, or a reload that does not finish, restarts it.
505
+ moveRoom = async (v) => {
506
+ if (roomKey === v.room.key) return;
507
+ roomKey = null;
508
+ const own = ownRoom(v.room);
509
+ if (own && runsOwn && pk.exitCode === null && pk.signalCode === null) {
510
+ const build = installRoom(v.room);
511
+ if (await roomServes(build, 10000)) {
512
+ pruneRoom(build);
513
+ roomKey = v.room.key;
514
+ log(`→ multiplayer :${ports.mp} (reloaded on the new version; room state starts over)`);
515
+ return;
389
516
  }
517
+ }
518
+ await killMp(pk);
519
+ if (stopped) return;
520
+ pk = spawnMp(own ? (installRoom(v.room), liveRoomDir) : engine.roomServerDir);
521
+ runsOwn = own;
522
+ const up = await waitForRoomServer(ports.mp, { alive: () => pk.pid != null && pk.exitCode === null && pk.signalCode === null });
523
+ if (up === 'exited') throw new Error(`the multiplayer server (partykit on :${ports.mp}) stopped while starting — its errors are printed above with a [partykit] prefix`);
524
+ roomKey = v.room.key;
525
+ log(`→ multiplayer :${ports.mp} (restarted on the new version; room state starts over)`);
526
+ };
527
+ }
528
+
529
+ if (live) {
530
+ versions = createLiveVersions({
531
+ first,
532
+ auto: reload,
533
+ log,
534
+ snapshot: sourceFiles,
535
+ make: (o) => makeVersion(o),
536
+ // The game folder's own events start a version. The state folder holds
537
+ // the copies themselves, so its events are left out.
538
+ watch: [{ dir: project.dir, ignore: (rel) => STATE_DIR_NAMES.some((d) => rel === d || rel.startsWith(`${d}/`)) || rel === '.git' || rel.startsWith('.git/') || rel.startsWith('node_modules/') }],
539
+ // Every open page has been told to leave when the update started; the
540
+ // switch waits until none of them is left, so no running page fetches a
541
+ // file from the new copy.
542
+ show: async (v) => {
543
+ await staticServer.pagesGone(v.id);
544
+ await moveRoom(v);
545
+ staticServer.setGame({ id: v.id, mounts: mountsFor(v.dir), skeleton: v.room.skeleton ?? null, build: v.build });
390
546
  },
391
- });
392
- reloader.attach(roomServerCwd, pk);
393
- roomWatcher = watch ? createFileWatcher({
394
- files: roomInputs,
395
- dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(codeDir, d)),
396
- onChange: reloader.onChange,
397
- }) : null;
398
- // stop() (e.g. `joystack test` tearing down its stack) must also stop the
399
- // reloader: a rebuild in flight would otherwise spawn a replacement room
400
- // child AFTER the children list was killed — a leaked detached process.
401
- servers.push({
402
- close: () => {
403
- reloader.stop();
404
- roomWatcher?.close();
405
- },
547
+ retire: (v) => v.remove(),
548
+ onStatus: () => staticServer.statusChanged(),
406
549
  });
407
550
  }
408
551
 
@@ -424,12 +567,24 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
424
567
  log('→ share opening a public link (cloudflared)…');
425
568
  tunnel = await openShare({ port: ports.static }); // a failure stops the stack (see dev)
426
569
  staticServer.setShareHost(tunnel.hostname);
427
- for (const line of shareBanner({ url: tunnel.url, gamePath: `/games/${project.slug}/index.html`, resolved: tunnel.resolved, frozen: !!frozen })) log(line);
570
+ for (const line of shareBanner({ url: tunnel.url, gamePath: `/games/${project.slug}/index.html`, resolved: tunnel.resolved, frozen: live && !reload })) log(line);
428
571
  tunnel.exited.then(() => {
429
572
  if (!stopped) log('⚠ the share link stopped working (cloudflared exited). Restart `joystack dev --share` for a new one.');
430
573
  });
431
574
  }
432
575
  log('────────────────────────────────────────────────────────────');
433
576
 
434
- return { project, engine, ports, url, shareUrl: tunnel ? `${tunnel.url}/games/${project.slug}/index.html` : null, frozenDir: frozen?.dir ?? null, stop };
577
+ return {
578
+ project,
579
+ engine,
580
+ ports,
581
+ url,
582
+ shareUrl: tunnel ? `${tunnel.url}/games/${project.slug}/index.html` : null,
583
+ // The copy the page is served from now, while versions are served.
584
+ get frozenDir() {
585
+ return versions?.current.dir ?? null;
586
+ },
587
+ versions,
588
+ stop,
589
+ };
435
590
  }