lecodes-cli 2.4.10 → 2.5.1

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 (57) hide show
  1. package/dist/index.js +2580 -1095
  2. package/package.json +10 -10
  3. package/runtime/materials/decal-relief.mat +21 -46
  4. package/runtime/materials/decal.mat +41 -61
  5. package/runtime/materials/foliage-cards.mat +28 -140
  6. package/runtime/materials/foliage-impostor.mat +485 -0
  7. package/runtime/materials/foliage.mat +232 -0
  8. package/runtime/materials/grass.mat +244 -0
  9. package/runtime/materials/particles-quad.mat +5 -3
  10. package/runtime/materials/particles.mat +5 -3
  11. package/runtime/sdk-types.json +1 -1
  12. package/runtime/web/VERSION.json +3 -3
  13. package/runtime/web/assets/materials-Cs_z1zLi.bin +0 -0
  14. package/runtime/web/assets/materials-world-BtoAfznA.bin +0 -0
  15. package/runtime/web/player.js +3 -3
  16. package/runtime/web-lite/assets/{canvas-WxiusXCB.js → canvas-wFjS40IX.js} +1 -1
  17. package/runtime/web-lite/assets/creator2d-1pSkYRCn.js +1 -0
  18. package/runtime/web-lite/assets/creator2d-Ci5-pZGr.wasm +0 -0
  19. package/runtime/web-lite/assets/{pager-DpGa2uaE.js → pager-BZAO9gMC.js} +1 -1
  20. package/runtime/web-lite/assets/{plugins-CuEPHbHL.js → plugins-B7TuO_p4.js} +1 -1
  21. package/runtime/web-lite/assets/scene2d-F6T1Io7I.js +1 -0
  22. package/runtime/web-lite/assets/{tween-BnWYhJ7P.js → tween-DbrQTld4.js} +1 -1
  23. package/runtime/web-lite/assets/{video-BLj61nDx.js → video-bzR_t2i6.js} +1 -1
  24. package/runtime/web-lite/assets/{vlist-CwSYpLz-.js → vlist-Ap-F4H5J.js} +1 -1
  25. package/runtime/web-lite/features.json +18 -6
  26. package/runtime/web-lite/lite.js +6 -6
  27. package/src/commands/compile.ts +2 -1
  28. package/src/commands/hub/create.ts +100 -0
  29. package/src/commands/hub/credential.ts +32 -0
  30. package/src/commands/hub/index.ts +21 -0
  31. package/src/commands/hub/login.ts +89 -0
  32. package/src/commands/hub/status.ts +109 -0
  33. package/src/commands/index.ts +5 -1
  34. package/src/commands/level.ts +123 -0
  35. package/src/commands/lightmap.ts +41 -79
  36. package/src/commands/navmesh.ts +84 -67
  37. package/src/commands/secrets.ts +147 -0
  38. package/src/commands/update/index.ts +154 -64
  39. package/src/compile/designCompile.ts +2 -0
  40. package/src/compile/headlessBundle.ts +2 -0
  41. package/src/compile/projectCompile.ts +12 -1
  42. package/src/compile/sceneCompile.ts +2 -1
  43. package/src/dev/backendWorker.ts +6 -1
  44. package/src/dev/devServer.ts +2 -1
  45. package/src/dev/localBackend.ts +25 -16
  46. package/src/dev/projectBackend.ts +7 -3
  47. package/src/dev/watchProject.ts +2 -0
  48. package/src/hosts/desktopRenderer.ts +10 -2
  49. package/src/hosts/peerInstall.ts +7 -3
  50. package/src/hub/api.ts +220 -0
  51. package/src/hub/config.ts +53 -0
  52. package/src/hub/git.ts +148 -0
  53. package/src/platform/api.ts +21 -0
  54. package/src/project/env.ts +29 -0
  55. package/src/web/build.ts +149 -88
  56. package/runtime/web/assets/materials-CJzxzdUK.bin +0 -0
  57. package/runtime/web/assets/materials-world-BNQb0Z1l.bin +0 -0
@@ -1,4 +1,4 @@
1
- import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs"
1
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
2
2
  import { tmpdir } from "node:os"
3
3
  import { basename, dirname, join, relative, resolve, sep } from "node:path"
4
4
  import { deflateSync } from "node:zlib"
@@ -8,6 +8,7 @@ import { runDesktopRender } from "../hosts/desktopRenderer"
8
8
  import { loadPeer } from "../hosts/peerInstall"
9
9
  import { CliError, bool, c, defineCommand, log, logErr, note, num, str, warnErr } from "../cli"
10
10
  import { bundleFlags, bundleOptions, localProject } from "./shared"
11
+ import { decodeStatics, encodeStatics, stitchStatics } from "lecodes-sdk/compile"
11
12
 
12
13
  /*
13
14
  * `lecodes lightmap` — the baked lighting of a level (engines/bake).
@@ -29,9 +30,10 @@ import { bundleFlags, bundleOptions, localProject } from "./shared"
29
30
  * The bake is not in the shipped desktop / server hosts: it runs in lecodes-bake (hosts/desktop CREATOR_DESKTOP_BAKE),
30
31
  * the windowless host with the bake built in — resolved by hosts/desktopRenderer.ts resolveBakeExe (LECODES_BAKE_EXE
31
32
  * for a local build, else the published release product "bake").
32
- * Lightmap UVs: before the host runs, every GLB the app references that has none is unwrapped IN PLACE (lecodes-assets
33
- * `unwrapGlbFiles`, what `lecodes assets unwrap` runs — the pre-step below); `--no-unwrap` skips it. A level in one GLB
34
- * that wants groups or a zone is unwrapped by hand first (`lecodes assets unwrap --groups`), and then left alone here.
33
+ * Lightmap UVs: a static without TEXCOORD_1 is unwrapped BY THE BAKE (creator-bake unwrap.cpp): its charts go into the
34
+ * -uv1.bin sidecar with the split geometry the runtime rebuilds the primitive from, and no GLB of the project is ever
35
+ * written. Vegetation (the foliage tier) is never unwrapped: it reads the ground's light. A file that carries its own
36
+ * lightmap uvs (`lecodes assets unwrap`, another tool) keeps them: the bake packs those charts as they are.
35
37
  */
