@looop-games/cli 0.1.28 → 0.1.30

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,39 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.30] - 2026-08-21
18
+
19
+ ### Fixed
20
+ - `looop dev` now resolves `three` for a 3D game with **no import map of its
21
+ own** — it maps the bare `three` specifier to the engine's bundled copy, the
22
+ same as the published serve path. Before this, deleting your CDN three import
23
+ map (as engine 0.2.3 tells you to) white-screened the game under `looop dev`,
24
+ because the dev server didn't yet supply `three`. Requires engine 0.2.3+.
25
+
26
+ ## [0.1.29] - 2026-08-19
27
+
28
+ ### Added
29
+ - **Framework v2 (the trait model) support across the whole CLI.** `looop
30
+ create` now scaffolds a v2 game: entities and traits as plain functions
31
+ importing only from the `looop` doors — no manifest files, no `/shared/ui`
32
+ imports. `looop dev`, `looop test`, and `looop publish` run v2 games: the
33
+ dev stack injects the `looop` import doors (`looop`, `looop/traits`,
34
+ `looop/client`, …) into the browser import map, serves the game off the
35
+ same lowered graph the room embeds, and `publish` refuses up front when a
36
+ game imports a door its pinned engine doesn't carry (previously it would
37
+ publish and then fail silently at serve). v2 games pair with engine 0.2.0
38
+ (`npx looop update`); v1 games are untouched. The engine side of v2 is in
39
+ the engine changelog.
40
+ - `looop dev` now actually runs **your game's server-side code**. Until now
41
+ dev fell through to the engine's stock room server, so a game's own
42
+ primitives were silently skipped on your machine and only ran in
43
+ production — the worst way round. When a game has server code, dev builds
44
+ a per-game room server for it and runs that instead.
45
+ - `looop test` now preloads the engine's module resolver into your unit tests
46
+ and smokes, so a test file can **`import { openWorld } from 'looop/test'` at
47
+ the top** like any other import — no per-file bootstrap. (Games on an engine
48
+ too old to ship the resolver run exactly as before.)
49
+
17
50
  ## [0.1.28] - 2026-08-18
18
51
 
19
52
  ### Added
package/lib/create.mjs CHANGED
@@ -113,6 +113,11 @@ async function scaffold({ dir, name, install, cliSpec, apiBase, log, ensure, rec
113
113
  name,
114
114
  private: true,
115
115
  description: `A Looop game. Play at https://play.looop.games/g/${name}`,
116
+ // The scaffold is a framework-v2 game (entities + traits + a world root,
117
+ // lowered to the engine runtime on this machine). This flag is what makes
118
+ // the room server build the v2 room and the serve path inject the v2
119
+ // import map + skeleton.
120
+ looop: { framework: 'v2' },
116
121
  scripts: { dev: 'looop dev', publish: 'looop publish', test: 'looop test', lint: 'looop lint' },
117
122
  devDependencies: { '@looop-games/cli': cliSpec, playwright: PLAYWRIGHT_SPEC, eslint: ESLINT_SPEC },
118
123
  allowScripts: ALLOW_SCRIPTS,
package/lib/dev.mjs CHANGED
@@ -17,7 +17,7 @@ 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 { resolvePorts, portInUse, killPort, lanIp } from './ports.mjs';
20
- import { assertNoInertOverride, scanPrimitives } from './primitives.mjs';
20
+ import { assertNoInertOverride, isFrameworkV2, scanPrimitives } from './primitives.mjs';
21
21
  import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
22
22
  import { createFileWatcher, createRoomReloader } from './room-reload.mjs';
23
23
  import { hasDeclaredAssets, assetsPageUrl } from './assets-page.mjs';
@@ -92,7 +92,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
92
92
  // creator is told the actual problem: the server cannot take this file.
93
93
  assertNoInertOverride({ projectDir: project.dir, engine });
94
94
  const serverPrimitives = scanPrimitives(project.dir);
95
- const { cwd: roomServerCwd, inputs: roomInputs } = await buildDevRoomServer({ projectDir: project.dir, engine });
95
+ const { cwd: roomServerCwd, inputs: roomInputs, skeleton: roomSkeleton = null } = await buildDevRoomServer({ projectDir: project.dir, engine });
96
96
  const ownServer = roomServerCwd !== engine.roomServerDir;
97
97
 
98
98
  const stop = () => {
@@ -142,6 +142,9 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
142
142
  { url: '/shared/', dir: engine.sharedDir },
143
143
  ],
144
144
  watchDirs: [project.dir, engine.sharedDir],
145
+ // Present only for a framework-v2 game — injects the looop import map + the
146
+ // lowered skeleton into the entry HTML so startGame() boots in the browser.
147
+ skeleton: roomSkeleton,
145
148
  });
