@looop-games/cli 0.1.30 → 0.1.32

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,27 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.32] - 2026-08-23
18
+
19
+ ### Fixed
20
+ - `looop dev` no longer hands your game a **browser-blocked port**. It derives the
21
+ multiplayer and services ports from the game port, and a few combinations land
22
+ on ports browsers refuse to connect to (`ERR_UNSAFE_PORT` — e.g. game port 8050
23
+ derives multiplayer port 2049/NFS). The game booted but its room socket died
24
+ instantly, so it rendered blank with no owned player — and because `curl` and
25
+ headless tests ignore the blocklist, every check looked healthy. Dev now skips
26
+ any port block a browser can't reach, and warns if you pin an unreachable one
27
+ with `--port`.
28
+
29
+ ## [0.1.31] - 2026-08-21
30
+
31
+ ### Changed
32
+ - `looop dev` no longer starts a multiplayer server for a **single-player** game.
33
+ A game with no `config({ multiplayer: true })` runs its whole simulation in the
34
+ browser, so the local PartyKit server was dead weight — dev now skips it and
35
+ says so. Structural hot-reload (adding an entity/trait) still works. A
36
+ multiplayer game is unaffected.
37
+
17
38
  ## [0.1.30] - 2026-08-21
18
39
 
19
40
  ### Fixed
package/lib/dev.mjs CHANGED
@@ -16,8 +16,8 @@ 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 { resolvePorts, portInUse, killPort, lanIp } from './ports.mjs';
20
- import { assertNoInertOverride, isFrameworkV2, scanPrimitives } from './primitives.mjs';
19
+ import { resolvePorts, portInUse, killPort, lanIp, isRestricted, nextBrowserSafe } from './ports.mjs';
20
+ import { assertNoInertOverride, isFrameworkV2, isSinglePlayerV2, 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';
@@ -73,8 +73,18 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
73
73
  requested: explicit,
74
74
  });
75
75
  for (const s of steppedAside) {
76
- const who = s.projectDir ? `another Looop game (${basename(s.projectDir)})` : 'another app';
77
- log(`→ :${s.port} (held by ${who}leaving it alone)`);
76
+ const who = s.reason === 'browser-restricted'
77
+ ? 'browser-restricted (mp/shim would be blocked) stepping past'
78
+ : `held by ${s.projectDir ? `another Looop game (${basename(s.projectDir)})` : 'another app'} — leaving it alone`;
79
+ log(`→ :${s.port} (${who})`);
80
+ }
81
+ // An EXPLICIT --port is obeyed as given, but if it derives a browser-blocked
82
+ // port (e.g. --port 8050 → mp 2049/NFS) the game would look broken with no
83
+ // clue, so say so loudly rather than silently binding it.
84
+ for (const [name, p] of [['multiplayer', ports.mp], ['services', ports.shim], ['static', ports.static]]) {
85
+ if (isRestricted(p)) {
86
+ log(`⚠ ${name} port :${p} is browser-restricted (ERR_UNSAFE_PORT) — browsers will refuse it. Re-run with a --port whose ${name} port is reachable (e.g. --port ${nextBrowserSafe(ports.static)}).`);
87
+ }
78
88
  }
79
89
  const children = [];
80
90
  const servers = [];
@@ -151,8 +161,40 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
151
161
  log(`→ static :${ports.static} (serving ${project.dir}, engine ${engine.version}, auto-reload on)`);
152
162
 
153
163
  // ─── multiplayer (partykit on the bundle's room code) ───
