@looop-games/cli 0.1.17 → 0.1.18

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,21 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.18] - 2026-07-15
18
+
19
+ ### Added
20
+
21
+ - **`looop dev`, `looop test`, and `looop publish` understand entity
22
+ components.** A game that declares behavior in `components/` folders (the
23
+ entity system — see `shared/practices/entities.md` in your installed engine)
24
+ now works across the whole command surface: `dev` bundles your components
25
+ into the local room server, `test` runs the entity graph lint out of the
26
+ installed engine before anything else (it runs even when eslint isn't
27
+ installed in the game), and `publish` bundles them into your game's own
28
+ multiplayer server — a component game is server-backed automatically,
29
+ exactly like a primitives game. Requires engine 0.1.27 or newer; run
30
+ `looop update` if your game pins an older one.
31
+
17
32
  ## [0.1.17] - 2026-07-14
18
33
 
19
34
  ### Added
@@ -17,6 +17,7 @@
17
17
  // class past the setInterval/park cost boundary. Keeping it external means the
18
18
  // worker's room code is always ours.
19
19
  import { assertNoOverrideCycle, PRIMITIVES_DIR, SERVER_BARREL, renderBarrel, scanPrimitives } from './primitives.mjs';
20
+ import { scanEntityComponents, renderEntityComponents } from './room-server.mjs';
20
21
  import { shadowResolvePlugin } from './shadow-resolve.mjs';
21
22
  import { join } from 'node:path';
22
23
 
@@ -29,17 +30,19 @@ export const PRIMITIVES_ENTRY = SERVER_BARREL;
29
30
  // engine import in the primitives bundle is rewritten to this.
30
31
  export const ENGINE_ARTIFACT_MODULE = './rooms-runtime.js';
31
32
 
