@bitmagic/cli 0.1.1 → 0.1.3

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.
Files changed (46) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +23 -11
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/forge.d.ts +107 -0
  5. package/dist/commands/forge.js +393 -0
  6. package/dist/commands/forge.js.map +1 -0
  7. package/dist/commands/generate.js +58 -45
  8. package/dist/commands/generate.js.map +1 -1
  9. package/dist/commands/verify.js +16 -16
  10. package/dist/commands/verify.js.map +1 -1
  11. package/dist/forge/apply-modifications.d.ts +19 -0
  12. package/dist/forge/apply-modifications.js +184 -0
  13. package/dist/forge/apply-modifications.js.map +1 -0
  14. package/dist/forge/bake-progress.d.ts +44 -0
  15. package/dist/forge/bake-progress.js +77 -0
  16. package/dist/forge/bake-progress.js.map +1 -0
  17. package/dist/forge/browser-host.d.ts +100 -0
  18. package/dist/forge/browser-host.js +351 -0
  19. package/dist/forge/browser-host.js.map +1 -0
  20. package/dist/forge/local-store.d.ts +30 -0
  21. package/dist/forge/local-store.js +130 -0
  22. package/dist/forge/local-store.js.map +1 -0
  23. package/dist/forge/run-pipeline.d.ts +105 -0
  24. package/dist/forge/run-pipeline.js +269 -0
  25. package/dist/forge/run-pipeline.js.map +1 -0
  26. package/dist/forge/stream.d.ts +106 -0
  27. package/dist/forge/stream.js +286 -0
  28. package/dist/forge/stream.js.map +1 -0
  29. package/dist/forge/transport.d.ts +58 -0
  30. package/dist/forge/transport.js +145 -0
  31. package/dist/forge/transport.js.map +1 -0
  32. package/dist/forge/upload-proxy.d.ts +21 -0
  33. package/dist/forge/upload-proxy.js +134 -0
  34. package/dist/forge/upload-proxy.js.map +1 -0
  35. package/dist/generate/stream.d.ts +1 -10
  36. package/dist/generate/stream.js +5 -62
  37. package/dist/generate/stream.js.map +1 -1
  38. package/dist/http/sse.d.ts +40 -0
  39. package/dist/http/sse.js +98 -0
  40. package/dist/http/sse.js.map +1 -0
  41. package/dist/local-port.d.ts +30 -0
  42. package/dist/local-port.js +53 -0
  43. package/dist/local-port.js.map +1 -0
  44. package/dist/scaffold/project-files.js +89 -9
  45. package/dist/scaffold/project-files.js.map +1 -1
  46. package/package.json +3 -2
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A tiny loopback HTTP server that lets the ENGINE reach api-server's signed-upload endpoint.
3
+ *
4
+ * The engine uploads its own `.vxl`/`.vwld` bytes — hundreds of megabytes, so they never cross the
5
+ * message channel — by asking for a presigned PUT first. `StorageUploadUtil.ts` does that with a
6
+ * hardcoded path and no credentials:
7
+ *
8
+ * fetch(`${getAgentUrl()}/api/generate-upload-url`, {
9
+ * headers: { 'Content-Type': 'application/json' }, // no Authorization
10
+ *
11
+ * Neither half of that reaches api-server. The path it calls is game-play-agent's
12
+ * (`mastra/routes.ts`); api-server serves `POST /api/cli/v1/uploads/signed-url`. And every route
13
+ * under `/api/cli/v1/*` is Auth0 + Admin gated, while a browser page has no token — so pointing
14
+ * `agentUrl` straight at api-server answers 401 to every upload, which is exactly what the first
15
+ * end-to-end forge did: 14 archetypes dropped and the level bake failed after voxelizing all 392
16
+ * chunks.
17
+ *
18
+ * So the CLI bridges it instead of the engine changing. That direction is deliberate:
19
+ *
20
+ * - **The engine cannot be the fix.** Published games carry frozen template code compiled against
21
+ * the current engine (see the engine-API rule in AGENTS.md), and `StorageUploadUtil` is shared
22
+ * by every lane. Teaching it to send a bearer token is a breaking change to all of them to serve
23
+ * one.
24
+ * - **The token must not enter the page.** It stays in this process. The forge browser runs with
25
+ * `--disable-web-security`, so a token in page scope is worth strictly less than one outside it.
26
+ *
27
+ * Bound to 127.0.0.1 for the same reason: this server holds a creator's access token and answers
28
+ * with write credentials into Bitmagic's bucket. It must not be reachable off the machine.
29
+ *
30
+ * `gameId` is FORCED from the CLI's own project context rather than taken from the request body.
31
+ * api-server checks ownership regardless (`assertOwnsGame`, and the key is pinned to
32
+ * `worlds/v3/<gameId>/`), so this is defence in depth — but it is cheap, and it means a page that
33
+ * somehow holds the wrong id cannot even ask for the wrong namespace.
34
+ */
35
+ import * as http from 'http';
36
+ import { CliError } from '../errors.js';
37
+ /** The path the ENGINE calls. Not api-server's — that is what this proxy translates. */
38
+ export const ENGINE_UPLOAD_PATH = '/api/generate-upload-url';
39
+ /** The api-server route it forwards to. */
40
+ export const API_SERVER_UPLOAD_PATH = '/api/cli/v1/uploads/signed-url';
41
+ function readBody(req) {
42
+ return new Promise((resolve, reject) => {
43
+ let body = '';
44
+ req.on('data', (chunk) => { body += String(chunk); });
45
+ req.on('end', () => resolve(body));
46
+ req.on('error', reject);
47
+ });
48
+ }
49
+ /**
50
+ * CORS is answered explicitly rather than relying on `--disable-web-security`. The flag is there
51
+ * for the presigned PUT to GCS, not as this proxy's security model, and a request the browser
52
+ * refuses to make is indistinguishable from a server that is down.
53
+ */
54
+ function writeCors(res) {
55
+ res.setHeader('Access-Control-Allow-Origin', '*');
56
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
57
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
58
+ }
59
+ export async function startUploadProxy(options) {
60
+ const log = options.log ?? (() => { });
61
+ const doFetch = options.fetchImpl ?? fetch;
62
+ const apiBase = options.apiUrl.replace(/\/$/, '');
63
+ const server = http.createServer((req, res) => {
64
+ void (async () => {
65
+ writeCors(res);
66
+ if (req.method === 'OPTIONS') {
67
+ res.writeHead(204).end();
68
+ return;
69
+ }
70
+ if (req.method !== 'POST' || req.url !== ENGINE_UPLOAD_PATH) {
71
+ // Named rather than silent: an engine that starts calling a different path should show up
72
+ // as this line in the forge log, not as a mute upload failure.
73
+ log(`[upload-proxy] ignoring ${req.method ?? '?'} ${req.url ?? '?'}`);
74
+ res.writeHead(404, { 'Content-Type': 'application/json' })
75
+ .end(JSON.stringify({ error: 'Not found' }));
76
+ return;
77
+ }
78
+ let parsed = {};
79
+ try {
80
+ const raw = await readBody(req);
81
+ if (raw)
82
+ parsed = JSON.parse(raw);
83
+ }
84
+ catch {
85
+ res.writeHead(400, { 'Content-Type': 'application/json' })
86
+ .end(JSON.stringify({ error: 'Request body must be JSON' }));
87
+ return;
88
+ }
89
+ try {
90
+ const upstream = await doFetch(`${apiBase}${API_SERVER_UPLOAD_PATH}`, {
91
+ method: 'POST',
92
+ headers: {
93
+ 'Content-Type': 'application/json',
94
+ Authorization: `Bearer ${options.token}`,
95
+ },
96
+ // `noCache` is dropped: api-server pins its own immutable cacheControl. gameId is ours,
97
+ // not the page's — see the header.
98
+ body: JSON.stringify({
99
+ gameId: options.gameId,
100
+ filename: parsed.filename,
101
+ contentType: parsed.contentType,
102
+ }),
103
+ });
104
+ const text = await upstream.text();
105
+ if (!upstream.ok) {
106
+ log(`[upload-proxy] api-server refused ${String(parsed.filename ?? '?')}: ${upstream.status} ${text.slice(0, 200)}`);
107
+ }
108
+ res.writeHead(upstream.status, { 'Content-Type': 'application/json' }).end(text);
109
+ }
110
+ catch (error) {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ log(`[upload-proxy] could not reach api-server: ${message}`);
113
+ res.writeHead(502, { 'Content-Type': 'application/json' })
114
+ .end(JSON.stringify({ error: `Could not reach api-server: ${message}` }));
115
+ }
116
+ })();
117
+ });
118
+ await new Promise((resolve, reject) => {
119
+ server.once('error', reject);
120
+ server.listen(0, '127.0.0.1', resolve);
121
+ });
122
+ const address = server.address();
123
+ if (address === null || typeof address === 'string') {
124
+ server.close();
125
+ throw new CliError('The upload proxy could not bind a local port, so the level cannot be baked.');
126
+ }
127
+ const url = `http://127.0.0.1:${address.port}`;
128
+ log(`Upload proxy on ${url} → ${apiBase}${API_SERVER_UPLOAD_PATH}`);
129
+ return {
130
+ url,
131
+ close: () => new Promise((resolve) => server.close(() => resolve())),
132
+ };
133
+ }
134
+ //# sourceMappingURL=upload-proxy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload-proxy.js","sourceRoot":"","sources":["../../src/forge/upload-proxy.ts"],"names":[],"mappings":"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"}
@@ -1,15 +1,6 @@
1
1
  import type { WorldPatch } from '@bitmagic/asset-core';
2
2
  import type { Environment } from '../config/environments.js';
3
- export interface SseEvent {
4
- event: string;
5
- data: unknown;
6
- }
7
- /**
8
- * Pure, synchronous parse of a full set of chunks — cheap and exhaustive to test. Production
9
- * streaming uses the same underlying buffer (`createSseFrameBuffer`) one network read at a time,
10
- * via `requestGeneration`, so this function is not itself on that path.
11
- */
12
- export declare function parseSseChunks(chunks: string[]): SseEvent[];
3
+ export { parseSseChunks, type SseEvent } from '../http/sse.js';
13
4
  export interface GenerationOutcome {
14
5
  success: boolean;
15
6
  message: string;
@@ -1,66 +1,9 @@
1
1
  import { CliError } from '../errors.js';
2
- /**
3
- * The unit of `event:`/`data:` lines terminated by a blank line, per the SSE wire format
4
- * `api-server/src/cli/cli-assets.ts` writes. Splits on `\n`, joining multiple `data:` lines (the
5
- * spec allows more than one) with `\n` before parsing as JSON. A leading `:` line (the server's
6
- * `: keepalive` comment) carries no `data:`, so it naturally produces no event.
7
- */
8
- function parseFrame(frame) {
9
- let eventName = 'message';
10
- const dataLines = [];
11
- for (const line of frame.split('\n')) {
12
- if (line.startsWith('event:')) {
13
- eventName = line.slice('event:'.length).trim();
14
- }
15
- else if (line.startsWith('data:')) {
16
- dataLines.push(line.slice('data:'.length).trim());
17
- }
18
- // Any other line (including a `:`-prefixed comment, or blank) carries no event data.
19
- }
20
- if (dataLines.length === 0)
21
- return null;
22
- return { event: eventName, data: JSON.parse(dataLines.join('\n')) };
23
- }
24
- /**
25
- * Incremental frame buffer: SSE frames arrive in arbitrary chunks over the wire — one frame can
26
- * span two reads, and two frames can arrive in one — so this accumulates bytes and only emits
27
- * events once a full `\n\n`-delimited frame is available. The trailing, not-yet-delimited
28
- * remainder stays in `buffer` for the next push. Shared by both `parseSseChunks` (fed the whole
29
- * chunk array at once) and `requestGeneration` (fed one chunk per network read), so the two
30
- * cannot drift apart.
31
- */
32
- function createSseFrameBuffer() {
33
- let buffer = '';
34
- return {
35
- push(chunk) {
36
- buffer += chunk;
37
- const frames = buffer.split('\n\n');
38
- // The last element is either '' (buffer ended exactly on a delimiter) or an incomplete
39
- // trailing frame — either way it is not yet a complete frame, so keep it buffered.
40
- buffer = frames.pop() ?? '';
41
- const events = [];
42
- for (const frame of frames) {
43
- const event = parseFrame(frame);
44
- if (event)
45
- events.push(event);
46
- }
47
- return events;
48
- },
49
- };
50
- }
51
- /**
52
- * Pure, synchronous parse of a full set of chunks — cheap and exhaustive to test. Production
53
- * streaming uses the same underlying buffer (`createSseFrameBuffer`) one network read at a time,
54
- * via `requestGeneration`, so this function is not itself on that path.
55
- */
56
- export function parseSseChunks(chunks) {
57
- const frameBuffer = createSseFrameBuffer();
58
- const events = [];
59
- for (const chunk of chunks) {
60
- events.push(...frameBuffer.push(chunk));
61
- }
62
- return events;
63
- }
2
+ import { createSseFrameBuffer } from '../http/sse.js';
3
+ // The frame buffer and its parser moved to ../http/sse.ts when `bitmagic forge` gained a second
4
+ // consumer of the identical wire format. Re-exported from here so this module's own callers and
5
+ // tests are unaffected by that move.
6
+ export { parseSseChunks } from '../http/sse.js';
64
7
  function isRecord(value) {
65
8
  return typeof value === 'object' && value !== null;
66
9
  }
@@ -1 +1 @@
1
- {"version":3,"file":"stream.js","sourceRoot":"","sources":["../../src/generate/stream.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"stream.js","sourceRoot":"","sources":["../../src/generate/stream.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The SSE wire format the CLI-lane endpoints speak (`api-server/src/cli/cli-assets.ts`,
3
+ * `cli-forge.ts`): `event:` / `data:` lines terminated by a blank line, with `: keepalive`
4
+ * comment lines interleaved.
5
+ *
6
+ * Extracted from `generate/stream.ts` — which still re-exports `parseSseChunks` so its own
7
+ * tests and callers are unchanged — because `forge/stream.ts` consumes the identical format
8
+ * from a different endpoint. Two copies of a frame buffer that must agree byte-for-byte with
9
+ * one server is exactly the kind of thing that drifts silently: a fix to one (a multi-line
10
+ * `data:`, a chunk boundary mid-frame) would not reach the other, and the symptom would be a
11
+ * dropped terminal frame on one command only.
12
+ */
13
+ export interface SseEvent {
14
+ event: string;
15
+ data: unknown;
16
+ }
17
+ /**
18
+ * Incremental frame buffer: SSE frames arrive in arbitrary chunks over the wire — one frame can
19
+ * span two reads, and two frames can arrive in one — so this accumulates bytes and only emits
20
+ * events once a full `\n\n`-delimited frame is available. The trailing, not-yet-delimited
21
+ * remainder stays in `buffer` for the next push.
22
+ */
23
+ export declare function createSseFrameBuffer(): {
24
+ push: (chunk: string) => SseEvent[];
25
+ };
26
+ /**
27
+ * Pure, synchronous parse of a full set of chunks — cheap and exhaustive to test. Production
28
+ * streaming uses the same underlying buffer one network read at a time, so this function is not
29
+ * itself on that path.
30
+ */
31
+ export declare function parseSseChunks(chunks: string[]): SseEvent[];
32
+ /**
33
+ * Read an SSE response body to completion, handing every parsed event to `onEvent`. Stops early
34
+ * — and returns `true` — the moment `onEvent` reports the terminal frame arrived, so a server
35
+ * that holds the connection open after its result does not stall the command.
36
+ *
37
+ * Returns `false` when the stream ended with no terminal frame: a truncated connection, which
38
+ * every caller must report as "not a confirmed failure" rather than as a clean empty result.
39
+ */
40
+ export declare function consumeSseStream(body: ReadableStream<Uint8Array>, onEvent: (event: SseEvent) => boolean): Promise<boolean>;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The SSE wire format the CLI-lane endpoints speak (`api-server/src/cli/cli-assets.ts`,
3
+ * `cli-forge.ts`): `event:` / `data:` lines terminated by a blank line, with `: keepalive`
4
+ * comment lines interleaved.
5
+ *
6
+ * Extracted from `generate/stream.ts` — which still re-exports `parseSseChunks` so its own
7
+ * tests and callers are unchanged — because `forge/stream.ts` consumes the identical format
8
+ * from a different endpoint. Two copies of a frame buffer that must agree byte-for-byte with
9
+ * one server is exactly the kind of thing that drifts silently: a fix to one (a multi-line
10
+ * `data:`, a chunk boundary mid-frame) would not reach the other, and the symptom would be a
11
+ * dropped terminal frame on one command only.
12
+ */
13
+ /**
14
+ * One `\n\n`-delimited frame. Joins multiple `data:` lines (the spec allows more than one)
15
+ * with `\n` before parsing as JSON. A leading `:` line (the server's `: keepalive` comment)
16
+ * carries no `data:`, so it naturally produces no event.
17
+ */
18
+ function parseFrame(frame) {
19
+ let eventName = 'message';
20
+ const dataLines = [];
21
+ for (const line of frame.split('\n')) {
22
+ if (line.startsWith('event:')) {
23
+ eventName = line.slice('event:'.length).trim();
24
+ }
25
+ else if (line.startsWith('data:')) {
26
+ dataLines.push(line.slice('data:'.length).trim());
27
+ }
28
+ // Any other line (including a `:`-prefixed comment, or blank) carries no event data.
29
+ }
30
+ if (dataLines.length === 0)
31
+ return null;
32
+ return { event: eventName, data: JSON.parse(dataLines.join('\n')) };
33
+ }
34
+ /**
35
+ * Incremental frame buffer: SSE frames arrive in arbitrary chunks over the wire — one frame can
36
+ * span two reads, and two frames can arrive in one — so this accumulates bytes and only emits
37
+ * events once a full `\n\n`-delimited frame is available. The trailing, not-yet-delimited
38
+ * remainder stays in `buffer` for the next push.
39
+ */
40
+ export function createSseFrameBuffer() {
41
+ let buffer = '';
42
+ return {
43
+ push(chunk) {
44
+ buffer += chunk;
45
+ const frames = buffer.split('\n\n');
46
+ // The last element is either '' (buffer ended exactly on a delimiter) or an incomplete
47
+ // trailing frame — either way it is not yet a complete frame, so keep it buffered.
48
+ buffer = frames.pop() ?? '';
49
+ const events = [];
50
+ for (const frame of frames) {
51
+ const event = parseFrame(frame);
52
+ if (event)
53
+ events.push(event);
54
+ }
55
+ return events;
56
+ },
57
+ };
58
+ }
59
+ /**
60
+ * Pure, synchronous parse of a full set of chunks — cheap and exhaustive to test. Production
61
+ * streaming uses the same underlying buffer one network read at a time, so this function is not
62
+ * itself on that path.
63
+ */
64
+ export function parseSseChunks(chunks) {
65
+ const frameBuffer = createSseFrameBuffer();
66
+ const events = [];
67
+ for (const chunk of chunks) {
68
+ events.push(...frameBuffer.push(chunk));
69
+ }
70
+ return events;
71
+ }
72
+ /**
73
+ * Read an SSE response body to completion, handing every parsed event to `onEvent`. Stops early
74
+ * — and returns `true` — the moment `onEvent` reports the terminal frame arrived, so a server
75
+ * that holds the connection open after its result does not stall the command.
76
+ *
77
+ * Returns `false` when the stream ended with no terminal frame: a truncated connection, which
78
+ * every caller must report as "not a confirmed failure" rather than as a clean empty result.
79
+ */
80
+ export async function consumeSseStream(body, onEvent) {
81
+ const reader = body.getReader();
82
+ const decoder = new TextDecoder();
83
+ const frameBuffer = createSseFrameBuffer();
84
+ for (;;) {
85
+ const { value, done } = await reader.read();
86
+ if (done)
87
+ return false;
88
+ // `{ stream: true }` retains any trailing partial multi-byte UTF-8 sequence inside the
89
+ // decoder itself, so a character split across two network chunks decodes correctly once its
90
+ // remaining bytes arrive in the next read, instead of emitting U+FFFD.
91
+ const text = decoder.decode(value, { stream: true });
92
+ for (const event of frameBuffer.push(text)) {
93
+ if (onEvent(event))
94
+ return true;
95
+ }
96
+ }
97
+ }
98
+ //# sourceMappingURL=sse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse.js","sourceRoot":"","sources":["../../src/http/sse.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The port window the platform's bucket CORS policy allowlists as an origin.
3
+ *
4
+ * A scaffolded project fetches its forged level (`.vwld`) and its GLBs from the portal CDN, so a
5
+ * project served on localhost is making CROSS-ORIGIN requests for them. The bucket answers with
6
+ * `Access-Control-Allow-Origin` only for the origins named in its CORS config — the portal and
7
+ * creator hosts, plus `http://localhost:3000`–`3199` (roleverse-infra,
8
+ * the portal-cdn terragrunt config per environment). `bitmagic dev` sits inside that window at
9
+ * 3010; anything that asks the OS for an ephemeral port (`listen(0)` → 49xxx+) falls outside it.
10
+ *
11
+ * The failure is quiet and easy to misread. The fetch is blocked, `WorldGenerator` falls back to
12
+ * an empty voxel world, the level's terrain simply is not there, and the symptom is a player
13
+ * falling through the floor forever — which reads as a broken forge, not a blocked request. The
14
+ * forge's own browser hides it too, because it runs with `--disable-web-security` for the presigned
15
+ * PUTs, so uploads and reads both succeed there while a normal browser fails.
16
+ */
17
+ export declare const CORS_ALLOWED_PORT_MIN = 3000;
18
+ export declare const CORS_ALLOWED_PORT_MAX = 3199;
19
+ /**
20
+ * A free port the CDN will serve assets to, or `null` when the whole window is taken.
21
+ *
22
+ * Returning `null` rather than silently falling back to an ephemeral port is deliberate: the
23
+ * caller can then say what the consequence is, instead of the run proceeding to a level with no
24
+ * terrain and no explanation.
25
+ *
26
+ * The scan starts at a pseudo-random offset so two concurrent runs do not both take 3000 and race;
27
+ * `tryListen` closing before the caller binds leaves a window either way, which is why callers
28
+ * must handle a bind failure rather than trusting this.
29
+ */
30
+ export declare function reserveCorsAllowedPort(): Promise<number | null>;
@@ -0,0 +1,53 @@
1
+ import * as net from 'net';
2
+ /**
3
+ * The port window the platform's bucket CORS policy allowlists as an origin.
4
+ *
5
+ * A scaffolded project fetches its forged level (`.vwld`) and its GLBs from the portal CDN, so a
6
+ * project served on localhost is making CROSS-ORIGIN requests for them. The bucket answers with
7
+ * `Access-Control-Allow-Origin` only for the origins named in its CORS config — the portal and
8
+ * creator hosts, plus `http://localhost:3000`–`3199` (roleverse-infra,
9
+ * the portal-cdn terragrunt config per environment). `bitmagic dev` sits inside that window at
10
+ * 3010; anything that asks the OS for an ephemeral port (`listen(0)` → 49xxx+) falls outside it.
11
+ *
12
+ * The failure is quiet and easy to misread. The fetch is blocked, `WorldGenerator` falls back to
13
+ * an empty voxel world, the level's terrain simply is not there, and the symptom is a player
14
+ * falling through the floor forever — which reads as a broken forge, not a blocked request. The
15
+ * forge's own browser hides it too, because it runs with `--disable-web-security` for the presigned
16
+ * PUTs, so uploads and reads both succeed there while a normal browser fails.
17
+ */
18
+ export const CORS_ALLOWED_PORT_MIN = 3000;
19
+ export const CORS_ALLOWED_PORT_MAX = 3199;
20
+ function tryListen(port) {
21
+ return new Promise((resolve) => {
22
+ const server = net.createServer();
23
+ server.once('error', () => resolve(null));
24
+ server.listen(port, '127.0.0.1', () => {
25
+ const address = server.address();
26
+ const bound = address !== null && typeof address !== 'string' ? address.port : null;
27
+ server.close(() => resolve(bound));
28
+ });
29
+ });
30
+ }
31
+ /**
32
+ * A free port the CDN will serve assets to, or `null` when the whole window is taken.
33
+ *
34
+ * Returning `null` rather than silently falling back to an ephemeral port is deliberate: the
35
+ * caller can then say what the consequence is, instead of the run proceeding to a level with no
36
+ * terrain and no explanation.
37
+ *
38
+ * The scan starts at a pseudo-random offset so two concurrent runs do not both take 3000 and race;
39
+ * `tryListen` closing before the caller binds leaves a window either way, which is why callers
40
+ * must handle a bind failure rather than trusting this.
41
+ */
42
+ export async function reserveCorsAllowedPort() {
43
+ const span = CORS_ALLOWED_PORT_MAX - CORS_ALLOWED_PORT_MIN + 1;
44
+ const start = Math.floor(Math.random() * span);
45
+ for (let i = 0; i < span; i += 1) {
46
+ const port = CORS_ALLOWED_PORT_MIN + ((start + i) % span);
47
+ const bound = await tryListen(port);
48
+ if (bound !== null)
49
+ return bound;
50
+ }
51
+ return null;
52
+ }
53
+ //# sourceMappingURL=local-port.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-port.js","sourceRoot":"","sources":["../src/local-port.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,GAAG,MAAM,KAAK,CAAC;AAE3B;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAC1C,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAE1C,SAAS,SAAS,CAAC,IAAY;IAC7B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,MAAM,MAAM,GAAG,GAAG,CAAC,YAAY,EAAE,CAAC;QAClC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;QAC1C,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE;YACpC,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC;YACjC,MAAM,KAAK,GAAG,OAAO,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;YACpF,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;QACrC,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,sBAAsB;IAC1C,MAAM,IAAI,GAAG,qBAAqB,GAAG,qBAAqB,GAAG,CAAC,CAAC;IAC/D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC;IAC/C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACjC,MAAM,IAAI,GAAG,qBAAqB,GAAG,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;QAC1D,MAAM,KAAK,GAAG,MAAM,SAAS,CAAC,IAAI,CAAC,CAAC;QACpC,IAAI,KAAK,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC;IACnC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
@@ -8,20 +8,60 @@ const DEPENDENCIES = {
8
8
  i18next: '^24.2.3',
9
9
  'i18next-browser-languagedetector': '^8.0.4',
10
10
  jszip: '^3.10.1',
11
- three: '^0.183.0',
11
+ three: '^0.185.1',
12
12
  };
13
13
  const DEV_DEPENDENCIES = {
14
14
  '@types/node': '^24.10.0',
15
- '@types/three': '^0.183.0',
15
+ '@types/three': '^0.185.4',
16
16
  // engine/types/ammo.d.ts opens with a triple-slash reference to this package's ambient types.
17
17
  'ammojs-typed': '^1.0.6',
18
+ // Declares `navigator.gpu`, which the engine reads to choose its renderer backend. Named in
19
+ // tsconfig's `types` too — see the note there for why installing it is not enough.
20
+ '@webgpu/types': '^0.1.69',
18
21
  typescript: '^5.9.2',
19
22
  vite: '^7.3.1',
20
23
  };
24
+ /**
25
+ * The three.js release every `three*` import map entry below resolves to. Written once because
26
+ * the entries repeat it eight times, including four times inside the `three` shim, and a bump
27
+ * that updates some copies but not others splits the engine across two module instances — the
28
+ * exact failure the comments below describe.
29
+ */
30
+ const THREE_VERSION = '0.185.1';
31
+ /**
32
+ * `three` resolves to a shim, not straight to the webgpu build. r185's three.webgpu.js exports
33
+ * neither `UniformsUtils` (which every classic postprocessing pass imports from `three`) nor
34
+ * `ShaderLib`/`UniformsLib` (`lines/LineMaterial.js`), so the vendored engine — which imports
35
+ * those passes statically in GameEngine.ts — fails to load with "The requested module 'three'
36
+ * does not provide an export named 'UniformsUtils'". The shim re-exports the webgpu build from
37
+ * the SAME URL as the `three/webgpu` entry, so it stays ONE module instance, and adds the
38
+ * missing names from three's own src. Absolute URLs only: a data: module has no base URL and so
39
+ * cannot resolve bare or relative specifiers. Keep in step with game/index.html.
40
+ */
41
+ const THREE_SHIM = [
42
+ 'data:text/javascript,',
43
+ `export*from'https://esm.sh/three@${THREE_VERSION}/webgpu';`,
44
+ `export{UniformsUtils}from'https://esm.sh/three@${THREE_VERSION}/src/renderers/shaders/UniformsUtils.js';`,
45
+ `export{ShaderLib}from'https://esm.sh/three@${THREE_VERSION}/src/renderers/shaders/ShaderLib.js';`,
46
+ `export{UniformsLib}from'https://esm.sh/three@${THREE_VERSION}/src/renderers/shaders/UniformsLib.js'`,
47
+ ].join('');
21
48
  /** Resolved in the browser from a CDN, not from node_modules — see the design's import-map note. */
22
49
  const CDN_IMPORTS = {
23
- three: 'https://esm.sh/three@0.182.0',
24
- 'three/': 'https://esm.sh/three@0.182.0/',
50
+ // `three` and `three/webgpu` are ONE module instance (see THREE_SHIM). Pointed at separate
51
+ // builds (core + webgpu) they are two, and r185's LightsNode then fails to recognise lights
52
+ // built by the other instance — the scene renders unlit. Keep in step with game/index.html.
53
+ three: THREE_SHIM,
54
+ 'three/webgpu': `https://esm.sh/three@${THREE_VERSION}/webgpu`,
55
+ 'three/tsl': `https://esm.sh/three@${THREE_VERSION}/tsl`,
56
+ // The addon entries use esm.sh's `*` build, which keeps `three` a BARE specifier so addons
57
+ // resolve back through this map. Plain esm.sh inlines its own copy of three, which shares no
58
+ // module with the webgpu build: GLTFLoader then hands back Meshes from a second Mesh class and
59
+ // the engine's `instanceof THREE.Mesh` scans find nothing. Same one-instance rule as above.
60
+ // The classic WebGL addons are not excepted — the shim, not a pinned inlined-three build, is
61
+ // what gives them the UniformsUtils / ShaderLib / UniformsLib they import from bare `three`.
62
+ 'three/addons/': `https://esm.sh/*three@${THREE_VERSION}/addons/`,
63
+ 'three/examples/jsm/': `https://esm.sh/*three@${THREE_VERSION}/examples/jsm/`,
64
+ 'three/': `https://esm.sh/three@${THREE_VERSION}/`,
25
65
  '@dimforge/rapier3d-compat': 'https://esm.sh/@dimforge/rapier3d-compat@0.19.3',
26
66
  '@dimforge/rapier2d-compat': 'https://esm.sh/@dimforge/rapier2d-compat@0.19.3',
27
67
  i18next: 'https://esm.sh/i18next@24.2.3',
@@ -72,7 +112,14 @@ export function renderTsconfig() {
72
112
  noImplicitAny: true,
73
113
  resolveJsonModule: true,
74
114
  sourceMap: true,
75
- types: [],
115
+ // `types: []` opts out of auto-including every @types package — deliberate, because the
116
+ // vendored engine ships its own and a stray global would shadow them. The consequence is
117
+ // that a package whose whole job is a GLOBAL augmentation has to be named here or it is
118
+ // invisible: `@webgpu/types` declares `navigator.gpu`, which the engine reads to pick its
119
+ // renderer backend (`engine/GameEngine.ts`). Without it a scaffolded project fails to
120
+ // type-check with "Property 'gpu' does not exist on type 'Navigator'" — the engine's own
121
+ // package.json carries the dependency, so nothing catches this in the monorepo.
122
+ types: ['@webgpu/types'],
76
123
  typeRoots: ['engine/types', 'node_modules/@types', 'node_modules/ammojs-typed/ammo/ambient'],
77
124
  },
78
125
  include: ['src/**/*', 'engine/**/*'],
@@ -245,10 +292,43 @@ Four things worth knowing before you use these:
245
292
  4. **A running \`bitmagic dev\` will not show the new asset until the browser reloads.** The
246
293
  command reminds you.
247
294
 
248
- **Not available from the CLI yet:** 3D models, voxel assets and block types. Those require a
249
- browser to voxelize and upload, which this toolchain does not yet run. Do not try to work around
250
- it by hand-editing \`world.json\` to point at a model you have not generated — the asset will not
251
- exist and the game will fail to load it.
295
+ **Not available as \`generate\` subcommands:** individual 3D models, voxel assets and block types.
296
+ Those need a browser to voxelize and upload. Do not work around it by hand-editing
297
+ \`world.json\` to point at a model you have not generated — the asset will not exist and the game
298
+ will fail to load it.
299
+
300
+ ## Forging a whole level
301
+
302
+ \`\`\`
303
+ bitmagic forge --prompt "a ruined desert temple with a central courtyard"
304
+ \`\`\`
305
+
306
+ This designs a scene, builds its geometry, bakes it to voxels and writes the finished level —
307
+ terrain, props, spawn points — into \`src/work/world.json\`. Add \`--city\`, \`--dungeon\`,
308
+ \`--platformer\` or \`--freeform\` (at most one) for a different kind of place; omit them all for
309
+ natural terrain.
310
+
311
+ Five things worth knowing:
312
+
313
+ 1. **It is slow and it is expensive.** A forge runs for many minutes — the level bake alone can
314
+ take twenty — and costs considerably more than any single \`generate\`. Run it once,
315
+ deliberately. Progress is printed throughout, the bake included.
316
+ 2. **It needs Google Chrome installed.** The bake runs in a real headless browser, because
317
+ voxelizing is the engine's own code. The command builds and serves the project itself, so you
318
+ do not need \`bitmagic dev\` running and it will not collide with one that is.
319
+ 3. **It rewrites large parts of \`world.json\`** (assets, environmentObjects, the level registry,
320
+ spawn points), so re-read the file afterwards if you were editing it. The command prints which
321
+ paths it changed. It also drops a \`forged-heightfield.json\` beside it, which later placements
322
+ use to sit objects on the new terrain.
323
+ 4. **Every failure says whether \`world.json\` changed** — and it only ever changes on the final
324
+ step, all at once. A failure before that leaves the project exactly as it was.
325
+ 5. **Resume rather than re-run.** The command prints a job id. If it fails after the design
326
+ succeeded, \`bitmagic forge --resume <jobId>\` skips the steps already paid for. The failure
327
+ message says when resuming is worth it, and when the failure is deterministic and you should
328
+ change the prompt instead.
329
+
330
+ Afterwards run \`bitmagic dev\` and actually walk the level — one that loads is not necessarily
331
+ one you can move through.
252
332
  `;
253
333
  }
254
334
  export function renderGenerateSkill() {