lecodes-cli 0.19.0 → 0.19.2

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 (254) hide show
  1. package/dist/index.js +730 -235
  2. package/package.json +14 -13
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk-types.json +1 -1
  5. package/src/api.ts +302 -0
  6. package/src/browserAuth.ts +87 -0
  7. package/src/cmgenTool.ts +104 -0
  8. package/src/commands/app.ts +891 -0
  9. package/src/commands/appAndroid.ts +603 -0
  10. package/src/commands/appDesktop.ts +326 -0
  11. package/src/commands/appDesktopMac.ts +470 -0
  12. package/src/commands/appIcon.ts +187 -0
  13. package/src/commands/appShared.ts +448 -0
  14. package/src/commands/appTemplates.ts +883 -0
  15. package/src/commands/appTemplatesAndroid.ts +599 -0
  16. package/src/commands/appTemplatesGradlew.ts +9 -0
  17. package/src/commands/assets.ts +28 -0
  18. package/src/commands/clone.ts +60 -0
  19. package/src/commands/compile.ts +253 -0
  20. package/src/commands/create.ts +59 -0
  21. package/src/commands/design.ts +863 -0
  22. package/src/commands/designTemplates.ts +11 -0
  23. package/src/commands/desktop.ts +122 -0
  24. package/src/commands/dev.ts +214 -0
  25. package/src/commands/diff.ts +59 -0
  26. package/src/commands/init.ts +191 -0
  27. package/src/commands/install.ts +198 -0
  28. package/src/commands/lightmap.ts +290 -0
  29. package/src/commands/link.ts +147 -0
  30. package/src/commands/login.ts +24 -0
  31. package/src/commands/navmesh.ts +225 -0
  32. package/src/commands/pn.ts +305 -0
  33. package/src/commands/projectTemplates.ts +129 -0
  34. package/src/commands/pull.ts +109 -0
  35. package/src/commands/push.ts +173 -0
  36. package/src/commands/render.ts +414 -0
  37. package/src/commands/scene.ts +148 -0
  38. package/src/commands/shaders.ts +157 -0
  39. package/src/commands/status.ts +29 -0
  40. package/src/commands/test.ts +370 -0
  41. package/src/commands/thumbs.ts +178 -0
  42. package/src/commands/types.ts +87 -0
  43. package/src/commands/update.ts +190 -0
  44. package/src/compile/assetIcons.ts +214 -0
  45. package/src/compile/collect.ts +46 -0
  46. package/src/compile/collectLocal.ts +37 -0
  47. package/src/compile/designCompile.ts +104 -0
  48. package/src/compile/fonts.ts +156 -0
  49. package/src/compile/headlessBundle.ts +129 -0
  50. package/src/compile/nativeStack.ts +21 -0
  51. package/src/compile/projectCompile.ts +109 -0
  52. package/src/compile/sceneCompile.ts +113 -0
  53. package/src/compile/screenEntry.ts +127 -0
  54. package/src/compile/shaders.ts +245 -0
  55. package/src/config.ts +42 -0
  56. package/src/designMeta.ts +35 -0
  57. package/src/desktopRenderer.ts +532 -0
  58. package/src/desktopScript.ts +276 -0
  59. package/src/dev/clientTemplates.ts +160 -0
  60. package/src/dev/devServer.ts +292 -0
  61. package/src/dev/wsServer.ts +144 -0
  62. package/src/distRoot.ts +20 -0
  63. package/src/ignore.ts +163 -0
  64. package/src/index.ts +491 -0
  65. package/src/lecodes-3d-editor.d.ts +41 -0
  66. package/src/lecodes-assets.d.ts +7 -0
  67. package/src/lecodes-design.d.ts +191 -0
  68. package/src/lecodes-renderer.d.ts +131 -0
  69. package/src/localFiles.ts +144 -0
  70. package/src/manifest.ts +46 -0
  71. package/src/matcTool.ts +137 -0
  72. package/src/peers.ts +40 -0
  73. package/src/project.ts +61 -0
  74. package/src/projectEnv.ts +94 -0
  75. package/src/qrcode-terminal.d.ts +9 -0
  76. package/src/releases.ts +125 -0
  77. package/src/serverDiff.ts +78 -0
  78. package/src/textDiff.ts +103 -0
  79. package/src/types.ts +0 -0
  80. package/src/util.ts +146 -0
  81. package/runtime/sdk/animate/animate.ts +0 -238
  82. package/runtime/sdk/animate/bezier.ts +0 -138
  83. package/runtime/sdk/animate/easings.ts +0 -126
  84. package/runtime/sdk/canvas/Canvas.ts +0 -305
  85. package/runtime/sdk/compile/aspectMacro.ts +0 -86
  86. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  87. package/runtime/sdk/compile/assetMacro.ts +0 -146
  88. package/runtime/sdk/compile/assetName.ts +0 -50
  89. package/runtime/sdk/compile/bundler.ts +0 -287
  90. package/runtime/sdk/compile/compileProject.ts +0 -129
  91. package/runtime/sdk/compile/detectEntry.ts +0 -128
  92. package/runtime/sdk/compile/fontMacro.ts +0 -459
  93. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  94. package/runtime/sdk/compile/header.ts +0 -67
  95. package/runtime/sdk/compile/index.ts +0 -100
  96. package/runtime/sdk/compile/libraryImports.ts +0 -52
  97. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  98. package/runtime/sdk/compile/sceneEditor.ts +0 -88
  99. package/runtime/sdk/compile/serverSplit.ts +0 -233
  100. package/runtime/sdk/compile/serverTypes.ts +0 -227
  101. package/runtime/sdk/compile/sfnt.ts +0 -98
  102. package/runtime/sdk/compile/shaderSchema.ts +0 -202
  103. package/runtime/sdk/compile/shaderTargets.ts +0 -81
  104. package/runtime/sdk/compile/sourcemap.ts +0 -25
  105. package/runtime/sdk/core/Aspect.ts +0 -568
  106. package/runtime/sdk/core/InspectorUI.ts +0 -212
  107. package/runtime/sdk/core/StateMachine.ts +0 -308
  108. package/runtime/sdk/core/color.ts +0 -66
  109. package/runtime/sdk/core/compWrite.ts +0 -42
  110. package/runtime/sdk/core/events.ts +0 -38
  111. package/runtime/sdk/core/fields.ts +0 -120
  112. package/runtime/sdk/core/registry.ts +0 -23
  113. package/runtime/sdk/core/signals.ts +0 -277
  114. package/runtime/sdk/core/time.ts +0 -84
  115. package/runtime/sdk/g2/Camera2D.ts +0 -40
  116. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  117. package/runtime/sdk/g2/Node2D.ts +0 -267
  118. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  119. package/runtime/sdk/g2/Physics2D.ts +0 -346
  120. package/runtime/sdk/g2/Scene2D.ts +0 -209
  121. package/runtime/sdk/g2/Shape2D.ts +0 -259
  122. package/runtime/sdk/g2/Sprite.ts +0 -89
  123. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  124. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  125. package/runtime/sdk/g2/Texture2D.ts +0 -47
  126. package/runtime/sdk/g2/Tilemap.ts +0 -41
  127. package/runtime/sdk/g2/Tileset.ts +0 -71
  128. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  129. package/runtime/sdk/g2/autotile.ts +0 -433
  130. package/runtime/sdk/g2/cells.ts +0 -91
  131. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  132. package/runtime/sdk/g2/groups2d.ts +0 -106
  133. package/runtime/sdk/g2/loop.ts +0 -50
  134. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  135. package/runtime/sdk/g2/touch.ts +0 -83
  136. package/runtime/sdk/gl/Camera.ts +0 -160
  137. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  138. package/runtime/sdk/gl/CharacterController.ts +0 -238
  139. package/runtime/sdk/gl/Gearbox.ts +0 -212
  140. package/runtime/sdk/gl/Geometry.ts +0 -279
  141. package/runtime/sdk/gl/IK.ts +0 -193
  142. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  143. package/runtime/sdk/gl/Light.ts +0 -107
  144. package/runtime/sdk/gl/Lightmap.ts +0 -256
  145. package/runtime/sdk/gl/Material.ts +0 -260
  146. package/runtime/sdk/gl/Mesh.ts +0 -83
  147. package/runtime/sdk/gl/Model.ts +0 -124
  148. package/runtime/sdk/gl/NavAgent.ts +0 -337
  149. package/runtime/sdk/gl/NavMesh.ts +0 -397
  150. package/runtime/sdk/gl/Node.ts +0 -350
  151. package/runtime/sdk/gl/Noise.ts +0 -30
  152. package/runtime/sdk/gl/Particles.ts +0 -676
  153. package/runtime/sdk/gl/Physics.ts +0 -222
  154. package/runtime/sdk/gl/Plane.ts +0 -53
  155. package/runtime/sdk/gl/Ragdoll.ts +0 -270
  156. package/runtime/sdk/gl/Ray.ts +0 -16
  157. package/runtime/sdk/gl/Scene.ts +0 -486
  158. package/runtime/sdk/gl/Shape.ts +0 -409
  159. package/runtime/sdk/gl/Terrain.ts +0 -1092
  160. package/runtime/sdk/gl/Texture.ts +0 -63
  161. package/runtime/sdk/gl/Trigger.ts +0 -45
  162. package/runtime/sdk/gl/Vehicle.ts +0 -473
  163. package/runtime/sdk/gl/Wheel.ts +0 -240
  164. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  165. package/runtime/sdk/gl/animation/Animator.ts +0 -120
  166. package/runtime/sdk/gl/animation/Layer.ts +0 -31
  167. package/runtime/sdk/gl/animation/Loop.ts +0 -30
  168. package/runtime/sdk/gl/animation/Playback.ts +0 -44
  169. package/runtime/sdk/gl/animation/core.ts +0 -336
  170. package/runtime/sdk/gl/controls.ts +0 -95
  171. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  172. package/runtime/sdk/gl/scenarios.ts +0 -291
  173. package/runtime/sdk/gl/state.ts +0 -6
  174. package/runtime/sdk/gl/terrainMesh.ts +0 -219
  175. package/runtime/sdk/gl/touch.ts +0 -68
  176. package/runtime/sdk/inject.ts +0 -197
  177. package/runtime/sdk/math/Mathf.ts +0 -118
  178. package/runtime/sdk/math/mat4.ts +0 -278
  179. package/runtime/sdk/math/quat.ts +0 -232
  180. package/runtime/sdk/math/vec.ts +0 -255
  181. package/runtime/sdk/net/codec.ts +0 -119
  182. package/runtime/sdk/net/core.ts +0 -384
  183. package/runtime/sdk/net/index.ts +0 -181
  184. package/runtime/sdk/net/replication.ts +0 -622
  185. package/runtime/sdk/plugins/camera.ts +0 -81
  186. package/runtime/sdk/plugins/geolocation.ts +0 -123
  187. package/runtime/sdk/plugins/oauth.ts +0 -61
  188. package/runtime/sdk/plugins/permission.ts +0 -7
  189. package/runtime/sdk/plugins/push.ts +0 -132
  190. package/runtime/sdk/plugins/qr.ts +0 -73
  191. package/runtime/sdk/plugins/service.ts +0 -47
  192. package/runtime/sdk/runtime/app.ts +0 -101
  193. package/runtime/sdk/runtime/appEvents.ts +0 -54
  194. package/runtime/sdk/runtime/channel.ts +0 -61
  195. package/runtime/sdk/runtime/clipboard.ts +0 -20
  196. package/runtime/sdk/runtime/datetime.ts +0 -329
  197. package/runtime/sdk/runtime/device.ts +0 -293
  198. package/runtime/sdk/runtime/fetch.ts +0 -77
  199. package/runtime/sdk/runtime/files.ts +0 -23
  200. package/runtime/sdk/runtime/input.ts +0 -175
  201. package/runtime/sdk/runtime/media.ts +0 -111
  202. package/runtime/sdk/runtime/misc.ts +0 -16
  203. package/runtime/sdk/runtime/net.ts +0 -36
  204. package/runtime/sdk/runtime/rpc.ts +0 -218
  205. package/runtime/sdk/runtime/service.ts +0 -83
  206. package/runtime/sdk/runtime/share.ts +0 -9
  207. package/runtime/sdk/runtime/storage.ts +0 -13
  208. package/runtime/sdk/runtime/touch.ts +0 -76
  209. package/runtime/sdk/scene/defineScene.ts +0 -1353
  210. package/runtime/sdk/scene/editorPlugins.ts +0 -110
  211. package/runtime/sdk/scene/gizmos.ts +0 -148
  212. package/runtime/sdk/scene/grammar.ts +0 -120
  213. package/runtime/sdk/scene/material.ts +0 -188
  214. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  215. package/runtime/sdk/server/auth/global.ts +0 -80
  216. package/runtime/sdk/server/auth/host.ts +0 -318
  217. package/runtime/sdk/server/auth/models.ts +0 -83
  218. package/runtime/sdk/server/auth/types.ts +0 -50
  219. package/runtime/sdk/server/channel.ts +0 -56
  220. package/runtime/sdk/server/context.ts +0 -36
  221. package/runtime/sdk/server/db/defineDb.ts +0 -237
  222. package/runtime/sdk/server/db/fields.ts +0 -132
  223. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  224. package/runtime/sdk/server/db/index.ts +0 -7
  225. package/runtime/sdk/server/db/marci/query.ts +0 -412
  226. package/runtime/sdk/server/db/types.ts +0 -202
  227. package/runtime/sdk/server/errors.ts +0 -12
  228. package/runtime/sdk/server/host.ts +0 -74
  229. package/runtime/sdk/server/inject.ts +0 -13
  230. package/runtime/sdk/server/runtime.ts +0 -133
  231. package/runtime/sdk/server/validate.ts +0 -87
  232. package/runtime/sdk/ui/NativeView.ts +0 -142
  233. package/runtime/sdk/ui/UI.ts +0 -39
  234. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  235. package/runtime/sdk/ui/UIButton.ts +0 -101
  236. package/runtime/sdk/ui/UIContainer.ts +0 -60
  237. package/runtime/sdk/ui/UIImage.ts +0 -83
  238. package/runtime/sdk/ui/UIInput.ts +0 -185
  239. package/runtime/sdk/ui/UIModal.ts +0 -139
  240. package/runtime/sdk/ui/UINode.ts +0 -830
  241. package/runtime/sdk/ui/UIPager.ts +0 -362
  242. package/runtime/sdk/ui/UIPopover.ts +0 -100
  243. package/runtime/sdk/ui/UIScreen.ts +0 -123
  244. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  245. package/runtime/sdk/ui/UISpacer.ts +0 -14
  246. package/runtime/sdk/ui/UITabs.ts +0 -236
  247. package/runtime/sdk/ui/UIText.ts +0 -51
  248. package/runtime/sdk/ui/UIVideo.ts +0 -88
  249. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  250. package/runtime/sdk/ui/UIWidget.ts +0 -127
  251. package/runtime/sdk/ui/fonts.ts +0 -13
  252. package/runtime/sdk/ui/presentable.ts +0 -117
  253. package/runtime/sdk/ui/router.ts +0 -132
  254. package/runtime/sdk/ui/theme.ts +0 -84