154
- if (noMp) {
155
- log(`→ multiplayer :${ports.mp} (skippedNO_MP=1)`);
164
+ // A single-player framework-v2 game (no config({ multiplayer: true })) runs its
165
+ // authoritative sim in-process via LocalRoom its client never opens a socket,
166
+ // so standing up partykit here just burns a port and a workerd process. Skip it,
167
+ // mirroring publish, which deploys no per-game room worker for the same game.
168
+ const singlePlayer = isSinglePlayerV2({ projectDir: project.dir, skeleton: roomSkeleton });
169
+ if (noMp || singlePlayer) {
170
+ log(
171
+ singlePlayer && !noMp
172
+ ? `→ multiplayer :${ports.mp} (skipped — single-player; add config({ multiplayer: true }) to enable)`
173
+ : `→ multiplayer :${ports.mp} (skipped — NO_MP=1)`,
174
+ );
175
+ // No room worker, but the client still assembles its graph against the injected
176
+ // skeleton — so a STRUCTURAL edit (new entity/trait) must rebuild the skeleton
177
+ // and push it to the static server, or the page reloads onto a stale skeleton
178
+ // and assembleGraph throws ("re-ran N ticks but the skeleton has M"). The full
179
+ // partykit reloader below does this as a side effect; single-player needs a
180
+ // stripped-down version with no child to respawn. (NO_MP on a real multiplayer
181
+ // game has no client to serve a skeleton to, so it needs none.)
182
+ if (singlePlayer) {
183
+ const skelWatcher = createFileWatcher({
184
+ files: roomInputs,
185
+ dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(project.dir, d)),
186
+ onChange: async () => {
187
+ try {
188
+ const built = await buildDevRoomServer({ projectDir: project.dir, engine });
189
+ staticServer.setSkeleton(built.skeleton ?? null);
190
+ skelWatcher.setFiles(built.inputs);
191
+ } catch (err) {
192
+ log(`[skeleton] rebuild failed — fix the error and save again: ${err.message}`);
193
+ }
194
+ },
195
+ });
196
+ servers.push({ close: () => skelWatcher.close() });
197
+ }
156
198
  } else {
157
199
  if (tookOver && (await portInUse(ports.mp))) {
158
200
  log(`→ multiplayer :${ports.mp} (our stale server — reclaiming)`);
package/lib/ports.mjs CHANGED
@@ -35,6 +35,40 @@ export const STATIC_PORT_BASE = 8000;
35
35
  export const MP_PORT_BASE = 1999;
36
36
  export const SHIM_PORT_BASE = 8788;
37
37
 
38
+ // Ports a BROWSER refuses to connect to (ERR_UNSAFE_PORT), from Chromium's
39
+ // net/base/port_util.cc `kRestrictedPorts` (Firefox bans the same set). We
40
+ // derive mp/shim from the static port, so a block landing ANY of the three on
41
+ // one of these silently breaks the browser connection — while curl and a
42
+ // headless probe connect fine, so every server-side check looks healthy. The
43
+ // one that bites in the normal range is 2049 (NFS): static 8050 → mp 2049. The
44
+ // full set keeps it robust if the derivation range ever shifts. Reported by a
45
+ // creator (feedback fb_c690e35cff30511e).
46
+ export const RESTRICTED_PORTS = new Set([
47
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79,
48
+ 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137,
49
+ 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532,
50
+ 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723,
51
+ 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697,
52
+ 10080,
53
+ ]);
54
+
55
+ export function isRestricted(port) {
56
+ return RESTRICTED_PORTS.has(port);
57
+ }
58
+
59
+ /** True if a static base derives a block where static, mp, OR shim is browser-restricted. */
60
+ export function blockIsRestricted(staticPort) {
61
+ const p = portsFor(staticPort);
62
+ return isRestricted(p.static) || isRestricted(p.mp) || isRestricted(p.shim);
63
+ }
64
+
65
+ /** The first static port ≥ `staticPort` whose whole derived block is browser-reachable. */
66
+ export function nextBrowserSafe(staticPort) {
67
+ let p = staticPort;
68
+ while (blockIsRestricted(p)) p += 1;
69
+ return p;
70
+ }
71
+
38
72
  // Ports move in BLOCKS: the browser derives mp/shim from location.port, so the
39
73
  // whole stack shifts together. 8000/1999/8788 → 8010/2009/8798 → …
40
74
  export const BLOCK_STRIDE = 10;
@@ -97,6 +131,15 @@ export async function resolvePorts({
97
131
  const steppedAside = [];
98
132
  for (let i = 0; i < blocks; i++) {
99
133
  const ports = portsFor(base + i * BLOCK_STRIDE);
134
+ // A block whose static/mp/shim lands on a browser-restricted port is
135
+ // unusable no matter how free it is — the browser refuses the connection
136
+ // (ERR_UNSAFE_PORT) even though curl and server checks pass. Skip it before
137
+ // probing, so auto-allocation only ever hands the creator a browser-
138
+ // reachable stack. (feedback fb_c690e35cff30511e — mp 2049/NFS on base 8050.)
139
+ if (blockIsRestricted(base + i * BLOCK_STRIDE)) {
140
+ steppedAside.push({ port: ports.static, projectDir: null, reason: 'browser-restricted' });
141
+ continue;
142
+ }
100
143
  const owner = (await portInUse(ports.static)) ? await whoIsOn(ports.static) : undefined;
101
144
 
102
145
  if (owner === undefined) {
@@ -166,6 +166,17 @@ export function isServerBacked(projectDir) {
166
166
  });
167
167
  }
168
168
 
169
+ // VQ7M2K: a framework-v2 game is SINGLE-PLAYER unless it declares
170
+ // config({ multiplayer: true }). A single-player game runs its authoritative sim
171
+ // in-process (the LocalRoom transport) with no wire — so dev stands up no partykit
172
+ // room and publish deploys no per-game room worker. The multiplayer flag lives in
173
+ // the built skeleton's config (emitSkeleton → graph.config, default false); a game
174
+ // with no skeleton (a v1 primitive/entity game) is never single-player in this
175
+ // sense and keeps its room. Callers pass the skeleton they already built.
176
+ export function isSinglePlayerV2({ projectDir, skeleton }) {
177
+ return isFrameworkV2(projectDir) && skeleton?.config?.multiplayer !== true;
178
+ }
179
+
169
180
  // The one cycle shadowing makes possible, caught in the creator's own words.
170
181
  //
171
182
  // A NEW primitive may subclass an engine one — that is the RECOMMENDED way to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
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",