@looop-games/cli 0.1.27 → 0.1.29
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 +35 -0
- package/bin/looop.mjs +9 -3
- package/lib/changelog.mjs +23 -4
- package/lib/create.mjs +5 -0
- package/lib/dev.mjs +18 -3
- package/lib/engine.mjs +5 -1
- package/lib/inject.mjs +28 -1
- package/lib/primitives.mjs +24 -0
- package/lib/publish.mjs +35 -20
- package/lib/room-server.mjs +228 -0
- package/lib/static-server.mjs +19 -1
- package/lib/test-cmd.mjs +25 -4
- package/lib/update.mjs +89 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,41 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.29] - 2026-08-19
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **Framework v2 (the trait model) support across the whole CLI.** `looop
|
|
21
|
+
create` now scaffolds a v2 game: entities and traits as plain functions
|
|
22
|
+
importing only from the `looop` doors — no manifest files, no `/shared/ui`
|
|
23
|
+
imports. `looop dev`, `looop test`, and `looop publish` run v2 games: the
|
|
24
|
+
dev stack injects the `looop` import doors (`looop`, `looop/traits`,
|
|
25
|
+
`looop/client`, …) into the browser import map, serves the game off the
|
|
26
|
+
same lowered graph the room embeds, and `publish` refuses up front when a
|
|
27
|
+
game imports a door its pinned engine doesn't carry (previously it would
|
|
28
|
+
publish and then fail silently at serve). v2 games pair with engine 0.2.0
|
|
29
|
+
(`npx looop update`); v1 games are untouched. The engine side of v2 is in
|
|
30
|
+
the engine changelog.
|
|
31
|
+
- `looop dev` now actually runs **your game's server-side code**. Until now
|
|
32
|
+
dev fell through to the engine's stock room server, so a game's own
|
|
33
|
+
primitives were silently skipped on your machine and only ran in
|
|
34
|
+
production — the worst way round. When a game has server code, dev builds
|
|
35
|
+
a per-game room server for it and runs that instead.
|
|
36
|
+
- `looop test` now preloads the engine's module resolver into your unit tests
|
|
37
|
+
and smokes, so a test file can **`import { openWorld } from 'looop/test'` at
|
|
38
|
+
the top** like any other import — no per-file bootstrap. (Games on an engine
|
|
39
|
+
too old to ship the resolver run exactly as before.)
|
|
40
|
+
|
|
41
|
+
## [0.1.28] - 2026-08-18
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- `looop update --rc <version>` takes a specific **release candidate** — a
|
|
45
|
+
pre-release engine (e.g. `0.2.0-rc.1`) you've been handed to test before it
|
|
46
|
+
becomes an official release. It installs that exact version and re-pins your
|
|
47
|
+
game to it. Candidates are unlisted: a plain `looop update` never picks one
|
|
48
|
+
up, and they don't appear in `looop changelog`. A candidate can be re-cut
|
|
49
|
+
under the same name while it's iterated on, so `--rc` always re-downloads it
|
|
50
|
+
rather than trusting a cached copy.
|
|
51
|
+
|
|
17
52
|
## [0.1.27] - 2026-08-09
|
|
18
53
|
|
|
19
54
|
### Added
|
package/bin/looop.mjs
CHANGED
|
@@ -33,7 +33,7 @@ Usage:
|
|
|
33
33
|
looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
|
|
34
34
|
looop lint [--fix] Check the game against the Looop rules (runs inside 'looop test')
|
|
35
35
|
looop changelog [<v>] What changed in the engine (default: everything newer than your pin)
|
|
36
|
-
looop update
|
|
36
|
+
looop update [--rc <v>] Move this game to the latest engine release (--rc <v> takes a specific pre-release)
|
|
37
37
|
looop model bake <glb> Re-bake a 3D model's server-side hit data now (normally automatic)
|
|
38
38
|
looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
|
|
39
39
|
looop feedback Send reports + replies under notes/feedback/; pull outcomes back in
|
|
@@ -104,9 +104,15 @@ try {
|
|
|
104
104
|
all: rest.includes('--all'),
|
|
105
105
|
});
|
|
106
106
|
break;
|
|
107
|
-
case 'update':
|
|
108
|
-
|
|
107
|
+
case 'update': {
|
|
108
|
+
// `--rc <version>` takes an exact release candidate by reference (a
|
|
109
|
+
// pre-release you were handed); no flag → the normal newest-stable update.
|
|
110
|
+
const rcIdx = rest.indexOf('--rc');
|
|
111
|
+
const target = rcIdx >= 0 ? rest[rcIdx + 1] : null;
|
|
112
|
+
if (rcIdx >= 0 && !target) throw new Error('`--rc` needs a version, e.g. `looop update --rc 0.2.0-rc.1`');
|
|
113
|
+
await update({ target });
|
|
109
114
|
break;
|
|
115
|
+
}
|
|
110
116
|
case 'model': {
|
|
111
117
|
// `model` is the accessor for 3D-model tooling; `bake` is its first verb.
|
|
112
118
|
// Baking is normally automatic (dev/test/publish re-bake changed models);
|
package/lib/changelog.mjs
CHANGED
|
@@ -24,13 +24,32 @@ const RULE = '─'.repeat(64);
|
|
|
24
24
|
// Semver by NUMBER. String order would put 0.1.9 after 0.1.10 and quietly show
|
|
25
25
|
// the wrong set — the kind of bug nobody notices until a release is missing
|
|
26
26
|
// from someone's update.
|
|
27
|
+
//
|
|
28
|
+
// Release candidates carry a pre-release suffix (`0.2.0-rc.1`); a game pinned to
|
|
29
|
+
// one still asks this to compare versions (e.g. `looop changelog` filters by the
|
|
30
|
+
// pin). Standard semver precedence: a pre-release ranks just BELOW its release
|
|
31
|
+
// (`0.2.0-rc.1` < `0.2.0`), and two pre-releases of the same core order by their
|
|
32
|
+
// suffix. Without this the raw `split('.').map(Number)` produced NaN and silently
|
|
33
|
+
// mis-ranked every suffixed version.
|
|
27
34
|
export function compareVersions(a, b) {
|
|
28
|
-
const
|
|
29
|
-
|
|
35
|
+
const parse = (v) => {
|
|
36
|
+
const s = String(v);
|
|
37
|
+
const dash = s.indexOf('-');
|
|
38
|
+
const core = dash < 0 ? s : s.slice(0, dash);
|
|
39
|
+
const pre = dash < 0 ? '' : s.slice(dash + 1);
|
|
40
|
+
return { nums: core.split('.').map(Number), pre };
|
|
41
|
+
};
|
|
42
|
+
const A = parse(a);
|
|
43
|
+
const B = parse(b);
|
|
30
44
|
for (let i = 0; i < 3; i++) {
|
|
31
|
-
if ((
|
|
45
|
+
if ((A.nums[i] ?? 0) !== (B.nums[i] ?? 0)) return (A.nums[i] ?? 0) > (B.nums[i] ?? 0) ? 1 : -1;
|
|
32
46
|
}
|
|
33
|
-
|
|
47
|
+
// Same numeric core: a release outranks its pre-releases; two pre-releases
|
|
48
|
+
// order lexically by suffix (a stable, total order — the label is opaque).
|
|
49
|
+
if (A.pre === B.pre) return 0;
|
|
50
|
+
if (!A.pre) return 1;
|
|
51
|
+
if (!B.pre) return -1;
|
|
52
|
+
return A.pre < B.pre ? -1 : 1;
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
export function selectReleases(releases, { pinned, version, all } = {}) {
|
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 ?
|
|
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/engine.mjs
CHANGED
|
@@ -122,7 +122,11 @@ export async function ensureEngine(
|
|
|
122
122
|
|
|
123
123
|
const cache = cacheDir();
|
|
124
124
|
const cached = join(cache, `engine-${pin}.tgz`);
|
|
125
|
-
|
|
125
|
+
// A release candidate (a `-suffix` pin) is MUTABLE — the same label can be
|
|
126
|
+
// re-cut with new bytes — so its cache entry can be stale. Always re-download
|
|
127
|
+
// a candidate; the content-addressed stable releases are safe to cache forever.
|
|
128
|
+
const isCandidate = pin.includes('-');
|
|
129
|
+
if (!existsSync(cached) || isCandidate) {
|
|
126
130
|
log(`Downloading engine ${pin} from ${apiBase}…`);
|
|
127
131
|
const dlUrl = `${apiBase}/api/creator/engine/${pin}`;
|
|
128
132
|
let res = await fetchImpl(dlUrl, { headers: auth });
|
package/lib/inject.mjs
CHANGED
|
@@ -26,8 +26,35 @@ function jsJson(value) {
|
|
|
26
26
|
return JSON.stringify(value).replaceAll('<', '\\u003c');
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
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
|
+
};
|
|
46
|
+
|
|
47
|
+
export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL, skeleton = null } = {}) {
|
|
30
48
|
const parts = [];
|
|
49
|
+
// A framework-v2 game resolves `looop`/`looop/client` through an import map and
|
|
50
|
+
// reads its lowered graph from globalThis.__LOOOP_SKELETON__. BOTH must precede
|
|
51
|
+
// any module script (the platform tag AND game.js): a browser ignores an import
|
|
52
|
+
// map once a module has begun executing, and startGame() reads the skeleton at
|
|
53
|
+
// module-eval time. So they go FIRST.
|
|
54
|
+
if (skeleton) {
|
|
55
|
+
parts.push(`<script type="importmap">${jsJson({ imports: V2_IMPORTS })}</script>`);
|
|
56
|
+
parts.push(`<script>globalThis.__LOOOP_SKELETON__ = ${jsJson(skeleton)};</script>`);
|
|
57
|
+
}
|
|
31
58
|
if (slug) parts.push(`<script>window.GAME_SLUG = ${jsJson(slug)};</script>`);
|
|
32
59
|
parts.push(`<script>window.LOOOP_IDENTITY = ${jsJson(identity)};</script>`);
|
|
33
60
|
parts.push(
|
package/lib/primitives.mjs
CHANGED
|
@@ -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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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');
|
package/lib/room-server.mjs
CHANGED
|
@@ -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) {
|
package/lib/static-server.mjs
CHANGED
|
@@ -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 {
|
|
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/lib/update.mjs
CHANGED
|
@@ -80,6 +80,8 @@ export async function update({
|
|
|
80
80
|
ensure = ensureEngine,
|
|
81
81
|
reconcile = reconcileAgentSurface,
|
|
82
82
|
syncCliFn = syncCli,
|
|
83
|
+
// `looop update --rc <version>` — take an EXACT release candidate by reference.
|
|
84
|
+
target = null,
|
|
83
85
|
} = {}) {
|
|
84
86
|
const project = findProject(cwd);
|
|
85
87
|
const from = readEnginePin(project.dir);
|
|
@@ -91,6 +93,64 @@ export async function update({
|
|
|
91
93
|
if (!getToken()) throw new Error('login did not produce a token — run `looop login` and retry.');
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
// ── Release-candidate lane ────────────────────────────────────────────────
|
|
97
|
+
// A candidate (`--rc 0.2.0-rc.1`) is UNLISTED: it never appears in `latest` or
|
|
98
|
+
// `looop changelog`, so this lane bypasses the newest-stable comparison and
|
|
99
|
+
// installs the named version directly. Its changelog and any migration notes
|
|
100
|
+
// ship INSIDE the engine tarball (the docs the agent reads), not through the
|
|
101
|
+
// version-crossing callout the stable lane prints. syncCli runs FIRST for the
|
|
102
|
+
// same reason it does below — its npm install would prune the engine if it ran
|
|
103
|
+
// after the engine landed.
|
|
104
|
+
if (target) {
|
|
105
|
+
// Validate the version shape BEFORE anything mutates the repo. syncCli's npm
|
|
106
|
+
// install below prunes the engine, so a typo caught only after that point
|
|
107
|
+
// would leave the game engine-less on a bad pin. This mirrors the platform's
|
|
108
|
+
// ENGINE_VERSION_WITH_PRERELEASE_OK; a well-formed but unknown/removed
|
|
109
|
+
// candidate is still caught below by restoring the pin on a failed download.
|
|
110
|
+
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$/.test(target)) {
|
|
111
|
+
throw new Error(`\`--rc\` needs a valid engine version like 0.2.0-rc.1 — got "${target}".`);
|
|
112
|
+
}
|
|
113
|
+
let cli;
|
|
114
|
+
try {
|
|
115
|
+
cli = await syncCliFn({ projectDir: project.dir, log });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
cli = { updated: false, error: err };
|
|
118
|
+
}
|
|
119
|
+
writeEnginePin(project.dir, target);
|
|
120
|
+
let engine;
|
|
121
|
+
try {
|
|
122
|
+
engine = await ensure(project.dir, { apiBase, log, fetchImpl });
|
|
123
|
+
} catch (err) {
|
|
124
|
+
// The candidate didn't download (a typo that still parsed, or one that was
|
|
125
|
+
// since removed). Restore the previous pin so the game isn't left pointing
|
|
126
|
+
// at a version that doesn't exist — the engine may have been pruned by the
|
|
127
|
+
// CLI install above, but is recoverable by a normal `looop dev`/`update` on
|
|
128
|
+
// the restored pin. A game with no prior pin keeps none.
|
|
129
|
+
if (from) writeEnginePin(project.dir, from);
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
const engineDir = engine.dir ?? null;
|
|
133
|
+
log('');
|
|
134
|
+
log(`✅ Engine candidate installed: ${from ?? '(none)'} → ${engine.version}`);
|
|
135
|
+
|
|
136
|
+
const surface = engineDir ? reconcile(project.dir, engineDir, { log }) : { skipped: true };
|
|
137
|
+
if (!surface.skipped) report(log, surface.engineVersion ?? target, surface);
|
|
138
|
+
|
|
139
|
+
if (cli?.error) {
|
|
140
|
+
log('');
|
|
141
|
+
log(` The looop command could not be updated (${cli.error.message}).`);
|
|
142
|
+
log(' Retry with: npm update @looop-games/cli');
|
|
143
|
+
} else {
|
|
144
|
+
reportCli(log, cli);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
log('');
|
|
148
|
+
log(' This is a pre-release candidate — its changelog and any migration');
|
|
149
|
+
log(' notes ship inside the engine (read the handbook / engine docs), not');
|
|
150
|
+
log(' via `looop changelog`. Republish (`npx looop publish`) when ready.');
|
|
151
|
+
return { from, to: engine.version, updated: from !== engine.version, surface, crossed: null, cli, candidate: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
94
154
|
const res = await fetchImpl(`${apiBase}/api/creator/engine`, {
|
|
95
155
|
headers: { Authorization: `Bearer ${getToken()}` },
|
|
96
156
|
});
|
|
@@ -143,27 +203,41 @@ export async function update({
|
|
|
143
203
|
let engineDir = null;
|
|
144
204
|
let updated = false;
|
|
145
205
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
206
|
+
// Forward-only: `looop update` moves a game to `latest` only when `latest` is
|
|
207
|
+
// actually NEWER than the pin. It never moves a game that is already on latest
|
|
208
|
+
// OR ahead of it — the latter is real now that release candidates exist: a
|
|
209
|
+
// testbed pinned to `0.2.0-rc.3` while the newest stable is `0.1.10` must not
|
|
210
|
+
// be silently DOWNGRADED to 0.1.10 (which would break every 0.2.0 API it uses)
|
|
211
|
+
// just because the plain `update` was run out of habit. `--rc` is the lane that
|
|
212
|
+
// moves onto a candidate; the stable lane only ever moves forward.
|
|
213
|
+
const cmp = from ? compareVersions(from, latest) : -1;
|
|
214
|
+
if (cmp >= 0) {
|
|
215
|
+
// On latest, or ahead of it on a pre-release. Reconcile the surface anyway —
|
|
216
|
+
// a repo can sit on the right engine with a STILL out-of-date surface (one
|
|
217
|
+
// scaffolded before this mechanism existed never had its skills adopted) —
|
|
218
|
+
// but never rewrite the pin.
|
|
219
|
+
const label = from ?? latest;
|
|
220
|
+
const ahead = cmp > 0;
|
|
151
221
|
try {
|
|
152
222
|
engineDir = resolveEngine(project.dir).dir;
|
|
153
|
-
log(
|
|
223
|
+
log(
|
|
224
|
+
ahead
|
|
225
|
+
? `✅ Engine ${label} — a pre-release ahead of the latest release (${latest}); not downgrading.`
|
|
226
|
+
: `✅ Engine ${label} — already up to date.`,
|
|
227
|
+
);
|
|
154
228
|
} catch {
|
|
155
|
-
// Pinned to
|
|
156
|
-
// game runs; node_modules is the truth, and they disagree — because a
|
|
157
|
-
// `npm install` (the creator's own, or ours above) prunes the engine,
|
|
158
|
-
// npm never recorded. "Already up to date" while the engine is missing
|
|
159
|
-
// lie that leaves every engine-reading command broken, and re-running
|
|
160
|
-
// could never fix it. Put it back — from the local cache, so this is
|
|
161
|
-
// works offline.
|
|
162
|
-
log(`Engine ${
|
|
229
|
+
// Pinned to this version, but NOT on disk. The pin is a claim about what
|
|
230
|
+
// this game runs; node_modules is the truth, and they disagree — because a
|
|
231
|
+
// plain `npm install` (the creator's own, or ours above) prunes the engine,
|
|
232
|
+
// which npm never recorded. "Already up to date" while the engine is missing
|
|
233
|
+
// is a lie that leaves every engine-reading command broken, and re-running
|
|
234
|
+
// update could never fix it. Put it back — from the local cache, so this is
|
|
235
|
+
// fast and works offline.
|
|
236
|
+
log(`Engine ${label} is pinned but missing from node_modules — reinstalling it.`);
|
|
163
237
|
const engine = await ensure(project.dir, { apiBase, log, fetchImpl });
|
|
164
238
|
engineDir = engine.dir ?? null;
|
|
165
239
|
log('');
|
|
166
|
-
log(`✅ Engine ${
|
|
240
|
+
log(`✅ Engine ${label} — restored.`);
|
|
167
241
|
}
|
|
168
242
|
} else {
|
|
169
243
|
// Rewrite the pin first; ensureEngine honors it (download → install → pin).
|