lecodes-cli 2.5.1 → 2.8.0

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 (36) hide show
  1. package/dist/index.js +20618 -19047
  2. package/package.json +15 -18
  3. package/runtime/materials/foliage-cards.mat +16 -2
  4. package/runtime/materials/foliage-impostor.mat +103 -26
  5. package/runtime/materials/foliage.mat +12 -1
  6. package/runtime/materials/grass.mat +6 -3
  7. package/runtime/sdk-types.json +1 -1
  8. package/runtime/web/VERSION.json +3 -3
  9. package/runtime/web/assets/{materials-Cs_z1zLi.bin → materials-DE8QZ7Hn.bin} +0 -0
  10. package/runtime/web/assets/materials-world-CGNyYz_-.bin +0 -0
  11. package/runtime/web/player.js +16 -16
  12. package/runtime/web-lite/lite.js +2 -2
  13. package/src/commands/call.ts +96 -0
  14. package/src/commands/compile.ts +8 -4
  15. package/src/commands/desktop.ts +5 -4
  16. package/src/commands/dev.ts +42 -3
  17. package/src/commands/index.ts +2 -0
  18. package/src/commands/init/templates.ts +67 -3
  19. package/src/commands/plugin.ts +1 -1
  20. package/src/commands/render.ts +31 -22
  21. package/src/commands/test.ts +90 -24
  22. package/src/commands/web.ts +13 -4
  23. package/src/compile/headlessBundle.ts +5 -6
  24. package/src/compile/projectCompile.ts +35 -5
  25. package/src/declarations/lecodes-headless.d.ts +51 -2
  26. package/src/dev/backendCall.ts +44 -0
  27. package/src/dev/backendWorker.ts +60 -7
  28. package/src/dev/bots.ts +133 -0
  29. package/src/dev/devLink.ts +136 -0
  30. package/src/dev/devServer.ts +19 -6
  31. package/src/dev/localBackend.ts +57 -9
  32. package/src/dev/projectBackend.ts +227 -49
  33. package/src/hosts/desktopRenderer.ts +8 -2
  34. package/src/hosts/liteRunner.ts +1 -0
  35. package/src/plugin/generate.ts +5 -3
  36. package/runtime/web/assets/materials-world-BtoAfznA.bin +0 -0
