@looop-games/cli 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,55 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.17] - 2026-07-14
18
+
19
+ ### Added
20
+ - **`looop test <pattern>` runs just the tests you name.** While you're iterating
21
+ on one part of your game, running the *whole* suite every time is most of the
22
+ wait. `looop test charge` now runs only the files whose name contains `charge`
23
+ (`charge.smoke.mjs`, `game-charge.test.mjs`) and skips the rest — a focused,
24
+ fast re-run. Name more than one (`looop test charge aim`) to widen it. It's for
25
+ the inner loop, not a replacement for the gate: a pattern that matches nothing
26
+ stops with an error (so a typo can never pass green having run nothing), and you
27
+ still run the full `looop test` before you call the work done — a change can
28
+ break a part you didn't touch, and only the whole suite catches that.
29
+
30
+ ### Fixed
31
+ - **`looop test` no longer pins your whole machine while smokes run.** A headless
32
+ browser has no graphics card, so a 3D game's tests were rendering on the CPU —
33
+ one test could quietly eat 8+ processor cores and freeze a modest laptop solid.
34
+ Tests now render on your actual GPU instead, which is what it's for: the same
35
+ test that hogged 8+ cores now uses a fraction of one, and renders several times
36
+ faster. Nothing in your game changes, and your tests check exactly what they
37
+ did before. On a machine with no usable GPU it simply falls back to the old
38
+ behaviour — never worse. (Set `LOOOP_TEST_NO_GPU=1` to force the old path.)
39
+
40
+ ## [0.1.16] - 2026-07-13
41
+
42
+ ### Added
43
+ - `looop lane <name>` — open an isolated copy of your game to experiment in. It
44
+ lives in its own folder, on its own branch, with its own dev stack, so an
45
+ experiment can't disturb the game you're in the middle of. Whatever you have
46
+ unsaved stays exactly where it is. Like the winner? Merge it. Don't? Delete
47
+ the folder. The lane borrows the engine from your main folder rather than
48
+ downloading it again, so opening one is close to instant. Your agent knows
49
+ when to offer this — ask it for `/worktree`.
50
+
51
+ ### Fixed
52
+ - **`looop publish` no longer guesses a name inside a lane.** Publishing claims
53
+ a name permanently, and the name defaults to your folder — so publishing from
54
+ a lane would have claimed `<game>-<lane>` forever for a throwaway experiment
55
+ (and, if your `package.json` sets `looop.slug`, would have overwritten your
56
+ live game with it). From a lane, publish now asks you to say the name out
57
+ loud: `--slug <name>`. Publishing your actual game is unchanged.
58
+ - **`looop dev` no longer kills a dev server that isn't its own.** It used to
59
+ take over ports 8000/1999/8788 unconditionally, so a second game — or a lane —
60
+ would shut down the game you already had running and steal its ports. Now it
61
+ steps around anything else on those ports and picks the next free set, telling
62
+ you which it took. Run as many games and lanes at once as you like; they no
63
+ longer fight. It still reclaims *its own* stale server, so you never end up
64
+ looking at yesterday's code.
65
+
17
66
  ## [0.1.15] - 2026-07-13
18
67
 
19
68
  ### Changed
package/bin/looop.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // Runs inside a standalone game folder (its own repo). The agent is the
5
5
  // primary user: keep output plain, actionable, and machine-legible.
6
6
  import { dev } from '../lib/dev.mjs';
7
+ import { lane } from '../lib/lane.mjs';
7
8
  import { login, whoami } from '../lib/login.mjs';
8
9
  import { publish } from '../lib/publish.mjs';
9
10
  import { create } from '../lib/create.mjs';
@@ -26,6 +27,7 @@ const HELP = `looop — build and run Looop games
26
27
  Usage:
27
28
  looop create <name> Bootstrap a new game folder (multiplayer works out of the box)
28
29
  looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
30
+ looop lane <name> Open an isolated copy of the game to experiment in, safely
29
31
  looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
30
32
  looop lint [--fix] Check the game against the Looop rules (runs inside 'looop test')
31
33
  looop changelog [<v>] What changed in the engine (default: everything newer than your pin)
@@ -38,7 +40,9 @@ Usage:
38
40
  looop help This message
39
41
 
40
42
  The dev stack serves your game at http://localhost:8000/games/<name>/index.html
41
- with local multiplayer on :1999 and the platform services shim on :8788.
43
+ with local multiplayer on :1999 and the platform services shim on :8788. Run
44
+ several at once (a lane, another game) and each picks its own free ports —
45
+ 'looop dev' never disturbs a stack that isn't its own.
42
46
  `;
