@ludra/cli 0.0.0-stage → 0.4.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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -2
  3. package/dist/commands/check.d.ts +3 -0
  4. package/dist/commands/check.js +21 -0
  5. package/dist/commands/components.d.ts +3 -0
  6. package/dist/commands/components.js +55 -0
  7. package/dist/commands/dev.d.ts +4 -0
  8. package/dist/commands/dev.js +67 -0
  9. package/dist/commands/new.d.ts +3 -0
  10. package/dist/commands/new.js +46 -0
  11. package/dist/commands/play.d.ts +12 -0
  12. package/dist/commands/play.js +126 -0
  13. package/dist/commands/test.d.ts +3 -0
  14. package/dist/commands/test.js +55 -0
  15. package/dist/format.d.ts +11 -0
  16. package/dist/format.js +24 -0
  17. package/dist/headless.d.ts +16 -0
  18. package/dist/headless.js +202 -0
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +80 -0
  21. package/dist/project.d.ts +29 -0
  22. package/dist/project.js +94 -0
  23. package/dist/server.d.ts +17 -0
  24. package/dist/server.js +93 -0
  25. package/package.json +49 -4
  26. package/templates/platformer/CLAUDE.md +30 -0
  27. package/templates/platformer/_gitignore +2 -0
  28. package/templates/platformer/assets/coin.svg +22 -0
  29. package/templates/platformer/assets/flag.svg +15 -0
  30. package/templates/platformer/assets/ground.svg +12 -0
  31. package/templates/platformer/assets/lift.svg +9 -0
  32. package/templates/platformer/assets/platform.svg +9 -0
  33. package/templates/platformer/assets/player.svg +65 -0
  34. package/templates/platformer/assets/slime.svg +21 -0
  35. package/templates/platformer/assets/spikes.svg +8 -0
  36. package/templates/platformer/ludra.json +5 -0
  37. package/templates/platformer/package.json +13 -0
  38. package/templates/platformer/scenes/main.scene.json +154 -0
  39. package/templates/platformer/tests/gameplay.test.json +36 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ludra contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,38 @@
