@looop-games/cli 0.1.12 → 0.1.14

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,46 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.14] - 2026-07-13
18
+
19
+ > How to write a room primitive — the folder, the filename-is-type rule, and
20
+ > subclassing an engine primitive — is a change to the engine's room library, so
21
+ > it lives in the engine changelog (`looop changelog`), not here. This file is
22
+ > only the `looop` command itself.
23
+
24
+ ### Changed
25
+ - `looop dev` and `looop publish` now **refuse an override the room server cannot
26
+ run**, naming the file and what to do instead. Copying a room internal
27
+ (`host-core.js`, `primitives/effects.js`, …) into `overrides/` used to be
28
+ accepted in silence and then simply never run on the server — your browser and
29
+ the room quietly ran different code. The only override the server honours is a
30
+ primitive, so that is now the only one the commands accept.
31
+
32
+ ### Fixed
33
+ - `looop dev` now runs **your game's own server primitives**. It always ran the
34
+ engine's stock room server instead, so a primitive you wrote worked once you
35
+ published but not on your own machine — which is the worst way round, because
36
+ you could only find the difference after shipping. `looop dev` and `looop
37
+ publish` now decide the same way whether your game has a server of its own.
38
+
39
+ ## [0.1.13] - 2026-07-12
40
+
41
+ ### Fixed
42
+ - **`looop update` deleted your engine.** The engine is not an npm package — it
43
+ installs without a record in `package.json`, so *any* `npm install` in your game
44
+ treats it as junk and removes it. `looop update` then updated the `looop` command
45
+ itself with an npm install — **after** putting the engine in place — and wiped it
46
+ out every time. The update said it succeeded; `node_modules/@looop-games/engine`
47
+ was gone. Everything that reads the engine then failed, and re-running
48
+ `looop update` cheerfully said "already up to date" and fixed nothing.
49
+
50
+ The engine is now the last thing installed, and an update that finds it missing
51
+ puts it back (from the local cache — instant, no download). If you hit this, one
52
+ `npx looop update` repairs it.
53
+ - `looop lint` said "this engine ships no rules yet" when the real problem was that
54
+ the engine was not installed at all — sending you to `looop update`, the one
55
+ command that could not help. It now tells you which of the two it is.
56
+
17
57
  ## [0.1.12] - 2026-07-12
18
58
 
19
59
  ### Added
@@ -16,24 +16,32 @@
16
16
  // duplicate the room runtime and, worse, let a primitive smuggle its own room
17
17
  // class past the setInterval/park cost boundary. Keeping it external means the
18
18
  // worker's room code is always ours.
19
- import { existsSync } from 'node:fs';
19
+ import { assertNoOverrideCycle, PRIMITIVES_DIR, SERVER_BARREL, renderBarrel, scanPrimitives } from './primitives.mjs';
20
+ import { shadowResolvePlugin } from './shadow-resolve.mjs';
20
21
  import { join } from 'node:path';
21
22
 
22
- // The primitives barrel — same path the endpoint detects a server-backed game
23
- // by (SERVER_PRIMITIVES_PATH in game-server-deploy.ts).
24
- export const PRIMITIVES_ENTRY = '_local/primitives/index.js';
23
+ // The bundled barrel's path in the upload the same path the endpoint detects a
24
+ // server-backed game by (SERVER_PRIMITIVES_PATH in game-server-deploy.ts).
25
+ // The creator never writes it; the CLI generates it from the directory scan.
26
+ export const PRIMITIVES_ENTRY = SERVER_BARREL;
25
27
 
26
28
  // The module name the generated worker entry imports the runtime from; every
27
29
  // engine import in the primitives bundle is rewritten to this.
28
30
  export const ENGINE_ARTIFACT_MODULE = './rooms-runtime.js';
29
31
 