32
- export async function bundlePrimitives(projectDir, { esbuildImpl } = {}) {
33
+ export async function bundlePrimitives(projectDir, { esbuildImpl, engineSharedDir } = {}) {
33
34
  // 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.
35
+ // a file to `overrides/shared/ui/room/primitives/` — and an entity component
36
+ // by adding a folder under components/ there is no list to keep in sync,
37
+ // and therefore no way for the list and the folder to disagree.
36
38
  const primitives = scanPrimitives(projectDir); // throws on a reserved index.js
37
- if (!primitives.length) {
39
+ const engineShared = engineSharedDir ?? join(projectDir, 'node_modules/@looop-games/engine/shared');
40
+ const entityComponents = await scanEntityComponents(projectDir, engineShared); // throws on a malformed component
41
+ if (!primitives.length && !entityComponents.length) {
38
42
  throw new Error(
39
- `no server primitives to bundle — ${PRIMITIVES_DIR}/ is empty (this is not a server-backed game)`,
43
+ `nothing to bundle — ${PRIMITIVES_DIR}/ and components/ are both empty (this is not a server-backed game)`,
40
44
  );
41
45
  }
42
- const engineShared = join(projectDir, 'node_modules/@looop-games/engine/shared');
43
46
  assertNoOverrideCycle(primitives, projectDir, engineShared);
44
47
 
45
48
  const esbuild = esbuildImpl ?? (await import('esbuild'));
@@ -68,7 +71,7 @@ export async function bundlePrimitives(projectDir, { esbuildImpl } = {}) {
68
71
  // against the game root so `./overrides/...` means what it says. Same shape
69
72
  // the dev room server uses (lib/room-server.mjs) — one contract, two builds.
70
73
  stdin: {
71
- contents: renderBarrel(primitives),
74
+ contents: `${renderBarrel(primitives)}\n${renderEntityComponents(entityComponents)}\n`,
72
75
  resolveDir: projectDir,
73
76
  sourcefile: 'looop-primitives.js',
74
77
  loader: 'js',
package/lib/lint.mjs CHANGED
@@ -57,21 +57,46 @@ async function loadRulesFromEngine(projectDir) {
57
57
  return { configs: mod.configs ?? mod.default?.configs ?? null, why: 'ok' };
58
58
  }
59
59
 
60
+ // The entity graph lint — the SECOND shared step, alongside the eslint rules.
61
+ // Like the rules, the implementation lives in the ENGINE (shared/ui/room/
62
+ // entity/lint-run.mjs), so every gate that checks a game runs the identical
63
+ // pass — there is no second copy to drift. An engine too old to carry it
64
+ // skips — same degradation contract as the rules.
65
+ async function loadEntityLintFromEngine(projectDir) {
66
+ let engineDir;
67
+ try {
68
+ ({ dir: engineDir } = resolveEngine(projectDir));
69
+ } catch {
70
+ return null;
71
+ }
72
+ const entry = join(engineDir, 'shared', 'ui', 'room', 'entity', 'lint-run.mjs');
73
+ if (!existsSync(entry)) return null;
74
+ const mod = await import(pathToFileURL(entry).href);
75
+ return mod.runEntityLint ?? null;
76
+ }
77
+
60
78
  export async function lintCmd({
61
79
  cwd = process.cwd(),
62
80
  fix = false,
63
81
  log = console.log,
64
82
  loadEslint = loadFromProject,
65
83
  loadRules = loadRulesFromEngine,
84
+ loadEntityLint = loadEntityLintFromEngine,
66
85
  } = {}) {
67
86
  const project = findProject(cwd);
68
87
 
88
+ // The entity graph check runs regardless of the eslint tooling below — a
89
+ // missing dev tool must never switch off a structural guarantee. It skips
90
+ // itself silently for games with no entity folders.
91
+ const runEntity = await loadEntityLint(project.dir);
92
+ const entity = runEntity ? await runEntity(project.dir, { log }) : { ok: true, skipped: true };
93
+
69
94
  const ESLint = await loadEslint(project.dir);
70
95
  if (!ESLint) {
71
96
  log('⚠️ looop lint: eslint is not installed in this game — skipping the rule checks.');
72
97
  log(' To turn them on (recommended — they catch bugs that silently pass your tests):');
73
98
  log(' npm i -D eslint');
74
- return { ok: true, skipped: true, errorCount: 0, warningCount: 0 };
99
+ return { ok: entity.ok, skipped: true, errorCount: 0, warningCount: 0, entity };
75
100
  }
76
101
 
77
102
  const { configs, why } = await loadRules(project.dir);
@@ -87,7 +112,7 @@ export async function lintCmd({
87
112
  log('⚠️ looop lint: your engine is older than the Looop rules — skipping.');
88
113
  log(' Get an engine that carries them: looop update');
89
114
  }
90
- return { ok: true, skipped: true, errorCount: 0, warningCount: 0 };
115
+ return { ok: entity.ok, skipped: true, errorCount: 0, warningCount: 0, entity };
91
116
  }
92
117
 
93
118
  // A game that ships its own eslint.config.js owns its rules — we do not
@@ -131,10 +156,11 @@ export async function lintCmd({
131
156
  }
132
157
 
133
158
  return {
134
- ok: errorCount === 0,
159
+ ok: errorCount === 0 && entity.ok,
135
160
  skipped: false,
136
161
  errorCount,
137
162
  warningCount,
163
+ entity,
138
164
  files: results.map((r) => relative(project.dir, r.filePath)),
139
165
  };
140
166
  }
@@ -127,8 +127,19 @@ export function scanPrimitives(projectDir) {
127
127
  // other. (They used to disagree: publish looked for `_local/primitives/index.js`
128
128
  // while dev looked for a `partykit.json` that nothing ever created, so a
129
129
  // creator's primitive ran in production and silently did not run in dev.)
130
+ //
131
+ // TWO trees make a game server-backed: vendored primitives (overrides/) and
132
+ // entity components (components/, or entity-local ones under entities/). The
133
+ // entity half is a cheap folder check, not a scan — a malformed component
134
+ // still makes the game server-backed; the build is where it fails loudly.
130
135
  export function isServerBacked(projectDir) {
131
- return scanPrimitives(projectDir).length > 0;
136
+ if (scanPrimitives(projectDir).length > 0) return true;
137
+ if (existsSync(join(projectDir, 'components'))) return true;
138
+ const entitiesDir = join(projectDir, 'entities');
139
+ if (!existsSync(entitiesDir)) return false;
140
+ return readdirSync(entitiesDir).some((name) => {
141
+ try { return existsSync(join(entitiesDir, name, 'components')); } catch { return false; }
142
+ });
132
143
  }
133
144
 
134
145
  // The one cycle shadowing makes possible, caught in the creator's own words.
package/lib/publish.mjs CHANGED
@@ -18,6 +18,7 @@ import { ensureEngine } from './engine.mjs';
18
18
  import { getToken, getApiBase } from './config.mjs';
19
19
  import { DEFAULT_API_BASE } from './llm-shim.mjs';
20
20
  import { bundlePrimitives } from './bundle-primitives.mjs';
21
+ import { scanEntityComponents } from './room-server.mjs';
21
22
  import {
22
23
  BROWSER_REGISTRY,
23
24
  SERVER_BARREL,
@@ -119,13 +120,22 @@ export async function publish({
119
120
  // are killing is an override that uploads, serves, and silently never runs.
120
121
  assertNoInertOverride({ projectDir: project.dir, engine });
121
122
  const serverPrimitives = scanPrimitives(project.dir); // throws on a reserved index.js
122
- if (serverPrimitives.length) {
123
- const { source, externals } = await bundlePrimitives(project.dir);
123
+ const entityComponents = await scanEntityComponents(project.dir, engine.sharedDir); // throws on a malformed component
124
+ if (serverPrimitives.length || entityComponents.length) {
125
+ const { source, externals } = await bundlePrimitives(project.dir, { engineSharedDir: engine.sharedDir });
124
126
  files.set(SERVER_BARREL, Buffer.from(source, 'utf8'));
125
- files.set(BROWSER_REGISTRY, Buffer.from(renderBrowserRegistry(serverPrimitives), 'utf8'));
126
- const names = serverPrimitives.map((p) => p.type).join(', ');
127
+ // The browser prediction registry exists for PRIMITIVES only — entity
128
+ // components' client/shared files ship as ordinary game files, and the
129
+ // client imports its own manifests directly.
130
+ if (serverPrimitives.length) {
131
+ files.set(BROWSER_REGISTRY, Buffer.from(renderBrowserRegistry(serverPrimitives), 'utf8'));
132
+ }
133
+ const names = [
134
+ ...serverPrimitives.map((p) => p.type),
135
+ ...entityComponents.map((c) => `${c.name} (component)`),
136
+ ].join(', ');
127
137
  log(
128
- `Bundled server primitives — ${names} (${(source.length / 1024).toFixed(0)} KB, engine external${externals.length ? `: ${[...new Set(externals)].length} module(s)` : ''}).`,
138
+ `Bundled server code — ${names} (${(source.length / 1024).toFixed(0)} KB, engine external${externals.length ? `: ${[...new Set(externals)].length} module(s)` : ''}).`,
129
139
  );
130
140
  }
131
141
 
@@ -23,8 +23,9 @@
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 { mkdirSync, rmSync, writeFileSync } from 'node:fs';
27
- import { join } from 'node:path';
26
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
27
+ import { dirname, join, relative } from 'node:path';
28
+ import { pathToFileURL } from 'node:url';
28
29
  import {
29
30
  assertNoOverrideCycle,
30
31
  isServerBacked,
@@ -65,17 +66,76 @@ function engineAliasPlugin(sharedDir) {
65
66
  };
66
67
  }
67
68
 
69
+ // Entity components take the same trip as vendored primitives: scanned from
70
+ // the game tree, bundled, handed to the host — through the sibling
71
+ // `extraEntityComponents()` seam. The room bundle carries each component's
72
+ // MANIFEST (so channel gates are trusted — a world spec arrives from a
73
+ // client and may not define them) and its SIM entry: `server/sim.js` when
74
+ // the component has one, else `shared/sim.js` (predicted sims live in
75
+ // shared/ so the browser can run them too; server/ is for genuinely
76
+ // server-only adjudication and may import shared/ itself).
77
+ //
78
+ // The scan implementation lives in the ENGINE (shared/ui/room/entity/) at
79
+ // the same relative path in the repo and the built artifact. An engine too
80
+ // old to carry it means no entity components exist for that game — skip.
81
+ export async function scanEntityComponents(projectDir, sharedDir) {
82
+ const scanPath = join(sharedDir, 'ui', 'room', 'entity', 'scan.js');
83
+ if (!existsSync(scanPath)) return [];
84
+ const { scanGame } = await import(pathToFileURL(scanPath).href);
85
+ const { components, errors } = scanGame(projectDir);
86
+ if (errors.length) {
87
+ throw new Error(`this game's entity components cannot build a room server:\n - ${errors.join('\n - ')}`);
88
+ }
89
+ const posix = (p) => relative(projectDir, p).split('\\').join('/');
90
+ return [...components.values()].map((c) => {
91
+ const serverSim = join(c.dir, 'server', 'sim.js');
92
+ const sharedSim = join(c.dir, 'shared', 'sim.js');
93
+ const code = existsSync(serverSim) ? serverSim : existsSync(sharedSim) ? sharedSim : null;
94
+ return {
95
+ name: c.name,
96
+ manifestRel: posix(c.manifestPath),
97
+ codeRel: code ? posix(code) : null,
98
+ };
99
+ });
100
+ }
101
+
102
+ export function renderEntityComponents(components) {
103
+ if (!components.length) return 'export const extraEntityComponents = null;';
104
+ const lines = [];
105
+ const manifests = [];
106
+ const code = [];
107
+ components.forEach((c, i) => {
108
+ lines.push(`import entityManifest${i} from './${c.manifestRel}';`);
109
+ manifests.push(`entityManifest${i}`);
110
+ if (c.codeRel) {
111
+ lines.push(`import * as entityCode${i} from './${c.codeRel}';`);
112
+ code.push(` '${c.name}': entityCode${i},`);
113
+ }
114
+ });
115
+ return [
116
+ ...lines,
117
+ 'export const extraEntityComponents = {',
118
+ ` manifests: [${manifests.join(', ')}],`,
119
+ ' code: {',
120
+ ...code,
121
+ ' },',
122
+ '};',
123
+ ].join('\n');
124
+ }
125
+
68
126
  // The room the game actually gets: the engine's room class, subclassed to hand
69
127
  // the host the creator's primitives through the `extraPrimitives()` seam the
70
128
  // engine already reads (server.js). Identical in shape to what the publish
71
129
  // endpoint generates for the per-game Worker — one contract, two builders.
72
- function renderEntry(primitives) {
130
+ function renderEntry(primitives, entityComponents) {
73
131
  return [
74
132
  renderBarrel(primitives),
133
+ renderEntityComponents(entityComponents),
75
134
  "import LooopRoom from '/shared/ui/room/server.js';",
76
135
  '',
77
136
  'export default class GameRoom extends LooopRoom {',
78
137
  ' extraPrimitives() { return extraPrimitives; }',
138
+ ' extraEntityComponents() { return extraEntityComponents; }',
79
139
  '}',
80
140
  '',
81
141
  ].join('\n');
@@ -88,7 +148,8 @@ function renderEntry(primitives) {
88
148
  // dev must not quietly serve a room that is missing the creator's code.
89
149
  export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
90
150
  const primitives = scanPrimitives(projectDir); // throws on a reserved index.js
91
- if (!primitives.length) {
151
+ const entityComponents = await scanEntityComponents(projectDir, engine.sharedDir); // throws on a malformed component
152
+ if (!primitives.length && !entityComponents.length) {
92
153
  // Not server-backed (any more). A PREVIOUS run may have generated a room
93
154
  // server and a registry shadow; both must go, or dev keeps mounting a stale
94
155
  // `.looop/overrides/shared/.../index.js` that imports a primitive the creator
@@ -109,7 +170,7 @@ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
109
170
  // against the game root, so `./overrides/...` means what it says and no
110
171
  // machine-specific absolute path can leak into the output.
111
172
  stdin: {
112
- contents: renderEntry(primitives),
173
+ contents: renderEntry(primitives, entityComponents),
113
174
  resolveDir: projectDir,
114
175
  sourcefile: 'looop-room-server.js',
115
176
  loader: 'js',
@@ -156,9 +217,15 @@ export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
156
217
  // same in dev (static mount fall-through) and in production (the import map):
157
218
  // /shared/ui/room/primitives/engine.js → not shadowed → the engine's list
158
219
  // /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));
220
+ if (primitives.length) {
221
+ const registryDir = join(projectDir, DEV_OVERRIDES_DIR, 'ui/room/primitives');
222
+ mkdirSync(registryDir, { recursive: true });
223
+ writeFileSync(join(registryDir, 'index.js'), renderBrowserRegistry(primitives));
224
+ } else {
225
+ // Entity components alone need no primitive-registry shadow — and a stale
226
+ // one from a removed primitive must not stay mounted.
227
+ rmSync(join(projectDir, DEV_OVERRIDES_DIR), { recursive: true, force: true });
228
+ }
162
229
 
163
230
  return outDir;
164
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
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",