@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 +40 -0
- package/lib/bundle-primitives.mjs +38 -9
- package/lib/dev.mjs +43 -28
- package/lib/lint.mjs +21 -6
- package/lib/primitives.mjs +345 -0
- package/lib/publish.mjs +38 -9
- package/lib/room-server.mjs +166 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/update.mjs +44 -14
- package/package.json +1 -1
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 {
|
|
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
|
|
23
|
-
// by (SERVER_PRIMITIVES_PATH in game-server-deploy.ts).
|
|
24
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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 — ${
|
|
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
|
-
|
|
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: [
|
|
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
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
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/…
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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:
|
|
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(
|
|
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
|
|
52
|
+
return { configs: null, why: 'no-engine' };
|
|
47
53
|
}
|
|
48
54
|
const entry = join(engineDir, 'eslint', 'index.js');
|
|
49
|
-
if (!existsSync(entry)) return null
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
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
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
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(
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
129
|
-
//
|
|
130
|
-
//
|
|
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
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
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 (${
|
|
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) {
|