@@ -0,0 +1,96 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
2
+ import { dirname, join } from "node:path"
3
+ import { collectLocalEntries } from "../compile/collectLocal"
4
+ import { callEndpoint, type CallDevice } from "../dev/backendCall"
5
+ import { devRunBackend, findRunningDev } from "../dev/devLink"
6
+ import { runBackend } from "../dev/projectBackend"
7
+ import { filesDirOf } from "../dev/localBackend"
8
+ import { LECODES_DIR } from "../project/manifest"
9
+ import { CliError, bool, c, defineCommand, logErr, str } from "../cli"
10
+ import { localProject } from "./shared"
11
+
12
+ /*
13
+ * `lecodes call <id> [args…]` — call one endpoint of the project's backend from the terminal, as a device
14
+ * would (the transport's wire and first-call rule: src/dev/backendCall.ts), and print what it answered.
15
+ * While a `lecodes dev` of the project runs, the call goes to ITS backend — the one the phone plays on
16
+ * (devLink.ts: one backend per project); otherwise to the TEST backend (`lecodes test`'s: the database in
17
+ * .lecodes/test-db), started for the call. The database is kept from call to call (`lecodes test` empties
18
+ * the test one when it starts; `--reset` empties the one called), and so is the session of each `--as`
19
+ * name — one file per backend (.lecodes/dev-sessions.json, .lecodes/test-sessions.json): `--as ann` twice
20
+ * is one device.
21
+ */
22
+
23
+ const SESSIONS_FILE = { dev: "dev-sessions.json", test: "test-sessions.json" }
24
+
25
+ /** `["1234","Аня"]` (one JSON array) → the args; else every word is JSON, or a string when it is not. */
26
+ export const parseCallArgs = (words: string[]): unknown[] => {
27
+ if (words.length === 1 && words[0].trim().startsWith("[")) {
28
+ try {
29
+ const all = JSON.parse(words[0])
30
+ if (Array.isArray(all)) return all
31
+ } catch { /* a word that happens to start with "[" */ }
32
+ }
33
+ return words.map((w) => { try { return JSON.parse(w) } catch { return w } })
34
+ }
35
+
36
+ export default defineCommand({
37
+ name: "call",
38
+ summary: "Call an endpoint of the project's backend (a running lecodes dev's, else the test one), as a device would",
39
+ usage: "<file#export> [args…]",
40
+ description: "Calls one server function (`game.server.ts#joinRoom`) and prints the result as JSON (an error: its status and message, exit 1). While a `lecodes dev` of the project runs, the call goes to its backend — the database the phone plays on; otherwise to the TEST backend, the one `lecodes test` runs on its own database (.lecodes/test-db), kept from call to call (`lecodes test` empties it when it starts). --reset empties the database called first (the running dev's, or the test one).\n\nArgs are JSON, one per word (a word that is not JSON is a string: `'\"1234\"'` for a string of digits), or one JSON array of them all. --as <name> calls as that device: its session token is kept per backend (.lecodes/dev-sessions.json, .lecodes/test-sessions.json), so `request.session` is the same from call to call; without --as every call is a new device.",
41
+ flags: {
42
+ as: str("call as this device (its session is kept between calls)", { value: "<name>" }),
43
+ reset: bool("empty the database called (a running dev's, else the test one) and forget its saved sessions first"),
44
+ },
45
+ examples: [
46
+ "lecodes call game.server.ts#clock",
47
+ "lecodes call game.server.ts#createRoom Аня --as ann",
48
+ "lecodes call game.server.ts#joinRoom '[\"1234\", \"Боб\"]' --as bob",
49
+ ],
50
+ run: async ({ args, flags }) => {
51
+ const [id, ...rest] = args
52
+ if (!id || !id.includes("#")) throw new CliError(`Name the endpoint: lecodes call <file>#<export> [args…] (e.g. "game.server.ts#joinRoom").`)
53
+ const callArgs = parseCallArgs(rest)
54
+ const { root } = localProject()
55
+ // one backend per project: a running dev's, else the test one
56
+ const dev = await findRunningDev(root)
57
+ const sessionsPath = join(root, LECODES_DIR, SESSIONS_FILE[dev ? "dev" : "test"])
58
+ if (flags.reset) {
59
+ if (!dev) {
60
+ const dbDir = join(root, LECODES_DIR, "test-db")
61
+ rmSync(dbDir, { recursive: true, force: true })
62
+ rmSync(filesDirOf(dbDir), { recursive: true, force: true })
63
+ }
64
+ rmSync(sessionsPath, { force: true })
65
+ }
66
+ const sessions: Record<string, string> = existsSync(sessionsPath) ? (() => { try { return JSON.parse(readFileSync(sessionsPath, "utf8")) } catch { return {} } })() : {}
67
+
68
+ const backend = dev ? devRunBackend(dev) : runBackend({ root, command: "lecodes call", test: true, keepTestDb: true, log: (msg) => { if (msg.includes("[server:")) logErr(msg) } })
69
+ try {
70
+ const { entries } = collectLocalEntries(root, null, "")
71
+ const url = await backend.start(entries, (w) => logErr(c.dim(w)))
72
+ if (!url) throw new CliError("This project has no server files (*.server.ts): there is nothing to call.")
73
+ if (dev) {
74
+ logErr(c.dim(`→ the running lecodes dev's backend (port ${dev.port})`))
75
+ if (flags.reset) await backend.reset()
76
+ }
77
+ let token: string | null = flags.as !== undefined ? sessions[flags.as] ?? null : null
78
+ const device: CallDevice = { token: () => token, store: (t) => { token = t } }
79
+ const out = await callEndpoint(url, id, callArgs, device)
80
+ if (flags.as !== undefined && token !== (sessions[flags.as] ?? null)) {
81
+ if (token) sessions[flags.as] = token
82
+ else delete sessions[flags.as]
83
+ mkdirSync(dirname(sessionsPath), { recursive: true })
84
+ writeFileSync(sessionsPath, JSON.stringify(sessions, null, 2) + "\n")
85
+ }
86
+ if (!out.ok) {
87
+ logErr(`${c.red("✗")} ${id}: ${out.status} ${out.message}`)
88
+ process.exitCode = 1
89
+ return
90
+ }
91
+ process.stdout.write(JSON.stringify(out.result, null, 2) + "\n")
92
+ } finally {
93
+ await backend.close()
94
+ }
95
+ },
96
+ })
@@ -5,7 +5,7 @@ import { getProject } from "../platform/api"
5
5
  import { findProjectRoot, readManifest, type Manifest } from "../project/manifest"
6
6
  import { collectEntries } from "../compile/collect"
7
7
  import { collectLocalEntries } from "../compile/collectLocal"
8
- import { ambiguousEntryWarning, compileProjectBundle, localAssetNames, type CompileEntry } from "../compile/projectCompile"
8
+ import { ambiguousEntryWarning, compileProjectBundle, localAssetNames, requireUnambiguousEntry, type AmbiguousEntryPolicy, type CompileEntry } from "../compile/projectCompile"
9
9
  import { applyProjectFonts } from "../compile/fonts"
10
10
  import { applyLocalShaders, desktopShaderPlatform, type StagedShader } from "../compile/shaders"
11
11
  import type { DesktopRenderer } from "../hosts/desktopRenderer"
@@ -96,6 +96,9 @@ export type BuildBundleOptions = {
96
96
  * started here (dev/projectBackend.ts) and that backend's url in its stubs. Without it the stubs
97
97
  * of a `*.server.ts` have no server to call. */
98
98
  backend?: RunBackend
99
+ /** A guessed entry (several import-graph roots, no main.ts): `warn` (default — compile, desktop run, …)
100
+ * or `error`, thrown before the compile (`render --desktop|--sim`, `test --desktop|--sim`). */
101
+ ambiguousEntry?: AmbiguousEntryPolicy
99
102
  }
100
103
 