1
- # Temporary Holding Version
1
+ # @ludra/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **The game engine built for AI agents.** This package provides the `ludra` command.
4
+
5
+ Games are plain JSON, every error comes with a fix, and agents play-test their own games headlessly before a human ever touches them.
6
+
7
+ ```bash
8
+ npx @ludra/cli new my-game
9
+ cd my-game
10
+ npx @ludra/cli check # validate; every problem has a fix
11
+ npx @ludra/cli play --inputs "right:1s, jump:0.4s" --ascii # simulate and see the frame as text
12
+ npx @ludra/cli test # run tests/*.test.json
13
+ npx @ludra/cli dev # play it in the browser
14
+ ```
15
+
16
+ Or install it once and use the short command:
17
+
18
+ ```bash
19
+ npm install -g @ludra/cli
20
+ ludra new my-game
21
+ ```
22
+
23
+ | Command | What it does |
24
+ | --- | --- |
25
+ | `ludra new <folder>` | Create a game from the platformer template (art, effects and tests included) |
26
+ | `ludra check [folder]` | Validate the scene and its assets |
27
+ | `ludra play [folder]` | Deterministic headless run: timeline, events, ASCII map |
28
+ | `ludra test [folder]` | Scripted inputs plus assertions such as `won == true` or `deaths == 0` |
29
+ | `ludra components [name]` | Reference for every component, field and effect |
30
+ | `ludra dev [folder]` | Browser runtime with keyboard controls, sound and live reload |
31
+
32
+ Every command accepts `--json`. Requires Node.js 22.18 or newer.
33
+
34
+ The engine runtime is published separately as [`@ludra/core`](https://www.npmjs.com/package/@ludra/core).
35
+
36
+ Documentation, the Claude Code plugin and examples: **https://github.com/ersingvn/ludra**
37
+
38
+ MIT licensed.
@@ -0,0 +1,3 @@
1
+ export declare function check(dir: string, options: {
2
+ json?: boolean;
3
+ }): number;
@@ -0,0 +1,21 @@
1
+ import { formatDiagnostics, paint } from "../format.js";
2
+ import { loadProject, readScene } from "../project.js";
3
+ export function check(dir, options) {
4
+ const project = loadProject(dir);
5
+ const { scene, diagnostics } = readScene(project);
6
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
7
+ const warnings = diagnostics.length - errors;
8
+ if (options.json) {
9
+ console.log(JSON.stringify({ ok: errors === 0, file: project.entryLabel, errors, warnings, diagnostics }, null, 2));
10
+ return errors > 0 ? 1 : 0;
11
+ }
12
+ if (diagnostics.length > 0)
13
+ console.log(`${formatDiagnostics(diagnostics, project.entryLabel)}\n`);
14
+ if (errors > 0) {
15
+ console.log(paint("red", `✗ ${project.entryLabel}: ${errors} error(s), ${warnings} warning(s)`));
16
+ return 1;
17
+ }
18
+ const count = scene?.entities.length ?? 0;
19
+ console.log(paint("green", `✓ ${project.entryLabel}: ${count} entities, ${warnings === 0 ? "no problems" : `${warnings} warning(s)`}`));
20
+ return 0;
21
+ }
@@ -0,0 +1,3 @@
1
+ export declare function components(name: string | undefined, options: {
2
+ json?: boolean;
3
+ }): number;
@@ -0,0 +1,55 @@
1
+ import { COMPONENT_NAMES, COMPONENTS, EFFECT_FIELDS, EFFECTS_DESCRIPTION, EVENT_TYPES, isComponentName, PARTICLE_FIELDS, SOUND_PRESETS, } from "@ludra/core";
2
+ import { pad, paint } from "../format.js";
3
+ import { CliError } from "../project.js";
4
+ const EFFECTS_EXAMPLE = `"effects": {
5
+ "collect": { "sound": "coin", "particles": { "count": 12, "color": "#ffd166", "speed": 140 } },
6
+ "die": { "sound": "explosion", "shake": 8, "particles": { "count": 24, "color": "#ff5d73" } }
7
+ }`;
8
+ export function components(name, options) {
9
+ if (name !== undefined && name !== "effects" && !isComponentName(name)) {
10
+ throw new CliError(`Unknown component "${name}". Components: ${COMPONENT_NAMES.join(", ")}, plus "effects" for scene effects.`);
11
+ }
12
+ const names = name === undefined ? COMPONENT_NAMES : isComponentName(name) ? [name] : [];
13
+ const showEffects = name === undefined || name === "effects";
14
+ if (options.json) {
15
+ const effects = { description: EFFECTS_DESCRIPTION, events: EVENT_TYPES, soundPresets: SOUND_PRESETS, fields: EFFECT_FIELDS, particles: PARTICLE_FIELDS };
16
+ const output = Object.fromEntries(names.map((n) => [n, COMPONENTS[n]]));
17
+ console.log(JSON.stringify(showEffects ? { ...output, effects } : output, null, 2));
18
+ return 0;
19
+ }
20
+ for (const n of names) {
21
+ const spec = COMPONENTS[n];
22
+ console.log(`${paint("bold", n)} — ${spec.description}`);
23
+ if (spec.requires?.length)
24
+ console.log(paint("dim", ` requires: ${spec.requires.join(", ")}`));
25
+ printFields(spec.fields);
26
+ console.log();
27
+ }
28
+ if (showEffects) {
29
+ console.log(`${paint("bold", "effects")} (scene level, not a component) — ${EFFECTS_DESCRIPTION}`);
30
+ console.log(paint("dim", ` events: ${EVENT_TYPES.join(", ")}`));
31
+ console.log(paint("dim", ` sound presets: ${SOUND_PRESETS.join(", ")}`));
32
+ printFields(EFFECT_FIELDS);
33
+ console.log(` ${pad("particles", 13)} ${pad("object", 34)} ${pad("optional", 18)} ${paint("dim", "A burst at the event position, with these fields:")}`);
34
+ printFields(PARTICLE_FIELDS, " ");
35
+ console.log(paint("dim", ` example:\n ${EFFECTS_EXAMPLE}`));
36
+ console.log();
37
+ }
38
+ return 0;
39
+ }
40
+ function printFields(fields, indent = " ") {
41
+ for (const [field, fieldSpec] of Object.entries(fields)) {
42
+ // Pad before coloring so escape codes don't break the alignment.
43
+ const required = fieldSpec.default === undefined ? paint("yellow", pad("required", 18)) : pad(`default ${JSON.stringify(fieldSpec.default)}`, 18);
44
+ console.log(`${indent}${pad(field, 15 - indent.length)} ${pad(typeLabel(fieldSpec), 34)} ${required} ${paint("dim", fieldSpec.description)}`);
45
+ }
46
+ }
47
+ function typeLabel(spec) {
48
+ if (spec.type === "enum")
49
+ return spec.values.map((v) => `"${v}"`).join(" | ");
50
+ if (spec.type === "number" && spec.min !== undefined)
51
+ return `number ≥ ${spec.min}`;
52
+ if (spec.type === "frames")
53
+ return 'frames ("0", "1-4", "0,2")';
54
+ return spec.type;
55
+ }
@@ -0,0 +1,4 @@
1
+ /** Serves the game for a human to play, and reloads the page when the scene or its assets change. */
2
+ export declare function dev(dir: string, options: {
3
+ port?: string;
4
+ }): number;
@@ -0,0 +1,67 @@
1
+ import { paint } from "../format.js";
2
+ import { CliError, loadProject } from "../project.js";
3
+ import { createGameServer, PROJECT_PREFIX } from "../server.js";
4
+ /** Serves the game for a human to play, and reloads the page when the scene or its assets change. */
5
+ export function dev(dir, options) {
6
+ const project = loadProject(dir);
7
+ const port = Number(options.port ?? 5173);
8
+ if (!Number.isInteger(port) || port <= 0)
9
+ throw new CliError(`Invalid --port "${options.port}".`);
10
+ const { server } = createGameServer(project, (browserModule) => page(project, browserModule));
11
+ server.on("error", (err) => {
12
+ console.error(paint("red", err.code === "EADDRINUSE" ? `Port ${port} is in use. Try --port ${port + 1}.` : err.message));
13
+ process.exit(1);
14
+ });
15
+ server.listen(port, "127.0.0.1", () => {
16
+ console.log(paint("green", `▶ ${project.config.name} is running at http://localhost:${port}`));
17
+ console.log(paint("dim", ` Arrows/WASD to move, Space/Z to jump, M to mute, R to restart.`));
18
+ console.log(paint("dim", ` Editing ${project.entryLabel} or its images and sounds reloads the page. Ctrl+C to stop.`));
19
+ });
20
+ return 0;
21
+ }
22
+ function page(project, browserModule) {
23
+ const { width, height } = project.config.window;
24
+ const title = escapeHtml(project.config.name);
25
+ return `<!doctype html>
26
+ <html lang="en">
27
+ <head>
28
+ <meta charset="utf-8">
29
+ <meta name="viewport" content="width=device-width, initial-scale=1">
30
+ <title>${title} · Ludra</title>
31
+ <style>
32
+ html, body { margin: 0; height: 100%; background: #0b0e14; color: #e6e6e6; font-family: system-ui, sans-serif; }
33
+ body { display: grid; place-items: center; }
34
+ canvas { max-width: 100vw; max-height: 100vh; box-shadow: 0 0 0 1px #2a3142; }
35
+ #errors { max-width: 900px; padding: 24px; white-space: pre-wrap; font: 14px/1.5 ui-monospace, monospace; color: #ff9b9b; }
36
+ </style>
37
+ </head>
38
+ <body>
39
+ <canvas id="game" width="${width}" height="${height}"></canvas>
40
+ <pre id="errors" hidden></pre>
41
+ <script type="module">
42
+ import { startGame } from "${browserModule}";
43
+
44
+ const data = await (await fetch("/__ludra/scene")).json();
45
+ if (data.ok) {
46
+ await startGame(document.getElementById("game"), data.scene, { assetBase: "${PROJECT_PREFIX}" });
47
+ } else {
48
+ document.getElementById("game").hidden = true;
49
+ const errors = document.getElementById("errors");
50
+ errors.hidden = false;
51
+ errors.textContent = "The scene has errors (run \`ludra check\`):\\n\\n" +
52
+ data.diagnostics.map((d) => d.severity + " " + d.code + " at " + d.path + "\\n " + d.message + (d.fix ? "\\n fix: " + d.fix : "")).join("\\n\\n");
53
+ }
54
+
55
+ const version = await (await fetch("/__ludra/version")).text();
56
+ setInterval(async () => {
57
+ try {
58
+ if ((await (await fetch("/__ludra/version")).text()) !== version) location.reload();
59
+ } catch {}
60
+ }, 1000);
61
+ </script>
62
+ </body>
63
+ </html>`;
64
+ }
65
+ function escapeHtml(text) {
66
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
67
+ }
@@ -0,0 +1,3 @@
1
+ export declare function create(dir: string | undefined, options: {
2
+ template?: string;
3
+ }): number;
@@ -0,0 +1,46 @@
1
+ import { cpSync, existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, join, relative, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { paint } from "../format.js";
5
+ import { CliError } from "../project.js";
6
+ /** Same relative location from `src/commands` and `dist/commands`. */
7
+ const TEMPLATES_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
8
+ export function create(dir, options) {
9
+ if (!dir)
10
+ throw new CliError("Usage: ludra new <folder> [--template platformer]");
11
+ const template = options.template ?? "platformer";
12
+ const source = join(TEMPLATES_DIR, template);
13
+ if (!existsSync(source)) {
14
+ throw new CliError(`Unknown template "${template}". Templates: ${readdirSync(TEMPLATES_DIR).join(", ")}.`);
15
+ }
16
+ const target = resolve(dir);
17
+ if (existsSync(target) && readdirSync(target).length > 0) {
18
+ throw new CliError(`${target} already exists and is not empty. Choose a new folder.`);
19
+ }
20
+ const name = basename(target);
21
+ cpSync(source, target, { recursive: true });
22
+ // npm never publishes files named .gitignore, so templates ship it as _gitignore.
23
+ const gitignore = join(target, "_gitignore");
24
+ if (existsSync(gitignore))
25
+ renameSync(gitignore, join(target, ".gitignore"));
26
+ for (const file of listFiles(target)) {
27
+ const text = readFileSync(file, "utf8");
28
+ if (text.includes("{{name}}"))
29
+ writeFileSync(file, text.replaceAll("{{name}}", name));
30
+ }
31
+ const shown = relative(process.cwd(), target) || ".";
32
+ console.log(paint("green", `✓ Created ${name} from the "${template}" template in ${shown}`));
33
+ console.log(`
34
+ Next steps:
35
+ npx @ludra/cli check ${shown} validate the scene
36
+ npx @ludra/cli play ${shown} --ascii simulate it headlessly and see the final frame
37
+ npx @ludra/cli test ${shown} run tests/*.test.json
38
+ npx @ludra/cli dev ${shown} play it in the browser`);
39
+ return 0;
40
+ }
41
+ function listFiles(dir) {
42
+ return readdirSync(dir).flatMap((entry) => {
43
+ const path = join(dir, entry);
44
+ return statSync(path).isDirectory() ? listFiles(path) : [path];
45
+ });
46
+ }
@@ -0,0 +1,12 @@
1
+ export interface PlayOptions {
2
+ inputs?: string;
3
+ duration?: string;
4
+ watch?: string;
5
+ every?: string;
6
+ ascii?: boolean;
7
+ columns?: string;
8
+ screenshot?: string;
9
+ out?: string;
10
+ json?: boolean;
11
+ }
12
+ export declare function play(dir: string, options: PlayOptions): Promise<number>;
@@ -0,0 +1,126 @@
1
+ import { defaultDuration, InputScript, parseDuration, renderAscii, secondsToFrames, simulate, World, } from "@ludra/core";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { join, relative, resolve } from "node:path";
4
+ import { fixed, pad, padStart, paint } from "../format.js";
5
+ import { captureScreenshots } from "../headless.js";
6
+ import { CliError, loadProject, requireScene } from "../project.js";
7
+ const MAX_EVENTS_SHOWN = 40;
8
+ export async function play(dir, options) {
9
+ const project = loadProject(dir);
10
+ const scene = requireScene(project);
11
+ const script = parse(() => InputScript.parse(options.inputs ?? ""));
12
+ const frames = secondsToFrames(options.duration ? parse(() => parseDuration(options.duration)) : defaultDuration(script));
13
+ const every = Math.max(1, secondsToFrames(parse(() => parseDuration(options.every ?? "0.25s"))));
14
+ const world = new World(scene);
15
+ const watch = options.watch
16
+ ? options.watch.split(",").map((id) => id.trim()).filter(Boolean)
17
+ : world.entities.filter((e) => e.platformer).map((e) => e.id);
18
+ for (const id of watch) {
19
+ if (!world.get(id)) {
20
+ throw new CliError(`--watch: no entity "${id}". Entities: ${world.entities.map((e) => e.id).join(", ")}.`);
21
+ }
22
+ }
23
+ const rows = [];
24
+ simulate(world, script, frames, (w) => {
25
+ if (w.frame % every !== 0 && w.frame !== frames)
26
+ return;
27
+ const snapshot = w.snapshot();
28
+ const input = w.frame === 0 ? [] : [...script.at(w.frame - 1)];
29
+ rows.push({ time: w.time, input, entities: Object.fromEntries(watch.map((id) => [id, snapshot.entities[id] ?? null])) });
30
+ });
31
+ const columns = Number(options.columns ?? 80);
32
+ if (!Number.isInteger(columns) || columns < 10)
33
+ throw new CliError(`--columns must be a whole number of at least 10, got "${options.columns}".`);
34
+ const ascii = options.ascii ? renderAscii(world, { columns }) : undefined;
35
+ const screenshots = options.screenshot ? await takeScreenshots(project, options, frames) : undefined;
36
+ if (options.json) {
37
+ console.log(JSON.stringify({ duration: frames / 60, frames, inputs: options.inputs ?? "", timeline: rows, events: world.events, final: world.snapshot(), ascii, screenshots }, null, 2));
38
+ return 0;
39
+ }
40
+ console.log(paint("bold", `▶ ${project.config.name}: simulated ${fixed(frames / 60, 2)}s (${frames} frames)`) +
41
+ paint("dim", ` inputs: ${options.inputs?.trim() || "none"}`));
42
+ if (watch.length > 0)
43
+ printTimeline(rows, watch);
44
+ printEvents(world.events);
45
+ const outcome = world.won ? paint("green", "level complete") : "level not complete";
46
+ console.log(`\n${paint("bold", "Final:")} ${outcome} · score ${world.score} · deaths ${world.deaths} · ${world.entities.length} entities`);
47
+ if (ascii)
48
+ console.log(`\n${ascii}`);
49
+ if (screenshots) {
50
+ console.log(`\n${paint("bold", "Screenshots")} ${paint("dim", "(open these PNGs to see the frames)")}`);
51
+ for (const shot of screenshots)
52
+ console.log(` ${pad(`${fixed(shot.time, 2)}s`, 7)} ${shot.file}`);
53
+ }
54
+ return 0;
55
+ }
56
+ /**
57
+ * Renders moments of the same run in a headless browser and writes them as
58
+ * PNGs. `--screenshot` is a comma list of times and/or "end".
59
+ */
60
+ async function takeScreenshots(project, options, lastFrame) {
61
+ const requested = options.screenshot.split(",").map((part) => {
62
+ const token = part.trim();
63
+ if (token === "end")
64
+ return lastFrame;
65
+ const frame = secondsToFrames(parse(() => parseDuration(token)));
66
+ if (frame > lastFrame) {
67
+ throw new CliError(`--screenshot ${token} is after the end of the run (${fixed(lastFrame / 60, 2)}s). Use a longer --duration.`);
68
+ }
69
+ return frame;
70
+ });
71
+ const frames = [...new Set(requested)].sort((a, b) => a - b);
72
+ const outDir = resolve(project.dir, options.out ?? "screenshots");
73
+ mkdirSync(outDir, { recursive: true });
74
+ const captured = await captureScreenshots(project, { inputs: options.inputs ?? "", frames });
75
+ return captured.map((shot) => {
76
+ const path = join(outDir, `t-${fixed(shot.time, 2)}s.png`);
77
+ writeFileSync(path, Buffer.from(shot.png.slice(shot.png.indexOf(",") + 1), "base64"));
78
+ return { time: shot.time, frame: shot.frame, file: relative(process.cwd(), path).replaceAll("\\", "/") };
79
+ });
80
+ }
81
+ function printTimeline(rows, watch) {
82
+ const idWidth = Math.max(6, ...watch.map((id) => id.length));
83
+ console.log(paint("dim", `\n ${pad("time", 7)} ${pad("input", 14)} ${pad("entity", idWidth)} ${padStart("x", 8)} ${padStart("y", 8)} ${padStart("vx", 8)} ${padStart("vy", 8)} grounded`));
84
+ for (const row of rows) {
85
+ watch.forEach((id, i) => {
86
+ const e = row.entities[id];
87
+ const time = i === 0 ? pad(`${fixed(row.time, 2)}s`, 7) : " ".repeat(7);
88
+ const input = i === 0 ? pad(row.input.join("+") || "-", 14) : " ".repeat(14);
89
+ const values = e
90
+ ? `${padStart(fixed(e.x), 8)} ${padStart(fixed(e.y), 8)} ${padStart(fixed(e.vx), 8)} ${padStart(fixed(e.vy), 8)} ${e.grounded ? "yes" : "no"}`
91
+ : paint("dim", "(removed)");
92
+ console.log(` ${time} ${input} ${pad(id, idWidth)} ${values}`);
93
+ });
94
+ }
95
+ }
96
+ function printEvents(events) {
97
+ console.log(`\n${paint("bold", "Events")}${events.length === 0 ? paint("dim", " (none)") : ""}`);
98
+ for (const event of events.slice(0, MAX_EVENTS_SHOWN)) {
99
+ console.log(` ${pad(`${fixed(event.time, 2)}s`, 7)} ${pad(event.type, 8)} ${describeEvent(event)}`);
100
+ }
101
+ if (events.length > MAX_EVENTS_SHOWN)
102
+ console.log(paint("dim", ` … ${events.length - MAX_EVENTS_SHOWN} more (use --json for all)`));
103
+ }
104
+ function describeEvent(event) {
105
+ switch (event.type) {
106
+ case "collect":
107
+ return `${event.entity} → ${event.item} (+${event.score})`;
108
+ case "stomp":
109
+ return `${event.entity} stomped ${event.enemy}${event.score ? ` (+${event.score})` : ""}`;
110
+ case "die":
111
+ return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}, respawned`;
112
+ case "win":
113
+ return `${event.entity} reached ${event.goal}`;
114
+ default:
115
+ return event.entity;
116
+ }
117
+ }
118
+ /** Turns engine parse errors into user-facing CLI errors. */
119
+ function parse(fn) {
120
+ try {
121
+ return fn();
122
+ }
123
+ catch (err) {
124
+ throw new CliError(err.message);
125
+ }
126
+ }
@@ -0,0 +1,3 @@
1
+ export declare function test(dir: string, options: {
2
+ json?: boolean;
3
+ }): number;
@@ -0,0 +1,55 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { runTest } from "@ludra/core";
4
+ import { paint } from "../format.js";
5
+ import { loadProject, requireScene } from "../project.js";
6
+ export function test(dir, options) {
7
+ const project = loadProject(dir);
8
+ const scene = requireScene(project);
9
+ const testsDir = join(project.dir, "tests");
10
+ const files = existsSync(testsDir) ? readdirSync(testsDir).filter((f) => f.endsWith(".test.json")).sort() : [];
11
+ if (files.length === 0) {
12
+ const message = 'No tests found. Add tests/<name>.test.json with { "tests": [{ "name": "...", "inputs": "right:1s", "assert": ["player.x > 300"] }] }.';
13
+ if (options.json)
14
+ console.log(JSON.stringify({ ok: false, error: message, files: [] }, null, 2));
15
+ else
16
+ console.log(paint("yellow", message));
17
+ return 1;
18
+ }
19
+ const results = files.map((file) => {
20
+ let cases;
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(join(testsDir, file), "utf8"));
23
+ cases = parsed.tests;
24
+ if (!Array.isArray(cases))
25
+ throw new Error('expected { "tests": [...] }');
26
+ }
27
+ catch (err) {
28
+ return { file, error: err.message, tests: [] };
29
+ }
30
+ return { file, tests: cases.map((c) => runTest(scene, c)) };
31
+ });
32
+ const all = results.flatMap((r) => r.tests);
33
+ const failed = all.filter((t) => !t.pass).length + results.filter((r) => r.error).length;
34
+ if (options.json) {
35
+ console.log(JSON.stringify({ ok: failed === 0, passed: all.length - failed, failed, files: results }, null, 2));
36
+ return failed > 0 ? 1 : 0;
37
+ }
38
+ for (const result of results) {
39
+ console.log(paint("bold", result.file));
40
+ if (result.error)
41
+ console.log(paint("red", ` ✗ could not read file: ${result.error}`));
42
+ for (const t of result.tests) {
43
+ console.log(t.pass ? paint("green", ` ✓ ${t.name}`) : paint("red", ` ✗ ${t.name}`));
44
+ if (t.error)
45
+ console.log(` ${t.error}`);
46
+ for (const r of t.results) {
47
+ if (!r.pass)
48
+ console.log(` ${paint("red", "✗")} ${r.source} ${paint("dim", "→")} ${r.message}`);
49
+ }
50
+ }
51
+ }
52
+ const summary = `\n${all.length - failed} passed, ${failed} failed`;
53
+ console.log(failed > 0 ? paint("red", summary) : paint("green", summary));
54
+ return failed > 0 ? 1 : 0;
55
+ }
@@ -0,0 +1,11 @@
1
+ import { styleText } from "node:util";
2
+ import type { Diagnostic } from "@ludra/core";
3
+ type Style = Parameters<typeof styleText>[0];
4
+ /** Colors only when writing to a terminal; agents reading piped output get plain text. */
5
+ export declare function paint(style: Style, text: string): string;
6
+ export declare function formatDiagnostic(d: Diagnostic, file: string): string;
7
+ export declare function formatDiagnostics(diagnostics: readonly Diagnostic[], file: string): string;
8
+ export declare function fixed(n: number, digits?: number): string;
9
+ export declare function pad(text: string, width: number): string;
10
+ export declare function padStart(text: string, width: number): string;
11
+ export {};
package/dist/format.js ADDED
@@ -0,0 +1,24 @@
1
+ import { styleText } from "node:util";
2
+ /** Colors only when writing to a terminal; agents reading piped output get plain text. */
3
+ export function paint(style, text) {
4
+ return styleText(style, text, { stream: process.stdout });
5
+ }
6
+ export function formatDiagnostic(d, file) {
7
+ const icon = d.severity === "error" ? paint("red", "✗ error") : paint("yellow", "! warning");
8
+ const lines = [`${icon} ${d.code} ${paint("dim", `${file} → ${d.path}`)}`, ` ${d.message}`];
9
+ if (d.fix)
10
+ lines.push(` ${paint("cyan", "fix:")} ${d.fix}`);
11
+ return lines.join("\n");
12
+ }
13
+ export function formatDiagnostics(diagnostics, file) {
14
+ return diagnostics.map((d) => formatDiagnostic(d, file)).join("\n\n");
15
+ }
16
+ export function fixed(n, digits = 1) {
17
+ return n.toFixed(digits);
18
+ }
19
+ export function pad(text, width) {
20
+ return text.length >= width ? text : text + " ".repeat(width - text.length);
21
+ }
22
+ export function padStart(text, width) {
23
+ return text.length >= width ? text : " ".repeat(width - text.length) + text;
24
+ }
@@ -0,0 +1,16 @@
1
+ import type { CapturedFrame } from "@ludra/core/browser";
2
+ import { type Project } from "./project.ts";
3
+ /**
4
+ * Finds an installed Chromium-based browser: LUDRA_BROWSER if set, otherwise
5
+ * Chrome, Edge or Chromium in their usual places.
6
+ */
7
+ export declare function findBrowser(): string | undefined;
8
+ export interface ScreenshotRequest {
9
+ inputs: string;
10
+ frames: number[];
11
+ }
12
+ /**
13
+ * Renders frames of a project in a headless browser, using the same renderer
14
+ * players see. Starts a private game server and browser, and stops both.
15
+ */
16
+ export declare function captureScreenshots(project: Project, request: ScreenshotRequest): Promise<CapturedFrame[]>;