43
47
 
44
48
  try {
@@ -54,11 +58,20 @@ try {
54
58
  // Keep the process alive; servers + children hold the loop open.
55
59
  break;
56
60
  }
61
+ case 'lane': {
62
+ const name = rest.find((a) => !a.startsWith('--'));
63
+ if (!name) throw new Error('Name the lane: looop lane <name> (e.g. looop lane judder)');
64
+ const { dir } = await lane(name);
65
+ console.log(`\nWork in it:\n cd ${dir} && npx looop dev\n`);
66
+ break;
67
+ }
57
68
  case 'create':
58
69
  await create({ name: rest.find((a) => !a.startsWith('--')) });
59
70
  break;
60
71
  case 'test': {
61
- const { ok } = await testCmd();
72
+ // Positional args scope the run: `looop test charge` runs only files whose
73
+ // path contains "charge". Flags are left for future options (e.g. --changed).
74
+ const { ok } = await testCmd({ patterns: rest.filter((a) => !a.startsWith('--')) });
62
75
  process.exit(ok ? 0 : 1);
63
76
  }
64
77
  case 'lint': {
package/lib/dev.mjs CHANGED
@@ -10,13 +10,13 @@
10
10
  import { spawn } from 'node:child_process';
11
11
  import { existsSync, readFileSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
- import { join, dirname } from 'node:path';
13
+ import { join, dirname, basename } from 'node:path';
14
14
  import { findProject } from './project.mjs';
15
15
  import { ensureEngine } from './engine.mjs';
16
16
  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
- import { portsFor, portInUse, killPort, lanIp } from './ports.mjs';
19
+ import { resolvePorts, portInUse, killPort, lanIp } from './ports.mjs';
20
20
  import { assertNoInertOverride, scanPrimitives } from './primitives.mjs';
21
21
  import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
22
22
 
@@ -52,7 +52,21 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
52
52
  // Q4 revision: the engine is not an npm dependency — install/refresh it from
53
53
  // the platform's login-gated registry (no-op when the pinned version is in).
54
54
  const engine = await ensureEngine(project.dir, { log });
55
- const ports = portsFor(port ?? Number(process.env.LOOOP_DEV_PORT ?? 8000));
55
+
56
+ // Which ports, and may we take them? An explicit --port is obeyed as given
57
+ // (takeover included). With no flag we step around anyone else's stack —
58
+ // another lane, another game, an unrelated app — and only ever reclaim our
59
+ // OWN stale server. See ports.mjs; this is what makes a lane collision-free
60
+ // without the creator having to remember a flag.
61
+ const explicit = port ?? (process.env.LOOOP_DEV_PORT ? Number(process.env.LOOOP_DEV_PORT) : null);
62
+ const { ports, tookOver, steppedAside } = await resolvePorts({
63
+ projectDir: project.dir,
64
+ requested: explicit,
65
+ });
66
+ for (const s of steppedAside) {
67
+ const who = s.projectDir ? `another Looop game (${basename(s.projectDir)})` : 'another app';
68
+ log(`→ :${s.port} (held by ${who} — leaving it alone)`);
69
+ }
56
70
  const children = [];
57
71
  const servers = [];
58
72
 
@@ -89,8 +103,11 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
89
103
  };
90
104
 
91
105
  // ─── static ───
92
- if (await portInUse(ports.static)) {
93
- log(`→ static :${ports.static} (in use killing to take over)`);
106
+ // `tookOver` is the ONLY licence to kill: an explicit --port, or our own
107
+ // stale server on this block. resolvePorts already walked away from
108
+ // everyone else's, so anything still here is fair game.
109
+ if (tookOver && (await portInUse(ports.static))) {
110
+ log(`→ static :${ports.static} (our stale server — reclaiming)`);
94
111
  await killPort(ports.static);
95
112
  }
96
113
  // overrides/shared/… : the game's engine modifications mount ahead of the
@@ -108,6 +125,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
108
125
  const overridesDir = join(project.dir, 'overrides', 'shared');
109
126
  const staticServer = createStaticServer({
110
127
  slug: project.slug,
128
+ projectDir: project.dir, // answers /__looop/whoami — see ports.mjs
111
129
  mounts: [
112
130
  { url: `/games/${project.slug}/`, dir: project.dir },
113
131
  { url: '/shared/', dir: generatedOverridesDir },
@@ -124,8 +142,8 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
124
142
  if (noMp) {
125
143
  log(`→ multiplayer :${ports.mp} (skipped — NO_MP=1)`);
126
144
  } else {
127
- if (await portInUse(ports.mp)) {
128
- log(`→ multiplayer :${ports.mp} (in usekilling to take over)`);
145
+ if (tookOver && (await portInUse(ports.mp))) {
146
+ log(`→ multiplayer :${ports.mp} (our stale server reclaiming)`);
129
147
  await killPort(ports.mp);
130
148
  }
131
149
  const pk = spawn(
package/lib/lane.mjs ADDED
@@ -0,0 +1,127 @@
1
+ // `looop lane <name>` — open an isolated lane for an experiment.
2
+ //
3
+ // A lane is this game checked out a second time, in its own folder, on its own
4
+ // branch, with its own dev stack. Work in a lane cannot disturb the main
5
+ // checkout: different files, different branch, different servers, different
6
+ // multiplayer room. Win → merge it. Lose → delete the folder; nothing that
7
+ // mattered was ever touched.
8
+ //
9
+ // This exists because the lane used to be a PROCEDURE — a bash block in the
10
+ // build skill: commit-guard, `git worktree add`, `npm install`, and remember a
11
+ // distinct `--port` or you'd kill the main checkout's servers. Four steps,
12
+ // three of them footguns, one of them slow. Agents read that, priced the
13
+ // ceremony, and skipped isolating altogether — repeatedly. The COST was the
14
+ // bug. A procedure a tool can just perform should never be written down and
15
+ // hoped for.
16
+ //
17
+ // What used to be expensive, and isn't any more:
18
+ //
19
+ // npm install → node_modules is CLONED from the main checkout. On APFS
20
+ // (macOS) and btrfs/xfs (Linux) that's a copy-on-write
21
+ // reflink: effectively instant, near-zero extra disk. The
22
+ // engine is already sitting one directory up; resolving it
23
+ // again over the network was always silly.
24
+ // --port 8010 → gone. `looop dev` resolves its own ports now and steps
25
+ // around anyone else's stack (ports.mjs). A lane just runs
26
+ // `npx looop dev`, like everywhere else.
27
+ import { execFileSync } from 'node:child_process';
28
+ import { cpSync, existsSync, statSync } from 'node:fs';
29
+ import { basename, join, resolve } from 'node:path';
30
+ import { findProject } from './project.mjs';
31
+ import { runNpm } from './npm.mjs';
32
+
33
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
34
+
35
+ // Is this folder a lane (a linked git worktree) rather than the game itself?
36
+ //
37
+ // Git's own marker, no subprocess: in a linked worktree `.git` is a FILE
38
+ // holding `gitdir: …/.git/worktrees/<name>`; in an ordinary repo it's a
39
+ // directory. A folder with no git at all is not a lane.
40
+ //
41
+ // This matters to `publish`, which is global, permanent and irreversible — see
42
+ // the guard there.
43
+ export function isLane(dir) {
44
+ try {
45
+ return statSync(join(dir, '.git')).isFile();
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // Clone a directory tree as cheaply as the filesystem allows.
52
+ //
53
+ // `cp -c` (macOS) and `cp --reflink=auto` (Linux) ask for copy-on-write: the
54
+ // new tree shares its blocks with the old one until something writes. For a
55
+ // node_modules of tens of thousands of files that is the difference between
56
+ // "instant" and "go and make a coffee". Neither is guaranteed — a non-APFS
57
+ // volume, a filesystem without reflinks — so both fall back to a real copy,
58
+ // which is still local, and still beats a network resolve.
59
+ export function cloneTree(src, dest) {
60
+ const args =
61
+ process.platform === 'darwin'
62
+ ? ['-c', '-R', src, dest] // APFS clonefile
63
+ : process.platform === 'linux'
64
+ ? ['-r', '--reflink=auto', src, dest] // btrfs/xfs, degrades to a copy
65
+ : null;
66
+ if (args) {
67
+ try {
68
+ execFileSync('cp', args, { stdio: 'ignore' });
69
+ return 'cloned';
70
+ } catch {
71
+ // Not a CoW filesystem (or no `cp` worth the name — Windows). Fall through.
72
+ }
73
+ }
74
+ cpSync(src, dest, { recursive: true });
75
+ return 'copied';
76
+ }
77
+
78
+ export async function lane(name, { cwd = process.cwd(), log = console.log } = {}) {
79
+ const project = findProject(cwd);
80
+ const slug = project.slug;
81
+ const branch = `lane/${name}`;
82
+ const dir = resolve(project.dir, '..', `${slug}-${name}`);
83
+
84
+ if (existsSync(dir)) {
85
+ throw new Error(
86
+ `A lane called "${name}" already exists at ${dir}.\n` +
87
+ `Work in it (cd ${basename(dir)} && npx looop dev), or remove it first ` +
88
+ `(git worktree remove ${dir}).`,
89
+ );
90
+ }
91
+
92
+ // `git worktree add` on a repo with NO commits silently produces an empty
93
+ // orphan lane — the folder exists, the game isn't in it. To a creator that
94
+ // reads as "the tool deleted my game". Save first, but ONLY in that case:
95
+ // committing on someone's behalf when they didn't ask is its own betrayal.
96
+ let savedFirst = false;
97
+ try {
98
+ git(project.dir, 'rev-parse', 'HEAD');
99
+ } catch {
100
+ log('→ saving your game first (a lane needs at least one save to branch from)');
101
+ git(project.dir, 'add', '-A');
102
+ git(project.dir, 'commit', '-m', 'save: initial');
103
+ savedFirst = true;
104
+ }
105
+
106
+ // Their uncommitted work is deliberately NOT carried across: the lane starts
107
+ // from the last save. That IS the isolation — the whole point is that what
108
+ // they're in the middle of stays exactly where it is, untouched.
109
+ git(project.dir, 'worktree', 'add', '-b', branch, dir);
110
+ log(`→ lane ${dir} (branch ${branch})`);
111
+
112
+ // node_modules is gitignored, so the lane arrives empty. Without it, `npx
113
+ // looop` falls through to a WRONG public package on npm and dies with an
114
+ // error that reads like a bug in the creator's game.
115
+ const src = join(project.dir, 'node_modules');
116
+ let installed = false;
117
+ if (existsSync(src)) {
118
+ const how = cloneTree(src, join(dir, 'node_modules'));
119
+ log(`→ engine ${how} from the main checkout (no npm install needed)`);
120
+ } else {
121
+ log('→ engine installing (the main checkout has no node_modules to clone)');
122
+ runNpm(['install'], { cwd: dir, stdio: 'inherit' });
123
+ installed = true;
124
+ }
125
+
126
+ return { dir, branch, savedFirst, installed };
127
+ }
package/lib/ports.mjs CHANGED
@@ -8,6 +8,25 @@
8
8
  // Takeover: `looop dev` must "just work" — a stale dev session or orphaned
9
9
  // workerd holding a port means the user gets stale code with no recourse, so
10
10
  // we TERM (then KILL) the listener instead of skipping, exactly like dev.sh.
11
+ //
12
+ // …but ONLY our own. Takeover used to be unconditional: `portInUse()` is a bare
13
+ // TCP connect, so dev killed whatever answered without asking whose it was. In
14
+ // a worktree lane that meant a plain `npx looop dev` SIGKILLed the MAIN
15
+ // checkout's whole stack (game + multiplayer + services) and stole its ports —
16
+ // the tool manufacturing the exact collision a lane exists to prevent, with a
17
+ // remembered `--port 8010` as the only defence. Isolation you have to remember
18
+ // isn't isolation.
19
+ //
20
+ // So a port now has an OWNER, and the answer decides:
21
+ //
22
+ // free → take it
23
+ // our own dev server → take it OVER (the stale-code case above)
24
+ // another project's, or a
25
+ // foreign app's → LEAVE IT and step to the next block
26
+ //
27
+ // Ownership is asked, not inferred: the static server answers `/__looop/whoami`
28
+ // with its project dir (static-server.mjs). No registry file to go stale, and
29
+ // nothing to clean up after a crash — if it answers, it's alive.
11
30
  import { execFileSync } from 'node:child_process';
12
31
  import net from 'node:net';
13
32
  import { networkInterfaces } from 'node:os';
@@ -16,11 +35,90 @@ export const STATIC_PORT_BASE = 8000;
16
35
  export const MP_PORT_BASE = 1999;
17
36
  export const SHIM_PORT_BASE = 8788;
18
37
 
38
+ // Ports move in BLOCKS: the browser derives mp/shim from location.port, so the
39
+ // whole stack shifts together. 8000/1999/8788 → 8010/2009/8798 → …
40
+ export const BLOCK_STRIDE = 10;
41
+ export const WHOAMI_PATH = '/__looop/whoami';
42
+
19
43
  export function portsFor(staticPort = STATIC_PORT_BASE) {
20
44
  const offset = staticPort - STATIC_PORT_BASE;
21
45
  return { static: staticPort, mp: MP_PORT_BASE + offset, shim: SHIM_PORT_BASE + offset };
22
46
  }
23
47
 
48
+ // Who is on this port? → { projectDir, pid } if one of our dev servers answers,
49
+ // null if the port is silent, or if something that isn't us holds it (a foreign
50
+ // app owes us no answer — and either way the verdict is "not mine, don't touch").
51
+ export function whoIsOn(port, timeoutMs = 400) {
52
+ return new Promise((resolve) => {
53
+ const req = net.connect({ port, host: '127.0.0.1' });
54
+ let body = '';
55
+ const done = (v) => {
56
+ req.destroy();
57
+ resolve(v);
58
+ };
59
+ req.setTimeout(timeoutMs, () => done(null));
60
+ req.on('error', () => done(null)); // nothing listening
61
+ req.on('connect', () => {
62
+ req.write(`GET ${WHOAMI_PATH} HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n`);
63
+ });
64
+ req.on('data', (chunk) => {
65
+ body += chunk;
66
+ const i = body.indexOf('\r\n\r\n');
67
+ if (i === -1) return; // headers still arriving
68
+ if (!/^HTTP\/1\.[01] 200/.test(body)) return done(null); // answered, but not ours
69
+ try {
70
+ const parsed = JSON.parse(body.slice(i + 4));
71
+ done(parsed.projectDir ? parsed : null);
72
+ } catch {
73
+ /* body may still be arriving; wait for more, or for the timeout */
74
+ }
75
+ });
76
+ req.on('end', () => done(null));
77
+ });
78
+ }
79
+
80
+ // Pick the port block this dev stack should bind.
81
+ //
82
+ // `requested` (an explicit --port) is obeyed as given, takeover included: you
83
+ // named the port, you get the port. Explicit beats clever — it's the AUTO path
84
+ // that has to be safe, because that's the one nobody thought about.
85
+ // `base` exists so the tests can run in a private port range: a suite that
86
+ // probes :8000 for real would fight (and report on) whatever dev servers the
87
+ // developer has running — which is how this file's own tests first "failed".
88
+ export async function resolvePorts({
89
+ projectDir,
90
+ requested = null,
91
+ blocks = 24,
92
+ base = STATIC_PORT_BASE,
93
+ } = {}) {
94
+ if (requested != null) {
95
+ return { ports: portsFor(requested), tookOver: true, steppedAside: [] };
96
+ }
97
+ const steppedAside = [];
98
+ for (let i = 0; i < blocks; i++) {
99
+ const ports = portsFor(base + i * BLOCK_STRIDE);
100
+ const owner = (await portInUse(ports.static)) ? await whoIsOn(ports.static) : undefined;
101
+
102
+ if (owner === undefined) {
103
+ // The game port is free — but a block only isolates if the WHOLE stack
104
+ // fits in it. Half a stack here and half somewhere else is worse than
105
+ // moving on, so a contaminated block is skipped entire.
106
+ if ((await portInUse(ports.mp)) || (await portInUse(ports.shim))) {
107
+ steppedAside.push({ port: ports.static, projectDir: null });
108
+ continue;
109
+ }
110
+ return { ports, tookOver: false, steppedAside };
111
+ }
112
+ if (owner && owner.projectDir === projectDir) {
113
+ return { ports, tookOver: true, steppedAside }; // our own stale server
114
+ }
115
+ steppedAside.push({ port: ports.static, projectDir: owner?.projectDir ?? null });
116
+ }
117
+ throw new Error(
118
+ `No free port block after trying ${blocks}. Close a dev server, or pass --port explicitly.`,
119
+ );
120
+ }
121
+
24
122
  export function portInUse(port) {
25
123
  return new Promise((resolve) => {
26
124
  const sock = net.connect({ port, host: '127.0.0.1' });
package/lib/publish.mjs CHANGED
@@ -13,6 +13,7 @@ import { createHash } from 'node:crypto';
13
13
  import { readdirSync, readFileSync, statSync } from 'node:fs';
14
14
  import { join, relative } from 'node:path';
15
15
  import { findProject } from './project.mjs';
16
+ import { isLane } from './lane.mjs';
16
17
  import { ensureEngine } from './engine.mjs';
17
18
  import { getToken, getApiBase } from './config.mjs';
18
19
  import { DEFAULT_API_BASE } from './llm-shim.mjs';
@@ -65,6 +66,26 @@ export async function publish({
65
66
  log = console.log,
66
67
  } = {}) {
67
68
  const project = findProject(cwd);
69
+
70
+ // A publish is GLOBAL, PERMANENT and IRREVERSIBLE — the first one claims the
71
+ // name for this account forever. A lane is a throwaway experiment in a folder
72
+ // named after it, and the slug defaults to the folder name, so a bare publish
73
+ // from one would claim "riven-judder" for all time (or 403 against whoever
74
+ // owns it). And when a game overrides `looop.slug`, the lane inherits it and
75
+ // would silently overwrite the LIVE game with the experiment.
76
+ //
77
+ // A name this permanent is never guessed inside a lane. The guard is on
78
+ // GUESSING, not on publishing: name it with --slug and it goes through.
79
+ if (!slug && isLane(project.dir)) {
80
+ throw new Error(
81
+ `This is a lane, not your game. Publishing from here would claim the name ` +
82
+ `"${project.slug}" permanently.\n\n` +
83
+ ` npx looop publish --slug ${project.slug}-test # publish an A/B copy to compare\n` +
84
+ ` npx looop publish --slug <your-game> # ship this lane AS the real game\n\n` +
85
+ `Or merge the lane into your game and publish from there.`,
86
+ );
87
+ }
88
+
68
89
  // The declared engineVersion must be a version the platform can resolve —
69
90
  // ensureEngine keeps the local install synced to the game's pin.
70
91
  const engine = await ensureEngine(project.dir, { apiBase, log });
@@ -0,0 +1,75 @@
1
+ // Preloaded before every browser smoke via `node --import`. Its whole job is to
2
+ // make headless Chromium render on the real GPU instead of SwiftShader.
3
+ //
4
+ // Why it matters: a headless browser has no GPU, so WebGL falls back to
5
+ // SwiftShader — software rasterization spread across a thread pool sized to the
6
+ // machine's core count. A single 3D-game smoke can pin 8+ cores that way, which
7
+ // on a modest laptop means a frozen machine, not just a slow test. Rendering on
8
+ // the actual GPU (Metal / D3D / GL, chosen by ANGLE per platform) moves that work
9
+ // off the CPU entirely: measured ~8.5 cores → ~0.1, with pixel-identical output.
10
+ //
11
+ // It runs as a preload so an UNMODIFIED smoke needs no change: it patches the
12
+ // `chromium.launch` of the GAME's own playwright (resolved from cwd, the same
13
+ // instance the smoke imports) to append the flags. Fetch-only smokes that never
14
+ // import playwright, and machines with no usable GPU, both fall through
15
+ // untouched — ANGLE simply falls back to software, so this is never worse than
16
+ // before. Opt out with LOOOP_TEST_NO_GPU=1.
17
+ import { createRequire } from 'node:module';
18
+ import { pathToFileURL } from 'node:url';
19
+ import { readFileSync } from 'node:fs';
20
+ import { join, dirname } from 'node:path';
21
+
22
+ export function gpuArgs() {
23
+ if (process.env.LOOOP_TEST_NO_GPU === '1') return [];
24
+ return ['--enable-gpu', '--ignore-gpu-blocklist'];
25
+ }
26
+
27
+ // Append `extra` to whatever args a caller already passed to chromium.launch(),
28
+ // preserving theirs. Idempotent enough for our use: called once per smoke process.
29
+ export function patchLaunch(chromium, extra = gpuArgs()) {
30
+ if (!chromium || typeof chromium.launch !== 'function' || extra.length === 0) return false;
31
+ const orig = chromium.launch.bind(chromium);
32
+ chromium.launch = (opts = {}) => orig({ ...opts, args: [...(opts.args || []), ...extra] });
33
+ return true;
34
+ }
35
+
36
+ // Resolve the SAME playwright module object a smoke's `import 'playwright'` gets.
37
+ // The catch: a bare ESM import resolves via the package's "import" export
38
+ // (index.mjs), while createRequire().resolve() returns the "require" main
39
+ // (index.js) — a DIFFERENT module instance. Patching that one would miss, and the
40
+ // smoke would silently fall back to software with no error. So resolve the
41
+ // package dir, then import its ESM entry explicitly.
42
+ //
43
+ // Resolve from the SMOKE FILE's directory first (process.argv[1] under
44
+ // `node --import <preload> <smoke>`), then cwd. A smoke normally runs from its
45
+ // own game folder (cwd == the smoke's dir), but a runner may invoke it from a
46
+ // different working directory — resolving off the smoke's own location makes the
47
+ // same playwright install resolve either way.
48
+ async function loadGamePlaywright() {
49
+ const bases = [];
50
+ const smoke = process.argv[1];
51
+ if (smoke) bases.push(join(dirname(smoke), 'package.json'));
52
+ bases.push(join(process.cwd(), 'package.json'));
53
+ let lastErr;
54
+ for (const base of bases) {
55
+ try {
56
+ const require = createRequire(base);
57
+ const pkgDir = dirname(require.resolve('playwright')); // .../node_modules/playwright
58
+ const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
59
+ const entry = pkg.exports?.['.']?.import ?? pkg.module ?? pkg.main ?? 'index.js';
60
+ return import(pathToFileURL(join(pkgDir, entry)).href);
61
+ } catch (e) { lastErr = e; }
62
+ }
63
+ throw lastErr;
64
+ }
65
+
66
+ // Self-execute on preload. Kept in try/catch so a smoke without playwright (a
67
+ // fetch-only boot smoke) or a missing install never turns a preload into a crash.
68
+ if (gpuArgs().length > 0) {
69
+ try {
70
+ const pw = await loadGamePlaywright();
71
+ patchLaunch(pw.chromium ?? pw.default?.chromium);
72
+ } catch {
73
+ // No playwright here, or it couldn't be patched — leave the smoke unchanged.
74
+ }
75
+ }
@@ -13,6 +13,7 @@ import { EventEmitter } from 'node:events';
13
13
  import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
14
14
  import { extname, join, normalize, sep } from 'node:path';
15
15
  import { injectHeadTags, rewriteHtmlScripts, rewriteJsImports, PLATFORM_URL } from './inject.mjs';
16
+ import { WHOAMI_PATH } from './ports.mjs';
16
17
 
17
18
  const RELOAD_CLIENT = `<script>
18
19
  (() => {
@@ -96,6 +97,7 @@ function mtimesChanged(a, b) {
96
97
  export function createStaticServer({
97
98
  slug,
98
99
  mounts,
100
+ projectDir = null,
99
101
  watchDirs = [],
100
102
  injectReload = true,
101
103
  watchIntervalMs = 400,
@@ -214,6 +216,18 @@ export function createStaticServer({
214
216
  const reqUrl = new URL(req.url, 'http://x');
215
217
  const urlPath = reqUrl.pathname;
216
218
  if (urlPath === '/__reload') return handleSse(res);
219
+ // Ownership probe (ports.mjs): "whose dev server is this?". It is what lets
220
+ // another `looop dev` — a lane, another game — tell OUR stale server (kill
221
+ // and reclaim) from a live one that belongs to somebody else (step around,
222
+ // never touch). Answering honestly here is what keeps lanes from colliding.
223
+ if (urlPath === WHOAMI_PATH) {
224
+ return sendBody(
225
+ res,
226
+ 200,
227
+ JSON.stringify({ projectDir, slug, pid: process.pid }),
228
+ 'application/json',
229
+ );
230
+ }
217
231
  if (urlPath === '/' || urlPath === '/index.html') {
218
232
  res.writeHead(302, { Location: gameEntry });
219
233
  return res.end();
package/lib/test-cmd.mjs CHANGED
@@ -57,16 +57,46 @@ async function freeTestPort(base = 8100) {
57
57
  throw new Error('no free port triple found for the test dev stack');
58
58
  }
59
59
 
60
- export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev, lintFn = lintCmd } = {}) {
60
+ // Preload that GPU-offloads each smoke's browser (see smoke-gpu-preload.mjs).
61
+ // A file URL so `node --import` resolves it regardless of the smoke's cwd.
62
+ const SMOKE_PRELOAD = new URL('./smoke-gpu-preload.mjs', import.meta.url).href;
63
+
64
+ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn = dev, lintFn = lintCmd, runFn = run, patterns = [] } = {}) {
61
65
  const project = findProject(cwd);
62
- const { unit, smokes } = discoverTestFiles(project.dir);
66
+ const all = discoverTestFiles(project.dir);
67
+
68
+ // Scoped run (`looop test <pattern>…`): keep only files whose path contains a
69
+ // pattern. The file-naming convention already encodes the aspect
70
+ // (`charge.smoke.mjs`, `riven-charge.test.mjs`), so a substring on the path
71
+ // reaches both a unit test and its smoke without any source→test mapping.
72
+ // This is a PER-STEP speed lever, not the gate — the full suite still runs at
73
+ // milestone close and before publish (qa.md T3). With no pattern, everything
74
+ // runs, exactly as before.
75
+ const scoped = patterns.length > 0;
76
+ const matches = (f) => patterns.some((p) => relative(project.dir, f).includes(p));
77
+ const unit = scoped ? all.unit.filter(matches) : all.unit;
78
+ const smokes = scoped ? all.smokes.filter(matches) : all.smokes;
63
79
 
64
80
  // Lint FIRST — it needs no servers and no browser, and the defects it catches
65
81
  // (a hardcoded multiplayer host, a smoke pointed at the wrong port) are
66
82
  // exactly the ones that make the suite below pass while testing nothing.
67
83
  // Failing here does not skip the tests: the creator should see everything
68
84
  // that is wrong in one run, not peel it one gate at a time.
69
- const lint = await lintFn({ cwd: project.dir, log });
85
+ //
86
+ // A SCOPED run skips it: lint is the full gate's job and runs at milestone
87
+ // close with the whole suite — a `looop test <pattern>` is a focused per-step
88
+ // re-run, not the gate.
89
+ const lint = scoped ? { ok: true, skipped: true } : await lintFn({ cwd: project.dir, log });
90
+
91
+ // A pattern that matches nothing FAILS LOUD. "0 tests, all green" is the exact
92
+ // false-pass this whole gate exists to prevent — a mistyped scope must never
93
+ // read as "everything's fine." (A game with genuinely no tests and no pattern
94
+ // is the legitimately-green fresh-game case below.)
95
+ if (patterns.length && unit.length + smokes.length === 0) {
96
+ log(`No tests matched ${patterns.map((p) => `"${p}"`).join(', ')} in ${project.dir}.`);
97
+ log(`(${all.unit.length + all.smokes.length} test file(s) exist; none contain that pattern. Check the spelling, or run \`looop test\` with no pattern to run the whole suite.)`);
98
+ return { ok: false, ran: 0, lint };
99
+ }
70
100
 
71
101
  if (unit.length + smokes.length === 0) {
72
102
  log(`No tests yet in ${project.dir} — nothing to run.`);
@@ -74,6 +104,10 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
74
104
  return { ok: lint.ok, ran: 0 };
75
105
  }
76
106
 
107
+ if (patterns.length) {
108
+ log(`▶ scoped to ${patterns.map((p) => `"${p}"`).join(', ')}: ${unit.length + smokes.length} of ${all.unit.length + all.smokes.length} file(s) — full suite still runs at milestone close.`);
109
+ }
110
+
77
111
  let ok = lint.ok;
78
112
 
79
113
  // If looop test itself runs under a node --test parent, the inherited
@@ -117,7 +151,7 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
117
151
  try {
118
152
  for (const file of smokes) {
119
153
  const rel = relative(project.dir, file);
120
- const code = await run([file], {
154
+ const code = await runFn(['--import', SMOKE_PRELOAD, file], {
121
155
  cwd: project.dir,
122
156
  env: {
123
157
  ...env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",