@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 +49 -0
- package/bin/looop.mjs +15 -2
- package/lib/dev.mjs +25 -7
- package/lib/lane.mjs +127 -0
- package/lib/ports.mjs +98 -0
- package/lib/publish.mjs +21 -0
- package/lib/smoke-gpu-preload.mjs +75 -0
- package/lib/static-server.mjs +14 -0
- package/lib/test-cmd.mjs +38 -4
- package/package.json +1 -1
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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} (
|
|
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
|
+
}
|
package/lib/static-server.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
154
|
+
const code = await runFn(['--import', SMOKE_PRELOAD, file], {
|
|
121
155
|
cwd: project.dir,
|
|
122
156
|
env: {
|
|
123
157
|
...env,
|