@@ -0,0 +1,225 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { basename, join, relative, resolve } from "node:path"
4
+ import { findProjectRoot } from "../manifest"
5
+ import { buildBundle } from "./compile"
6
+ import { readAppConfigAt, resolveDesktopRenderer } from "./appShared"
7
+ import { runDesktopRender } from "../desktopRenderer"
8
+ import { CliError, c, flagStr, flagBool, logErr, note, warnErr, type Args } from "../util"
9
+ import { loadCreatorNav, NavBuilder, readNgeo, navMeshTriangles, type BuildParams } from "lecodes-creator-nav"
10
+
11
+ /*
12
+ * `lecodes navmesh bake` — build the level's navigation mesh (docs/navmesh-plan.md §4). Two halves:
13
+ *
14
+ * 1. The app is compiled with a synthetic entry that puts it in BAKE mode
15
+ * (`globalThis.__lecodesNavmesh = { outDir, name }`, then `import "./main"`) and run in the
16
+ * native desktop host; when the level is complete — the scene file's `env.navmesh`, or a
17
+ * hand-written `NavMesh.load(...)` — the SDK calls `_creator.physicsStaticGeometry` instead of
18
+ * loading: every STATIC Jolt body's triangles land in `<out>/<name>.navgeo`, and the host's
19
+ * stdout carries `[navmesh] geometry …` / `[navmesh] config {…}` / `[navmesh] done`.
20
+ * 2. Recast runs HERE (creator-nav's wasm): the dump + the scene's agent size (+ the flags below)
21
+ * → a tiled Detour navmesh → `<out>/<name>.navmesh` (LNAV: header JSON + tiles).
22
+ *
23
+ * Running the build in the CLI keeps the host generic (a geometry dump, useful on its own),
24
+ * lets the bake parameters iterate without a host rebuild, and is the same code a future headless
25
+ * bake reuses. The desktop host must know the dump (built from packages/desktop, or a release that
26
+ * ships it: LECODES_DESKTOP_EXE for a local build until then).
27
+ */
28
+
29
+ const num = (args: Args, key: string, fallback: number, min: number, max: number): number => {
30
+ const raw = flagStr(args, key)
31
+ if (raw === undefined) return fallback
32
+ const n = Number(raw)
33
+ if (!Number.isFinite(n) || n < min || n > max) throw new CliError(`--${key} wants a number in ${min}..${max}, got "${raw}".`)
34
+ return n
35
+ }
36
+
37
+ type BakeConfigLine = {
38
+ agent: { radius: number, height: number, climb: number, slope: number }
39
+ areas: Record<string, number>
40
+ costs: Record<string, number>
41
+ data?: string
42
+ }
43
+
44
+ /** The first `*.scene.ts` under src/ (or the root) that declares `navmesh:` names the output by default. */
45
+ const guessName = (root: string): string | null => {
46
+ const dirs = [ join(root, "src"), root ]
47
+ for (const dir of dirs) {
48
+ if (!existsSync(dir)) continue
49
+ let entries: string[]
50
+ try { entries = readdirSync(dir) } catch { continue }
51
+ for (const f of entries) {
52
+ if (!f.endsWith(".scene.ts")) continue
53
+ try {
54
+ if (/\bnavmesh\s*:/.test(readFileSync(join(dir, f), "utf8"))) return f.slice(0, -".scene.ts".length)
55
+ } catch { /* skip */ }
56
+ }
57
+ }
58
+ return null
59
+ }
60
+
61
+ /** An empty LNAV (header only, one dummy tile slot) so the compiler can stage the file before the
62
+ * first bake; NavMesh.load reads `stats.polys === 0` as "placeholder — rebake". */
63
+ const placeholderLnav = (): Buffer => {
64
+ const json = Buffer.from(JSON.stringify({ version: 1, placeholder: true, stats: { tiles: 0, polys: 0 } }), "utf8")
65
+ const jsonPadded = Buffer.alloc(Math.ceil(json.length / 4) * 4)
66
+ json.copy(jsonPadded)
67
+ const head = Buffer.alloc(12)
68
+ head.writeUInt32LE(0x56414e4c, 0) // 'LNAV'
69
+ head.writeUInt32LE(1, 4)
70
+ head.writeUInt32LE(json.length, 8)
71
+ // dtNavMeshParams: orig[3] f32, tileWidth, tileHeight f32, maxTiles, maxPolys i32
72
+ const params = Buffer.alloc(28)
73
+ params.writeFloatLE(1, 12)
74
+ params.writeFloatLE(1, 16)
75
+ params.writeInt32LE(1, 20)
76
+ params.writeInt32LE(1, 24)
77
+ const tiles = Buffer.alloc(4)
78
+ return Buffer.concat([ head, jsonPadded, params, tiles ])
79
+ }
80
+
81
+ const writeObj = (path: string, tris: Float32Array): void => {
82
+ const lines: string[] = [ "# navmesh polygons — lecodes navmesh bake --obj" ]
83
+ for (let i = 0; i < tris.length; i += 3) lines.push(`v ${tris[i]!.toFixed(4)} ${tris[i + 1]!.toFixed(4)} ${tris[i + 2]!.toFixed(4)}`)
84
+ for (let i = 0; i < tris.length / 3; i += 3) lines.push(`f ${i + 1} ${i + 2} ${i + 3}`)
85
+ writeFileSync(path, lines.join("\n") + "\n")
86
+ }
87
+
88
+ const bake = async (args: Args): Promise<void> => {
89
+ let root: string
90
+ try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() }
91
+ const outDir = resolve(root, flagStr(args, "out") ?? "assets/nav")
92
+ const entryFlag = flagStr(args, "entry")
93
+ const entryFile = entryFlag ?? "main.ts"
94
+ if (!existsSync(resolve(root, entryFile))) {
95
+ 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).`)
96
+ }
97
+ const name = flagStr(args, "name") ?? guessName(root) ?? "scene"
98
+ const frames = num(args, "frames", 900, 30, 100000)
99
+ const win = readAppConfigAt(root)?.desktop?.window
100
+ const width = Number(flagStr(args, "width")) || win?.width || 1280
101
+ const height = Number(flagStr(args, "height")) || win?.height || 720
102
+ const logs = flagBool(args, "logs")
103
+ const keepGeometry = flagBool(args, "keep-geometry")
104
+ const obj = flagBool(args, "obj")
105
+
106
+ // The navmesh file must exist BEFORE the compile: the scene references it through asset(), and a
107
+ // missing asset is a broken bundle. The first bake starts from a placeholder the real file overwrites.
108
+ mkdirSync(outDir, { recursive: true })
109
+ const navmeshPath = join(outDir, `${name}.navmesh`)
110
+ if (!existsSync(navmeshPath)) writeFileSync(navmeshPath, placeholderLnav())
111
+
112
+ const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
113
+ const entryPath = "/.navmesh-entry.ts"
114
+ const cfg = { outDir: outDir.replace(/\\/g, "/"), name }
115
+ const entryText = `globalThis.__lecodesNavmesh = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
116
+
117
+ const desktopRenderer = resolveDesktopRenderer(root, args)
118
+ const { js, resources } = await buildBundle(args, undefined, {
119
+ localAssets: true, desktopRenderer,
120
+ extraEntries: [{ path: entryPath, type: "text", text: entryText }],
121
+ entryOverride: entryPath,
122
+ })
123
+
124
+ const scratch = mkdtempSync(join(tmpdir(), "lecodes-navmesh-"))
125
+ const outPng = join(scratch, "frame.png")
126
+ note(`Dumping the level's static collision geometry through the desktop host → ${relative(root, outDir) || "."}/${name}.navgeo…`)
127
+ let output = ""
128
+ try {
129
+ const timeoutMs = Number(flagStr(args, "timeout")) || 600000
130
+ const res = await runDesktopRender(js, resources, {
131
+ renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs,
132
+ stopOnLine: (chunk) => chunk.includes("[navmesh] done") || chunk.includes("[navmesh] error"),
133
+ })
134
+ output = res.output
135
+ } catch (e) {
136
+ const msg = e instanceof Error ? e.message : String(e)
137
+ if (!/\[navmesh\] done/.test(msg)) throw e
138
+ output = msg
139
+ } finally {
140
+ rmSync(scratch, { recursive: true, force: true })
141
+ }
142
+
143
+ // the markers are console.log lines from the SDK — the host prefixes them ("[DEBUG] [navmesh] …")
144
+ const lines = output.split(/\r?\n/).filter((l) => l.includes("[navmesh]")).map((l) => l.slice(l.indexOf("[navmesh]")))
145
+ let config: BakeConfigLine | null = null
146
+ let triangles = 0
147
+ for (const l of lines) {
148
+ const text = l.slice("[navmesh]".length).trim()
149
+ if (text === "done") continue
150
+ if (text.startsWith("config ")) { try { config = JSON.parse(text.slice(7)) as BakeConfigLine } catch { /* reported below */ } continue }
151
+ const geo = /^geometry (\d+) triangles/.exec(text)
152
+ if (geo) triangles = Number(geo[1])
153
+ logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : c.dim(` ${text}`))
154
+ }
155
+ if (!/\[navmesh\] done/.test(output)) {
156
+ if (lines.some((l) => /cannot dump collision geometry/.test(l))) {
157
+ throw new CliError("This desktop host has no collision-geometry dump. Build packages/desktop and point LECODES_DESKTOP_EXE at build/lecodes-desktop.exe, or update: lecodes desktop update.")
158
+ }
159
+ if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
160
+ warnErr("The host ran to its frame cap without dumping geometry. Make sure the level declares `env.navmesh` (a scene file) or calls NavMesh.load(...) after its static bodies exist, and skips menus when NavMesh.baking is true. More load headroom: --frames <n>. Host output: --logs.")
161
+ throw new CliError("Nothing was baked.")
162
+ }
163
+ if (!config) throw new CliError("The host reported a dump but no `[navmesh] config` line — SDK/host mismatch?")
164
+ const geoPath = join(outDir, `${name}.navgeo`)
165
+ if (!existsSync(geoPath)) throw new CliError(`The host reported a dump but ${geoPath} is missing.`)
166
+
167
+ // ---- Recast (wasm) ----
168
+ const geo = readNgeo(new Uint8Array(readFileSync(geoPath)))
169
+ const params: BuildParams = {
170
+ agentRadius: num(args, "radius", config.agent.radius, 0.05, 10),
171
+ agentHeight: num(args, "height", config.agent.height, 0.1, 20),
172
+ agentMaxClimb: num(args, "climb", config.agent.climb, 0, 10),
173
+ agentMaxSlope: num(args, "slope", config.agent.slope, 0, 89),
174
+ cellSize: num(args, "cell", 0.25, 0.01, 5),
175
+ cellHeight: num(args, "cell-height", 0.1, 0.01, 5),
176
+ tileSize: num(args, "tile", 48, 0, 1024),
177
+ regionMinSize: num(args, "region-min", 8, 0, 1000),
178
+ regionMergeSize: num(args, "region-merge", 20, 0, 1000),
179
+ edgeMaxLen: num(args, "edge-max", 12, 0, 1000),
180
+ edgeMaxError: num(args, "edge-error", 1.3, 0.1, 10),
181
+ detailSampleDist: num(args, "detail", 6, 0, 100),
182
+ detailMaxError: num(args, "detail-error", 1, 0, 100),
183
+ }
184
+ note(`Building the navmesh: ${geo.triangleCount} triangles from ${geo.bodyCount} static bodies, agent r ${params.agentRadius} / h ${params.agentHeight} / climb ${params.agentMaxClimb} / slope ${params.agentMaxSlope}°, cell ${params.cellSize} m…`)
185
+ const mod = await loadCreatorNav()
186
+ const builder = new NavBuilder(mod, params)
187
+ let result
188
+ try {
189
+ builder.addSoup(geo.triangles, geo.areas)
190
+ const meta = {
191
+ scene: name, areas: config.areas, costs: config.costs, bakedAt: new Date().toISOString(),
192
+ params, triangles: geo.triangleCount, bodies: geo.bodyCount,
193
+ }
194
+ let lastTile = -1
195
+ result = builder.run(meta, (tile, count, message) => {
196
+ if (message) logErr(c.dim(` recast: ${message}`))
197
+ else if (count > 8 && tile !== lastTile && (tile + 1) % Math.max(1, Math.floor(count / 8)) === 0) { lastTile = tile; logErr(c.dim(` tile ${tile + 1}/${count}`)) }
198
+ })
199
+ } finally {
200
+ builder.destroy()
201
+ }
202
+ if (result.polys === 0) {
203
+ throw new CliError("The build produced no polygons — nothing walkable for this agent. Check the cell size against the level's scale, the slope, and that the floor is a STATIC body (Shape + Physics { motion: 'static' }).")
204
+ }
205
+ writeFileSync(navmeshPath, result.data)
206
+ if (obj) {
207
+ const objPath = join(outDir, `${name}.navmesh.obj`)
208
+ writeObj(objPath, navMeshTriangles(mod, result.data))
209
+ logErr(c.dim(` wrote ${relative(root, objPath)} (the polygon mesh, for inspection)`))
210
+ }
211
+ if (!keepGeometry) unlinkSync(geoPath)
212
+ const kb = Math.round(statSync(navmeshPath).size / 1024)
213
+ const b = result.bounds
214
+ logErr(c.dim(` ${result.tiles} tiles, ${result.polys} polygons, ${(b.max[0] - b.min[0]).toFixed(0)} × ${(b.max[2] - b.min[2]).toFixed(0)} m, ${result.time.toFixed(1)} s`))
215
+ logErr(c.green(`✓ baked → ${relative(root, navmeshPath).replace(/\\/g, "/")} (${kb} KB)`))
216
+ if (!config.data) {
217
+ logErr(c.dim(` load it with env.navmesh: { data: asset('<rel>/${basename(navmeshPath)}') } in the scene file, or NavMesh.load(scene, { data: asset(...) })`))
218
+ }
219
+ }
220
+
221
+ export const navmesh = async (args: Args): Promise<void> => {
222
+ const sub = args._[0]
223
+ if (sub === "bake") return bake({ ...args, _: args._.slice(1) })
224
+ throw new CliError("Usage: lecodes navmesh bake [--out assets/nav] [--name <scene>] [--entry main.ts] [--radius 0.4] [--height 1.8] [--climb 0.4] [--slope 50] [--cell 0.25] [--cell-height 0.1] [--tile 48] [--obj] [--keep-geometry] [--frames 900] [--logs]")
225
+ }
@@ -0,0 +1,305 @@
1
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import {
4
+ createPushKey, forgetPushDevice, listPushDevices, listPushKeys, revokePushKey, sendPush,
5
+ type PushDevice, type PushSendPayload,
6
+ } from "../api"
7
+ import { loadConfig, requireApiUrl, requireToken } from "../config"
8
+ import { findProjectRoot, readManifest } from "../manifest"
9
+ import { CliError, c, confirm, flagBool, flagStr, info, log, success, warn, type Args } from "../util"
10
+
11
+ /*
12
+ * `lecodes pn` — push notifications for this project (docs/push-plan.md). Named `pn` because
13
+ * `lecodes push` is already the code-upload command.
14
+ *
15
+ * pn send test-send using the PAT from `lecodes login` — /push/send accepts a
16
+ * developer-role user token exactly like a project push key, so the dev loop
17
+ * needs NO key at all.
18
+ * pn devices which devices actually registered — the first thing to check when a push
19
+ * doesn't arrive (an empty list means the app never called Push.register()).
20
+ * pn keys mint / list / revoke the `lecodes_pk_…` keys. The SERVER generates these;
21
+ * the plaintext exists in the mint response and nowhere else, so `keys new`
22
+ * is the only moment it can be captured (--env captures it for you).
23
+ *
24
+ * Everything is scoped to the project in .lecodes/manifest.json — no uuid to look up.
25
+ */
26
+
27
+ const ENV_VAR = "LECODES_PUSH_KEY"
28
+
29
+ type Context = { root: string, uuid: string, name: string, apiUrl: string, token: string }
30
+
31
+ const context = (): Context => {
32
+ const root = findProjectRoot(process.cwd())
33
+ const manifest = readManifest(root)
34
+ const config = loadConfig()
35
+ return { root, uuid: manifest.uuid, name: manifest.name, apiUrl: requireApiUrl(config), token: requireToken(config) }
36
+ }
37
+
38
+ /** A compact "3h ago" — absolute timestamps make these tables unreadable at a glance. */
39
+ const ago = (iso: string | null): string => {
40
+ if (!iso) return c.dim("never")
41
+ const ms = Date.now() - new Date(iso).getTime()
42
+ if (!Number.isFinite(ms)) return c.dim("—")
43
+ const mins = Math.round(ms / 60_000)
44
+ if (mins < 1) return "just now"
45
+ if (mins < 60) return `${mins}m ago`
46
+ const hours = Math.round(mins / 60)
47
+ if (hours < 24) return `${hours}h ago`
48
+ return `${Math.round(hours / 24)}d ago`
49
+ }
50
+
51
+ // Column widths must count PRINTED characters: on a TTY the cells carry ANSI color codes, and
52
+ // padEnd would count those too — every colored column drifting a few chars wide.
53
+ const ANSI = /\x1b\[[0-9;]*m/g
54
+ const printed = (s: string) => s.replace(ANSI, "").length
55
+
56
+ /** Print rows as aligned columns (last column unpadded so colors don't drag trailing spaces). */
57
+ const table = (rows: string[][]) => {
58
+ const widths = rows[0].map((_, i) => Math.max(...rows.map(r => printed(r[i] ?? ""))))
59
+ for (const row of rows) {
60
+ const cells = row.map((cell, i) =>
61
+ i === row.length - 1 ? cell : cell + " ".repeat(Math.max(0, widths[i] - printed(cell))))
62
+ log(" " + cells.join(" "))
63
+ }
64
+ }
65
+
66
+ /** Comma-separated list flag. parseArgs keeps only the LAST of a repeated `--user`, so repeats
67
+ * would silently drop targets — commas are the form that can't lose one. */
68
+ const listFlag = (args: Args, ...keys: string[]): string[] => {
69
+ const raw = flagStr(args, ...keys)
70
+ if (!raw) return []
71
+ return [ ...new Set(raw.split(",").map(s => s.trim()).filter(Boolean)) ]
72
+ }
73
+
74
+ /* ---------------------------------------- send ---------------------------------------- */
75
+
76
+ /** `lecodes pn send --title … [--user u42] [--to pa_…]` — a real send through the relay,
77
+ * authenticated with your own token. Targets are the same ones a production server uses. */
78
+ const pnSend = async (args: Args) => {
79
+ const ctx = context()
80
+
81
+ const title = flagStr(args, "title", "t") ?? args._[1]
82
+ if (!title) throw new CliError('A title is required: lecodes pn send --title "Order shipped" --user u42')
83
+
84
+ const users = listFlag(args, "user", "users", "u")
85
+ const to = listFlag(args, "to", "address", "addresses")
86
+ if (users.length === 0 && to.length === 0) {
87
+ throw new CliError("Nothing to send to — pass --user <id> (from Push.register({ user })) or --to <pa_…>. `lecodes pn devices` lists both.")
88
+ }
89
+
90
+ const badgeRaw = flagStr(args, "badge")
91
+ const badge = badgeRaw === undefined ? undefined : Number(badgeRaw)
92
+ if (badge !== undefined && !Number.isInteger(badge)) throw new CliError(`--badge must be a whole number (got "${badgeRaw}")`)
93
+
94
+ const dataRaw = flagStr(args, "data")
95
+ let data: Record<string, unknown> | undefined
96
+ if (dataRaw !== undefined) {
97
+ try { data = JSON.parse(dataRaw) as Record<string, unknown> } catch { throw new CliError("--data must be a JSON object, e.g. --data '{\"orderId\":42}'") }
98
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new CliError("--data must be a JSON object, e.g. --data '{\"orderId\":42}'")
99
+ }
100
+
101
+ const payload: PushSendPayload = {
102
+ title,
103
+ body: flagStr(args, "body", "b"),
104
+ url: flagStr(args, "url"),
105
+ users: users.length ? users : undefined,
106
+ to: to.length ? to : undefined,
107
+ data,
108
+ badge,
109
+ }
110
+
111
+ const result = await sendPush(ctx.apiUrl, ctx.token, ctx.uuid, payload)
112
+
113
+ if (result.sent > 0) success(`Sent to ${result.sent} device${result.sent === 1 ? "" : "s"}.`)
114
+ if (result.failed.length === 0) {
115
+ if (result.sent === 0) {
116
+ warn("Matched 0 devices — nobody with that user id has registered. Run `lecodes pn devices` to see who has.")
117
+ }
118
+ return
119
+ }
120
+ warn(`${result.failed.length} failed:`)
121
+ for (const failure of result.failed) log(` ${c.dim(failure.address)} ${c.red(failure.reason)}`)
122
+ }
123
+
124
+ /* -------------------------------------- devices --------------------------------------- */
125
+
126
+ const deviceLabel = (device: PushDevice): string => {
127
+ const env = device.environment === "sandbox" ? c.yellow(" sandbox") : ""
128
+ return `${device.platform}${env}`
129
+ }
130
+
131
+ /** `lecodes pn devices [--user u42] [--forget pa_…]` — the registration ledger. Raw OS tokens
132
+ * never leave the backend, so this is address + who/where/when only. --forget drops a dead row
133
+ * (rotated token, wrong project) that would otherwise sit here forever and fail every send. */
134
+ const pnDevices = async (args: Args) => {
135
+ const ctx = context()
136
+ const user = flagStr(args, "user", "u")
137
+
138
+ const forget = flagStr(args, "forget")
139
+ if (forget) {
140
+ if (!flagBool(args, "yes", "y")) {
141
+ warn(`Forgetting ${c.bold(forget)} — sends to this address stop immediately. The device gets a NEW address on its next Push.register().`)
142
+ if (!await confirm("Forget it?")) return info("Cancelled.")
143
+ }
144
+ await forgetPushDevice(ctx.apiUrl, ctx.token, ctx.uuid, forget)
145
+ success(`Forgot ${c.bold(forget)}.`)
146
+ return
147
+ }
148
+
149
+ const { devices, truncated } = await listPushDevices(ctx.apiUrl, ctx.token, ctx.uuid, user)
150
+
151
+ if (devices.length === 0) {
152
+ info(user
153
+ ? `No devices registered for user ${c.bold(user)} in ${c.bold(ctx.name)}.`
154
+ : `No devices registered in ${c.bold(ctx.name)} yet.`)
155
+ info(c.dim("A device appears here after the app calls Push.register(). Note that `lecodes dev`"))
156
+ info(c.dim("bundles are unattributed — register() rejects there; open a PUBLISHED project via QR."))
157
+ return
158
+ }
159
+
160
+ info(`${devices.length} device${devices.length === 1 ? "" : "s"} in ${c.bold(ctx.name)}${user ? ` for user ${c.bold(user)}` : ""}:`)
161
+ table([
162
+ [c.dim("ADDRESS"), c.dim("PLATFORM"), c.dim("USER"), c.dim("HOST"), c.dim("LAST SEEN")],
163
+ ...devices.map(device => [
164
+ device.disabledAt ? c.dim(device.address) : device.address,
165
+ deviceLabel(device),
166
+ device.user ?? c.dim("—"),
167
+ device.hostApp,
168
+ device.disabledAt ? c.red("disabled") : ago(device.lastSeenAt),
169
+ ]),
170
+ ])
171
+ if (devices.some(d => d.disabledAt)) {
172
+ info(c.dim("Disabled = the provider reported a dead token; re-registering from the device revives it."))
173
+ }
174
+ if (truncated) warn("Showing the 200 most recently seen — there are more.")
175
+ }
176
+
177
+ /* ---------------------------------------- keys ---------------------------------------- */
178
+
179
+ const pnKeysList = async (ctx: Context) => {
180
+ const keys = await listPushKeys(ctx.apiUrl, ctx.token, ctx.uuid)
181
+ if (keys.length === 0) {
182
+ info(`No push keys in ${c.bold(ctx.name)}.`)
183
+ info(c.dim("You only need one to send from your OWN server — `lecodes pn send` uses your login."))
184
+ info(c.dim("Mint one with `lecodes pn keys new \"prod server\" --env`."))
185
+ return
186
+ }
187
+ info(`${keys.length} push key${keys.length === 1 ? "" : "s"} in ${c.bold(ctx.name)}:`)
188
+ table([
189
+ [c.dim("ID"), c.dim("NAME"), c.dim("KEY"), c.dim("LAST USED")],
190
+ ...keys.map(key => [String(key.id), key.name, `${key.prefix}${c.dim("…")}`, ago(key.lastUsedAt)]),
191
+ ])
192
+ }
193
+
194
+ /** Append `LECODES_PUSH_KEY=…` to <root>/.env and make sure git can't swallow it. Returns the
195
+ * paths touched. A key already in .env is REPLACED — rotating shouldn't leave two live lines. */
196
+ const writeEnv = (root: string, key: string): string[] => {
197
+ const touched: string[] = []
198
+ const envPath = join(root, ".env")
199
+ const line = `${ENV_VAR}=${key}`
200
+
201
+ const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""
202
+ if (new RegExp(`^${ENV_VAR}=`, "m").test(existing)) {
203
+ writeFileSync(envPath, existing.replace(new RegExp(`^${ENV_VAR}=.*$`, "m"), line))
204
+ } else {
205
+ const separator = !existing || existing.endsWith("\n") ? "" : "\n"
206
+ appendFileSync(envPath, `${separator}${line}\n`)
207
+ }
208
+ touched.push(".env")
209
+
210
+ // A push key in a committed .env is the one way this command could make things worse.
211
+ const ignorePath = join(root, ".gitignore")
212
+ const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : ""
213
+ if (!ignore.split("\n").some(l => l.trim() === ".env")) {
214
+ const separator = !ignore || ignore.endsWith("\n") ? "" : "\n"
215
+ appendFileSync(ignorePath, `${separator}.env\n`)
216
+ touched.push(".gitignore")
217
+ }
218
+ return touched
219
+ }
220
+
221
+ /** `lecodes pn keys new [name] [--env]` — the server mints it; this is the ONLY time the
222
+ * plaintext exists anywhere, so print it loudly (or bank it in .env with --env). */
223
+ const pnKeysNew = async (ctx: Context, args: Args) => {
224
+ const name = args._[2] ?? flagStr(args, "name")
225
+ const created = await createPushKey(ctx.apiUrl, ctx.token, ctx.uuid, name)
226
+
227
+ success(`Created push key ${c.bold(created.name)} (id ${created.id}).`)
228
+ log()
229
+ log(` ${c.bold(created.key)}`)
230
+ log()
231
+
232
+ if (flagBool(args, "env")) {
233
+ const touched = writeEnv(ctx.root, created.key)
234
+ success(`Wrote ${ENV_VAR} to ${touched.join(" + ")}.`)
235
+ } else {
236
+ warn("Copy it now — it is never shown again. Lose it and you mint a new one.")
237
+ info(c.dim(` Tip: \`lecodes pn keys new --env\` writes it straight into .env as ${ENV_VAR}.`))
238
+ }
239
+
240
+ log()
241
+ info(c.dim("Send from your server with:"))
242
+ log(c.dim(` curl -X POST ${ctx.apiUrl}/api/projects/${ctx.uuid}/push/send \\`))
243
+ log(c.dim(` -H "Authorization: Bearer $${ENV_VAR}" -H "Content-Type: application/json" \\`))
244
+ log(c.dim(` -d '{"title":"Order shipped","users":["u42"]}'`))
245
+ }
246
+
247
+ /** `lecodes pn keys revoke <id>` — deletion, effective on the next request. Confirmed unless -y. */
248
+ const pnKeysRevoke = async (ctx: Context, args: Args) => {
249
+ const raw = args._[2]
250
+ if (!raw) throw new CliError("Which key? `lecodes pn keys` lists their ids, then `lecodes pn keys revoke <id>`.")
251
+ const id = Number(raw)
252
+ if (!Number.isInteger(id)) throw new CliError(`"${raw}" is not a key id — run \`lecodes pn keys\` to see them.`)
253
+
254
+ const keys = await listPushKeys(ctx.apiUrl, ctx.token, ctx.uuid)
255
+ const key = keys.find(k => k.id === id)
256
+ if (!key) throw new CliError(`No push key with id ${id} in ${ctx.name}.`)
257
+
258
+ if (!flagBool(args, "yes", "y")) {
259
+ warn(`Revoking ${c.bold(key.name)} (${key.prefix}…) — any server still using it stops sending immediately.`)
260
+ if (!await confirm("Revoke it?")) return info("Cancelled.")
261
+ }
262
+ await revokePushKey(ctx.apiUrl, ctx.token, ctx.uuid, id)
263
+ success(`Revoked push key ${c.bold(key.name)}.`)
264
+ }
265
+
266
+ const pnKeys = async (args: Args) => {
267
+ const ctx = context()
268
+ const sub = args._[1]
269
+ if (!sub || sub === "list") return pnKeysList(ctx)
270
+ if (sub === "new" || sub === "create") return pnKeysNew(ctx, args)
271
+ if (sub === "revoke" || sub === "delete" || sub === "rm") return pnKeysRevoke(ctx, args)
272
+ throw new CliError(`Unknown keys command "${sub}" — use: lecodes pn keys [list | new [name] | revoke <id>]`)
273
+ }
274
+
275
+ /* --------------------------------------- entry ---------------------------------------- */
276
+
277
+ const PN_HELP = `${c.bold("lecodes pn")} — push notifications for this project.
278
+
279
+ ${c.bold("Commands:")}
280
+ pn send --title <s> Send a notification (uses your login — no key needed)
281
+ --body <s> second line
282
+ --url <s> deep link (also arrives as app.launchUrl)
283
+ --user <ids> target Push.register({ user }) ids, comma-separated
284
+ --to <pa_…> target addresses, comma-separated
285
+ --badge <n> app icon badge
286
+ --data <json> your JSON payload (≤ 2 KB)
287
+ pn devices List the devices that registered for this project
288
+ --user <id> only that user's devices
289
+ --forget <pa_…> drop a dead registration (-y to skip confirm)
290
+ pn keys List this project's push keys
291
+ pn keys new [name] Mint one for YOUR server (owner only; shown once)
292
+ --env write it to .env as ${ENV_VAR} (+ gitignore)
293
+ pn keys revoke <id> Revoke a key (-y to skip the confirmation)
294
+
295
+ ${c.dim("A push key is generated BY the server — you can't choose or recover one. It only sends")}
296
+ ${c.dim("for this project, which is why deploying it beats deploying a personal access token.")}`
297
+
298
+ export const pn = async (args: Args) => {
299
+ const sub = args._[0]
300
+ if (!sub || sub === "help") return log(PN_HELP)
301
+ if (sub === "send") return pnSend(args)
302
+ if (sub === "devices" || sub === "device") return pnDevices(args)
303
+ if (sub === "keys" || sub === "key") return pnKeys(args)
304
+ throw new CliError(`Unknown pn command "${sub}" — use: lecodes pn <send | devices | keys>. Run \`lecodes pn\` for the full list.`)
305
+ }
@@ -0,0 +1,129 @@
1
+ /* Templates `lecodes init` / `lecodes create` scaffold into a new APP project (the design folder
2
+ * has its own set — designTemplates.ts). CLAUDE.md is the AI-assistant counterpart of README.md:
3
+ * it teaches the headless feedback loop (render / --script / test) and the conventions that make
4
+ * an app testable, so an assistant opening the project drives the real app instead of guessing.
5
+ * Facts here mirror lecodes-renderer's scenario engine — update both when the format changes. */
6
+
7
+ export const appClaudeMd = (name: string) => `# ${name} — LeCodes app
8
+
9
+ Guidance for AI assistants working in this folder. Human docs: README.md. The whole SDK is
10
+ available as globals (\`UIScreen\`, \`UIText\`, \`Scene2D\`, …) — no SDK imports. \`main.ts\` is
11
+ the entry point; split code into other \`.ts\` files and import them (relative imports only) —
12
+ a file no other file imports is treated as an entry candidate, so keep everything reachable
13
+ from \`main.ts\`.
14
+
15
+ ## Feedback loop — run the app headless instead of guessing
16
+
17
+ \`\`\`
18
+ lecodes render # semantic JSON of the launched UI (structure, text, names, values)
19
+ lecodes render --png shot.png # pixels (needs @napi-rs/canvas installed)
20
+ lecodes render --png card.png --clip card # ONE component/region: a node name or x,y,w,h
21
+ lecodes render src/screens/cart.ts # ONE screen module, without booting the app: any file that
22
+ # \`export default\`s a UIScreen (or a (state) => UIScreen —
23
+ # --state <name> picks the branch). Same flags as above.
24
+ lecodes render --desktop --png shot.png # NATIVE render: real Filament 3D + UI + 2D composited —
25
+ # use this to SEE 3D scenes / procedural geometry (headless
26
+ # renders record 3D as JSON only). Windows/Linux; downloads
27
+ # the renderer on first use. --frames <n> for slow loads.
28
+ lecodes render --script <file> # drive the app through a scenario (format below)
29
+ lecodes render --desktop --view iso --png iso.png # 3D: inspection camera fitted to the WHOLE
30
+ # scene (iso|top|front|side|back [--fov N]) — or --camera x,y,z
31
+ # --look-at x,y,z; the app's camera is untouched. In scripts:
32
+ # { "view": "iso", "screenshot": "iso.png" } then { "view": "off" }
33
+ lecodes render --desktop --script <file> # the same scenario on the NATIVE host (3D/Jolt, real
34
+ # GPU): coordinates only ({ x, y }), expect.log checked,
35
+ # text/node/screen expects skipped; deterministic 16 ms frames
36
+ lecodes test # all tests/*.flow.json + design-derived flows; run before "done"
37
+ lecodes test --desktop # the file flows on the NATIVE host (3D/Jolt/real GPU; coordinate
38
+ # flows only — design flows stay headless)
39
+ \`\`\`
40
+
41
+ Iterate with \`--script\` scenarios and keep the useful ones as \`tests/<name>.flow.json\` — they
42
+ are the regression suite. A failing step dumps the failing frame (JSON + PNG) under
43
+ \`tests/.artifacts/\` so a red step explains itself.
44
+
45
+ ## Scenario format (tests/*.flow.json and --script files)
46
+
47
+ One action per step (\`tap\`, \`type\`+\`text\`, \`fill\`, \`scroll\` [+\`by\`], \`wait\`, and for
48
+ games: \`frames\` (advance N fixed 1/60 steps, no sleeping), \`drag\`+\`to\` [+\`ms\`], \`hold\`+\`ms\`,
49
+ \`key\`+\`ms\` / \`keyDown\` / \`keyUp\` (KeyboardEvent.code: "ArrowRight", "KeyW", "Space" — mouse
50
+ buttons are keys too: "MouseLeft"/"MouseRight"), \`look: [dx, dy]\` [+\`ms\`] (mouse motion for
51
+ \`Input.mouse.delta\`), \`mouseTo: [x, y]\` (cursor position), \`wheel: [dx, dy]\`, gamepad:
52
+ \`stick: { left: [x, y], right: [x, y], triggers: [l, r] }\` (values persist; \`index\` = pad),
53
+ \`gamepad: "connect" | "disconnect"\`, pad buttons as keys: \`keyDown: "GamepadSouth"\`), then an
54
+ optional \`expect\` (\`screen\`, \`node\`/\`notNode\`, \`text\`/\`notText\`,
55
+ \`value: { target, equals }\`, \`log\` — a \`console.log\` line contains it), then outputs
56
+ (\`screenshot\` [+\`clip\`: a node name or \`{ x, y, width, height }\`], \`json\`). Targets resolve by
57
+ \`name\` → exact visible text → substring → input placeholder; a tap/drag on empty space reaches
58
+ the 2D scene (sprite \`click\`/\`touchstart\`, \`ev.track()\`). Envelope keys: \`name\`,
59
+ \`width\`/\`height\`, \`settle\`, \`fixtures\`, \`steps\`.
60
+
61
+ \`\`\`json
62
+ { "steps": [
63
+ { "expect": { "screen": "home", "text": "Taps: 0" } },
64
+ { "tap": "tap", "expect": { "text": "Taps: 1" }, "screenshot": "after.png" },
65
+ { "key": "ArrowRight", "ms": 500, "expect": { "log": "hero x=" } },
66
+ { "frames": 60, "screenshot": "hud.png", "clip": "hud" }
67
+ ] }
68
+ \`\`\`
69
+
70
+ Game time is deterministic headless: \`setLoop\`, 2D physics/animation and aspect \`update(dt)\`
71
+ advance in fixed 1/60 steps driven by \`wait\`/\`frames\`/\`drag\`/\`key\` — never by a wall clock —
72
+ so a flow that passes once passes every time. \`Input.key(code)\` reads the keys a flow holds and
73
+ \`Input.on('keydown' | 'keyup', e => e.code)\` fires for each \`key\`/\`keyDown\`/\`keyUp\` step
74
+ (events land before the next loop tick); \`stick\` values are what \`Input.gamepad(0).axis()\` reads.
75
+ Headless draws UI + the 2D scene (sprites, tilemaps, Box2D); 3D is captured as JSON and Jolt does
76
+ not simulate — use \`--desktop\` for 3D pixels. \`console.log\` + \`expect.log\` is the assertion
77
+ channel for game state that has no UI.
78
+
79
+ ## Make the app testable
80
+
81
+ - Give interactive elements a \`name\`: \`UIButton(...).style({ name: "login" })\`.
82
+ - Name every screen's ROOT node with the screen's id — \`expect.screen\` reads it.
83
+ - If the project has a \`design/\` folder, keep the design's screen ids and activator \`name\`s
84
+ in the app code: \`lecodes test\` then checks the built app against the designed navigation
85
+ automatically (conventions in design/CLAUDE.md).
86
+ - Backend-gated screens: canned responses in \`tests/fixtures.json\` (URL pattern →
87
+ \`{ status?, json?, text? }\`; \`*\` wildcards full-match, plain strings substring-match, first
88
+ match wins). \`lecodes test\` auto-loads it for design-derived flows; a scenario file opts in
89
+ with \`"fixtures": "fixtures.json"\` in its envelope.
90
+ - \`fill\` fills every empty input — use it before tapping a disabled-until-filled submit.
91
+
92
+ ## SDK documentation
93
+
94
+ \`.lecodes/types/\` is the exact API surface (signatures only; generated and gitignored — run
95
+ \`lecodes update\` if it's missing, never commit \`.lecodes/\`). For semantics and examples,
96
+ fetch the docs — they are served as raw markdown for AI tools:
97
+
98
+ - https://le.codes/llms.txt — the docs map; fetch it first to find the right page
99
+ - https://le.codes/en/docs/api/<area>/<page>.md — one reference page as raw markdown
100
+ (e.g. \`ui/styling.md\`, \`2d/scene2d.md\`, \`3d/scene.md\`, \`runtime/storage.md\`)
101
+ - https://le.codes/en/docs/guide/<page>.md — guide pages (project anatomy, CLI, tracks)
102
+ - https://le.codes/llms-full.txt — everything in one file (large; prefer per-page)
103
+
104
+ Always fetch the \`.md\` URLs — the HTML routes are a JS app and return an empty shell.
105
+
106
+ ## Other commands
107
+
108
+ \`lecodes dev\` (run on a device with hot reload + QR), \`lecodes compile -o app.js\`
109
+ (single-file bundle), \`lecodes design\` (screen-design board — the living spec).
110
+ `
111
+
112
+ /** `.claude/settings.json` scaffolded next to CLAUDE.md: pre-approves doc fetches from
113
+ * le.codes so the assistant can follow the "SDK documentation" pointers without a
114
+ * per-domain permission prompt on first use. */
115
+ export const CLAUDE_SETTINGS = JSON.stringify({
116
+ permissions: { allow: ["WebFetch(domain:le.codes)"] },
117
+ }, null, 2) + "\n"
118
+
119
+ /** A flow test that passes against init's starter counter app — proves the loop works out of the
120
+ * box and gives an in-project example of the format (the button/screen carry matching names). */
121
+ export const STARTER_FLOW = `{
122
+ "name": "counter",
123
+ "steps": [
124
+ { "expect": { "screen": "home", "text": "Taps: 0" } },
125
+ { "tap": "tap", "expect": { "text": "Taps: 1" } },
126
+ { "tap": "tap", "expect": { "text": "Taps: 2" } }
127
+ ]
128
+ }
129
+ `