36
38
 
37
39
  const range = (name: string, value: number, min: number, max: number): number => {
@@ -78,6 +80,18 @@ const cubePatternsOf = (argv: string[]): string[] => {
78
80
  return out.filter((p) => p !== "")
79
81
  }
80
82
 
83
+ /** The static level cache beside a bake (`<stem>.static`, written by `lecodes level build`) takes the fresh bake's records:
84
+ * a pure join by node path, no host run — the next load rebuilds the statics with the new rects. Nothing to do without one. */
85
+ export const stitchLevelCache = (root: string, staticPath: string, bake: BakeData): void => {
86
+ if (!existsSync(staticPath)) return
87
+ const file = decodeStatics(new Uint8Array(readFileSync(staticPath)))
88
+ if (!file) { warnErr(`${relative(root, staticPath)} is not a level cache of this version — rerun \`lecodes level build\``); return }
89
+ const stitched = stitchStatics(file, bake)
90
+ writeFileSync(staticPath, encodeStatics(stitched))
91
+ const records = stitched.placements.reduce((n, p) => n + p.records.length, 0)
92
+ logErr(c.dim(` level cache ${relative(root, staticPath).replace(/\\/g, "/")}: ${records} record(s) of this bake stitched in (${stitched.placements.length} placements)`))
93
+ }
94
+
81
95
  /** The stem of a `.bake` path: `assets/lightmap/level.bake` → `level`. */
82
96
  const stemOf = (path: string): string => basename(path).replace(/\.bake$/i, "")
83
97
 
@@ -118,80 +132,10 @@ const bakeFileOf = (root: string, flag: string | undefined): string => {
118
132
  return resolve(root, found[0])
119
133
  }
120
134
 
121
- // ---- the unwrap pre-step --------------------------------------------------------------------------
122
- // The bake traces TEXCOORD_1, and a raw import has none: before the host runs, the GLBs the bundle names (asset()
123
- // compiles to `_creatorFetch.local("<name>")` — the reference list `app build desktop` ships by) whose JSON shows a
124
- // primitive the unwrap would lay out without one are unwrapped IN PLACE by lecodes-assets (`unwrapGlbFiles`, the
125
- // `lecodes assets unwrap` code). Nothing is rebuilt after it: the bundle names the files and carries none of their bytes,
126
- // and the run dir links them only when the host starts. A file that has TEXCOORD_1 is never opened past its JSON.
127
-
128
- type GltfJson = {
129
- nodes?: { mesh?: number, skin?: number }[]
130
- meshes?: { primitives?: { mode?: number, indices?: number, attributes?: Record<string, number> }[] }[]
131
- accessors?: { count?: number }[]
132
- }
133
-
134
- /** A GLB's JSON chunk — the 20 header bytes and the chunk itself, never the binary one — or null when it is not a GLB. */
135
- const readGlbJson = (file: string): GltfJson | null => {
136
- let fd: number | undefined
137
- try {
138
- fd = openSync(file, "r")
139
- const head = Buffer.alloc(20)
140
- if (readSync(fd, head, 0, 20, 0) !== 20) return null
141
- if (head.readUInt32LE(0) !== 0x46546c67 || head.readUInt32LE(16) !== 0x4e4f534a) return null // "glTF", then a "JSON" chunk
142
- const json = Buffer.alloc(head.readUInt32LE(12))
143
- if (readSync(fd, json, 0, json.length, 20) !== json.length) return null
144
- return JSON.parse(json.toString("utf8")) as GltfJson
145
- } catch {
146
- return null
147
- } finally {
148
- if (fd !== undefined) closeSync(fd)
149
- }
150
- }
151
-
152
- /** A primitive the unwrap lays out (a triangle list with a triangle in it, on a mesh no skinned node draws) without
153
- * TEXCOORD_1 — lecodes-assets' planUnwrap verdict, read off the JSON. */
154
- const lacksLightmapUv = (g: GltfJson): boolean => {
155
- const skinned = new Set((g.nodes ?? []).filter((n) => n.skin !== undefined && n.mesh !== undefined).map((n) => n.mesh))
156
- const count = (accessor: number | undefined): number => (accessor === undefined ? 0 : g.accessors?.[accessor]?.count ?? 0)
157
- return (g.meshes ?? []).some((mesh, i) => !skinned.has(i) && (mesh.primitives ?? []).some((p) => {
158
- const pos = count(p.attributes?.POSITION)
159
- return (p.mode ?? 4) === 4 && pos > 0 && (p.indices === undefined ? pos : count(p.indices)) >= 3 && p.attributes?.TEXCOORD_1 === undefined
160
- }))
161
- }
162
-
163
- /** The project GLBs the bundle names. */
164
- const referencedGlbs = (js: string, resources: BundleResource[]): string[] => {
165
- const wanted = new Set([ ...js.matchAll(/(?:fetchLocal|_creatorFetch\.local)\("([^"]+)"\)/g) ].map((m) => m[1]))
166
- return [ ...new Set(resources.filter((r) => !r.remote && !r.shader && r.absPath && /\.glb$/i.test(r.name) && wanted.has(r.name)).map((r) => r.absPath!)) ]
167
- }
168
-
169
- const unwrapReferenced = async (js: string, resources: BundleResource[], root: string, texel: number): Promise<void> => {
170
- const glbs = referencedGlbs(js, resources)
171
- const raw = glbs.filter((f) => { const g = readGlbJson(f); return g !== null && lacksLightmapUv(g) })
172
- if (raw.length === 0) { note(`Lightmap uvs: the ${glbs.length} GLB(s) the app references carry them`); return }
173
- note(`Unwrapping ${raw.length} of the ${glbs.length} GLB(s) the app references — no lightmap uvs; written in place (--no-unwrap skips)…`)
174
- const assets = await loadPeer<typeof import("lecodes-assets/cli")>("lecodes-assets", "cli", { for: "lecodes lightmap bake (lightmap uvs)" })
175
- if (typeof assets.unwrapGlbFiles !== "function") {
176
- throw new CliError("The lecodes-assets this CLI loaded has no unwrapGlbFiles (it predates the bake's unwrap) — `lecodes update` moves it forward; --no-unwrap bakes the GLBs as they are.")
177
- }
178
- const t0 = performance.now()
179
- const rel = (f: string): string => relative(root, f).replace(/\\/g, "/")
180
- const reports = await assets.unwrapGlbFiles(raw, {
181
- texel,
182
- onFile: (r) => {
183
- if (r.status === "failed") warnErr(`${rel(r.input)}: ${r.summary} — it bakes without lightmap uvs`)
184
- else logErr(c.dim(` ${r.status === "unwrapped" ? "unwrapped " : ""}${rel(r.input)}: ${r.summary}`))
185
- },
186
- })
187
- const count = (s: string): number => reports.filter((r) => r.status === s).length
188
- note(`Lightmap uvs: ${count("unwrapped")} file(s) unwrapped${count("skipped") ? `, ${count("skipped")} had nothing to unwrap` : ""}${count("failed") ? `, ${count("failed")} failed` : ""} in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
189
- }
190
-
191
135
  const bake = defineCommand({
192
136
  name: "bake",
193
137
  summary: "Bake the sun, the sky and the lamps into a lux atlas for the level's statics",
194
- description: "Runs the app in bake mode in lecodes-bake — the windowless host with the bake built in (no physics, 2D, audio, net or navmesh), downloaded on first use like the desktop host; LECODES_BAKE_EXE points at a local build (hosts/desktop build.ps1 -Bake). The statics are what the app registers with Lightmap.add (a scene file does it for every node no Physics aspect moves), the bake fires at its Lightmap.load call. Needs a GPU with hardware ray tracing (--tracer).\n\nWhere: the level's own bake('<dir>/<stem>.bake') — env: { lightmap: bake('./assets/lightmap/level.bake') } in a scene file, the path relative to that file — names the target; nothing has to exist before the first bake. Writes into <dir>: <stem>.bake (the manifest: it names every file below, and the next compile reads it — nothing to paste into the scene) + <stem>-light[_n].ktx2 (irradiance, BC6H, a page per _n) + <stem>-aux[_n].ktx2 (sky visibility, the directional share and direction of the light) + <stem>-probes.ktx2 (the reflection probes: a cubemap of the level's radiance per probe, prefiltered per roughness; --probe-spacing 0 = none) + <stem>.lgrid (the light grid the movers read; --volume 0 = none) + <stem>-uv1.bin (the packed lightmap uv1 the rects map) + with --split <stem>-direct[_n].ktx2 / <stem>-indirect[_n].ktx2.\n\nLightmap UVs: the GLBs the app references that have none (TEXCOORD_1) are unwrapped first, IN PLACE \u2014 the same as lecodes assets unwrap, run on just those files; a file that has them is left alone, --no-unwrap skips the step. A whole level in one GLB that wants --groups / --zone is unwrapped by hand first (lecodes assets unwrap --help).",
138
+ description: "Runs the app in bake mode in lecodes-bake — the windowless host with the bake built in (no physics, 2D, audio, net or navmesh), downloaded on first use like the desktop host; LECODES_BAKE_EXE points at a local build (hosts/desktop build.ps1 -Bake). The statics are what the app registers with Lightmap.add (a scene file does it for every node no Physics aspect moves), the bake fires at its Lightmap.load call. Needs a GPU with hardware ray tracing (--tracer).\n\nWhere: the level's own bake('<dir>/<stem>.bake') — env: { lightmap: bake('./assets/lightmap/level.bake') } in a scene file, the path relative to that file — names the target; nothing has to exist before the first bake. Writes into <dir>: <stem>.bake (the manifest: it names every file below, and the next compile reads it — nothing to paste into the scene) + <stem>-light[_n].ktx2 (irradiance, BC6H, a page per _n) + <stem>-aux[_n].ktx2 (sky visibility, the directional share and direction of the light) + <stem>-probes.ktx2 (the reflection probes: a cubemap of the level's radiance per probe, prefiltered per roughness; --probe-spacing 0 = none) + <stem>.lgrid (the light grid the movers read; --volume 0 = none) + <stem>-uv1.bin (the packed lightmap uv1 the rects map) + with --split <stem>-direct[_n].ktx2 / <stem>-indirect[_n].ktx2.\n\nLightmap UVs: a static without TEXCOORD_1 is unwrapped by the bake itself; its charts land in <stem>-uv1.bin with the split geometry the runtime rebuilds the primitive from, and no GLB of the project is written. Vegetation (foliage: true) is never unwrapped: it reads the ground's light. A GLB that carries lightmap uvs of its own (lecodes assets unwrap) keeps them.",
195
139
  flags: {
196
140
  out: str("write the files into this folder instead of next to the level's .bake (a second seed for `lightmap diff`)", { value: "<dir>" }),
197
141
  name: str("bake the level whose bake() names <stem>.bake — when the entry reaches several", { value: "<stem>" }),
@@ -222,7 +166,7 @@ const bake = defineCommand({
222
166
  "probe-layout": str("rooms = every indoor point of the grid slides to the middle of its room and the points of one room merge: one probe per room, far from the walls, its box the room (stations every ~8 m along a room over 12 m), outdoor points stay on the grid; grid = everything stays on the grid. Either way the pages carry which probes each block of texels sees (rays at bake time)", { value: "<rooms|grid>", default: "rooms" }),
223
167
  "min-texels": num("instances smaller than this many ATLAS TEXELS along every axis (x --texel: 12 at 5 cm = 0.6 m) get no place in the atlas: they still cast their baked shadow and bounce, their own light is baked where they stand (six sides, no atlas texels) — cartridges, boxes, pipe elbows, a fire extinguisher: a baked shadow does not read on them and their tight charts are where the seams are. 0 = everything bakes; --dry-run lists what falls under it", { default: 12 }),
224
168
  "thin-texels": num("ROUND AND NARROW objects leave the atlas like --min-texels: an instance none of whose lightmap charts is wider than this many texels across its short side (at --texel) — a railing's tubes, a cable, a pipe run, blinds, a gun barrel. A strip of 1-3 texels around a tube cannot hold the light's change around it and its uv seam shows as a step (a railing: two texels 5 mm apart on the seam, one lit, one shadowed); the ambient cube baked where it stands is smooth. A flat object has a wide chart somewhere and stays. 0 = off; --dry-run lists what falls under it", { default: 4 }),
225
- cube: str("BAKE AS CUBES BY NAME: a regular expression (ECMAScript, case-sensitive) searched in every record's key — <node path>@<ordinal>, one record per mesh of a model, so SM_pipe takes every pipe node and @1$ the second mesh of each. A record that matches leaves the atlas like a --min-texels / --thin-texels one, whatever its size or charts: an occluder whose own light is baked where it stands as ambient cubes (in slabs along a long one, --object-slab) — a pipe run, a fence, a prop whose lightmap seams show. Repeat the flag (--cube SM_pipe --cube SM_fence) or join with | in one (--cube \"SM_pipe|SM_fence\", quoted for the shell); a pattern that starts with - goes as --cube=<regex>. The level's bake() option cube adds its own patterns, a node's lightmap: 'cube' marks one node by hand; --dry-run lists what matches", { value: "<regex>" }),
169
+ cube: str("BAKE AS CUBES BY NAME: a regular expression (ECMAScript, case-sensitive) searched in every record's key — <node path>@<ordinal>, one record per node primitive of a model, so SM_pipe takes every pipe node and @1$ the second node primitive of each. A record that matches leaves the atlas like a --min-texels / --thin-texels one, whatever its size or charts: an occluder whose own light is baked where it stands as ambient cubes (in slabs along a long one, --object-slab) — a pipe run, a fence, a prop whose lightmap seams show. Repeat the flag (--cube SM_pipe --cube SM_fence) or join with | in one (--cube \"SM_pipe|SM_fence\", quoted for the shell); a pattern that starts with - goes as --cube=<regex>. The level's bake() option cube adds its own patterns, a node's lightmap: 'cube' marks one node by hand; --dry-run lists what matches", { value: "<regex>" }),
226
170
  "object-slab": num("A LONG object the atlas left out (--min-texels, --thin-texels, --cube) takes its light in SLABS of this many metres along the longest axis of its bounds — round(length / this) ambient cubes, 16 at most — a 6 m pipe run, a railing along a wall: one end under a lamp, the other in the dark, where one cube gave all of it the mean. The shader blends the two slabs around each pixel along that axis. 0 = one cube an object", { value: "<m>", default: 1 }),
227
171
  "emissive-nits": num("EMISSIVE SURFACES LIGHT THE LEVEL: cd/m² of emission 1.0 (emissive factor x strength x map) — every lit region of an emissive surface (a panel drawn in a ceiling's texture, a screen, a sign) bakes as a rectangle lamp. Default: what the level's camera makes of 1.0, 1.2 x 2^EV100, so a panel lights as brightly as it looks. 0 = emissive surfaces light nothing"),
228
172
  "detail-texels": num("A SMALL SURFACE GETS A FINER TEXEL: an instance whose surface is under this many atlas texels (area / texel²) is laid out denser by sqrt(this / its texels), 3x at most — a pipe of 1 m is ~300 texels at 5 cm, its charts a few texels wide and every chart edge a step; at 2000 it bakes at ~2 cm. 0 = off", { default: 2000 }),
@@ -236,7 +180,6 @@ const bake = defineCommand({
236
180
  height: num("the bake host's virtual viewport height (default: app.json desktop.window, else 720)"),
237
181
  renderer: str("ignored: the bake host is one binary (lecodes-bake, GL shaders on Filament's NOOP backend); kept so older scripts still parse", { value: "<gl|vulkan>" }),
238
182
  logs: bool("forward the host's output"),
239
- "no-unwrap": bool("leave the referenced GLBs as they are: no lightmap unwrap of those without TEXCOORD_1 (they bake without lightmap uvs)"),
240
183
  },
241
184
  examples: [
242
185
  "lecodes lightmap bake",
@@ -305,6 +248,7 @@ const bake = defineCommand({
305
248
  const entryPath = "/.lightmap-entry.ts"
306
249
  const entryText = `globalThis.__lecodesLightmap = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
307
250
 
251
+ const tCommand = Date.now()
308
252
  const desktopRenderer = "gl" as const // lecodes-bake is the GL tree on Filament's NOOP backend: `_creator.backend` "desktop" fetches the GL shaders
309
253
  const { js, resources, bakes } = await buildBundle(bundleOptions(flags), {
310
254
  localAssets: true, desktopRenderer,
@@ -318,7 +262,6 @@ const bake = defineCommand({
318
262
  mkdirSync(outDir, { recursive: true })
319
263
  if (debugDir) mkdirSync(debugDir, { recursive: true })
320
264
  note(`Target: ${relative(root, join(outDir, `${stem}.bake`)).replace(/\\/g, "/")}${target.exists ? "" : " (the first bake: nothing there yet)"}${flags.out ? " (--out)" : ""}`)
321
- if (!flags["no-unwrap"]) await unwrapReferenced(js, resources, root, cfg.texel)
322
265
 
323
266
  // The host writes the bake itself; the PNG the render driver insists on goes to a scratch dir.
324
267
  const scratch = mkdtempSync(join(tmpdir(), "lecodes-lightmap-"))
@@ -327,11 +270,18 @@ const bake = defineCommand({
327
270
  note(`Baking up to ${cfg.pages} × ${cfg.size}² at ${(cfg.texel * 100).toFixed(1)} cm texels (${cfg.sunRays} sun + ${cfg.skyRays} sky + ${cfg.lightRays} lamp rays${bounce}) → ${relative(root, outDir).split(sep).join("/") || "."}/${stem}.*…`)
328
271
  let output = ""
329
272
  if (flags["dry-run"]) process.env.CBK_LAYOUT_ONLY = "1" // the host inherits it: creator-bake stops after the layout
273
+ // the wall clock: when the host's marker lines arrive (the compile is reported by the compile itself)
274
+ const tCompiled = Date.now()
275
+ const marks: Record<string, number> = {}
276
+ const MARKS: [RegExp, string][] = [[/initial\.js evaluated/, "up"], [/\[glb\] loads:/, "glb"], [/\[lightmap\] describing/, "level"], [/\[lightmap\] described in/, "described"], [/\[lightmap\] (done|error)\s*$/m, "done"]]
277
+ // the adapter's own `[lightmap] done` line ends the run - not creator-bake's "done: N page(s)…" summary, which comes first
278
+ const ended = (text: string) => /\[lightmap\] (done|error)\s*$/m.test(text) || /\[lightmap\] error:/.test(text)
330
279
  try {
331
280
  const timeoutMs = flags.timeout || 24 * 3600 * 1000
332
281
  const res = await runDesktopRender(js, resources, {
333
282
  host: "bake", renderer: desktopRenderer, width, height, frames, outPng, logs: flags.logs, timeoutMs,
334
- stopOnLine: (chunk) => chunk.includes("[lightmap] done") || chunk.includes("[lightmap] error"),
283
+ onChunk: (chunk) => { for (const [re, key] of MARKS) if (!(key in marks) && re.test(chunk)) marks[key] = Date.now() },
284
+ stopOnLine: ended,
335
285
  })
336
286
  output = res.output
337
287
  } catch (e) {
@@ -348,6 +298,17 @@ const bake = defineCommand({
348
298
  if (text === "done") continue
349
299
  logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : text.startsWith("warning") ? c.yellow(` ${text}`) : c.dim(` ${text}`))
350
300
  }
301
+ {
302
+ // the command's wall clock, stage by stage (each from the previous mark; a stage whose mark never came is left out)
303
+ const tEnd = Date.now()
304
+ const secs = (ms: number) => `${(ms / 1000).toFixed(1)} s`
305
+ const stages: [string, number | undefined, number | undefined][] = [
306
+ ["compile", tCommand, tCompiled], ["host up", tCompiled, marks.up], ["glb loaded", marks.up, marks.glb], ["level built (js)", marks.glb, marks.level],
307
+ ["described", marks.level, marks.described], ["creator-bake", marks.described, marks.done], ["host stopped + files", marks.done, tEnd],
308
+ ]
309
+ const parts = stages.filter(([, a, b]) => a !== undefined && b !== undefined).map(([name, a, b]) => `${name} ${secs(b! - a!)}`)
310
+ logErr(c.dim(` wall clock ${secs(tEnd - tCommand)}: ${parts.join(", ")}`))
311
+ }
351
312
  if (flags["dry-run"] && /\[lightmap\] done/.test(output)) {
352
313
  for (const l of lines) if (/atlas \d+ x|packed per file|largest rect|do not fit|fill:|--cube|lightmap: 'cube'/.test(l)) logErr(l.replace(/^\[lightmap\] /, " "))
353
314
  logErr(c.green("✓ layout only — nothing written"))
@@ -364,6 +325,7 @@ const bake = defineCommand({
364
325
  ? ` written to --out: the level's bake() still reads ${target.path}`
365
326
  : ` the manifest names them: the next compile of ${entryFile} stages them (bake('…') in the scene reads it)`))
366
327
  if (debugDir) logErr(c.dim(` debug layers: ${relative(root, debugDir)} (lecodes lightmap inspect --bake ${relative(root, join(outDir, `${stem}.bake`)).replace(/\\/g, "/")} --debug-dir ${relative(root, debugDir).replace(/\\/g, "/")})`))
328
+ stitchLevelCache(root, join(outDir, `${stem}.static`), data)
367
329
  return
368
330
  }
369
331
  if (/\[lightmap\] error: this host has no bake module/.test(output)) { // console.error: the host prefixes the line ("[ERROR] [lightmap] …")
@@ -45,7 +45,7 @@ type BakeConfigLine = {
45
45
  }
46
46
 
47
47
  /** The first `*.scene.ts` under src/ (or the root) that declares `navmesh:` names the output by default. */
48
- const guessName = (root: string): string | null => {
48
+ export const guessNavmeshName = (root: string): string | null => {
49
49
  const dirs = [ join(root, "src"), root ]
50
50
  for (const dir of dirs) {
51
51
  if (!existsSync(dir)) continue
@@ -63,7 +63,7 @@ const guessName = (root: string): string | null => {
63
63
 
64
64
  /** An empty LNAV (header only, one dummy tile slot) so the compiler can stage the file before the
65
65
  * first bake; NavMesh.load reads `stats.polys === 0` as "placeholder — rebake". */
66
- const placeholderLnav = (): Buffer => {
66
+ export const placeholderLnav = (): Buffer => {
67
67
  const json = Buffer.from(JSON.stringify({ version: 1, placeholder: true, stats: { tiles: 0, polys: 0 } }), "utf8")
68
68
  const jsonPadded = Buffer.alloc(Math.ceil(json.length / 4) * 4)
69
69
  json.copy(jsonPadded)
@@ -88,15 +88,8 @@ const writeObj = (path: string, tris: Float32Array): void => {
88
88
  writeFileSync(path, lines.join("\n") + "\n")
89
89
  }
90
90
 
91
- const bake = defineCommand({
92
- name: "bake",
93
- summary: "Build the level's navigation mesh for NavAgent / NavMesh",
94
- description: "The desktop host dumps every STATIC body's collision triangles at the scene's env.navmesh (or NavMesh.load) point, then Recast builds the tiled mesh here → <out>/<name>.navmesh. The agent size defaults to what the scene declares. Needs a desktop host with the geometry dump (LECODES_DESKTOP_EXE for a local build).",
95
- flags: {
96
- out: str("where it lands", { value: "<dir>", default: "assets/nav" }),
97
- name: str("file name (default: the scene file that declares navmesh:, else scene)", { value: "<n>" }),
98
- ...bundleFlags,
99
- entry: str("the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load", { value: "<file>", default: "main.ts" }),
91
+ /** The Recast parameters `navmesh bake` and `level build` share. */
92
+ export const NAVMESH_FLAGS = {
100
93
  radius: num("agent radius (m; default: the scene's)"),
101
94
  height: num("agent height (m; default: the scene's)"),
102
95
  climb: num("agent max step height (m; default: the scene's)"),
@@ -112,63 +105,17 @@ const bake = defineCommand({
112
105
  "detail-error": num("detail mesh max error (voxels)", { default: 1 }),
113
106
  obj: bool("also write <name>.navmesh.obj to inspect the polygons"),
114
107
  "keep-geometry": bool("keep the host's <name>.navgeo dump"),
115
- frames: num("host frames to run before giving up — load headroom", { default: 900 }),
116
- timeout: num("host time cap (ms)", { default: 600000 }),
117
- width: num("host window width (default: app.json desktop.window, else 1280)"),
118
- renderer: str("desktop host + shader variant (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
119
- logs: bool("forward the host's output"),
120
- },
121
- examples: ["lecodes navmesh bake", "lecodes navmesh bake --name yard --cell 0.2 --obj --logs"],
122
- run: async ({ flags }) => {
123
- const { root } = localProject()
124
- const outDir = resolve(root, flags.out)
125
- const entryFile = flags.entry
126
- if (!existsSync(resolve(root, entryFile))) {
127
- throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load).`)
128
- }
129
- const name = flags.name ?? guessName(root) ?? "scene"
130
- const frames = range("frames", flags.frames, 900, 30, 100000)
131
- const win = readAppConfigAt(root)?.desktop?.window
132
- const width = flags.width || win?.width || 1280
133
- const height = win?.height || 720
134
- const logs = flags.logs
135
-
136
- // The navmesh file must exist BEFORE the compile: the scene references it through asset(), and a
137
- // missing asset is a broken bundle. The first bake starts from a placeholder the real file overwrites.
138
- mkdirSync(outDir, { recursive: true })
139
- const navmeshPath = join(outDir, `${name}.navmesh`)
140
- if (!existsSync(navmeshPath)) writeFileSync(navmeshPath, placeholderLnav())
141
-
142
- const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
143
- const entryPath = "/.navmesh-entry.ts"
144
- const cfg = { outDir: outDir.replace(/\\/g, "/"), name }
145
- const entryText = `globalThis.__lecodesNavmesh = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
146
-
147
- const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
148
- const { js, resources } = await buildBundle(bundleOptions(flags), {
149
- localAssets: true, desktopRenderer,
150
- extraEntries: [{ path: entryPath, type: "text", text: entryText }],
151
- entryOverride: entryPath,
152
- })
153
-
154
- const scratch = mkdtempSync(join(tmpdir(), "lecodes-navmesh-"))
155
- const outPng = join(scratch, "frame.png")
156
- note(`Dumping the level's static collision geometry through the desktop host → ${relative(root, outDir) || "."}/${name}.navgeo…`)
157
- let output = ""
158
- try {
159
- const res = await runDesktopRender(js, resources, {
160
- renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs: flags.timeout || 600000,
161
- stopOnLine: (chunk) => chunk.includes("[navmesh] done") || chunk.includes("[navmesh] error"),
162
- })
163
- output = res.output
164
- } catch (e) {
165
- const msg = e instanceof Error ? e.message : String(e)
166
- if (!/\[navmesh\] done/.test(msg)) throw e
167
- output = msg
168
- } finally {
169
- rmSync(scratch, { recursive: true, force: true })
170
- }
108
+ }
109
+ export type NavmeshFlags = {
110
+ radius?: number, height?: number, climb?: number, slope?: number, cell?: number, "cell-height"?: number, tile?: number
111
+ "region-min"?: number, "region-merge"?: number, "edge-max"?: number, "edge-error"?: number, detail?: number, "detail-error"?: number
112
+ obj?: boolean, "keep-geometry"?: boolean
113
+ }
171
114
 
115
+ /** From the host's output to `<outDir>/<name>.navmesh`: the `[navmesh]` lines read, the geometry dump handed to Recast.
116
+ * Shared by `navmesh bake` and `level build` (one host run serves both the navmesh and the static level cache). */
117
+ export const finishNavmesh = async (root: string, outDir: string, name: string, output: string, flags: NavmeshFlags): Promise<void> => {
118
+ const navmeshPath = join(outDir, `${name}.navmesh`)
172
119
  // the markers are console.log lines from the SDK — the host prefixes them ("[DEBUG] [navmesh] …")
173
120
  const lines = output.split(/\r?\n/).filter((l) => l.includes("[navmesh]")).map((l) => l.slice(l.indexOf("[navmesh]")))
174
121
  let config: BakeConfigLine | null = null
@@ -245,6 +192,76 @@ const bake = defineCommand({
245
192
  if (!config.data) {
246
193
  logErr(c.dim(` load it with env.navmesh: { data: asset('<rel>/${basename(navmeshPath)}') } in the scene file, or NavMesh.load(scene, { data: asset(...) })`))
247
194
  }
195
+ }
196
+
197
+ const bake = defineCommand({
198
+ name: "bake",
199
+ summary: "Build the level's navigation mesh for NavAgent / NavMesh",
200
+ description: "The desktop host dumps every STATIC body's collision triangles at the scene's env.navmesh (or NavMesh.load) point, then Recast builds the tiled mesh here → <out>/<name>.navmesh. The agent size defaults to what the scene declares. Needs a desktop host with the geometry dump (LECODES_DESKTOP_EXE for a local build).",
201
+ flags: {
202
+ out: str("where it lands", { value: "<dir>", default: "assets/nav" }),
203
+ name: str("file name (default: the scene file that declares navmesh:, else scene)", { value: "<n>" }),
204
+ ...bundleFlags,
205
+ entry: str("the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load", { value: "<file>", default: "main.ts" }),
206
+ ...NAVMESH_FLAGS,
207
+ frames: num("host frames to run before giving up — load headroom", { default: 900 }),
208
+ timeout: num("host time cap (ms)", { default: 600000 }),
209
+ width: num("host window width (default: app.json desktop.window, else 1280)"),
210
+ renderer: str("desktop host + shader variant (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
211
+ logs: bool("forward the host's output"),
212
+ },
213
+ examples: ["lecodes navmesh bake", "lecodes navmesh bake --name yard --cell 0.2 --obj --logs"],
214
+ run: async ({ flags }) => {
215
+ const { root } = localProject()
216
+ const outDir = resolve(root, flags.out)
217
+ const entryFile = flags.entry
218
+ if (!existsSync(resolve(root, entryFile))) {
219
+ throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load).`)
220
+ }
221
+ const name = flags.name ?? guessNavmeshName(root) ?? "scene"
222
+ const frames = range("frames", flags.frames, 900, 30, 100000)
223
+ const win = readAppConfigAt(root)?.desktop?.window
224
+ const width = flags.width || win?.width || 1280
225
+ const height = win?.height || 720
226
+ const logs = flags.logs
227
+
228
+ // The navmesh file must exist BEFORE the compile: the scene references it through asset(), and a
229
+ // missing asset is a broken bundle. The first bake starts from a placeholder the real file overwrites.
230
+ mkdirSync(outDir, { recursive: true })
231
+ const navmeshPath = join(outDir, `${name}.navmesh`)
232
+ if (!existsSync(navmeshPath)) writeFileSync(navmeshPath, placeholderLnav())
233
+
234
+ const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
235
+ const entryPath = "/.navmesh-entry.ts"
236
+ const cfg = { outDir: outDir.replace(/\\/g, "/"), name }
237
+ const entryText = `globalThis.__lecodesNavmesh = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
238
+
239
+ const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
240
+ const { js, resources } = await buildBundle(bundleOptions(flags), {
241
+ localAssets: true, desktopRenderer,
242
+ extraEntries: [{ path: entryPath, type: "text", text: entryText }],
243
+ entryOverride: entryPath,
244
+ })
245
+
246
+ const scratch = mkdtempSync(join(tmpdir(), "lecodes-navmesh-"))
247
+ const outPng = join(scratch, "frame.png")
248
+ note(`Dumping the level's static collision geometry through the desktop host → ${relative(root, outDir) || "."}/${name}.navgeo…`)
249
+ let output = ""
250
+ try {
251
+ const res = await runDesktopRender(js, resources, {
252
+ renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs: flags.timeout || 600000,
253
+ stopOnLine: (chunk) => chunk.includes("[navmesh] done") || chunk.includes("[navmesh] error"),
254
+ })
255
+ output = res.output
256
+ } catch (e) {
257
+ const msg = e instanceof Error ? e.message : String(e)
258
+ if (!/\[navmesh\] done/.test(msg)) throw e
259
+ output = msg
260
+ } finally {
261
+ rmSync(scratch, { recursive: true, force: true })
262
+ }
263
+
264
+ await finishNavmesh(root, outDir, name, output, flags)
248
265
  },
249
266
  })
250
267
 
@@ -0,0 +1,147 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs"
2
+ import { join, relative } from "node:path"
3
+ import { ENV_NAME, isPublicEnvName, isServerPath, scanEnvReads } from "lecodes-sdk/compile"
4
+ import { CliError, UsageError, bool, c, defineCommand, info, log, note, promptHidden, relativeTime, str, success, table } from "../cli"
5
+ import { listSecrets, removeSecret, setSecret } from "../platform/api"
6
+ import { readAppEnv } from "../project/env"
7
+ import { linkedProject } from "./shared"
8
+
9
+ /*
10
+ * `lecodes secrets` — the project's keys on le.codes (Project Settings → Keys), what server code reads
11
+ * as `env.NAME` and the app as `env.PUBLIC_NAME`. On this machine the same keys live in the project's
12
+ * `.env`; this command carries them to the platform (never back: a value is set, replaced or removed,
13
+ * the platform shows no value). `list` is the one place both sides are seen together with what the
14
+ * code reads, so a missing key is found before a build says so.
15
+ */
16
+
17
+ const SKIP_DIRS = new Set(["node_modules", ".lecodes", ".git", "dist", "design"])
18
+
19
+ /** Every `env.NAME` read in the project's code: name → "server" | "app" | "both". */
20
+ export const readsOf = (root: string): Map<string, "server" | "app" | "both"> => {
21
+ const out = new Map<string, "server" | "app" | "both">()
22
+ const walk = (dir: string) => {
23
+ for (const entry of readdirSync(dir)) {
24
+ if (entry.startsWith("_") || entry.startsWith(".") || SKIP_DIRS.has(entry)) continue
25
+ const path = join(dir, entry)
26
+ let isDir = false
27
+ try { isDir = statSync(path).isDirectory() } catch { continue }
28
+ if (isDir) { walk(path); continue }
29
+ if (!/\.[tj]s$/.test(entry) || entry.endsWith(".d.ts")) continue
30
+ const side = isServerPath(path) ? "server" : "app"
31
+ let text = ""
32
+ try { text = readFileSync(path, "utf8") } catch { continue }
33
+ for (const name of scanEnvReads(text)) {
34
+ const had = out.get(name)
35
+ out.set(name, had && had !== side ? "both" : side)
36
+ }
37
+ }
38
+ }
39
+ walk(root)
40
+ return out
41
+ }
42
+
43
+ const checkName = (name: string | undefined): string => {
44
+ if (!name) throw new UsageError("A key name is needed: lecodes secrets set NAME")
45
+ if (!ENV_NAME.test(name)) throw new CliError(`"${name}" is not a key name — capital letters, digits and underscores (STRIPE_KEY, PUBLIC_MAPS_KEY)`)
46
+ return name
47
+ }
48
+
49
+ const list = defineCommand({
50
+ name: "list",
51
+ summary: "The project's keys: on le.codes, in .env, and what the code reads",
52
+ run: async () => {
53
+ const { root, manifest, apiUrl, token } = linkedProject()
54
+ // the platform lists a name the code reads before anyone set it (`set: false`): here the code is scanned on disk
55
+ const remote = new Map((await listSecrets(apiUrl, token, manifest.uuid)).filter(s => s.set !== false).map(s => [s.name, s]))
56
+ const local = readAppEnv(root)
57
+ const reads = readsOf(root)
58
+ const names = [...new Set([...remote.keys(), ...Object.keys(local), ...reads.keys()])].sort()
59
+ if (!names.length) { info("No keys: none on le.codes, none in .env, none read by the code."); return }
60
+ const rows = names.map(name => {
61
+ const r = remote.get(name)
62
+ const by = reads.get(name)
63
+ const flag = by && !r ? c.yellow("missing on le.codes") : by && !(name in local) ? c.dim("not in .env") : ""
64
+ return [name, r ? `le.codes ${c.dim(relativeTime(r.updatedAt))}` : c.dim("—"), name in local ? ".env" : c.dim("—"), by ? `read by ${by === "both" ? "server + app" : by}` : c.dim("not read"), flag]
65
+ })
66
+ table(rows)
67
+ const secretsInApp = names.filter(n => !isPublicEnvName(n) && (reads.get(n) === "app" || reads.get(n) === "both"))
68
+ if (secretsInApp.length) log(c.red(` ${secretsInApp.join(", ")}: read by app code — a key that is not PUBLIC_* is a secret; read it in a *.server.ts file`))
69
+ note("A value never comes back from le.codes: `secrets set NAME --from-env` sends the .env one, `secrets push` sends them all.")
70
+ },
71
+ })
72
+
73
+ const set = defineCommand({
74
+ name: "set",
75
+ summary: "Set (or replace) a key on le.codes",
76
+ usage: "<NAME>",
77
+ flags: {
78
+ "from-env": bool("Take the value from the project's .env instead of asking"),
79
+ value: str("The value itself (visible in the shell history — prefer the prompt or --from-env)", { value: "<value>", optional: true }),
80
+ },
81
+ description: "The value is asked for without echo unless --from-env (the .env line of that name) or --value gives it. Only the project's owner may set a key; a developer sees the names (`list`).",
82
+ run: async ({ args, flags }) => {
83
+ const name = checkName(args[0])
84
+ const { root, manifest, apiUrl, token } = linkedProject()
85
+ let value = flags.value
86
+ if (flags["from-env"]) {
87
+ const local = readAppEnv(root)
88
+ if (!(name in local)) throw new CliError(`${name} is not in ${relative(process.cwd(), join(root, ".env")) || ".env"} — add a line \`${name}=value\` first`)
89
+ value = local[name]
90
+ }
91
+ if (value === undefined) {
92
+ if (!process.stdin.isTTY) throw new CliError("No terminal to ask the value in: pass --from-env or --value")
93
+ value = await promptHidden(`Value for ${name}`)
94
+ }
95
+ if (!value) throw new CliError("An empty value — nothing set")
96
+ await setSecret(apiUrl, token, manifest.uuid, name, value)
97
+ success(`${name} set on le.codes — ${isPublicEnvName(name) ? "the app gets it with its next build" : "the server gets it with the next build"}`)
98
+ },
99
+ })
100
+
101
+ const remove = defineCommand({
102
+ name: "remove",
103
+ summary: "Remove a key from le.codes (the .env line stays)",
104
+ usage: "<NAME>",
105
+ aliases: ["rm", "delete"],
106
+ run: async ({ args }) => {
107
+ const name = checkName(args[0])
108
+ const { manifest, apiUrl, token } = linkedProject()
109
+ await removeSecret(apiUrl, token, manifest.uuid, name)
110
+ success(`${name} removed from le.codes`)
111
+ },
112
+ })
113
+
114
+ const push = defineCommand({
115
+ name: "push",
116
+ summary: "Send every key of the project's .env to le.codes",
117
+ usage: "[NAME...]",
118
+ description: "Every app key of .env (the capital names that are not the CLI's LECODES_* / CREATOR_*), or only the names given. A key already on le.codes is replaced.",
119
+ run: async ({ args }) => {
120
+ const { root, manifest, apiUrl, token } = linkedProject()
121
+ const local = readAppEnv(root)
122
+ const names = args.length ? args.map(checkName) : Object.keys(local).sort()
123
+ if (!names.length) { info("Nothing to send: .env has no app keys (a capital NAME=value line)."); return }
124
+ for (const name of names) {
125
+ if (!(name in local)) throw new CliError(`${name} is not in .env`)
126
+ await setSecret(apiUrl, token, manifest.uuid, name, local[name])
127
+ log(` ${c.green("set")} ${name}`)
128
+ }
129
+ success(`${names.length} key${names.length === 1 ? "" : "s"} on le.codes — picked up with the next build`)
130
+ },
131
+ })
132
+
133
+ export default defineCommand({
134
+ name: "secrets",
135
+ summary: "The project's keys on le.codes (env.NAME in code)",
136
+ aliases: ["keys"],
137
+ description: "A key — a token, a client secret, a maps key — is read in code as env.NAME: every key in *.server.ts files, only PUBLIC_* ones in the app (its bundle is public). On this machine the keys live in the project's .env (NAME=value, never pushed); on le.codes in Project Settings → Keys, which these commands edit. A value never comes back from le.codes.\n\nNames are capital letters, digits and underscores. `lecodes dev` and the builds say which key the code reads and does not have.",
138
+ commands: [list, set, remove, push],
139
+ examples: [
140
+ "lecodes secrets list",
141
+ "lecodes secrets set STRIPE_KEY # asks for the value, no echo",
142
+ "lecodes secrets set STRIPE_KEY --from-env # the .env line",
143
+ "lecodes secrets push # every .env key",
144
+ "lecodes secrets remove STRIPE_KEY",
145
+ ],
146
+ run: (ctx) => list.run!(ctx),
147
+ })