30
32
  export async function bundlePrimitives(projectDir, { esbuildImpl } = {}) {
31
- const entry = join(projectDir, PRIMITIVES_ENTRY);
32
- if (!existsSync(entry)) {
33
+ // The barrel is GENERATED, not authored. A creator adds a primitive by adding
34
+ // a file to `overrides/shared/ui/room/primitives/` — there is no list to keep
35
+ // in sync, and therefore no way for the list and the folder to disagree.
36
+ const primitives = scanPrimitives(projectDir); // throws on a reserved index.js
37
+ if (!primitives.length) {
33
38
  throw new Error(
34
- `no server primitives to bundle — ${PRIMITIVES_ENTRY} not found (this is not a server-backed game)`,
39
+ `no server primitives to bundle — ${PRIMITIVES_DIR}/ is empty (this is not a server-backed game)`,
35
40
  );
36
41
  }
42
+ const engineShared = join(projectDir, 'node_modules/@looop-games/engine/shared');
43
+ assertNoOverrideCycle(primitives, projectDir, engineShared);
44
+
37
45
  const esbuild = esbuildImpl ?? (await import('esbuild'));
38
46
 
39
47
  // Every engine specifier the primitives import, captured for reporting.
@@ -56,7 +64,16 @@ export async function bundlePrimitives(projectDir, { esbuildImpl } = {}) {
56
64
  };
57
65
 
58
66
  const result = await esbuild.build({
59
- entryPoints: [entry],
67
+ // No entry file on disk: the generated barrel is fed straight in, resolved
68
+ // against the game root so `./overrides/...` means what it says. Same shape
69
+ // the dev room server uses (lib/room-server.mjs) — one contract, two builds.
70
+ stdin: {
71
+ contents: renderBarrel(primitives),
72
+ resolveDir: projectDir,
73
+ sourcefile: 'looop-primitives.js',
74
+ loader: 'js',
75
+ },
76
+ absWorkingDir: projectDir,
60
77
  bundle: true,
61
78
  write: false,
62
79
  format: 'esm',
@@ -70,7 +87,19 @@ export async function bundlePrimitives(projectDir, { esbuildImpl } = {}) {
70
87
  // Inline WASM as bytes so the bundle stays ONE self-contained module — no
71
88
  // extra CompiledWasm part to thread through the 3-module upload.
72
89
  loader: { '.wasm': 'binary' },
73
- plugins: [enginePlugin],
90
+ plugins: [
91
+ // An override's relative imports fall through to the engine, as they do in
92
+ // the browser — and here the engine is EXTERNAL, so they land on the
93
+ // release's pre-bundled runtime rather than being inlined. That is what
94
+ // makes `import { PlayerBody } from './engine.js'` work inside an override.
95
+ shadowResolvePlugin({
96
+ projectDir,
97
+ sharedDir: engineShared,
98
+ engineExternal: ENGINE_ARTIFACT_MODULE,
99
+ onEngineHit: (spec) => externals.push(spec),
100
+ }),
101
+ enginePlugin,
102
+ ],
74
103
  });
75
104
 
76
105
  return { source: result.outputFiles[0].text, externals };
package/lib/dev.mjs CHANGED
@@ -17,6 +17,8 @@ import { createStaticServer } from './static-server.mjs';
17
17
  import { createLlmShim, DEFAULT_API_BASE } from './llm-shim.mjs';
18
18
  import { getToken, getApiBase } from './config.mjs';
19
19
  import { portsFor, portInUse, killPort, lanIp } from './ports.mjs';
20
+ import { assertNoInertOverride, scanPrimitives } from './primitives.mjs';
21
+ import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
20
22
 
21
23
  // Resolve the partykit CLI entry from OUR dependencies (the game never
22
24
  // declares partykit; it rides @looop-games/cli). partykit's `exports` map hides
@@ -36,27 +38,14 @@ export function partykitBin() {
36
38
  }
37
39
  }
38
40
 
39
- // Per-game server parity: a game shipping its own
40
- // `partykit.json` runs its OWN server in dev — partykit gets the game folder
41
- // as cwd, resolving the game's `main` entry (which imports the room server
42
- // from the installed engine). Without one, the bundle's shared room server
43
- // runs, exactly as before. A config whose `main`
44
- // doesn't exist is ignored LOUDLY a dead file must not take dev's
45
- // multiplayer down with it.
46
- export function partykitCwdFor(project, engine, warn = console.warn) {
47
- const configPath = join(project.dir, 'partykit.json');
48
- if (existsSync(configPath)) {
49
- try {
50
- const config = JSON.parse(readFileSync(configPath, 'utf8'));
51
- const main = config?.main ?? 'server.js';
52
- if (existsSync(join(project.dir, main))) return project.dir;
53
- warn(`partykit.json found but its main (${main}) doesn't exist — running the shared room server instead`);
54
- } catch (err) {
55
- warn(`partykit.json is unreadable (${err.message}) — running the shared room server instead`);
56
- }
57
- }
58
- return engine.roomServerDir;
59
- }
41
+ // Per-game server parity: a game with primitives of its own
42
+ // (overrides/shared/ui/room/primitives/) runs its OWN room server in dev, built
43
+ // from those classes. Without them, the engine's stock room server runs.
44
+ //
45
+ // The predicate is `isServerBacked` — the SAME one publish uses. It used to be
46
+ // "does a partykit.json exist", which nothing in this CLI ever creates, so dev
47
+ // and publish disagreed and a creator's primitive ran in production but not on
48
+ // their own machine. One question, one answer, both commands.
60
49
 
61
50
  export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', log = console.log } = {}) {
62
51
  const project = findProject(cwd);
@@ -67,6 +56,22 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
67
56
  const children = [];
68
57
  const servers = [];
69
58
 
59
+ // The game's own server code, if any. Built BEFORE any server binds a port —
60
+ // both because a malformed primitives folder should fail before we start
61
+ // taking over ports, and because this generates the merged browser registry
62
+ // the static server is about to mount. Building it after would leave a window
63
+ // where the page loads and predicts with the engine's classes instead.
64
+ //
65
+ // The inert-override check runs FIRST, before the scan. An override of, say,
66
+ // `primitives/effects.js` is a room-runtime file that merely happens to live in
67
+ // the primitives folder — scanned first, it would be reported as "a primitive
68
+ // with no default export", which is true and useless. Checked first, the
69
+ // creator is told the actual problem: the server cannot take this file.
70
+ assertNoInertOverride({ projectDir: project.dir, engine });
71
+ const serverPrimitives = scanPrimitives(project.dir);
72
+ const roomServerCwd = await buildDevRoomServer({ projectDir: project.dir, engine });
73
+ const ownServer = roomServerCwd !== engine.roomServerDir;
74
+
70
75
  const stop = () => {
71
76
  for (const c of children) {
72
77
  // partykit is npx-style: node wrapper forks workerd. Kill the tree.
@@ -88,14 +93,24 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
88
93
  log(`→ static :${ports.static} (in use — killing to take over)`);
89
94
  await killPort(ports.static);
90
95
  }
91
- // overrides/shared/… (Slice 2, Q1): the game's first-class engine
92
- // modifications mount ahead of the bundle and shadow it per-file — the same
93
- // shadowing the publish resolve step applies in production.
96
+ // overrides/shared/… : the game's engine modifications mount ahead of the
97
+ // bundle and shadow it per-file — the same shadowing the publish resolve step
98
+ // applies in production.
99
+ //
100
+ // Two override layers, in precedence order:
101
+ // 1. GENERATED (.looop/overrides/shared) — the merged primitive registry the
102
+ // CLI writes for a game with server primitives, so the client predicts
103
+ // with the game's classes. It goes first because it shadows a file
104
+ // (primitives/index.js) the creator is not allowed to write themselves.
105
+ // 2. THE CREATOR'S (overrides/shared) — everything they wrote.
106
+ // Then the engine. First mount that HAS the file wins (static-server.mjs).
107
+ const generatedOverridesDir = join(project.dir, DEV_OVERRIDES_DIR);
94
108
  const overridesDir = join(project.dir, 'overrides', 'shared');
95
109
  const staticServer = createStaticServer({
96
110
  slug: project.slug,
97
111
  mounts: [
98
112
  { url: `/games/${project.slug}/`, dir: project.dir },
113
+ { url: '/shared/', dir: generatedOverridesDir },
99
114
  { url: '/shared/', dir: overridesDir },
100
115
  { url: '/shared/', dir: engine.sharedDir },
101
116
  ],
@@ -113,12 +128,10 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
113
128
  log(`→ multiplayer :${ports.mp} (in use — killing to take over)`);
114
129
  await killPort(ports.mp);
115
130
  }
116
- const pkCwd = partykitCwdFor(project, engine, (m) => log(`[partykit] ${m}`));
117
- const ownServer = pkCwd === project.dir;
118
131
  const pk = spawn(
119
132
  process.execPath,
120
133
  [partykitBin(), 'dev', '--port', String(ports.mp), '--persist', join(tmpdir(), `looop-partykit-${ports.mp}`)],
121
- { cwd: pkCwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true },
134
+ { cwd: roomServerCwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true },
122
135
  );
123
136
  pk.stdout.on('data', () => {});
124
137
  pk.stderr.on('data', (d) => {
@@ -126,7 +139,9 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
126
139
  if (/error/i.test(s)) log(`[partykit] ${s.trim()}`);
127
140
  });
128
141
  children.push(pk);
129
- log(`→ multiplayer :${ports.mp} (partykit on ${ownServer ? "this game's OWN server" : "the bundle's room-server"}, pid ${pk.pid})`);
142
+ log(
143
+ `→ multiplayer :${ports.mp} (partykit on ${ownServer ? `this game's OWN server — ${serverPrimitives.map((p) => p.type).join(', ')}` : "the engine's room server"}, pid ${pk.pid})`,
144
+ );
130
145
  }
131
146
 
132
147
  // ─── platform services shim ───
package/lib/lint.mjs CHANGED
@@ -38,17 +38,23 @@ async function loadFromProject(projectDir) {
38
38
  }
39
39
 
40
40
  // The shareable config, out of the installed engine artifact.
41
+ //
42
+ // The two ways this can come up empty are DIFFERENT problems with different
43
+ // fixes, and conflating them sends people in a circle: the first version of this
44
+ // said "engine ships no rules — run looop update" when the engine was not
45
+ // installed at all, so `looop update` was the one command that could never help.
46
+ // Say which it is.
41
47
  async function loadRulesFromEngine(projectDir) {
42
48
  let engineDir;
43
49
  try {
44
50
  ({ dir: engineDir } = resolveEngine(projectDir));
45
51
  } catch {
46
- return null; // no engine installed yet — `looop dev` installs it
52
+ return { configs: null, why: 'no-engine' };
47
53
  }
48
54
  const entry = join(engineDir, 'eslint', 'index.js');
49
- if (!existsSync(entry)) return null; // engine predates the rules
55
+ if (!existsSync(entry)) return { configs: null, why: 'engine-too-old' };
50
56
  const mod = await import(pathToFileURL(entry).href);
51
- return mod.configs ?? mod.default?.configs ?? null;
57
+ return { configs: mod.configs ?? mod.default?.configs ?? null, why: 'ok' };
52
58
  }
53
59
 
54
60
  export async function lintCmd({
@@ -68,10 +74,19 @@ export async function lintCmd({
68
74
  return { ok: true, skipped: true, errorCount: 0, warningCount: 0 };
69
75
  }
70
76
 
71
- const configs = await loadRules(project.dir);
77
+ const { configs, why } = await loadRules(project.dir);
72
78
  if (!configs) {
73
- log('⚠️ looop lint: this engine ships no rules yet — skipping.');
74
- log(' Get the latest engine (it carries them): looop update');
79
+ if (why === 'no-engine') {
80
+ // The engine is not on disk. Usually it was PRUNED: it installs with
81
+ // `npm install --no-save`, so npm has no record of it and any later
82
+ // `npm install` in this game deletes it. It is not lost — it is cached.
83
+ log('⚠️ looop lint: the Looop engine is not installed in this game — skipping.');
84
+ log(' (A plain `npm install` removes it; it is cached, so putting it back is instant.)');
85
+ log(' Restore it with: looop update');
86
+ } else {
87
+ log('⚠️ looop lint: your engine is older than the Looop rules — skipping.');
88
+ log(' Get an engine that carries them: looop update');
89
+ }
75
90
  return { ok: true, skipped: true, errorCount: 0, warningCount: 0 };
76
91
  }
77
92
 
@@ -0,0 +1,345 @@
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
+ // That collapses what used to be two mechanisms with two folders and two mental
11
+ // models (`overrides/shared/…` for the browser, `_local/primitives/index.js` for
12
+ // the server) into one. It also collapses two *actions* into one: the engine has
13
+ // a `player-body.js` and does not have a `gravity-well.js`, so overriding and
14
+ // adding are the same act — put a file at the path. The creator never has to
15
+ // know which one they're doing.
16
+ //
17
+ // FILENAME IS THE TYPE NAME. `gravity-well.js` → `{ type: 'gravity-well' }` in a
18
+ // world spec. A folder with an `index.js` works too (that's how the engine ships
19
+ // `intelligent-npc`). There is no barrel to maintain and no `extraPrimitives`
20
+ // argument to remember — forgetting that argument used to make the CLIENT
21
+ // predict with the ENGINE's class while the SERVER ran the creator's, so the two
22
+ // disagreed every tick and the game rubber-banded. It can't happen now: the
23
+ // registry is generated from the directory, for both sides, from this one scan.
24
+ //
25
+ // `index.js` in the primitives dir is RESERVED — it IS the generated registry.
26
+ // A hand-written one would shadow the engine's and silently unregister every
27
+ // engine primitive, so we reject it loudly instead.
28
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
29
+ import { join } from 'node:path';
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 `looop 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 = '_looop/primitives.js';
51
+ export const BROWSER_REGISTRY = `${PRIMITIVES_DIR}/index.js`;
52
+
53
+ // Emitted by the engine build (packages/engine/build.mjs): the DERIVED set of
54
+ // `/shared/...` modules a game may NOT override — the room-runtime bundle closure
55
+ // minus catalogued primitives minus the browser-registry seam. The build does the
56
+ // derivation once; this guard and the publish endpoint (resolvePublish) both read
57
+ // the same file, so there is no second copy to drift.
58
+ export const SERVER_RESERVED_MANIFEST = 'room-server/server-reserved.json';
59
+
60
+ // A primitive's source is `<type>.js` or `<type>/index.js`. Anything else next
61
+ // to it — tests, a README, a helper module a primitive imports — is not one.
62
+ const IS_TEST = /\.(test|smoke)\.(js|mjs)$/;
63
+
64
+ // `export default class Foo {}` / `export default Foo` / `export { Foo as default }`.
65
+ const HAS_DEFAULT_EXPORT = /^\s*export\s+default\b|^\s*export\s*\{[^}]*\bas\s+default\b/m;
66
+
67
+ function isPrimitiveFile(name) {
68
+ return name.endsWith('.js') && !IS_TEST.test(name) && name !== 'index.js';
69
+ }
70
+
71
+ // → [{ type, module }], sorted by type. `module` is the game-root-relative path
72
+ // to the class file, always POSIX-separated (it goes into generated source).
73
+ //
74
+ // Sorted because the output is bundled and content-hashed at publish, and a
75
+ // release is immutable: an unstable order would mean an unstable hash for
76
+ // bytes that never changed.
77
+ export function scanPrimitives(projectDir) {
78
+ const root = join(projectDir, PRIMITIVES_DIR);
79
+ if (!existsSync(root)) return [];
80
+
81
+ const found = [];
82
+ for (const name of readdirSync(root)) {
83
+ const abs = join(root, name);
84
+
85
+ if (statSync(abs).isDirectory()) {
86
+ if (existsSync(join(abs, 'index.js'))) {
87
+ found.push({ type: name, module: `${PRIMITIVES_DIR}/${name}/index.js` });
88
+ }
89
+ continue;
90
+ }
91
+
92
+ if (name === 'index.js') {
93
+ throw new Error(
94
+ `${PRIMITIVES_DIR}/index.js is reserved — it is the primitive registry, and Looop generates it for you.\n` +
95
+ 'Delete it. Every .js file in that folder is registered automatically, using its filename as the type name\n' +
96
+ "(so `gravity-well.js` is `{ type: 'gravity-well' }` in your world spec).",
97
+ );
98
+ }
99
+
100
+ if (isPrimitiveFile(name)) {
101
+ // Every top-level .js here IS a primitive — the filename is the type name,
102
+ // so there is nowhere for a plain helper module to hide. Catch it now, in
103
+ // the creator's own words, rather than as an esbuild error about a missing
104
+ // default export in a barrel they never wrote and cannot see.
105
+ if (!HAS_DEFAULT_EXPORT.test(readFileSync(abs, 'utf8'))) {
106
+ throw new Error(
107
+ `${PRIMITIVES_DIR}/${name} has no default export, so it cannot be a primitive.\n` +
108
+ 'A primitive is `export default class { ... }`, and its filename is its type name.\n' +
109
+ `If ${name} is a HELPER your primitive imports, move it out of this folder — anywhere else in\n` +
110
+ `your game works, or a subfolder here (e.g. ${PRIMITIVES_DIR}/lib/${name}), which is not scanned.`,
111
+ );
112
+ }
113
+ found.push({ type: name.slice(0, -3), module: `${PRIMITIVES_DIR}/${name}` });
114
+ }
115
+ }
116
+
117
+ found.sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0));
118
+ return found;
119
+ }
120
+
121
+ // Does this game run its own code on the server? The ONE predicate — `dev` and
122
+ // `publish` both ask it, so a game can never be server-backed in one and not the
123
+ // other. (They used to disagree: publish looked for `_local/primitives/index.js`
124
+ // while dev looked for a `partykit.json` that nothing ever created, so a
125
+ // creator's primitive ran in production and silently did not run in dev.)
126
+ export function isServerBacked(projectDir) {
127
+ return scanPrimitives(projectDir).length > 0;
128
+ }
129
+
130
+ // The one cycle shadowing makes possible, caught in the creator's own words.
131
+ //
132
+ // A NEW primitive may subclass an engine one — that is the RECOMMENDED way to
133
+ // change a primitive's behaviour, because you keep taking engine updates to the
134
+ // base and write only what differs:
135
+ //
136
+ // // overrides/shared/ui/room/primitives/terrain-walk.js ← a NEW name
137
+ // import { PlayerBody } from './engine.js';
138
+ // export default class TerrainWalk extends PlayerBody { … }
139
+ //
140
+ // A file that OVERRIDES `player-body` cannot do the same, and the reason is not
141
+ // obvious: `engine.js` imports `./player-body.js`, and that specifier now resolves
142
+ // to the game's own file. engine.js → your file → engine.js. In the browser that
143
+ // surfaces as "Cannot access 'PlayerBody' before initialization" at boot, which
144
+ // tells the creator nothing about what they actually did wrong.
145
+ //
146
+ // So: overriding a primitive in place is a FORK — self-contained, no engine.js.
147
+ // Want to subclass? Give it a new name. That is what this error says.
148
+ export function assertNoOverrideCycle(primitives, projectDir, sharedDir) {
149
+ const engineDir = join(sharedDir, 'ui', 'room', 'primitives');
150
+ for (const p of primitives) {
151
+ const shadowsEngine =
152
+ existsSync(join(engineDir, `${p.type}.js`)) || existsSync(join(engineDir, p.type, 'index.js'));
153
+ if (!shadowsEngine) continue;
154
+ if (!IMPORTS_ENGINE_JS.test(readFileSync(join(projectDir, p.module), 'utf8'))) continue;
155
+
156
+ throw new Error(
157
+ `${p.module} overrides the engine's '${p.type}' primitive AND imports './engine.js' — that is a cycle.\n` +
158
+ `engine.js imports './${p.type}.js', which is now YOUR file, which imports engine.js.\n` +
159
+ '\n' +
160
+ 'Two ways out:\n' +
161
+ ` • Subclass instead of overriding — rename your file (e.g. my-${p.type}.js), keep the\n` +
162
+ ` './engine.js' import, and use { type: 'my-${p.type}' } in your world spec. You keep\n` +
163
+ ' taking engine updates to the base class. This is usually what you want.\n' +
164
+ ` • Or keep the name and make it a FORK — drop the './engine.js' import and write the\n` +
165
+ ` primitive self-contained. It stops receiving engine updates, like any override.`,
166
+ );
167
+ }
168
+ }
169
+
170
+ // A real `import … from './engine.js'` STATEMENT — line-anchored, and not inside
171
+ // a comment.
172
+ //
173
+ // The first version of this was just /from ['"]\.\/engine\.js['"]/, which happily
174
+ // matched the sentence "…import the base from './engine.js'" in a code COMMENT and
175
+ // refused to build a primitive that was doing nothing wrong. A guard that fires on
176
+ // prose is worse than no guard: it cries wolf, and the next person turns it off.
177
+ const IMPORTS_ENGINE_JS = /^(?!\s*(?:\/\/|\*|\/\*))\s*import\b[^\n]*?\bfrom\s*['"]\.\/engine\.js['"]/m;
178
+
179
+ // The generated registry, as source. Imported by the dev room server and bundled
180
+ // into the per-game worker at publish; both feed it to `GameRoom.extraPrimitives()`.
181
+ //
182
+ // Paths are relative to the GAME ROOT — the barrel is written to `.looop/` but
183
+ // esbuild is given the game root as its resolve base, so `./overrides/...`
184
+ // resolves the same in dev and at publish.
185
+ export function renderBarrel(primitives) {
186
+ const lines = [
187
+ '// GENERATED by the Looop CLI from ' + PRIMITIVES_DIR + '/ — do not edit, do not commit.',
188
+ '//',
189
+ '// Every .js file in that folder is a server primitive, registered under its',
190
+ '// filename. Add one by adding a file; there is nothing else to wire up.',
191
+ '',
192
+ ];
193
+ primitives.forEach((p, i) => {
194
+ lines.push(`import P${i} from './${p.module}';`);
195
+ });
196
+ lines.push('');
197
+ lines.push('export const extraPrimitives = {');
198
+ primitives.forEach((p, i) => {
199
+ lines.push(` '${p.type}': P${i},`);
200
+ });
201
+ lines.push('};');
202
+ lines.push('');
203
+ return lines.join('\n');
204
+ }
205
+
206
+ // The BROWSER half: a shadow of the engine's primitive registry, merged.
207
+ //
208
+ // This is what lets the CLIENT predict with the game's own classes. Without it,
209
+ // the server runs the creator's primitive while the client predicts with the
210
+ // ENGINE's class of the same name — they disagree every tick and the game
211
+ // rubber-bands. That used to be the creator's problem to remember
212
+ // (`createRoom({ extraPrimitives })`); now it is nobody's, because the platform
213
+ // generates it.
214
+ //
215
+ // It is written to `overrides/shared/ui/room/primitives/index.js`, which shadows
216
+ // the engine's `index.js` — a deliberate one-line re-export of `engine.js` that
217
+ // exists ONLY so this merge is possible. A shadow cannot import the file it
218
+ // replaces (that's a cycle), so it reaches the engine's list through `engine.js`,
219
+ // which nothing shadows.
220
+ //
221
+ // The served path this registry (and the engine's) lives at — `PRIMITIVES_DIR`
222
+ // with the game-repo `overrides/` prefix stripped. Imports below are keyed to
223
+ // THIS, absolutely.
224
+ const SERVED_PRIMITIVES_DIR = `/${PRIMITIVES_DIR.replace(/^overrides\//, '')}`;
225
+
226
+ // Every import here is ABSOLUTE `/shared/...`, NOT relative — and that is
227
+ // load-bearing. This file is a shared module served content-addressed: in
228
+ // production it lands at `/_shared/<hash>/shared/ui/room/primitives/index.js`
229
+ // and `/shared/...` specifiers resolve through the game's import map. A RELATIVE
230
+ // `./engine.js` would instead resolve against that hashed URL — same hash prefix
231
+ // → the SAME (index.js) bytes come back, whose `default` export doesn't exist →
232
+ // the client predictor never loads and the game boots broken in prod while
233
+ // working in dev (where `/shared/` is served live and relative happens to work).
234
+ // So the generated file is born absolute, like every other shared module.
235
+ // '/shared/ui/room/primitives/engine.js' → not shadowed → the engine's list
236
+ // '/shared/ui/room/primitives/beacon.js' → shadowed → the game's primitive
237
+ export function renderBrowserRegistry(primitives) {
238
+ const lines = [
239
+ '// GENERATED by the Looop CLI from ' + PRIMITIVES_DIR + '/ — do not edit, do not commit.',
240
+ '//',
241
+ "// This shadows the engine's primitive registry and merges your primitives",
242
+ '// into it, so the client predicts your movement with YOUR code — the same',
243
+ '// code the room is running. There is nothing to wire up in your game.',
244
+ '',
245
+ `import { PRIMITIVES as ENGINE } from '${SERVED_PRIMITIVES_DIR}/engine.js';`,
246
+ ];
247
+ primitives.forEach((p, i) => {
248
+ // Sibling paths INSIDE the shadowed folder — strip the repo-relative prefix.
249
+ const rel = p.module.slice(PRIMITIVES_DIR.length + 1);
250
+ lines.push(`import P${i} from '${SERVED_PRIMITIVES_DIR}/${rel}';`);
251
+ });
252
+ lines.push('');
253
+ lines.push('export const PRIMITIVES = {');
254
+ // ...ENGINE FIRST: a game primitive sharing an engine primitive's name must
255
+ // WIN. Reverse these and an override would be silently clobbered by the engine.
256
+ lines.push(' ...ENGINE,');
257
+ primitives.forEach((p, i) => {
258
+ lines.push(` '${p.type}': P${i},`);
259
+ });
260
+ lines.push('};');
261
+ lines.push('');
262
+ return lines.join('\n');
263
+ }
264
+
265
+ // ── The silent no-op, made loud ──────────────────────────────────────────────
266
+ //
267
+ // Shadowing is a BROWSER mechanism: the dev server and the published import map
268
+ // serve the game's copy of `/shared/<path>` instead of the engine's. The room
269
+ // SERVER never resolves `/shared/...` at all — it runs `rooms-runtime.js`, which
270
+ // esbuild bundled at engine-build time, long before this game existed.
271
+ //
272
+ // So shadowing a file that is inside that bundle gets you a SPLIT BRAIN: your
273
+ // browser runs your copy, the room keeps running the engine's, the two disagree
274
+ // every tick, and nothing anywhere says why. Before this guard the entire class
275
+ // was accepted in silence — the file uploaded, served, and simply never ran on
276
+ // the server.
277
+ //
278
+ // The one exception is a primitive CLASS. That is the supported case, and the
279
+ // reason it works is that the CLI does NOT rely on shadowing to get it there: it
280
+ // bundles the class into the game's own room server and injects it through
281
+ // `extraPrimitives`, so both sides run the same code.
282
+ //
283
+ // WHY THE LIST IS DERIVED, NOT WRITTEN
284
+ //
285
+ // The closure is deep and it moves: server.js → runtime.js → host-core.js →
286
+ // tags.js, body-integration.js, wallet-credit.js, room-identity.js,
287
+ // cannon-physics.js … and `/shared/ui/jump/jump.js`, which nobody would ever
288
+ // have guessed belongs on a hand-written list. A list that is 90% right is worse
289
+ // than none: the 10% it misses is a guard that silently stopped guarding. So the
290
+ // engine build emits the exact set (metafile closure minus catalogued primitives
291
+ // minus the browser-registry seam) and we read it — the same file the publish
292
+ // endpoint reads, so the CLI's answer and the server's cannot diverge.
293
+ function reservedModules(engine) {
294
+ const manifest = join(engine.dir, SERVER_RESERVED_MANIFEST);
295
+ // A game pinned to an engine released before this guard existed has no
296
+ // manifest. That engine also has no override support worth guarding, so skip
297
+ // the check rather than refusing to build.
298
+ if (!existsSync(manifest)) return null;
299
+ try {
300
+ return new Set(JSON.parse(readFileSync(manifest, 'utf8')));
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+
306
+ // Walk the game's authored overrides — `overrides/shared/**` — yielding the
307
+ // engine path each one claims to be (`/shared/ui/room/host-core.js`).
308
+ function* authoredOverrides(projectDir) {
309
+ const root = join(projectDir, 'overrides', 'shared');
310
+ if (!existsSync(root)) return;
311
+ const walk = function* (dir, rel) {
312
+ for (const name of readdirSync(dir)) {
313
+ const abs = join(dir, name);
314
+ const next = rel ? `${rel}/${name}` : name;
315
+ if (statSync(abs).isDirectory()) yield* walk(abs, next);
316
+ else if (name.endsWith('.js') && !IS_TEST.test(name)) yield { abs, shared: `/shared/${next}` };
317
+ }
318
+ };
319
+ yield* walk(root, '');
320
+ }
321
+
322
+ export function assertNoInertOverride({ projectDir, engine }) {
323
+ const reserved = reservedModules(engine);
324
+ if (!reserved) return;
325
+
326
+ for (const { shared } of authoredOverrides(projectDir)) {
327
+ if (!reserved.has(shared)) continue; // overridable (primitive or browser-only)
328
+
329
+ const rel = shared.replace('/shared/', '');
330
+ throw new Error(
331
+ `overrides/shared/${rel} cannot be overridden — the room server runs this file, and it CANNOT take your copy.\n` +
332
+ '\n' +
333
+ 'Your override would reach the browser but not the room: the server runs a runtime that was\n' +
334
+ 'bundled before your game existed. Your client and the room would then be running different\n' +
335
+ 'code, disagreeing every tick, with nothing to tell you why. So this is an error, not a warning.\n' +
336
+ '\n' +
337
+ 'What you CAN override on the server is a primitive — a file in\n' +
338
+ ` ${PRIMITIVES_DIR}/<name>.js\n` +
339
+ 'Those are bundled into your game\'s own room server, so they run on BOTH sides.\n' +
340
+ '\n' +
341
+ 'If you need behaviour this file has, put it in a primitive and use that instead.\n' +
342
+ 'If you think the engine itself should change here, send it with `looop feedback`.',
343
+ );
344
+ }
345
+ }
package/lib/publish.mjs CHANGED
@@ -16,7 +16,14 @@ import { findProject } from './project.mjs';
16
16
  import { ensureEngine } from './engine.mjs';
17
17
  import { getToken, getApiBase } from './config.mjs';
18
18
  import { DEFAULT_API_BASE } from './llm-shim.mjs';
19
- import { bundlePrimitives, PRIMITIVES_ENTRY } from './bundle-primitives.mjs';
19
+ import { bundlePrimitives } from './bundle-primitives.mjs';
20
+ import {
21
+ BROWSER_REGISTRY,
22
+ SERVER_BARREL,
23
+ assertNoInertOverride,
24
+ renderBrowserRegistry,
25
+ scanPrimitives,
26
+ } from './primitives.mjs';
20
27
 
21
28
  export const PLAY_BASE = 'https://play.looop.games';
22
29
 
@@ -68,15 +75,37 @@ export async function publish({
68
75
  throw new Error('this game has no index.html at its root — publish needs an entry page.');
69
76
  }
70
77
 
71
- // Server-backed game: its primitives can
72
- // import npm packages + WASM the platform's bundler-less endpoint can't
73
- // resolve, so we bundle them HERE (engine marked external) and send the
74
- // single self-contained module in place of the raw barrel. The endpoint
75
- // assembles the worker from it + our generated entry + the release runtime.
76
- if (files.has(PRIMITIVES_ENTRY)) {
78
+ // Server-backed game it has primitives of its own. The SAME predicate `looop
79
+ // dev` uses (isServerBacked), so a game can never be server-backed in one and
80
+ // not the other. It used to be "does the upload contain _local/primitives/
81
+ // index.js" here and "does a partykit.json exist" there, and they disagreed:
82
+ // a creator's primitive ran in production and silently not on their machine.
83
+ //
84
+ // Two files are GENERATED into the upload; neither is in the creator's git:
85
+ //
86
+ // the SERVER barrel — bundled here, not at the endpoint (a Pages Function has
87
+ // no bundler, and real primitives import npm packages and WASM). The engine
88
+ // is marked external and rewritten to the release's pre-bundled runtime, so
89
+ // the worker's room code is always ours.
90
+ //
91
+ // the BROWSER registry — shadows the engine's primitive registry, merged, so
92
+ // the CLIENT predicts with the game's classes too. Without it the server
93
+ // runs the creator's primitive while the client predicts with the engine's
94
+ // class of the same name, and the game rubber-bands. That used to be the
95
+ // creator's job to remember (`createRoom({ extraPrimitives })`).
96
+ // Refuse to SHIP an override the server can never honour. dev catches this
97
+ // first, but publish must not trust that dev ran: the whole failure mode we
98
+ // are killing is an override that uploads, serves, and silently never runs.
99
+ assertNoInertOverride({ projectDir: project.dir, engine });
100
+ const serverPrimitives = scanPrimitives(project.dir); // throws on a reserved index.js
101
+ if (serverPrimitives.length) {
77
102
  const { source, externals } = await bundlePrimitives(project.dir);
78
- files.set(PRIMITIVES_ENTRY, Buffer.from(source, 'utf8'));
79
- log(`Bundled server primitives (${(source.length / 1024).toFixed(0)} KB, engine external${externals.length ? `: ${[...new Set(externals)].length} module(s)` : ''}).`);
103
+ files.set(SERVER_BARREL, Buffer.from(source, 'utf8'));
104
+ files.set(BROWSER_REGISTRY, Buffer.from(renderBrowserRegistry(serverPrimitives), 'utf8'));
105
+ const names = serverPrimitives.map((p) => p.type).join(', ');
106
+ log(
107
+ `Bundled server primitives — ${names} (${(source.length / 1024).toFixed(0)} KB, engine external${externals.length ? `: ${[...new Set(externals)].length} module(s)` : ''}).`,
108
+ );
80
109
  }
81
110
 
82
111
  const meta = { slug: targetSlug, entry: 'index.html', engineVersion: engine.version, files: {} };
@@ -0,0 +1,166 @@
1
+ // The dev room server — the half of "you can run your own code on the server"
2
+ // that was never built.
3
+ //
4
+ // `looop publish` has always assembled a per-game Worker from a creator's
5
+ // primitives. `looop 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 { mkdirSync, rmSync, writeFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import {
29
+ assertNoOverrideCycle,
30
+ isServerBacked,
31
+ renderBarrel,
32
+ renderBrowserRegistry,
33
+ scanPrimitives,
34
+ } from './primitives.mjs';
35
+ import { shadowResolvePlugin } from './shadow-resolve.mjs';
36
+
37
+ // Build output, not source: gitignored by the scaffold and skipped by publish's
38
+ // file collection. The creator never opens it.
39
+ export const DEV_ROOM_SERVER_DIR = '.looop/room-server';
40
+
41
+ // The generated BROWSER registry lands here, and `looop dev` mounts it at
42
+ // `/shared/` AHEAD of both the creator's own overrides/ and the engine — so the
43
+ // client's primitive registry is `{ ...engine, ...the game's }`.
44
+ //
45
+ // It cannot live in the creator's `overrides/shared/` for two reasons:
46
+ // `index.js` there is RESERVED (a hand-written one would silently unregister
47
+ // every engine primitive), and a build artifact has no business in their repo.
48
+ // At publish the same bytes are injected straight into the upload, so neither
49
+ // path ever touches their git.
50
+ export const DEV_OVERRIDES_DIR = '.looop/overrides/shared';
51
+
52
+ // Resolve the `/shared/...` dev alias to the installed engine. Everything a
53
+ // primitive imports through it — and the room server itself — comes from the
54
+ // exact engine version this game is pinned to.
55
+ function engineAliasPlugin(sharedDir) {
56
+ return {
57
+ name: 'looop-shared-alias',
58
+ setup(build) {
59
+ build.onResolve({ filter: /^\/shared\// }, (args) => ({
60
+ path: join(sharedDir, args.path.slice('/shared/'.length)),
61
+ }));
62
+ // Workers-runtime built-ins are provided at execution; never bundle them.
63
+ build.onResolve({ filter: /^cloudflare:/ }, (args) => ({ path: args.path, external: true }));
64
+ },
65
+ };
66
+ }
67
+
68
+ // The room the game actually gets: the engine's room class, subclassed to hand
69
+ // the host the creator's primitives through the `extraPrimitives()` seam the
70
+ // engine already reads (server.js). Identical in shape to what the publish
71
+ // endpoint generates for the per-game Worker — one contract, two builders.
72
+ function renderEntry(primitives) {
73
+ return [
74
+ renderBarrel(primitives),
75
+ "import LooopRoom from '/shared/ui/room/server.js';",
76
+ '',
77
+ 'export default class GameRoom extends LooopRoom {',
78
+ ' extraPrimitives() { return extraPrimitives; }',
79
+ '}',
80
+ '',
81
+ ].join('\n');
82
+ }
83
+
84
+ // → the cwd to run partykit in. The engine's stock room server when the game has
85
+ // no primitives of its own; a freshly-built one when it does.
86
+ //
87
+ // Throws if the primitives dir is malformed (e.g. a hand-written index.js) —
88
+ // dev must not quietly serve a room that is missing the creator's code.
89
+ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
90
+ const primitives = scanPrimitives(projectDir); // throws on a reserved index.js
91
+ if (!primitives.length) {
92
+ // Not server-backed (any more). A PREVIOUS run may have generated a room
93
+ // server and a registry shadow; both must go, or dev keeps mounting a stale
94
+ // `.looop/overrides/shared/.../index.js` that imports a primitive the creator
95
+ // has since deleted — an ES-import 404 that stops the whole room library
96
+ // loading. The mount is unconditional (dev.mjs), so the cleanup has to be too.
97
+ rmSync(join(projectDir, DEV_ROOM_SERVER_DIR), { recursive: true, force: true });
98
+ rmSync(join(projectDir, DEV_OVERRIDES_DIR), { recursive: true, force: true });
99
+ return engine.roomServerDir;
100
+ }
101
+ assertNoOverrideCycle(primitives, projectDir, engine.sharedDir);
102
+
103
+ const esbuild = esbuildImpl ?? (await import('esbuild'));
104
+ const outDir = join(projectDir, DEV_ROOM_SERVER_DIR);
105
+ mkdirSync(outDir, { recursive: true });
106
+
107
+ await esbuild.build({
108
+ // No entry file on disk: the barrel + subclass are fed straight in, resolved
109
+ // against the game root, so `./overrides/...` means what it says and no
110
+ // machine-specific absolute path can leak into the output.
111
+ stdin: {
112
+ contents: renderEntry(primitives),
113
+ resolveDir: projectDir,
114
+ sourcefile: 'looop-room-server.js',
115
+ loader: 'js',
116
+ },
117
+ outfile: join(outDir, 'server.js'),
118
+ bundle: true,
119
+ format: 'esm',
120
+ // workerd is browser-shaped, and so is the engine's own room runtime build.
121
+ platform: 'browser',
122
+ target: 'esnext',
123
+ legalComments: 'none',
124
+ conditions: ['workerd', 'worker'],
125
+ loader: { '.wasm': 'binary' },
126
+ absWorkingDir: projectDir,
127
+ plugins: [
128
+ // Shadowing, for the bundler: an override's relative imports fall through
129
+ // to the engine exactly as they do in the browser. This is what lets an
130
+ // override subclass the primitive it overrides (`import { PlayerBody }
131
+ // from './engine.js'`) — it cannot import its own path.
132
+ shadowResolvePlugin({ projectDir, sharedDir: engine.sharedDir }),
133
+ engineAliasPlugin(engine.sharedDir),
134
+ ],
135
+ });
136
+
137
+ writeFileSync(
138
+ join(outDir, 'partykit.json'),
139
+ JSON.stringify(
140
+ {
141
+ $schema: 'https://www.partykit.io/schema.json',
142
+ name: 'looop-dev',
143
+ main: 'server.js',
144
+ compatibilityDate: '2024-09-23',
145
+ },
146
+ null,
147
+ 2,
148
+ ) + '\n',
149
+ );
150
+
151
+ // The BROWSER half. Building the room server fixes the SERVER; without this
152
+ // the client would still predict with the ENGINE's class of the same name,
153
+ // disagree with the server every tick, and rubber-band. Served by dev at
154
+ // `/shared/ui/room/primitives/index.js`, shadowing the engine's. Its imports
155
+ // are ABSOLUTE `/shared/...` (see renderBrowserRegistry) so they resolve the
156
+ // same in dev (static mount fall-through) and in production (the import map):
157
+ // /shared/ui/room/primitives/engine.js → not shadowed → the engine's list
158
+ // /shared/ui/room/primitives/beacon.js → shadowed → the game's primitive
159
+ const registryDir = join(projectDir, DEV_OVERRIDES_DIR, 'ui/room/primitives');
160
+ mkdirSync(registryDir, { recursive: true });
161
+ writeFileSync(join(registryDir, 'index.js'), renderBrowserRegistry(primitives));
162
+
163
+ return outDir;
164
+ }
165
+
166
+ export { isServerBacked };
@@ -0,0 +1,85 @@
1
+ // Shadowing, for the BUNDLER.
2
+ //
3
+ // `overrides/shared/<engine path>` works in the browser because the dev server
4
+ // (and, in production, the import map) resolves each `/shared/...` request
5
+ // against the game's overrides FIRST and the engine SECOND — per file. A game's
6
+ // override sits at the engine's own URL, so its neighbours resolve to the engine's.
7
+ //
8
+ // esbuild has no such notion. It resolves relative imports against the real
9
+ // filesystem, where the game's overrides folder holds only the one or two files
10
+ // the creator actually wrote. So this:
11
+ //
12
+ // // overrides/shared/ui/room/primitives/player-body.js
13
+ // import { PlayerBody } from './engine.js'; // ← the escape hatch
14
+ //
15
+ // resolves in the browser (engine.js is right there at /shared/ui/room/primitives/)
16
+ // and dies in the bundler ("Could not resolve ./engine.js") — the game's folder
17
+ // has no engine.js, and never will: it's the engine's.
18
+ //
19
+ // That import is not incidental. It is the ONLY way an override can subclass the
20
+ // primitive it overrides — it cannot import its own path, which is itself — and
21
+ // "change one method" is the most common reason to override anything. So the
22
+ // bundler has to learn the same fall-through the servers already do:
23
+ //
24
+ // a relative import from inside overrides/shared/…
25
+ // → the game's file, if it has one
26
+ // → otherwise the engine's file at the mirrored path
27
+ import { existsSync, realpathSync } from 'node:fs';
28
+ import { dirname, join, relative, resolve, sep } from 'node:path';
29
+
30
+ const OVERRIDES_ROOT = join('overrides', 'shared');
31
+
32
+ // esbuild hands us importer paths with symlinks already resolved, while the
33
+ // project dir arrives as the caller spelled it. On macOS that alone is enough to
34
+ // break the comparison below — a temp dir is `/var/…` to us and `/private/var/…`
35
+ // to esbuild — and the fall-through would silently never fire, leaving the
36
+ // creator with "Could not resolve ./engine.js" and no idea why.
37
+ function realOrSelf(p) {
38
+ try {
39
+ return realpathSync(p);
40
+ } catch {
41
+ return p; // doesn't exist yet — compare as given
42
+ }
43
+ }
44
+
45
+ // Is `file` inside the game's overrides/shared/ tree?
46
+ function underOverrides(file, overridesRoot) {
47
+ const rel = relative(overridesRoot, file);
48
+ return rel !== '' && !rel.startsWith('..') && !rel.startsWith(sep);
49
+ }
50
+
51
+ // engineExternal: at PUBLISH the engine is never bundled in — it is marked
52
+ // external and rewritten to the release's pre-bundled `rooms-runtime.js`, so the
53
+ // worker's room code is always ours. Pass the module specifier to rewrite to.
54
+ // In DEV we build the whole room server ourselves, so the engine's real file on
55
+ // disk is exactly what we want.
56
+ export function shadowResolvePlugin({ projectDir, sharedDir, engineExternal = null, onEngineHit = () => {} }) {
57
+ const overridesRoot = realOrSelf(join(projectDir, OVERRIDES_ROOT));
58
+
59
+ return {
60
+ name: 'looop-shadow-resolve',
61
+ setup(build) {
62
+ build.onResolve({ filter: /^\.{1,2}\// }, (args) => {
63
+ if (!args.importer) return null;
64
+ // Everything below is computed in real-path space, so the overrides-root
65
+ // comparison and the shadow mapping can't disagree about the same file.
66
+ const importer = realOrSelf(args.importer);
67
+ if (!underOverrides(importer, overridesRoot)) return null;
68
+
69
+ const candidate = resolve(dirname(importer), args.path);
70
+
71
+ // The game has this file — its own primitive, its own helper. Use it.
72
+ if (existsSync(candidate)) return { path: candidate };
73
+
74
+ // It doesn't. In the browser this would have fallen through to the
75
+ // engine, so do the same here: map the path back across the shadow.
76
+ const rel = relative(overridesRoot, candidate);
77
+ const engineFile = join(sharedDir, rel);
78
+ if (!existsSync(engineFile)) return null; // let esbuild report it honestly
79
+
80
+ onEngineHit(`/shared/${rel.split(sep).join('/')}`);
81
+ return engineExternal ? { path: engineExternal, external: true } : { path: engineFile };
82
+ });
83
+ },
84
+ };
85
+ }
package/lib/update.mjs CHANGED
@@ -97,19 +97,52 @@ export async function update({
97
97
  .sort((a, b) => compareVersions(b.version, a.version))
98
98
  : null;
99
99
 
100
+ // ── The `looop` command FIRST, before the engine lands ─────────────────────
101
+ //
102
+ // Order is load-bearing, and getting it wrong deleted the engine. The engine is
103
+ // not an npm package — it installs with `npm install --no-save`, so npm has no
104
+ // record of it and treats it as extraneous. ANY later `npm install` in the game
105
+ // prunes it. syncCli runs exactly such an install (`--save-dev` the new CLI), so
106
+ // running it AFTER the engine install wiped the engine every single time: the
107
+ // update reported success and left node_modules/@looop-games/engine gone. The
108
+ // next `looop lint` then found no engine at all (caught on a real game).
109
+ //
110
+ // So: do every npm install that this command is going to do BEFORE the engine
111
+ // is put on disk, and let the engine be the last thing to land.
112
+ //
113
+ // Wrapped: syncCli is already fail-soft, and a bug in it must not stop the
114
+ // engine update that is the point of this command.
115
+ let cli;
116
+ try {
117
+ cli = await syncCliFn({ projectDir: project.dir, log });
118
+ } catch (err) {
119
+ cli = { updated: false, error: err };
120
+ }
121
+
100
122
  let engineDir = null;
101
123
  let updated = false;
102
124
 
103
125
  if (from === latest) {
104
- log(`✅ Engine ${latest} — already up to date.`);
105
126
  // Reconcile anyway. A repo can sit on the latest engine and STILL have an
106
127
  // out-of-date surface: one scaffolded before this mechanism existed has
107
128
  // never had its skills adopted, and would otherwise wait forever for a
108
129
  // release it already has.
109
130
  try {
110
131
  engineDir = resolveEngine(project.dir).dir;
132
+ log(`✅ Engine ${latest} — already up to date.`);
111
133
  } catch {
112
- engineDir = null; // engine not installed yet the next `dev` fetches it
134
+ // Pinned to the latest, but NOT on disk. The pin is a claim about what this
135
+ // game runs; node_modules is the truth, and they disagree — because a plain
136
+ // `npm install` (the creator's own, or ours above) prunes the engine, which
137
+ // npm never recorded. "Already up to date" while the engine is missing is a
138
+ // lie that leaves every engine-reading command broken, and re-running update
139
+ // could never fix it. Put it back — from the local cache, so this is fast and
140
+ // works offline.
141
+ log(`Engine ${latest} is pinned but missing from node_modules — reinstalling it.`);
142
+ const engine = await ensure(project.dir, { apiBase, log, fetchImpl });
143
+ engineDir = engine.dir ?? null;
144
+ log('');
145
+ log(`✅ Engine ${latest} — restored.`);
113
146
  }
114
147
  } else {
115
148
  // Rewrite the pin first; ensureEngine honors it (download → install → pin).
@@ -125,21 +158,18 @@ export async function update({
125
158
  if (!surface.skipped) report(log, surface.engineVersion ?? latest, surface);
126
159
 
127
160
  // The third lane: the `looop` command itself (see self-update.mjs). The skills
128
- // we just reconciled ship WITH the engine and can name any command they like —
129
- // riven ended up on an engine whose skills say `looop changelog` while its CLI
130
- // was four versions too old to have it. So update moves this too.
161
+ // we reconciled ship WITH the engine and can name any command they like — a real
162
+ // game ended up on an engine whose skills say `looop changelog` while its CLI was
163
+ // four versions too old to have it. So update moves this too.
131
164
  //
132
- // Wrapped: syncCli is already fail-soft, but a bug in it must not undo an
133
- // engine update that has already landed on disk.
134
- let cli;
135
- try {
136
- cli = await syncCliFn({ projectDir: project.dir, log });
137
- reportCli(log, cli);
138
- } catch (err) {
139
- cli = { updated: false, error: err };
165
+ // It already RAN, at the top: its npm install has to happen before the engine
166
+ // lands or it prunes it. This is only the report.
167
+ if (cli?.error) {
140
168
  log('');
141
- log(` The looop command could not be updated (${err.message}).`);
169
+ log(` The looop command could not be updated (${cli.error.message}).`);
142
170
  log(' Your engine and skills are up to date. Retry with: npm update @looop-games/cli');
171
+ } else {
172
+ reportCli(log, cli);
143
173
  }
144
174
 
145
175
  if (updated) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",