101
104
  export default defineCommand({
@@ -221,13 +224,14 @@ export const buildBundle = async (bundle: BundleOptions, opts: BuildBundleOption
221
224
  const iconsHeader = await applyAppAssetIcons(entries)
222
225
  note(`Prepared shaders, fonts and icons in ${((performance.now() - tPrep) / 1000).toFixed(1)} s`)
223
226
 
224
- note("Compiling…")
225
- const tCompile = performance.now()
226
227
  const entryOverride = opts.entryOverride ?? bundle.entry
227
- if (!entryOverride) {
228
+ if (opts.ambiguousEntry === "error") requireUnambiguousEntry(entries, entryOverride)
229
+ else if (!entryOverride) {
228
230
  const ambiguous = ambiguousEntryWarning(entries)
229
231
  if (ambiguous) warnErr(ambiguous)
230
232
  }
233
+ note("Compiling…")
234
+ const tCompile = performance.now()
231
235
  const serverUrl = await opts.backend?.start(entries, (w) => warnings.push(w))
232
236
  const { js, bakes } = await compileProjectBundle({
233
237
  entries, name, publicUrl, entryOverride, serverUrl, env: readAppEnv(root),
@@ -7,7 +7,7 @@ import {
7
7
  import { desktopBuild } from "./app/desktop"
8
8
  import { desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
9
9
  import { buildBundle } from "./compile"
10
- import { runBackend } from "../dev/projectBackend"
10
+ import { projectRunBackend } from "../dev/devLink"
11
11
  import { bundleFlags, bundleOptions, localProject } from "./shared"
12
12
 
13
13
  /*
@@ -41,7 +41,7 @@ export const resolveRenderScale = (flag: string | undefined, cfg: number | undef
41
41
  const run = defineCommand({
42
42
  name: "run",
43
43
  summary: "Compile the current project and open it in a desktop window",
44
- description: "Window size / min size / title default from app.json's desktop block; asset() resources load from the working tree. A project with *.server.ts files gets its backend run on this machine for as long as the window is open (the database of `lecodes dev`: .lecodes/db). Multiplayer: --server starts the host as a dedicated server, --connect joins one — both only set Net.launch (the app decides).",
44
+ description: "Window size / min size / title default from app.json's desktop block; asset() resources load from the working tree. A project with *.server.ts files gets its backend: a running `lecodes dev`'s when one answers (its database and data — the app is one more device of it), else one run on this machine for as long as the window is open, on the database of `lecodes dev` (.lecodes/db). Multiplayer: --server starts the host as a dedicated server, --connect joins one — both only set Net.launch (the app decides).",
45
45
  flags: {
46
46
  width: num("window width (logical px)"),
47
47
  height: num("window height (logical px)"),
@@ -69,8 +69,9 @@ const run = defineCommand({
69
69
  // ONE renderer decision per run: it picks the host binary AND which .filamat gets staged beside
70
70
  // the bundle. Reading the config twice is how a Vulkan exe ends up next to an OpenGL shader.
71
71
  const renderer = resolveDesktopRenderer(root, flags.renderer)
72
- // The project's backend (its *.server.ts files), when it has one: run here while the window is open.
73
- const backend = runBackend({ root, command: "lecodes desktop run", log: logErr })
72
+ // The project's backend (its *.server.ts files), when it has one: a running `lecodes dev`'s, else run here
73
+ // while the window is open.
74
+ const backend = await projectRunBackend({ root, command: "lecodes desktop run", log: logErr, note })
74
75
  const { js, resources } = await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer: renderer, backend })
75
76
  // Native plugins: the project's plugins/ dir + declared plugin libraries, scanned by the host.
76
77
  const { dirs: pluginDirs, files: pluginFiles } = desktopPluginLibraries(root)
@@ -14,6 +14,8 @@ import { readAppConfigAt } from "./app/shared"
14
14
  import { resolveScale } from "./desktop"
15
15
  import { CliError, bool, c, defineCommand, log, note, num, str, warnErr } from "../cli"
16
16
  import { bundleFlags } from "./shared"
17
+ import { DEV_DEFAULT_PORT, findRunningDev, recordDevPort } from "../dev/devLink"
18
+ import { loadBotsFlow, parseBotVars, startBots, type BotsPlan } from "../dev/bots"
17
19
 
18
20
  /*
19
21
  * `lecodes dev` — run the project on a device straight from this machine, with hot reload.
@@ -48,12 +50,22 @@ import { bundleFlags } from "./shared"
48
50
  * connected, and re-running with --desktop is the reopen. Host errors degrade to a warning — the
49
51
  * QR path must keep working.
50
52
  *
53
+ * The project's backend runs here, and it is the ONE backend of the project on this machine
54
+ * (dev/devLink.ts): the port goes into .lecodes/dev.json, and while this runs `render`, `desktop run`,
55
+ * `call` and `web dev` talk to it instead of opening the database — only `lecodes test` keeps its own.
56
+ * A second dev server of the project (another `lecodes dev --port`, a `web dev` started first) finds
57
+ * the one that runs it and forwards its backend routes there.
58
+ *
59
+ * `--bots <flow.json>` (dev/bots.ts): the flow's clients join as headless players on this backend
60
+ * once the first compile is in — the phone is a live player among them; they run the flow once on
61
+ * real time and stay in the game until Ctrl+C. `--var name=value` gives the flow its `${name}`s.
62
+ *
51
63
  * Works inside a cloned project or standalone in any folder with an entry .ts file.
52
64
  * The desktop host speaks websocket since 1.0.0 (host_ws); `?t=poll` remains the fallback
53
65
  * for older desktop builds (or any host without a websocket bridge).
54
66
  */
55
67
 
56
- const DEFAULT_PORT = 4499
68
+ const DEFAULT_PORT = DEV_DEFAULT_PORT
57
69
  /*
58
70
  * The origin a phone on the same Wi-Fi can reach, best first.
59
71
  *
@@ -111,7 +123,7 @@ const printQr = async (url: string) => {
111
123
  export default defineCommand({
112
124
  name: "dev",
113
125
  summary: "Run the project on a device / the desktop / the browser from this machine, with hot reload",
114
- description: "Starts a LAN dev server + prints a QR code; scan it with the LeCodes app. Saves recompile + hot-swap the running app; device logs/errors stream back here. No login needed. The /web/ URL (the web player) is served always; --web, --android and --desktop each also open the app on that host.",
126
+ description: "Starts a LAN dev server + prints a QR code; scan it with the LeCodes app. Saves recompile + hot-swap the running app; device logs/errors stream back here. No login needed. The /web/ URL (the web player) is served always; --web, --android and --desktop each also open the app on that host.\n\nThe project's backend (its *.server.ts files, the database in .lecodes/db) runs here, and while it does it is the project's one backend on this machine: `render`, `desktop run`, `call` and `web dev` talk to it instead of opening the database (`lecodes test` keeps its own). --bots <flow.json> adds headless players from a flow file — the phone is a live player among them: they run the flow once on real time (a `clock` step is refused, a `wait` waits, an expect with a long \"timeout\" waits for you), a failing step is told and the next runs, and then they stay in the game until Ctrl+C. --var code=4821 sets ${code} for the flow (the room you made on the phone).",
115
127
  flags: {
116
128
  port: num("server port", { default: DEFAULT_PORT }),
117
129
  host: str("LAN address to advertise (default: auto-detect)", { value: "<ip>" }),
@@ -130,8 +142,10 @@ export default defineCommand({
130
142
  web: bool("also open the app in the browser (the web player in a device: same bundle + hot reload, the console)"),
131
143
  desktop: bool("also open the app in the native desktop host (same hot reload; window from app.json's desktop block; closing it keeps the server)"),
132
144
  fps: bool("(--desktop) draw the host's frame counter"),
145
+ bots: str("headless players from a flow file on this dev backend (its clients join as the phone does), on real time: the flow runs once, then they stay in the game until Ctrl+C — unless it ends with { \"clients\": \"*\", \"close\": true }", { value: "<flow.json>" }),
146
+ var: str("(--bots) values for ${name} in the flow, before it runs: code=4821 (several: a=1,b=2)", { value: "<name=value>" }),
133
147
  },
134
- examples: ["lecodes dev", "lecodes dev --web", "lecodes dev --android --time 12 --png phone.png", "lecodes dev --ios --time 12 --png sim.png", "lecodes dev --desktop --fps"],
148
+ examples: ["lecodes dev", "lecodes dev --web", "lecodes dev --android --time 12 --png phone.png", "lecodes dev --ios --time 12 --png sim.png", "lecodes dev --desktop --fps", "lecodes dev --bots tests/bots.flow.json --var code=4821"],
135
149
  run: async ({ flags }) => {
136
150
  // Inside a cloned project the server runs at its root; anywhere else it runs standalone against
137
151
  // the current directory (no login / manifest needed) — same rule as `lecodes design`.
@@ -156,6 +170,14 @@ export default defineCommand({
156
170
  const origin = `http://${host}:${port}`
157
171
  const token = loadDevToken(root)
158
172
 
173
+ // --bots: the flow is read and checked now — a typo is an error before the server starts
174
+ if (flags.var !== undefined && flags.bots === undefined) throw new CliError("--var gives a value to a --bots flow: add --bots <flow.json>.")
175
+ const bots: BotsPlan | null = flags.bots !== undefined ? await loadBotsFlow(root, flags.bots, parseBotVars(flags.var)) : null
176
+
177
+ // one backend per project on this machine: when a dev server of the project already runs it, this one forwards there
178
+ const owner = await findRunningDev(root)
179
+ if (owner && owner.port === port) throw new CliError(`A lecodes dev of this project already runs on port ${port}. Use that one — or pass --port <n> for a second dev server (it forwards to the first one's backend).`)
180
+
159
181
  const web = resolveWebRunner(note)
160
182
  if (flags.web && !web) {
161
183
  throw new CliError("No web player found for --web: this CLI has no runtime/web (a monorepo checkout needs `bun run build` in hosts/web-player).")
@@ -165,10 +187,24 @@ export default defineCommand({
165
187
  entryOverride: flags.entry,
166
188
  web,
167
189
  log: note,
190
+ backendUrl: owner?.url,
168
191
  }).catch((e: unknown) => {
169
192
  const msg = e instanceof Error ? e.message : String(e)
170
193
  throw new CliError(msg.includes("EADDRINUSE") ? `Port ${port} is already in use — pass --port <n>.` : msg)
171
194
  })
195
+ if (owner) note(`The project's backend runs in the lecodes dev on port ${owner.port} (its database): this server forwards to it.`)
196
+ else recordDevPort(root, server.port)
197
+
198
+ // the bots join once the app compiled (the first good compile)
199
+ let botsStarted = false
200
+ const startBotsOnce = () => {
201
+ if (!bots || botsStarted) return
202
+ botsStarted = true
203
+ // they live as long as this process (Ctrl+C ends them with it: their sockets close, the backend sees them leave)
204
+ startBots(bots, { port: server.port, token, url: `http://127.0.0.1:${server.port}${server.base}/server` }, note).catch(
205
+ (e: unknown) => warnErr(`[bots] could not start: ${e instanceof Error ? e.message : String(e)}`),
206
+ )
207
+ }
172
208
 
173
209
  note("Compiling…")
174
210
  const first = await server.recompile()
@@ -181,6 +217,7 @@ export default defineCommand({
181
217
  // ---- watcher: debounced whole-project recompile; a successful compile broadcasts the reload ----
182
218
  const watcher = watchProject(root, async () => {
183
219
  const result = await server.recompile()
220
+ if (result.ok) startBotsOnce()
184
221
  if (!result.ok) {
185
222
  warnErr(`Compile failed (devices keep the last good version):\n${result.error}`)
186
223
  } else if (result.changed) {
@@ -215,6 +252,8 @@ export default defineCommand({
215
252
  openBrowser(webLocal)
216
253
  }
217
254
 
255
+ if (first.ok) startBotsOnce()
256
+
218
257
  if (flags.android) {
219
258
  const seconds = flags.time ?? 0
220
259
  const png = flags.png
@@ -1,6 +1,7 @@
1
1
  import type { Group } from "../cli"
2
2
  import app from "./app"
3
3
  import assets from "./assets"
4
+ import call from "./call"
4
5
  import clone from "./clone"
5
6
  import compile from "./compile"
6
7
  import create from "./create"
@@ -45,6 +46,7 @@ export const commandGroups = (opts: { install: boolean }): Group[] => [
45
46
  compile,
46
47
  render,
47
48
  test,
49
+ call,
48
50
  desktop,
49
51
  serve,
50
52
  ] },
@@ -71,7 +71,42 @@ resolved style of a named node, \`audio: { played, playing }\`), then outputs
71
71
  runtime's tree with every node's box + resolved paint, identical on every host). Targets resolve
72
72
  by \`name\` → exact visible text → substring → input placeholder; a tap/drag on empty space reaches
73
73
  the 2D scene (sprite \`click\`/\`touchstart\`, \`ev.track()\`). Envelope keys: \`name\`,
74
- \`width\`/\`height\`, \`settle\`, \`fixtures\`, \`steps\`.
74
+ \`width\`/\`height\`, \`settle\`, \`fixtures\`, \`timeout\`, \`clients\`, \`steps\`.
75
+
76
+ An \`expect\` WAITS by itself until it holds — up to \`timeout\` ms (default 5000; per step, or the
77
+ envelope's \`timeout\`), stepping frames meanwhile at the pace of real time, so what the backend sends
78
+ lands; \`notText\`/\`notNode\` pass as soon as they hold; a failing one says what was on screen. An
79
+ action's target is waited for the same way. So no \`wait\` before an expect: \`wait\` is for time
80
+ itself to pass. \`save\` keeps a value for later steps: \`{ "tap": "create", "save": { "code": "room-code" } }\`
81
+ reads the named node's text (an input's value) once the step settled; \`\${code}\` then stands for it
82
+ in any later step's strings (targets, \`text\`, \`args\`, expect values), and \`\${client}\` for the
83
+ name of the client the step runs on. A variable no earlier step saved is an error naming the step.
84
+
85
+ **A case has one clock.** The app's \`Date.now()\` / \`new Date()\` / \`performance.now()\` read the
86
+ flow's virtual clock (from the real time the case started), and so does the backend: \`wait\` moves
87
+ both by its ms; \`{ "clock": "+92s" }\` (\`"+1500ms"\`, \`"+2m"\`) JUMPS every client and the server
88
+ at once without stepping frames — the timers due meanwhile fire once, as on a phone that was asleep
89
+ (a \`setLoop\` sees one long frame; the 2D/3D simulations do not run the span). Past a server
90
+ deadline: jump its length plus a margin — the server's clock also runs in real time between steps.
91
+
92
+ **Several clients** (headless, \`lecodes test\`): \`"clients": { "ann": {}, "bob": {}, "eve": { "height": 640 } }\`
93
+ starts the app once per client (each its own storage, socket and session token; a client may set its
94
+ own \`width\`/\`height\`/\`device\`/\`safeArea\`), all against the project's backend. Every step then
95
+ says where it runs — \`"client": "ann"\`, \`"clients": ["bob", "eve"]\` or \`"clients": "*"\` (in
96
+ parallel, one verdict) — except \`clock\` and \`call\`; after each step every client is at the same
97
+ time. \`{ "client": "bob", "offline": true }\` takes bob off the network (his calls fail, his socket
98
+ drops, \`online\` turns false; \`false\` brings him back); \`{ "restart": "bob" }\` starts his app again
99
+ on the same storage (\`signal().stored()\` values and his session survive, as a phone's restart);
100
+ \`{ "clients": "*", "close": true }\` ends clients (their sockets close; \`restart\` opens one again). A
101
+ \`screenshot\` on several clients is ONE picture, side by side in envelope order, each under its name.
102
+ A failure dumps every client's frame: tests/.artifacts/<case>/<client>/failure.json + .png.
103
+
104
+ \`call\` calls a server function directly: \`{ "call": "game.server.ts#submitVote", "as": "bob", "args":
105
+ ["\${code}", "x"], "expect": { "result": { "ok": true } }, "save": { "id": "players.0.id" } }\` — as
106
+ client bob's device (his session token), or, for a name no client has, as a device of its own
107
+ (\`"as": "guest1"\`; a list calls once per name, all at once). Its expect: \`result\` (matched by the
108
+ keys it names), \`error: { status?, message?, count? }\`, \`ok: <how many answered>\`. A case of only
109
+ \`call\`/\`clock\` steps and no \`clients\` starts no app: a server test.
75
110
 
76
111
  Three hosts run the same file, at a fixed 16 ms clock on all of them:
77
112
 
@@ -97,6 +132,18 @@ it (appends); the sim has no UI, so it takes coordinates and \`expect.log\` / \`
97
132
  ] }
98
133
  \`\`\`
99
134
 
135
+ \`\`\`json
136
+ { "clients": { "ann": {}, "bob": {} },
137
+ "steps": [
138
+ { "client": "ann", "tap": "create", "expect": { "screen": "lobby" }, "save": { "code": "room-code" } },
139
+ { "client": "bob", "type": "code", "text": "\${code}" },
140
+ { "client": "bob", "tap": "join", "expect": { "text": "2 players" } },
141
+ { "clock": "+46s" },
142
+ { "clients": "*", "expect": { "text": "Time is up" }, "screenshot": "both.png" },
143
+ { "call": "game.server.ts#join", "as": "guest", "args": ["\${code}"], "expect": { "error": { "status": 409 } } }
144
+ ] }
145
+ \`\`\`
146
+
100
147
  Game time is deterministic on every host: the Aspect / System phases (\`updateBefore\` / \`update\`),
101
148
  2D physics and animation advance in fixed 1/60 steps driven by \`wait\`/\`frames\`/\`drag\`/\`key\` —
102
149
  never by a wall clock — so a flow that passes once passes every time. \`Input.key(code)\` reads the
@@ -118,11 +165,28 @@ assertion channel for game state that has no UI.
118
165
  automatically (conventions in design/CLAUDE.md).
119
166
  - Backend-gated screens: canned responses in \`tests/fixtures.json\` (URL pattern →
120
167
  \`{ status?, json?, text? }\`; \`*\` wildcards full-match, plain strings substring-match, first
121
- match wins). \`lecodes test\` auto-loads it for design-derived flows; a scenario file opts in
168
+ match wins). A key may name a server function, \`"game.server.ts#submitGuess"\` (or \`…%23submitGuess\`):
169
+ its body is then the RPC envelope, \`{ "json": { "ok": true, "result": … } }\`. Fixtures are
170
+ headless-only, and a \`computed\` subscription can't be mocked (it goes over the socket to the real
171
+ backend). \`lecodes test\` auto-loads it for design-derived flows; a scenario file opts in
122
172
  with \`"fixtures": "fixtures.json"\` in its envelope.
123
173
  - A project with \`*.server.ts\` files: \`lecodes test\` runs its backend for the run, on a database
124
174
  of its own that is EMPTY before every case (a flow signs up, it never finds yesterday's data). A
125
- step waits for the server calls it started — no \`wait\` after a tap that saves.
175
+ step waits for the server calls it started — no \`wait\` after a tap that saves. Test a backend
176
+ app with its real backend and several \`clients\`, not fixtures; \`lecodes call <file#fn> [args]
177
+ [--as <name>]\` calls one function from the terminal.
178
+ - **One backend per project on this machine: while \`lecodes dev\` runs, the other commands talk to
179
+ it.** \`render\` (headless, \`--desktop\`, \`--sim\`), \`desktop run\`, \`lecodes call\` and \`lecodes web
180
+ dev\` use the running dev's backend — its database and data (the room open on the phone) — instead of
181
+ opening \`.lecodes/db\`; \`render --fresh\` empties it first, and a \`render --script\` beside it runs on
182
+ real time (no \`clock\` step). With no dev running they run the backend themselves (\`call\`: on the
183
+ test database). \`lecodes test\` never uses the dev's: its own database, always.
184
+ - Bots for a multiplayer app: \`lecodes dev --bots tests/bots.flow.json --var code=4821\` — the flow's
185
+ \`clients\` join the dev backend as headless players beside the phone, on REAL time (a \`clock\` step is
186
+ refused, \`wait\` waits; wait for the human with \`"expect": {…}, "timeout": 600000\`), run the flow once
187
+ and stay in the game until Ctrl+C (unless it ends with \`{ "clients": "*", "close": true }\`). A failing
188
+ step is printed (\`[bots] ✗ …\`) and the next runs. \`--var\` sets \`\${code}\`; the envelope's
189
+ \`"vars": ["code"]\` names what the flow needs, and \`lecodes test\` skips such a file.
126
190
  - A file dialog (\`openFilePicker\`) is answered by \`pick\`, on the step whose action opens it:
127
191
  \`{ "tap": "attach", "pick": "cat.png" }\` — a file beside the flow file, a list of them, or a
128
192
  file made for the run: \`{ "name": "me.png", "width": 400, "height": 300 }\`,
@@ -14,7 +14,7 @@ const gen = defineCommand({
14
14
  name: "gen",
15
15
  summary: "Generate a plugin's files from its contract.d.ts (--check: fail on a stale one)",
16
16
  usage: "[path]",
17
- description: "The path is one plugin (a directory with a contract.d.ts) or a repo that keeps its plugins in plugins/<id>/; the current directory when omitted.\n\nWrites sdk/<id>.gen.ts — the wrapper an app calls: the wire call by call, the class with the wire's methods as its own, and the public export when the plugin has no hand-written sdk/<id>.ts. Where the plugin has an ios/ folder, writes ios/<Name>.gen.swift — the contract's types, the protocol the plugin implements and the glue that reads a call off the wire. Where it has an android/ folder, writes the same as <Name>.gen.kt into the folder of the plugin's Kotlin package (android/ is laid out as a Gradle module: the package is the folder the path under android/src/main/java narrows down to). Where it has a web/ folder, writes web/<id>.gen.ts — the same for the web half, in a file that imports nothing; a web half that imports a LeCodes package is refused, because it would run on that host only. A repo of plugins also gets its Package.swift from the manifests: the iOS halves as one SwiftPM package, a target and a product per plugin, the SDK as a package on disk (LECODES_IOS_SDK_PATH, else the repo's package.json lecodes.iosSdkPath, else ../lecodes) — what an Xcode project links the halves as (a shell vendors the sources instead). A contract the generator does not understand is an error with its line, and every problem of a file is reported at once.\n\nA HALF FOLDER of a project — a manifest with `implements: \"<first-party id>\"`, no contract.d.ts and no sdk/ of its own — is generated from the contract of the plugin it implements (the one the SDK carries): `lecodes plugin gen plugins/ar-8thwall` writes web/ar.gen.ts, the half registers the service \"ar\", and app.json names the folder for that platform (`\"ar\": { \"web\": \"./plugins/ar-8thwall\" }`).\n\nGenerated files are committed. Reading a contract needs TypeScript in the plugin's repo.",
17
+ description: "The path is one plugin (a directory with a contract.d.ts) or a repo that keeps its plugins in plugins/<id>/; the current directory when omitted.\n\nWrites sdk/<id>.gen.ts — the wrapper an app calls: the wire call by call, the class with the wire's methods as its own, and the public export when the plugin has no hand-written sdk/<id>.ts. Where the plugin has an ios/ folder, writes ios/<Name>.gen.swift — the contract's types, the protocol the plugin implements and the glue that reads a call off the wire. Where it has an android/ folder, writes the same as <Name>.gen.kt into the folder of the plugin's Kotlin package (android/ is laid out as a Gradle module: the package is the folder the path under android/src/main/java narrows down to). Where it has a web/ folder, writes web/<id>.gen.ts — the same for the web half, in a file that imports nothing; a web half that imports a LeCodes package is refused, because it would run on that host only. A repo of plugins also gets its Package.swift from the manifests: the iOS halves as one SwiftPM package, a target and a product per plugin, the SDK as a package on disk (LECODES_IOS_SDK_PATH, else the repo's package.json lecodes.iosSdkPath, else ../lecodes) — what an Xcode project links the halves as (a shell vendors the sources instead). A contract the generator does not understand is an error with its line, and every problem of a file is reported at once.\n\nA HALF FOLDER of a project — a manifest with `implements: \"<first-party id>\"`, no contract.d.ts and no sdk/ of its own — is generated from the contract of the plugin it implements (the one the SDK carries): `lecodes plugin gen plugins/ar-8thwall` writes web/ar.gen.ts, the half registers the service \"ar\", and app.json names the folder for that platform (`\"ar\": { \"web\": \"./plugins/ar-8thwall\" }`).\n\nGenerated files are committed. Reading a contract needs the `typescript` package 5.x (TypeScript 6 is not read): the plugin's own, looked up from its folder upwards, else the one next to the CLI's install — a dependency of lecodes-cli after 2.4.9; a standalone binary of the CLI needs it in the plugin's repo (`npm install --save-dev \"typescript@^5.8.3\"`, or `bun add -g \"typescript@^5.8.3\"` next to a bun-installed CLI).",
18
18
  flags: {
19
19
  check: bool("write nothing; exit 1 when a generated file is missing or stale"),
20
20
  },
@@ -4,7 +4,8 @@ import { compileHeadlessBundle } from "../compile/headlessBundle"
4
4
  import { screenTargetFrom } from "../compile/screenEntry"
5
5
  import { findProjectRoot } from "../project/manifest"
6
6
  import { buildBundle } from "./compile"
7
- import { runBackend } from "../dev/projectBackend"
7
+ import { projectRunBackend } from "../dev/devLink"
8
+ import { callEndpoint } from "../dev/backendCall"
8
9
  import { resolvePicks } from "../hosts/flowPick"
9
10
  import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
10
11
  import { runDesktopRender } from "../hosts/desktopRenderer"
@@ -139,6 +140,8 @@ type ScenarioFile = {
139
140
  device?: string
140
141
  safeArea?: string
141
142
  fixtures?: Record<string, unknown> | string
143
+ /** How long an expect waits by default, ms. */
144
+ timeout?: number
142
145
  steps: unknown
143
146
  }
144
147
 
@@ -175,6 +178,7 @@ const readScenarioFile = (path: string): ScenarioFile => {
175
178
  if (typeof raw !== "object" || raw === null) throw new CliError(`Scenario "${path}" must be a JSON object with "steps" (or a bare step array).`)
176
179
  const file = raw as ScenarioFile
177
180
  if (file.steps === undefined) throw new CliError(`Scenario "${path}" has no "steps".`)
181
+ if ((raw as { clients?: unknown }).clients !== undefined) throw new CliError(`"clients" (several clients in one case) runs under \`lecodes test\`: \`lecodes test ${path}\`.`)
178
182
  return { ...file, steps: resolvePicks(file.steps, dirname(resolve(path))) }
179
183
  }
180
184
 
@@ -209,6 +213,7 @@ const renderFlags = {
209
213
  "out-dir": str("where --script step outputs land", { value: "<dir>", default: "." }),
210
214
  clip: str("(--png) crop to one node's rect or a logical-px rect — a component / region render", { value: "<name|x,y,w,h>" }),
211
215
  fixtures: str("canned fetch responses (URL pattern → { status?, json?, text? }) — make backend-gated screens reachable headless; merged over the scenario file's own", { value: "<file>" }),
216
+ fresh: bool("start on an EMPTY database: the project's backend (a running lecodes dev's, else .lecodes/db) is emptied first — by default its data stays"),
212
217
  scale: num("PNG device-pixel scale", { default: 2 }),
213
218
  time: num("render at this animation time (0 = the initial frame-0 state)", { value: "<ms>" }),
214
219
  "no-after-animations": bool("keep frame 0 instead of advancing animations to their resting state"),
@@ -301,12 +306,14 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
301
306
  const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
302
307
  // Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
303
308
  const { dirs: pluginDirs } = desktopPluginLibraries(root)
304
- // The project's backend, when it has one (the database of `lecodes dev`).
305
- const backend = runBackend({ root, command: "lecodes render", log: (msg) => { if (logs || msg.includes("[server:error]")) logErr(msg) } })
309
+ // The project's backend, when it has one: a running `lecodes dev`'s (it holds the database), else one
310
+ // started for the run on its database (.lecodes/db). --fresh empties it first.
311
+ const backend = await projectRunBackend({ root, command: "lecodes render", note, log: (msg) => { if (logs || msg.includes("[server:error]")) logErr(msg) } })
306
312
  const { js, resources } = await buildBundle(bundleOptions(flags), {
307
- localAssets: true, desktopRenderer, backend,
313
+ localAssets: true, desktopRenderer, backend, ambiguousEntry: "error",
308
314
  extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
309
315
  })
316
+ if (flags.fresh) await backend.reset()
310
317
  // The host writes PNGs but never creates folders — make every output dir up front.
311
318
  if (!sim) mkdirSync(dirname(resolve(pngFlag)), { recursive: true })
312
319
 
@@ -369,18 +376,11 @@ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Pro
369
376
  }, timeoutMs)
370
377
  killer.unref?.()
371
378
 
372
- // The project's backend, when it has one (the database of `lecodes dev`). Beside a running
373
- // `lecodes dev`, which holds that database, the picture is still made — without the backend.
374
- const run = runBackend({ root: projectRoot, command: "lecodes render", log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } })
375
- const backend: typeof run = {
376
- ...run,
377
- start: (entries, onWarning) => run.start(entries, onWarning).catch((e: Error) => {
378
- if (!/is in use/.test(e.message)) throw e
379
- warnErr(`${e.message} Rendering without the backend: every call to the server fails.`)
380
- return undefined
381
- }),
382
- }
379
+ // The project's backend, when it has one: a running `lecodes dev`'s (it holds the database), else one
380
+ // started for the run on its database (.lecodes/db). --fresh empties it first.
381
+ const backend = await projectRunBackend({ root: projectRoot, command: "lecodes render", note, log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } })
383
382
  const { js, warnings, useLocalAssets, root, serverUrl } = await compileHeadlessBundle({ screen, entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"], backend })
383
+ if (flags.fresh && serverUrl) await backend.reset()
384
384
  const renderer = await requireRenderer()
385
385
 
386
386
  // Flags override the scenario file's envelope; both fall back to the project default (a
@@ -427,16 +427,27 @@ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Pro
427
427
 
428
428
  // --script: drive the compiled app through the scenario steps against a live session.
429
429
  if (scenario) {
430
- const steps = renderer.parseScenarioSteps(scenario.steps)
430
+ // A running lecodes dev's backend keeps its devices' clock: the run is on real time then (no `clock`
431
+ // step; a `wait` waits), as `dev --bots` is.
432
+ const live = !!(serverUrl && backend.dev)
433
+ const steps = (() => {
434
+ try { return renderer.parseScenarioSteps(scenario.steps, live ? { realTime: true } : {}) }
435
+ catch (e) { throw new CliError(`${flags.script}: ${(e as Error).message}`) }
436
+ })()
431
437
  const outDir = flags["out-dir"]
432
438
  // Screenshots require pixels; otherwise rasterize opportunistically (napi installed → failure
433
439
  // PNGs and real Skia text metrics, absent → deterministic HeadlessHost metrics).
434
440
  const needsRaster = steps.some((s) => s.screenshot !== undefined)
435
441
  note(`Running ${steps.length} step${steps.length === 1 ? "" : "s"}…`)
436
- const session = await renderer.createSession(js, { width, height, ...timing, raster: needsRaster ? true : "auto" })
437
- try {
438
- const result = await renderer.runScenario(session, steps, fileScenarioIO(outDir))
439
- for (const w of [ ...warnings, ...session.warnings ]) warnErr(w)
442
+ // one client, the case runner's: its clock is the backend's too (not a dev's), and `call` steps reach the backend as it
443
+ let session: Awaited<ReturnType<typeof renderer.createSession>> | null = null
444
+ {
445
+ const result = await renderer.runCase({
446
+ clients: [{ name: "app", start: async (env) => (session = await renderer.createSession(js, { width, height, ...timing, raster: needsRaster ? true : "auto", ...env })) }],
447
+ single: true, steps, io: fileScenarioIO(outDir), timeoutMs: scenario.timeout, realTime: live,
448
+ backend: serverUrl ? { url: serverUrl, call: (id, args, device) => callEndpoint(serverUrl, id, args, device), ...(live ? {} : { setClock: (ms: number) => backend.setClock(ms) }) } : undefined,
449
+ })
450
+ for (const w of [ ...warnings, ...((session as { warnings: string[] } | null)?.warnings ?? []) ]) warnErr(w)
440
451
  for (const st of result.steps) {
441
452
  logErr(st.ok ? `${c.green("✓")} ${st.desc}` : `${c.red("✗")} ${st.desc}\n ${c.red(st.error ?? "failed")}`)
442
453
  }
@@ -445,8 +456,6 @@ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Pro
445
456
  }
446
457
  logErr(result.ok ? c.green(`✓ ${result.steps.length}/${steps.length} steps passed`) : c.red(`✗ failed at step ${result.steps.length} of ${steps.length}`))
447
458
  return done((cb) => cb(), result.ok ? 0 : 1)
448
- } finally {
449
- session.dispose()
450
459
  }
451
460
  }
452
461