146
149
  await staticServer.listen(ports.static);
147
150
  servers.push(staticServer);
@@ -212,8 +215,15 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
212
215
  }
213
216
  };
214
217
  const pk = spawnMp(roomServerCwd);
218
+ // A game's own room carries one of three kinds of server code — creator
219
+ // primitives (named), a framework-v2 lowered bundle, or v1 entity
220
+ // components — and only the first has a list to print. Naming the kind
221
+ // keeps a v2 or component-backed game from logging a dangling "— ".
222
+ const ownServerDesc = serverPrimitives.length
223
+ ? `this game's OWN server — ${serverPrimitives.map((p) => p.type).join(', ')}`
224
+ : `this game's OWN server — ${isFrameworkV2(project.dir) ? 'framework v2' : 'entity components'}`;
215
225
  log(
216
- `→ 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})`,
226
+ `→ multiplayer :${ports.mp} (partykit on ${ownServer ? ownServerDesc : "the engine's room server"}, pid ${pk.pid})`,
217
227
  );
218
228
 
219
229
  // The room bundle is frozen at build time, so the static watcher's page
@@ -238,6 +248,11 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
238
248
  log,
239
249
  onRebuilt: (built) => {
240
250
  roomWatcher?.setFiles(built.inputs);
251
+ // Push the rebuilt skeleton to the static server so the client injects
252
+ // the SAME lowered graph the room now embeds — otherwise a v2 hot-reload
253
+ // leaves the page on the startup skeleton and assembleGraph throws on the
254
+ // next load (client "re-ran N ticks but the skeleton has M").
255
+ staticServer.setSkeleton(built.skeleton ?? null);
241
256
  // A rebuild can flip which server the game runs on (first primitive
242
257
  // added, last one removed) — say so, or the startup line misleads for
243
258
  // the rest of the session.
package/lib/inject.mjs CHANGED
@@ -26,8 +26,38 @@ function jsJson(value) {
26
26
  return JSON.stringify(value).replaceAll('<', '\\u003c');
27
27
  }
28
28
 
29
- export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL } = {}) {
29
+ // A framework-v2 game imports the bare `looop[/subpath]` specifiers the only
30
+ // bare specifiers any browser game uses. The browser needs an import map to
31
+ // resolve them to the framework runtime under the (already-mounted) /shared/
32
+ // tree. This set is the import surface (shared/docs/api.md); keep it in sync
33
+ // with builder/functions/_shared/serve-import-map.ts V2_BARE_SPECIFIERS.
34
+ const V2_IMPORTS = {
35
+ looop: '/shared/framework/runtime/looop.js',
36
+ 'looop/traits': '/shared/framework/runtime/traits.js',
37
+ 'looop/client': '/shared/framework/runtime/client.js',
38
+ 'looop/renderers/canvas': '/shared/framework/runtime/renderers/canvas.js',
39
+ 'looop/renderers/three': '/shared/framework/runtime/renderers/three.js',
40
+ // The real harness (runtime/test.js) is Node-only. The browser gets a shim that
41
+ // throws a clear "Node-only" error, so a stray `import 'looop/test'` in game code
42
+ // never white-screens on a node:fs load. Node resolves looop/test via the build
43
+ // register hook (expand.js), which points at the real test.js regardless.
44
+ 'looop/test': '/shared/framework/runtime/test-browser.js',
45
+ // three, mapped to the engine's own vendored same-origin copy so a three-backed
46
+ // game needs no import map of its own (kept in sync with serve-import-map.ts).
47
+ three: '/shared/ui/room/vendor/three.js',
48
+ };
49
+
50
+ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL, skeleton = null } = {}) {
30
51
  const parts = [];
52
+ // A framework-v2 game resolves `looop`/`looop/client` through an import map and
53
+ // reads its lowered graph from globalThis.__LOOOP_SKELETON__. BOTH must precede
54
+ // any module script (the platform tag AND game.js): a browser ignores an import
55
+ // map once a module has begun executing, and startGame() reads the skeleton at
56
+ // module-eval time. So they go FIRST.
57
+ if (skeleton) {
58
+ parts.push(`<script type="importmap">${jsJson({ imports: V2_IMPORTS })}</script>`);
59
+ parts.push(`<script>globalThis.__LOOOP_SKELETON__ = ${jsJson(skeleton)};</script>`);
60
+ }
31
61
  if (slug) parts.push(`<script>window.GAME_SLUG = ${jsJson(slug)};</script>`);
32
62
  parts.push(`<script>window.LOOOP_IDENTITY = ${jsJson(identity)};</script>`);
33
63
  parts.push(
@@ -50,6 +50,13 @@ export const PRIMITIVES_DIR = 'overrides/shared/ui/room/primitives';
50
50
  export const SERVER_BARREL = '_looop/primitives.js';
51
51
  export const BROWSER_REGISTRY = `${PRIMITIVES_DIR}/index.js`;
52
52
 
53
+ // A framework-v2 game's publish upload carries its lowered room MODULE here (in
54
+ // place of a primitives barrel): the CLI-bundled framework runtime that computes
55
+ // the v1 trio at load and exports it as `frameworkV2Bundle`. Its presence is
56
+ // what makes the publish endpoint pick the v2 worker entry. MUST match
57
+ // builder/functions/_shared/game-server-deploy.ts SERVER_V2_ROOM_PATH.
58
+ export const SERVER_V2_ROOM = '_looop/v2-room.js';
59
+
53
60
  // Emitted by the engine build (packages/engine/build.mjs): the DERIVED set of
54
61
  // `/shared/...` modules a game may NOT override — the room-runtime bundle closure
55
62
  // minus catalogued primitives minus the browser-registry seam. The build does the
@@ -122,6 +129,22 @@ export function scanPrimitives(projectDir) {
122
129
  return found;
123
130
  }
124
131
 
132
+ // A framework-v2 game (package.json `looop.framework === 'v2'`) authors
133
+ // entities + traits + a world.js and lowers to v1 artifacts at build time.
134
+ // It always runs its own (lowered) code on the server, so it is server-backed
135
+ // by definition — even with no overrides/ or components/ folder. The room
136
+ // server for it is built by the framework compiler (buildV2RoomServer), not
137
+ // the primitive scanner.
138
+ export function isFrameworkV2(projectDir) {
139
+ const pkgPath = join(projectDir, 'package.json');
140
+ if (!existsSync(pkgPath)) return false;
141
+ try {
142
+ return JSON.parse(readFileSync(pkgPath, 'utf8'))?.looop?.framework === 'v2';
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
125
148
  // Does this game run its own code on the server? The ONE predicate — `dev` and
126
149
  // `publish` both ask it, so a game can never be server-backed in one and not the
127
150
  // other. (They used to disagree: publish looked for `_local/primitives/index.js`
@@ -133,6 +156,7 @@ export function scanPrimitives(projectDir) {
133
156
  // entity half is a cheap folder check, not a scan — a malformed component
134
157
  // still makes the game server-backed; the build is where it fails loudly.
135
158
  export function isServerBacked(projectDir) {
159
+ if (isFrameworkV2(projectDir)) return true;
136
160
  if (scanPrimitives(projectDir).length > 0) return true;
137
161
  if (existsSync(join(projectDir, 'components'))) return true;
138
162
  const entitiesDir = join(projectDir, 'entities');
package/lib/publish.mjs CHANGED
@@ -19,11 +19,13 @@ import { ensureEngine } from './engine.mjs';
19
19
  import { getToken, getApiBase } from './config.mjs';
20
20
  import { DEFAULT_API_BASE } from './llm-shim.mjs';
21
21
  import { bundlePrimitives } from './bundle-primitives.mjs';
22
- import { scanEntityComponents } from './room-server.mjs';
22
+ import { scanEntityComponents, buildV2PublishModule } from './room-server.mjs';
23
23
  import {
24
24
  BROWSER_REGISTRY,
25
25
  SERVER_BARREL,
26
+ SERVER_V2_ROOM,
26
27
  assertNoInertOverride,
28
+ isFrameworkV2,
27
29
  renderBrowserRegistry,
28
30
  scanPrimitives,
29
31
  } from './primitives.mjs';
@@ -170,28 +172,41 @@ export async function publish({
170
172
  // Refuse to SHIP an override the server can never honour. dev catches this
171
173
  // first, but publish must not trust that dev ran: the whole failure mode we
172
174
  // are killing is an override that uploads, serves, and silently never runs.
173
- assertNoInertOverride({ projectDir: project.dir, engine });
174
- const serverPrimitives = scanPrimitives(project.dir); // throws on a reserved index.js
175
- const entityComponents = await scanEntityComponents(project.dir, engine.sharedDir); // throws on a malformed component
176
- if (serverPrimitives.length || entityComponents.length) {
177
- const { source, externals } = await bundlePrimitives(project.dir, { engineSharedDir: engine.sharedDir });
178
- files.set(SERVER_BARREL, Buffer.from(source, 'utf8'));
179
- // The browser prediction registry exists for PRIMITIVES only — entity
180
- // components' client/shared files ship as ordinary game files, and the
181
- // client imports its own manifests directly.
182
- if (serverPrimitives.length) {
183
- files.set(BROWSER_REGISTRY, Buffer.from(renderBrowserRegistry(serverPrimitives), 'utf8'));
175
+ // A framework-v2 game authors entities + traits + a world; it has no
176
+ // overrides/ or components/ folder. It ships a single generated module — the
177
+ // lowered room module (built HERE by the framework compiler) plus its
178
+ // skeleton in meta, and takes none of the primitive/entity-component scan
179
+ // path below.
180
+ let frameworkMeta = {};
181
+ if (isFrameworkV2(project.dir)) {
182
+ const { source, skeleton } = await buildV2PublishModule({ projectDir: project.dir, engine });
183
+ files.set(SERVER_V2_ROOM, Buffer.from(source, 'utf8'));
184
+ frameworkMeta = { framework: 'v2', skeleton };
185
+ log(`Built the framework-v2 room module (${(source.length / 1024).toFixed(0)} KB) — lowered to v1 on your machine.`);
186
+ } else {
187
+ assertNoInertOverride({ projectDir: project.dir, engine });
188
+ const serverPrimitives = scanPrimitives(project.dir); // throws on a reserved index.js
189
+ const entityComponents = await scanEntityComponents(project.dir, engine.sharedDir); // throws on a malformed component
190
+ if (serverPrimitives.length || entityComponents.length) {
191
+ const { source, externals } = await bundlePrimitives(project.dir, { engineSharedDir: engine.sharedDir });
192
+ files.set(SERVER_BARREL, Buffer.from(source, 'utf8'));
193
+ // The browser prediction registry exists for PRIMITIVES only — entity
194
+ // components' client/shared files ship as ordinary game files, and the
195
+ // client imports its own manifests directly.
196
+ if (serverPrimitives.length) {
197
+ files.set(BROWSER_REGISTRY, Buffer.from(renderBrowserRegistry(serverPrimitives), 'utf8'));
198
+ }
199
+ const names = [
200
+ ...serverPrimitives.map((p) => p.type),
201
+ ...entityComponents.map((c) => `${c.name} (component)`),
202
+ ].join(', ');
203
+ log(
204
+ `Bundled server code — ${names} (${(source.length / 1024).toFixed(0)} KB, engine external${externals.length ? `: ${[...new Set(externals)].length} module(s)` : ''}).`,
205
+ );
184
206
  }
185
- const names = [
186
- ...serverPrimitives.map((p) => p.type),
187
- ...entityComponents.map((c) => `${c.name} (component)`),
188
- ].join(', ');
189
- log(
190
- `Bundled server code — ${names} (${(source.length / 1024).toFixed(0)} KB, engine external${externals.length ? `: ${[...new Set(externals)].length} module(s)` : ''}).`,
191
- );
192
207
  }
193
208
 
194
- const meta = { slug: targetSlug, entry: 'index.html', engineVersion: engine.version, files: {} };
209
+ const meta = { slug: targetSlug, entry: 'index.html', engineVersion: engine.version, files: {}, ...frameworkMeta };
195
210
  const form = new FormData();
196
211
  for (const [path, bytes] of files) {
197
212
  meta.files[path] = createHash('sha256').update(bytes).digest('hex');
@@ -23,11 +23,13 @@
23
23
  // imports EXTERNAL and rewrites them to the release's pre-bundled
24
24
  // `rooms-runtime.js`, because the publish endpoint is a Worker with no bundler.
25
25
  // Same seam, two deliveries.)
26
+ import { spawnSync } from 'node:child_process';
26
27
  import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
27
28
  import { dirname, isAbsolute, join, relative } from 'node:path';
28
29
  import { pathToFileURL } from 'node:url';
29
30
  import {
30
31
  assertNoOverrideCycle,
32
+ isFrameworkV2,
31
33
  isServerBacked,
32
34
  renderBarrel,
33
35
  renderBrowserRegistry,
@@ -123,6 +125,227 @@ export function renderEntityComponents(components) {
123
125
  ].join('\n');
124
126
  }
125
127
 
128
+ // ── framework v2 ─────────────────────────────────────────────────────────
129
+ //
130
+ // A v2 game (package.json `looop.framework === 'v2'`) authors entities + traits
131
+ // + a world.js; there is no overrides/ or components/ folder. It runs on v1's
132
+ // runtime by LOWERING to v1 artifacts. That lowering is a real compiler with a
133
+ // filesystem+AST front end (expand + the analyzer), so it runs HERE, in node,
134
+ // at build time — never in the workerd room. What ships to the room is the
135
+ // serializable SKELETON (names + schedule, no closures); the room re-runs the
136
+ // game's own closures and zips them back onto it, then lowers. See the engine's
137
+ // shared/framework/README.md ("build/ vs runtime/").
138
+ //
139
+ // So the generated room module has two halves: the SKELETON as an embedded JSON
140
+ // literal (computed here), and an import of the game's world + entity modules so
141
+ // the room can re-run their closures. At module load it calls assembleGraph +
142
+ // lowerGraph (both filesystem-free `runtime/` modules) to rebuild the lowered
143
+ // trio, and hands it to the host through the `frameworkV2()` seam LooopRoom reads.
144
+ // The generated v2 room module source. `skeleton` is emitSkeleton()'s output;
145
+ // its `kinds` carry each entity's export name + game-root-relative module, in
146
+ // the order the room must re-run them (assembleGraph zips row-for-row by this
147
+ // order). The world root is always `./world.js` (the v2 one-world-per-game
148
+ // convention). Imports are aliased K0/K1/… so a kind name can never collide
149
+ // with a keyword or another binding.
150
+ // The shared front matter both v2 room modules emit: import the framework
151
+ // runtime (assemble + lower) and the game's world + kinds, embed the shipped
152
+ // skeleton, and rebuild the lowered v1 trio at module load. The two callers add
153
+ // only how the trio is EXPOSED — dev subclasses LooopRoom and returns it from
154
+ // frameworkV2(); publish exports it as `frameworkV2Bundle` for the endpoint's
155
+ // generated entry to wrap. Kept as one function so the kind-aliasing, the
156
+ // `./world.js` root convention, and the assemble→lower call can never drift
157
+ // between the dev and publish builds.
158
+ function renderV2Preamble(skeleton) {
159
+ const imports = skeleton.kinds.map(
160
+ (k, i) => `import { ${k.name} as K${i} } from './${k.module}';`,
161
+ );
162
+ const kindList = skeleton.kinds.map((_, i) => `K${i}`).join(', ');
163
+ return [
164
+ "import { assembleGraph } from '/shared/framework/runtime/assemble.js';",
165
+ "import { lowerGraph } from '/shared/framework/runtime/lower.js';",
166
+ "import World from './world.js';",
167
+ ...imports,
168
+ '',
169
+ `const SKELETON = ${JSON.stringify(skeleton)};`,
170
+ '',
171
+ '// Re-run the game closures in the room and zip them onto the shipped',
172
+ '// skeleton, then lower to v1 artifacts. Runs once, at module load.',
173
+ `const GRAPH = assembleGraph(SKELETON, { world: World, kinds: [${kindList}] });`,
174
+ 'const TRIO = lowerGraph(GRAPH, SKELETON.schedule);',
175
+ ];
176
+ }
177
+
178
+ export function renderV2Entry(skeleton) {
179
+ return [
180
+ "import LooopRoom from '/shared/ui/room/server.js';",
181
+ ...renderV2Preamble(skeleton),
182
+ 'const BUNDLE = { ...TRIO, config: GRAPH.config };',
183
+ '',
184
+ 'export default class GameRoom extends LooopRoom {',
185
+ ' frameworkV2() { return BUNDLE; }',
186
+ '}',
187
+ '',
188
+ ].join('\n');
189
+ }
190
+
191
+ // Alias the `looop` doors a v2 game's world/entities/traits import from to the
192
+ // engine's runtime modules — resolved to the exact engine version this game
193
+ // pins, same discipline as the `/shared/` alias. Only the SERVER-valid doors are
194
+ // here: `looop` (the authoring verbs) and `looop/traits` (the composable traits).
195
+ // The browser-only doors (`looop/client`, `looop/renderers/*`) are deliberately
196
+ // absent — server code that imports one should fail to resolve LOUDLY, not bundle
197
+ // DOM/renderer code into the room worker. This is a fourth door-resolution
198
+ // context (the others: shared/framework/build/expand.js, packages/cli/lib/
199
+ // inject.mjs, builder/functions/_shared/serve-import-map.ts); the full import
200
+ // surface is shared/docs/api.md. A subpath door reaching here is regression-
201
+ // tested in packages/cli/lib/room-server-v2.test.mjs.
202
+ const V2_SERVER_DOORS = {
203
+ looop: 'looop.js',
204
+ 'looop/traits': 'traits.js',
205
+ };
206
+ function looopAliasPlugin(sharedDir) {
207
+ return {
208
+ name: 'looop-framework-alias',
209
+ setup(build) {
210
+ build.onResolve({ filter: /^looop(\/.*)?$/ }, (args) => {
211
+ const rel = V2_SERVER_DOORS[args.path];
212
+ if (!rel) return null; // browser-only or unknown looop door — let esbuild error clearly
213
+ return { path: join(sharedDir, 'framework', 'runtime', rel) };
214
+ });
215
+ },
216
+ };
217
+ }
218
+
219
+ // Build the v2 room server: expand + lower the game HERE (node), emit the
220
+ // skeleton, codegen the room module, esbuild it for workerd. Returns the same
221
+ // `{ cwd, inputs }` shape as the primitive path so dev watches the right files.
222
+ export async function buildV2RoomServer({ projectDir, engine, esbuildImpl }) {
223
+ // The skeleton is emitted in a FRESH child process, never in-process. `looop
224
+ // dev` is long-lived and rebuilds on every save; the gate loads the game via
225
+ // import(), so an in-process re-run would read Node's cached (pre-edit)
226
+ // modules and emit a STALE skeleton while esbuild bundles the fresh code — the
227
+ // room's embedded skeleton and its re-run code then disagree and the room
228
+ // crashes on reload (the "assemble: kind X re-ran N ... but the skeleton has
229
+ // M" error). A child starts with an empty module cache, so the skeleton always
230
+ // matches the code on disk. (The publish path builds once per process, so it
231
+ // stays in-process — see buildV2PublishModule.)
232
+ const gateRun = join(engine.sharedDir, 'framework', 'build', 'gate-run.mjs');
233
+ const res = spawnSync(process.execPath, [gateRun, projectDir, '--skeleton'], {
234
+ encoding: 'utf8',
235
+ maxBuffer: 64 * 1024 * 1024,
236
+ });
237
+ if (res.status !== 0) {
238
+ throw new Error(`this v2 game does not build:\n${(res.stderr || res.stdout || '(see .looop/diagnostics.json)').trim()}`);
239
+ }
240
+ const skeleton = JSON.parse(res.stdout);
241
+
242
+ const esbuild = esbuildImpl ?? (await import('esbuild'));
243
+ const outDir = join(projectDir, DEV_ROOM_SERVER_DIR);
244
+ mkdirSync(outDir, { recursive: true });
245
+
246
+ const result = await esbuild.build({
247
+ stdin: {
248
+ contents: renderV2Entry(skeleton),
249
+ resolveDir: projectDir,
250
+ sourcefile: 'looop-room-server.js',
251
+ loader: 'js',
252
+ },
253
+ outfile: join(outDir, 'server.js'),
254
+ bundle: true,
255
+ format: 'esm',
256
+ platform: 'browser',
257
+ target: 'esnext',
258
+ legalComments: 'none',
259
+ conditions: ['workerd', 'worker'],
260
+ loader: { '.wasm': 'binary' },
261
+ metafile: true,
262
+ absWorkingDir: projectDir,
263
+ plugins: [engineAliasPlugin(engine.sharedDir), looopAliasPlugin(engine.sharedDir)],
264
+ });
265
+
266
+ writeFileSync(
267
+ join(outDir, 'partykit.json'),
268
+ JSON.stringify(
269
+ {
270
+ $schema: 'https://www.partykit.io/schema.json',
271
+ name: 'looop-dev',
272
+ main: 'server.js',
273
+ compatibilityDate: '2024-09-23',
274
+ },
275
+ null,
276
+ 2,
277
+ ) + '\n',
278
+ );
279
+
280
+ const inputs = Object.keys(result.metafile?.inputs ?? {})
281
+ .filter((p) => p !== 'looop-room-server.js' && !p.startsWith('<'))
282
+ .map((p) => (isAbsolute(p) ? p : join(projectDir, p)));
283
+
284
+ // The CLIENT needs the SAME skeleton the room embeds: the serve path injects it
285
+ // as `globalThis.__LOOOP_SKELETON__` before game.js runs, so startGame() can
286
+ // re-run the game's closures and rebuild the trio in the browser. Hand it up.
287
+ return { cwd: outDir, inputs, skeleton };
288
+ }
289
+
290
+ // The PUBLISH counterpart of renderV2Entry. Publish and dev diverge in one way:
291
+ // dev bundles the WHOLE room (LooopRoom included) into one self-contained module
292
+ // partykit runs directly; publish uploads a THREE-module worker (a generated
293
+ // entry + the release's pre-bundled rooms-runtime.js + this game module), so
294
+ // this module must NOT bundle LooopRoom — the release runtime provides it, and
295
+ // re-bundling the creator's local copy would drift from the pinned release and
296
+ // duplicate partyserver. So it imports only the framework runtime (assemble +
297
+ // lower) and the game's own world + kinds, computes the lowered v1 trio at load,
298
+ // and EXPORTS it as `frameworkV2Bundle`. The generated entry
299
+ // (renderV2GameServerEntry, builder side) imports that export and wraps it with
300
+ // the release's LooopRoom.
301
+ export function renderV2RoomModule(skeleton) {
302
+ return [
303
+ ...renderV2Preamble(skeleton),
304
+ 'export const frameworkV2Bundle = { ...TRIO, config: GRAPH.config };',
305
+ '',
306
+ ].join('\n');
307
+ }
308
+
309
+ // Build the v2 game module the publish endpoint uploads: run the gate (expand +
310
+ // lower + checks) HERE in node, emit the skeleton, codegen the room module, and
311
+ // esbuild it into ONE self-contained module (LooopRoom NOT bundled — see
312
+ // renderV2RoomModule). Returns the bundled source text plus the skeleton (the
313
+ // client needs the SAME skeleton, injected as globalThis.__LOOOP_SKELETON__).
314
+ // `write: false` — the bytes go into the publish upload, never to disk.
315
+ export async function buildV2PublishModule({ projectDir, engine, esbuildImpl }) {
316
+ const buildUrl = (rel) => pathToFileURL(join(engine.sharedDir, 'framework', rel)).href;
317
+ const { runGate } = await import(buildUrl('build/gate.js'));
318
+ const { emitSkeleton } = await import(buildUrl('build/emit.js'));
319
+
320
+ const { ok, graph, report } = await runGate(projectDir, { write: false });
321
+ if (!ok) {
322
+ const errs = (report?.errors ?? []).map((e) => ` - ${e.message ?? JSON.stringify(e)}`).join('\n');
323
+ throw new Error(`this v2 game does not build:\n${errs || ' (see .looop/diagnostics.json)'}`);
324
+ }
325
+ const skeleton = emitSkeleton(graph);
326
+
327
+ const esbuild = esbuildImpl ?? (await import('esbuild'));
328
+ const result = await esbuild.build({
329
+ stdin: {
330
+ contents: renderV2RoomModule(skeleton),
331
+ resolveDir: projectDir,
332
+ sourcefile: 'looop-v2-room.js',
333
+ loader: 'js',
334
+ },
335
+ bundle: true,
336
+ write: false,
337
+ format: 'esm',
338
+ platform: 'browser',
339
+ target: 'esnext',
340
+ legalComments: 'none',
341
+ conditions: ['workerd', 'worker'],
342
+ loader: { '.wasm': 'binary' },
343
+ absWorkingDir: projectDir,
344
+ plugins: [engineAliasPlugin(engine.sharedDir), looopAliasPlugin(engine.sharedDir)],
345
+ });
346
+ return { source: result.outputFiles[0].text, skeleton };
347
+ }
348
+
126
349
  // The room the game actually gets: the engine's room class, subclassed to hand
127
350
  // the host the creator's primitives through the `extraPrimitives()` seam the
128
351
  // engine already reads (server.js). Identical in shape to what the publish
@@ -150,6 +373,11 @@ function renderEntry(primitives, entityComponents) {
150
373
  // Throws if the primitives dir is malformed (e.g. a hand-written index.js) —
151
374
  // dev must not quietly serve a room that is missing the creator's code.
152
375
  export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
376
+ // A v2 game runs its own (lowered) code on the server, so it gets its own room
377
+ // server — but built by the framework compiler, not the primitive scanner.
378
+ if (isFrameworkV2(projectDir)) {
379
+ return buildV2RoomServer({ projectDir, engine, esbuildImpl });
380
+ }
153
381
  const primitives = scanPrimitives(projectDir); // throws on a reserved index.js
154
382
  const entityComponents = await scanEntityComponents(projectDir, engine.sharedDir); // throws on a malformed component
155
383
  if (!primitives.length && !entityComponents.length) {
@@ -102,7 +102,19 @@ export function createStaticServer({
102
102
  injectReload = true,
103
103
  watchIntervalMs = 400,
104
104
  identity,
105
+ // A framework-v2 game's lowered skeleton — injected into the entry HTML as an
106
+ // import map + globalThis.__LOOOP_SKELETON__ so startGame() can boot. Null for
107
+ // a v1 game (no bare `looop` import, no skeleton).
108
+ skeleton = null,
105
109
  }) {
110
+ // The injected skeleton is LIVE, not frozen at startup: a v2 hot-reload
111
+ // rebuilds the room with a fresh skeleton, and `setSkeleton` below pushes that
112
+ // same skeleton here so the next page load injects it. Without this the client
113
+ // keeps injecting the startup skeleton after an edit while the room ships the
114
+ // new one, and assembleGraph throws "kind X re-ran N ... but the skeleton has
115
+ // M" on the next reload.
116
+ let currentSkeleton = skeleton;
117
+
106
118
  // Normalize mounts: url ends with '/', dir has no trailing separator.
107
119
  const table = mounts.map(({ url, dir }) => ({
108
120
  url: url.endsWith('/') ? url : url + '/',
@@ -175,7 +187,7 @@ export function createStaticServer({
175
187
  // pinned engine gets no 404ing tag (it gains the toolbox at `looop
176
188
  // update`). Per-request, so a bundle swap mid-session is honoured.
177
189
  const toolbox = !!resolveUrl(TOOLBOX_URL);
178
- html = injectHeadTags(html, m[1], { ...(tabIdentity ? { identity: tabIdentity } : {}), toolbox });
190
+ html = injectHeadTags(html, m[1], { ...(tabIdentity ? { identity: tabIdentity } : {}), toolbox, skeleton: currentSkeleton });
179
191
  }
180
192
  if (injectReload) {
181
193
  html = html.includes('</body>') ? html.replace('</body>', RELOAD_CLIENT + '</body>') : html + RELOAD_CLIENT;
@@ -268,6 +280,12 @@ export function createStaticServer({
268
280
  return server.address()?.port;
269
281
  },
270
282
  resolveUrl,
283
+ // Swap the skeleton injected into subsequent page loads — the v2 hot-reload
284
+ // path calls this after each room rebuild so the client and room never
285
+ // disagree on the lowered graph.
286
+ setSkeleton(next) {
287
+ currentSkeleton = next ?? null;
288
+ },
271
289
  listen(port, bind = '0.0.0.0') {
272
290
  return new Promise((resolveP, rejectP) => {
273
291
  server.once('error', rejectP);
package/lib/test-cmd.mjs CHANGED
@@ -8,10 +8,11 @@
8
8
  // The smoke stack boots on a free shifted port triple so it never seizes a
9
9
  // dev server the creator has running on :8000.
10
10
  import { spawn } from 'node:child_process';
11
- import { readdirSync, readFileSync } from 'node:fs';
11
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
12
  import { createRequire } from 'node:module';
13
13
  import { join, relative, dirname } from 'node:path';
14
- import { findProject } from './project.mjs';
14
+ import { pathToFileURL } from 'node:url';
15
+ import { findProject, resolveEngine } from './project.mjs';
15
16
  import { dev } from './dev.mjs';
16
17
  import { lintCmd } from './lint.mjs';
17
18
  import { portsFor, portInUse } from './ports.mjs';
@@ -61,6 +62,22 @@ async function freeTestPort(base = 8100) {
61
62
  // A file URL so `node --import` resolves it regardless of the smoke's cwd.
62
63
  const SMOKE_PRELOAD = new URL('./smoke-gpu-preload.mjs', import.meta.url).href;
63
64
 
65
+ // The looop resolver preload the installed engine ships
66
+ // (shared/framework/build/looop-test-preload.mjs): registering it lets a unit
67
+ // test or smoke import the looop doors (`import { openWorld } from
68
+ // 'looop/test'`) at top level, with no per-file bootstrap. Null when the
69
+ // engine is absent (pruned by an npm install — the next dev/publish self-heals
70
+ // it) or too old to ship the preload — the spawn then runs without it, as
71
+ // before.
72
+ function looopTestPreload(projectDir) {
73
+ try {
74
+ const preload = join(resolveEngine(projectDir).dir, 'shared', 'framework', 'build', 'looop-test-preload.mjs');
75
+ return existsSync(preload) ? pathToFileURL(preload).href : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
64
81
  export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev, lintFn = lintCmd, runFn = run, patterns = [] } = {}) {
65
82
  const project = findProject(cwd);
66
83
 
@@ -118,6 +135,10 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
118
135
 
119
136
  let ok = lint.ok;
120
137
 
138
+ // The looop resolver preload, if the installed engine ships it (see above).
139
+ const looopPreload = looopTestPreload(project.dir);
140
+ const looopImport = looopPreload ? ['--import', looopPreload] : [];
141
+
121
142
  // If looop test itself runs under a node --test parent, the inherited
122
143
  // NODE_TEST_CONTEXT makes a nested `node --test` exit 0 even on failure —
123
144
  // silently green. Strip it for every child.
@@ -126,7 +147,7 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
126
147
 
127
148
  if (unit.length) {
128
149
  log(`▶ unit: ${unit.map((f) => relative(project.dir, f)).join(', ')}`);
129
- if ((await run(['--test', ...unit], { cwd: project.dir, env })) !== 0) ok = false;
150
+ if ((await run([...looopImport, '--test', ...unit], { cwd: project.dir, env })) !== 0) ok = false;
130
151
  }
131
152
 
132
153
  if (smokes.length) {
@@ -159,7 +180,7 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
159
180
  try {
160
181
  for (const file of smokes) {
161
182
  const rel = relative(project.dir, file);
162
- const code = await runFn(['--import', SMOKE_PRELOAD, file], {
183
+ const code = await runFn(['--import', SMOKE_PRELOAD, ...looopImport, file], {
163
184
  cwd: project.dir,
164
185
  env: {
165
186
  ...env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
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",