@ludra/cli 0.7.0 → 0.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 (37) hide show
  1. package/dist/commands/check.d.ts +1 -0
  2. package/dist/commands/check.js +56 -17
  3. package/dist/commands/dev.js +7 -2
  4. package/dist/commands/play.d.ts +1 -0
  5. package/dist/commands/play.js +9 -3
  6. package/dist/commands/review.d.ts +22 -0
  7. package/dist/commands/review.js +183 -0
  8. package/dist/commands/test.d.ts +1 -0
  9. package/dist/commands/test.js +20 -5
  10. package/dist/headless.d.ts +2 -4
  11. package/dist/headless.js +18 -3
  12. package/dist/index.js +10 -2
  13. package/dist/project.d.ts +18 -5
  14. package/dist/project.js +59 -20
  15. package/dist/server.js +16 -9
  16. package/package.json +2 -2
  17. package/templates/breakout/CLAUDE.md +3 -2
  18. package/templates/breakout/package.json +1 -1
  19. package/templates/breakout/scenes/main.scene.json +7 -2
  20. package/templates/breakout/scripts/ball.ts +1 -2
  21. package/templates/breakout/scripts/rules.ts +24 -5
  22. package/templates/breakout/tests/gameplay.test.json +1 -1
  23. package/templates/platformer/CLAUDE.md +11 -7
  24. package/templates/platformer/assets/lantern.svg +9 -0
  25. package/templates/platformer/package.json +1 -1
  26. package/templates/platformer/prefabs/coin.prefab.json +8 -0
  27. package/templates/platformer/prefabs/slime.prefab.json +9 -0
  28. package/templates/platformer/scenes/level-2.scene.json +413 -0
  29. package/templates/platformer/scenes/main.scene.json +356 -59
  30. package/templates/platformer/scripts/backdrop.ts +75 -0
  31. package/templates/platformer/scripts/hud.ts +111 -0
  32. package/templates/platformer/scripts/lantern.ts +35 -0
  33. package/templates/platformer/shaders/glow.frag +29 -0
  34. package/templates/platformer/tests/gameplay.test.json +45 -7
  35. package/templates/platformer/tests/level-2.test.json +49 -0
  36. package/templates/topdown/CLAUDE.md +3 -2
  37. package/templates/topdown/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ /** Validates every scene in the project (entry scene first). */
1
2
  export declare function check(dir: string, options: {
2
3
  json?: boolean;
3
4
  }): Promise<number>;
@@ -1,26 +1,65 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
1
3
  import { checkDrawHooks } from "@ludra/core";
2
4
  import { formatDiagnostics, paint } from "../format.js";
3
- import { loadGame, loadProject } from "../project.js";
5
+ import { isInside, listScenes, loadGame, loadProject, scriptPaths } from "../project.js";
6
+ /** Validates every scene in the project (entry scene first). */
4
7
  export async function check(dir, options) {
5
8
  const project = loadProject(dir);
6
- const { game, diagnostics } = await loadGame(project);
7
- const scene = game?.scene;
8
- // Draw hooks only run in the browser, so paint once here to catch their errors early.
9
- if (game)
10
- diagnostics.push(...checkDrawHooks(game.scene, game.scripts));
11
- const errors = diagnostics.filter((d) => d.severity === "error").length;
12
- const warnings = diagnostics.length - errors;
9
+ const results = [];
10
+ const scripts = new Set();
11
+ for (const scene of listScenes(project)) {
12
+ const { game, diagnostics, label } = await loadGame(project, scene);
13
+ // Draw hooks only run in the browser, so paint once here to catch their errors early.
14
+ if (game) {
15
+ diagnostics.push(...checkDrawHooks(game.scene, game.scripts));
16
+ for (const path of scriptPaths(project, game.scene))
17
+ scripts.add(path);
18
+ }
19
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
20
+ results.push({ file: label, entities: game?.scene.entities.length ?? 0, errors, warnings: diagnostics.length - errors, diagnostics });
21
+ }
22
+ // world.goto("...") in scripts must point at real scenes.
23
+ const gotoProblems = missingGotoScenes(project, [...scripts]);
24
+ if (gotoProblems.length > 0) {
25
+ results.push({ file: "scripts", entities: 0, errors: gotoProblems.length, warnings: 0, diagnostics: gotoProblems });
26
+ }
27
+ const errors = results.reduce((n, r) => n + r.errors, 0);
28
+ const warnings = results.reduce((n, r) => n + r.warnings, 0);
13
29
  if (options.json) {
14
- console.log(JSON.stringify({ ok: errors === 0, file: project.entryLabel, errors, warnings, diagnostics }, null, 2));
30
+ const diagnostics = results.flatMap((r) => r.diagnostics.map((d) => ({ file: r.file, ...d })));
31
+ console.log(JSON.stringify({ ok: errors === 0, file: project.entryLabel, errors, warnings, diagnostics, scenes: results }, null, 2));
15
32
  return errors > 0 ? 1 : 0;
16
33
  }
17
- if (diagnostics.length > 0)
18
- console.log(`${formatDiagnostics(diagnostics, project.entryLabel)}\n`);
19
- if (errors > 0) {
20
- console.log(paint("red", `✗ ${project.entryLabel}: ${errors} error(s), ${warnings} warning(s)`));
21
- return 1;
34
+ for (const r of results) {
35
+ if (r.diagnostics.length > 0)
36
+ console.log(`${formatDiagnostics(r.diagnostics, r.file)}\n`);
37
+ if (r.errors > 0)
38
+ console.log(paint("red", `✗ ${r.file}: ${r.errors} error(s), ${r.warnings} warning(s)`));
39
+ else
40
+ console.log(paint("green", `✓ ${r.file}: ${r.entities} entities, ${r.warnings === 0 ? "no problems" : `${r.warnings} warning(s)`}`));
41
+ }
42
+ return errors > 0 ? 1 : 0;
43
+ }
44
+ /** Scene paths written literally in world.goto("...") calls that don't exist. */
45
+ function missingGotoScenes(project, scripts) {
46
+ const problems = [];
47
+ for (const src of scripts) {
48
+ const file = resolve(project.dir, src);
49
+ if (!existsSync(file))
50
+ continue;
51
+ for (const match of readFileSync(file, "utf8").matchAll(/\bgoto\(\s*["'`]([^"'`$]+)["'`]\s*\)/g)) {
52
+ const target = resolve(project.dir, match[1]);
53
+ if (isInside(project.dir, target) && existsSync(target))
54
+ continue;
55
+ problems.push({
56
+ severity: "error",
57
+ code: "E_SCENE_MISSING",
58
+ path: `${src} → goto("${match[1]}")`,
59
+ message: `${src} goes to "${match[1]}", which does not exist.`,
60
+ fix: `Scenes: ${listScenes(project).join(", ")}. Paths are relative to the folder with ludra.json.`,
61
+ });
62
+ }
22
63
  }
23
- const count = scene?.entities.length ?? 0;
24
- console.log(paint("green", `✓ ${project.entryLabel}: ${count} entities, ${warnings === 0 ? "no problems" : `${warnings} warning(s)`}`));
25
- return 0;
64
+ return problems;
26
65
  }
@@ -15,7 +15,7 @@ export function dev(dir, options) {
15
15
  server.listen(port, "127.0.0.1", () => {
16
16
  console.log(paint("green", `▶ ${project.config.name} is running at http://localhost:${port}`));
17
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.`));
18
+ console.log(paint("dim", ` Editing a scene, prefab, script or asset reloads the page. Ctrl+C to stop.`));
19
19
  });
20
20
  return 0;
21
21
  }
@@ -44,7 +44,12 @@ import { startGame } from "${browserModule}";
44
44
  const data = await (await fetch("/__ludra/scene")).json();
45
45
  if (data.ok) {
46
46
  const scriptPaths = await (await fetch("/__ludra/scripts")).json();
47
- await startGame(document.getElementById("game"), data.scene, { assetBase: "${PROJECT_PREFIX}", scriptBase: "${SCRIPT_PREFIX}", scriptPaths });
47
+ const loadScene = async (path) => {
48
+ const next = await (await fetch("/__ludra/scene?path=" + encodeURIComponent(path))).json();
49
+ if (!next.ok) throw new Error(next.diagnostics.map((d) => d.code + " at " + d.path + ": " + d.message).join(" "));
50
+ return next.scene;
51
+ };
52
+ await startGame(document.getElementById("game"), data.scene, { assetBase: "${PROJECT_PREFIX}", scriptBase: "${SCRIPT_PREFIX}", scriptPaths, loadScene });
48
53
  } else {
49
54
  document.getElementById("game").hidden = true;
50
55
  const errors = document.getElementById("errors");
@@ -1,4 +1,5 @@
1
1
  export interface PlayOptions {
2
+ scene?: string;
2
3
  inputs?: string;
3
4
  duration?: string;
4
5
  watch?: string;
@@ -7,7 +7,7 @@ import { CliError, loadProject, requireGame } from "../project.js";
7
7
  const MAX_EVENTS_SHOWN = 40;
8
8
  export async function play(dir, options) {
9
9
  const project = loadProject(dir);
10
- const { scene, scripts } = await requireGame(project);
10
+ const { scene, scripts } = await requireGame(project, options.scene);
11
11
  const script = parse(() => InputScript.parse(options.inputs ?? ""));
12
12
  const frames = secondsToFrames(options.duration ? parse(() => parseDuration(options.duration)) : defaultDuration(script));
13
13
  const every = Math.max(1, secondsToFrames(parse(() => parseDuration(options.every ?? "0.25s"))));
@@ -71,7 +71,7 @@ async function takeScreenshots(project, options, lastFrame) {
71
71
  const frames = [...new Set(requested)].sort((a, b) => a - b);
72
72
  const outDir = resolve(project.dir, options.out ?? "screenshots");
73
73
  mkdirSync(outDir, { recursive: true });
74
- const captured = await captureScreenshots(project, { inputs: options.inputs ?? "", frames });
74
+ const captured = await captureScreenshots(project, { scene: options.scene, inputs: options.inputs ?? "", frames });
75
75
  return captured.map((shot) => {
76
76
  const path = join(outDir, `t-${fixed(shot.time, 2)}s.png`);
77
77
  writeFileSync(path, Buffer.from(shot.png.slice(shot.png.indexOf(",") + 1), "base64"));
@@ -109,11 +109,17 @@ function describeEvent(event) {
109
109
  case "stomp":
110
110
  return `${event.entity} stomped ${event.enemy}${event.score ? ` (+${event.score})` : ""}`;
111
111
  case "die":
112
- return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}, respawned`;
112
+ return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}`;
113
113
  case "win":
114
114
  return event.entity === event.goal ? `${event.entity} (script) completed the level` : `${event.entity} reached ${event.goal}`;
115
115
  case "bounce":
116
116
  return `${event.entity} bounced off ${event.other}`;
117
+ case "checkpoint":
118
+ return `${event.entity} reached ${event.checkpoint} (respawns there now)`;
119
+ case "gameover":
120
+ return event.cause === "script" ? `game over (from ${event.entity}'s script)` : `${event.entity} is out of lives: game over`;
121
+ case "goto":
122
+ return `${event.entity} → ${event.scene}`;
117
123
  case "custom":
118
124
  return `from ${event.entity}'s script${event.data === undefined ? "" : ` ${JSON.stringify(event.data)}`}`;
119
125
  default:
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `ludra review`: the signs of a slop game, found automatically. Where `check`
3
+ * asks "is it valid?" and `test` asks "does it work?", review asks "is it
4
+ * finished?": proven, felt, styled and named. Issues should be fixed before a
5
+ * game is called done; ideas are worth considering.
6
+ */
7
+ type Area = "proof" | "feel" | "looks" | "identity";
8
+ type Level = "issue" | "idea";
9
+ export interface Finding {
10
+ level: Level;
11
+ area: Area;
12
+ code: string;
13
+ /** The scene or file it is about. */
14
+ where: string;
15
+ message: string;
16
+ fix: string;
17
+ }
18
+ export declare function review(dir: string, options: {
19
+ json?: boolean;
20
+ strict?: boolean;
21
+ }): Promise<number>;
22
+ export {};
@@ -0,0 +1,183 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { runTest } from "@ludra/core";
4
+ import { paint } from "../format.js";
5
+ import { listScenes, loadGame, loadProject } from "../project.js";
6
+ const AREAS = {
7
+ proof: "Proof: is it shown to work?",
8
+ feel: "Feel: does every moment answer the player?",
9
+ looks: "Looks: does it have a style of its own?",
10
+ identity: "Identity: is it a game, or a project folder?",
11
+ };
12
+ /** Events players cause or feel directly: silence on these reads as unfinished. */
13
+ const IMPORTANT_EVENTS = new Set(["collect", "die", "stomp", "win", "gameover", "checkpoint", "jump"]);
14
+ const LISTED = 5;
15
+ export async function review(dir, options) {
16
+ const project = loadProject(dir);
17
+ const findings = [];
18
+ const add = (level, area, code, where, message, fix) => findings.push({ level, area, code, where, message, fix });
19
+ // Load every scene; review only makes sense for a game that passes check.
20
+ const scenes = [];
21
+ for (const label of listScenes(project)) {
22
+ const { game, diagnostics } = await loadGame(project, label);
23
+ if (!game) {
24
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
25
+ add("issue", "proof", "R_CHECK_FAILS", label, `The scene has ${errors} error(s), so it can't be reviewed.`, "Run `ludra check` and fix them first.");
26
+ continue;
27
+ }
28
+ scenes.push({ label, game, results: [] });
29
+ }
30
+ const tests = readTests(project);
31
+ for (const problem of tests.problems)
32
+ add("issue", "proof", "R_TESTS_UNREADABLE", problem.file, problem.message, "Fix the JSON in that test file.");
33
+ for (const c of tests.cases) {
34
+ const scene = scenes.find((s) => s.label === (c.scene ?? project.entryLabel));
35
+ if (scene)
36
+ scene.results.push(runTest(scene.game.scene, c, { scripts: scene.game.scripts }));
37
+ }
38
+ if (tests.cases.length === 0) {
39
+ add("issue", "proof", "R_NO_TESTS", "tests/", "There are no tests, so nothing shows the game works.", 'Add tests/gameplay.test.json, starting with one that proves a level can be won: "won == true", "deaths == 0".');
40
+ }
41
+ const failing = scenes.flatMap((s) => s.results.filter((r) => !r.pass).map((r) => `"${r.name}"`));
42
+ if (failing.length > 0) {
43
+ add("issue", "proof", "R_FAILING_TESTS", "tests/", `${failing.length} test(s) fail: ${list(failing)}.`, "Run `ludra test` and fix the game (not the test) until they pass.");
44
+ }
45
+ for (const scene of scenes) {
46
+ reviewScene(project, scene, tests.cases.length > 0, add);
47
+ }
48
+ // Identity.
49
+ const folder = basename(project.dir);
50
+ if (project.config.name === "untitled" || project.config.name === folder) {
51
+ add("idea", "identity", "R_FOLDER_NAME", "ludra.json", `The game is called "${project.config.name}", the name of its folder.`, 'Give it a real title in ludra.json ("name"), the one players will see.');
52
+ }
53
+ const issues = findings.filter((f) => f.level === "issue").length;
54
+ const ideas = findings.length - issues;
55
+ if (options.json) {
56
+ console.log(JSON.stringify({ ok: issues === 0, issues, ideas, findings }, null, 2));
57
+ }
58
+ else {
59
+ print(project, findings, issues, ideas);
60
+ }
61
+ return options.strict && issues > 0 ? 1 : 0;
62
+ }
63
+ function reviewScene(project, info, hasTests, add) {
64
+ const { label, game, results } = info;
65
+ const { scene, scripts } = game;
66
+ const sources = scriptSources(project, scene);
67
+ const hooksOf = (src) => Object.keys(scripts.get(src) ?? {});
68
+ const uses = (hook) => scene.entities.some((e) => e.components.script && hooksOf(String(e.components.script.src)).includes(hook));
69
+ // ---------------------------------------------------------------- proof
70
+ const canWin = scene.entities.some((e) => e.components.goal) || [...sources.values()].some((code) => /\bwin\(\s*\)/.test(code));
71
+ const proves = (r, ...needed) => r.pass && needed.every((re) => r.results.some((a) => re.test(a.source.replace(/\s+/g, " "))));
72
+ if (canWin && hasTests && !results.some((r) => proves(r, /^won == true\b/, /^deaths == 0\b/))) {
73
+ add("issue", "proof", "R_NO_WIN_PROOF", label, "No passing test proves this scene can be won without dying.", 'Add a test with a full route and "won == true", "deaths == 0". For long routes, write a small autopilot (see the skill).');
74
+ }
75
+ // A mechanic is tested when an assertion names one of its entities or the events it emits.
76
+ const asserted = results.flatMap((r) => r.results.map((a) => a.source)).join("\n");
77
+ for (const [src, code] of sources) {
78
+ const hooks = hooksOf(src);
79
+ if (!hooks.some((h) => h === "start" || h === "update" || h === "touch"))
80
+ continue; // only draws
81
+ const ids = scene.entities.filter((e) => e.components.script?.src === src).map((e) => e.id);
82
+ const emitted = [...code.matchAll(/\bemit\(\s*["'`]([^"'`]+)["'`]/g)].map((m) => m[1]);
83
+ const mentioned = ids.some((id) => asserted.includes(`${id}.`)) || emitted.some((name) => asserted.includes(`events.${name}`));
84
+ if (hasTests && !mentioned) {
85
+ add("issue", "proof", "R_UNTESTED_MECHANIC", label, `${src} (on ${list(ids)}) has no test that checks what it does.`, `Add a test that isolates it and asserts its effect: ${ids[0] ? `"${ids[0]}.state.<key> == ..."` : ""}${emitted[0] ? ` or "events.${emitted[0]} >= 1"` : ""}.`);
86
+ }
87
+ }
88
+ // ---------------------------------------------------------------- feel
89
+ const fired = new Set(results.flatMap((r) => r.events));
90
+ const drawn = [...sources.values()].flatMap((code) => [...code.matchAll(/\brecent\(\s*["'`]([^"'`]+)["'`]/g)].map((m) => m[1]));
91
+ const silent = [...fired].filter((name) => name !== "goto" && !scene.effects?.[name] && !drawn.includes(name));
92
+ const important = silent.filter((name) => IMPORTANT_EVENTS.has(name));
93
+ if (important.length > 0) {
94
+ add("issue", "feel", "R_SILENT_EVENT", label, `These events happen in play but nothing answers them: ${list(important)}.`, 'Give each an "effects" entry (particles in the source\'s color, a sound, shake for big moments) or react to it in a draw hook with ctx.recent("<name>").');
95
+ }
96
+ const minor = silent.filter((name) => !IMPORTANT_EVENTS.has(name));
97
+ if (minor.length > 0) {
98
+ add("idea", "feel", "R_QUIET_EVENT", label, `These events have no feedback yet: ${list(minor)}.`, "Small touches (a puff on land, a blip on bounce, a flash for custom events) make a game feel alive.");
99
+ }
100
+ const mute = Object.entries(scene.effects ?? {}).filter(([name, effect]) => IMPORTANT_EVENTS.has(name) && effect && !effect.sound).map(([name]) => name);
101
+ if (mute.length > 0) {
102
+ add("idea", "feel", "R_NO_SOUND", label, `These effects have no sound: ${list(mute)}.`, "Add a preset (jump, coin, hit, explosion, powerup, blip, thud) or an audio file.");
103
+ }
104
+ // ---------------------------------------------------------------- looks
105
+ if (!uses("drawUI")) {
106
+ add("issue", "looks", "R_DEFAULT_HUD", label, "It uses the engine's default HUD and end screens.", "Write a drawUI hook: show what matters in this game (lives as hearts, crystals as icons, a meter), a title card and a win screen, in a font that fits the mood.");
107
+ }
108
+ else if (!scene.fonts || Object.keys(scene.fonts).length === 0) {
109
+ add("idea", "looks", "R_SYSTEM_FONT", label, "The interface uses the system font.", 'Load a display font that fits the mood: "fonts": { "<Family>": "google" }.');
110
+ }
111
+ const hasBackdrop = scene.entities.some((e) => (e.components.sprite && Number(e.components.sprite.layer ?? 0) < 0) || (!e.components.body && e.components.script && hooksOf(String(e.components.script.src)).includes("draw")));
112
+ if (!hasBackdrop) {
113
+ add("issue", "looks", "R_FLAT_BACKDROP", label, "Behind the game there is only a flat background color.", "Paint a backdrop: a sprite on layer -1 (sky, hills, floor tiles) or an entity with only a draw hook (gradients, a sun, stars, parallax).");
114
+ }
115
+ const plain = scene.entities
116
+ .filter((e) => e.components.sprite && !e.components.sprite.image && !(e.components.script && hooksOf(String(e.components.script.src)).includes("draw")))
117
+ .map((e) => e.id);
118
+ if (plain.length > 0) {
119
+ add("issue", "looks", "R_PLAIN_SHAPES", label, `${plain.length} entit${plain.length === 1 ? "y is" : "ies are"} drawn as flat colored shapes: ${list(plain)}.`, "Give them SVG art (sprite.image) or a draw hook.");
120
+ }
121
+ if (!scene.postfx) {
122
+ add("idea", "looks", "R_NO_POSTFX", label, "No post-processing.", 'A finishing shader ("postfx") ties a look together: bloom for anything that glows, a grade for mood, scanlines for arcade.');
123
+ }
124
+ if (!scene.name) {
125
+ add("idea", "identity", "R_UNNAMED_SCENE", label, "The scene has no name.", 'Add "name" (e.g. "Crystal Grove") to the scene.');
126
+ }
127
+ }
128
+ /** Source code of the scripts a scene uses, by path. */
129
+ function scriptSources(project, scene) {
130
+ const sources = new Map();
131
+ for (const e of scene.entities) {
132
+ const src = e.components.script?.src;
133
+ if (typeof src !== "string" || sources.has(src))
134
+ continue;
135
+ const file = resolve(project.dir, src);
136
+ if (existsSync(file))
137
+ sources.set(src, readFileSync(file, "utf8"));
138
+ }
139
+ return sources;
140
+ }
141
+ function readTests(project) {
142
+ const dir = join(project.dir, "tests");
143
+ const cases = [];
144
+ const problems = [];
145
+ if (!existsSync(dir))
146
+ return { cases, problems };
147
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".test.json")).sort()) {
148
+ try {
149
+ const parsed = JSON.parse(readFileSync(join(dir, file), "utf8"));
150
+ if (!Array.isArray(parsed.tests))
151
+ throw new Error('expected { "tests": [...] }');
152
+ cases.push(...parsed.tests);
153
+ }
154
+ catch (err) {
155
+ problems.push({ file: `tests/${file}`, message: `Could not read it: ${err.message}` });
156
+ }
157
+ }
158
+ return { cases, problems };
159
+ }
160
+ function list(items) {
161
+ return items.length <= LISTED ? items.join(", ") : `${items.slice(0, LISTED).join(", ")} and ${items.length - LISTED} more`;
162
+ }
163
+ function print(project, findings, issues, ideas) {
164
+ console.log(paint("bold", `Review of ${project.config.name}`));
165
+ for (const area of Object.keys(AREAS)) {
166
+ const inArea = findings.filter((f) => f.area === area).sort((a, b) => (a.level === b.level ? 0 : a.level === "issue" ? -1 : 1));
167
+ console.log(`\n${paint("bold", AREAS[area])}`);
168
+ if (inArea.length === 0) {
169
+ console.log(paint("green", " ✓ nothing to flag"));
170
+ continue;
171
+ }
172
+ for (const f of inArea) {
173
+ const mark = f.level === "issue" ? paint("red", "✗") : paint("yellow", "○");
174
+ console.log(` ${mark} ${paint("dim", `[${f.where}]`)} ${f.message}`);
175
+ console.log(` ${paint("dim", "→")} ${f.fix}`);
176
+ }
177
+ }
178
+ const summary = `\n${issues} issue(s), ${ideas} idea(s).`;
179
+ if (issues === 0)
180
+ console.log(paint("green", `${summary} Nothing marks this as unfinished.${ideas > 0 ? " The ideas are worth a look." : ""}`));
181
+ else
182
+ console.log(paint("red", `${summary} Fix the issues before calling the game done.`));
183
+ }
@@ -1,3 +1,4 @@
1
+ /** Runs every tests/*.test.json case against its scene (the entry scene unless the case names one). */
1
2
  export declare function test(dir: string, options: {
2
3
  json?: boolean;
3
4
  }): Promise<number>;
@@ -3,9 +3,17 @@ import { join } from "node:path";
3
3
  import { runTest } from "@ludra/core";
4
4
  import { paint } from "../format.js";
5
5
  import { loadProject, requireGame } from "../project.js";
6
+ /** Runs every tests/*.test.json case against its scene (the entry scene unless the case names one). */
6
7
  export async function test(dir, options) {
7
8
  const project = loadProject(dir);
8
- const { scene, scripts } = await requireGame(project);
9
+ // Each test runs its own scene ("scene" in the test case), loaded once.
10
+ const games = new Map([[project.entryLabel, await requireGame(project)]]);
11
+ const gameFor = async (scene) => {
12
+ const key = scene ?? project.entryLabel;
13
+ if (!games.has(key))
14
+ games.set(key, await requireGame(project, key));
15
+ return games.get(key);
16
+ };
9
17
  const testsDir = join(project.dir, "tests");
10
18
  const files = existsSync(testsDir) ? readdirSync(testsDir).filter((f) => f.endsWith(".test.json")).sort() : [];
11
19
  if (files.length === 0) {
@@ -16,7 +24,8 @@ export async function test(dir, options) {
16
24
  console.log(paint("yellow", message));
17
25
  return 1;
18
26
  }
19
- const results = files.map((file) => {
27
+ const results = [];
28
+ for (const file of files) {
20
29
  let cases;
21
30
  try {
22
31
  const parsed = JSON.parse(readFileSync(join(testsDir, file), "utf8"));
@@ -25,10 +34,16 @@ export async function test(dir, options) {
25
34
  throw new Error('expected { "tests": [...] }');
26
35
  }
27
36
  catch (err) {
28
- return { file, error: err.message, tests: [] };
37
+ results.push({ file, error: err.message, tests: [] });
38
+ continue;
29
39
  }
30
- return { file, tests: cases.map((c) => runTest(scene, c, { scripts })) };
31
- });
40
+ const tests = [];
41
+ for (const c of cases) {
42
+ const { scene, scripts } = await gameFor(c.scene);
43
+ tests.push(runTest(scene, c, { scripts }));
44
+ }
45
+ results.push({ file, tests });
46
+ }
32
47
  const all = results.flatMap((r) => r.tests);
33
48
  const failed = all.filter((t) => !t.pass).length + results.filter((r) => r.error).length;
34
49
  if (options.json) {
@@ -6,11 +6,9 @@ import { type Project } from "./project.ts";
6
6
  */
7
7
  export declare function findBrowser(): string | undefined;
8
8
  export interface ScreenshotRequest {
9
+ /** Scene to render, relative to the project. Defaults to the entry scene. */
10
+ scene?: string;
9
11
  inputs: string;
10
12
  frames: number[];
11
13
  }
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
14
  export declare function captureScreenshots(project: Project, request: ScreenshotRequest): Promise<CapturedFrame[]>;
package/dist/headless.js CHANGED
@@ -37,6 +37,9 @@ export function findBrowser() {
37
37
  * Renders frames of a project in a headless browser, using the same renderer
38
38
  * players see. Starts a private game server and browser, and stops both.
39
39
  */
40
+ function sceneUrl(scene) {
41
+ return scene ? `/__ludra/scene?path=${encodeURIComponent(scene)}` : "/__ludra/scene";
42
+ }
40
43
  export async function captureScreenshots(project, request) {
41
44
  const browserPath = findBrowser();
42
45
  if (!browserPath) {
@@ -73,7 +76,7 @@ export async function captureScreenshots(project, request) {
73
76
  await waitFor(cdp, sessionId, `location.origin === ${JSON.stringify(origin)} && document.readyState === "complete"`);
74
77
  const script = `(async () => {
75
78
  const { captureFrames } = await import(${JSON.stringify(browserModule)});
76
- const data = await (await fetch("/__ludra/scene")).json();
79
+ const data = await (await fetch(${JSON.stringify(sceneUrl(request.scene))})).json();
77
80
  if (!data.ok) throw new Error("The scene has errors. Run ludra check.");
78
81
  const canvas = document.createElement("canvas");
79
82
  canvas.width = ${width};
@@ -105,8 +108,20 @@ export async function captureScreenshots(project, request) {
105
108
  });
106
109
  }
107
110
  server.close();
108
- // The browser can hold its profile for a moment after exiting on Windows.
109
- rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
111
+ removeProfile(profile);
112
+ }
113
+ }
114
+ /**
115
+ * The browser can keep its profile busy for a moment after exiting: files held open
116
+ * on Windows, helper processes still writing on macOS (ENOTEMPTY). Retry, and if it
117
+ * still won't go, leave it to the OS's temp cleanup rather than fail a capture that worked.
118
+ */
119
+ function removeProfile(profile) {
120
+ try {
121
+ rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 });
122
+ }
123
+ catch (err) {
124
+ console.warn(`Could not remove the temporary browser profile ${profile}: ${err.message}`);
110
125
  }
111
126
  }
112
127
  /** Chrome prints "DevTools listening on ws://..." to stderr once it is ready. */
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { dev } from "./commands/dev.js";
7
7
  import { create } from "./commands/new.js";
8
8
  import { play } from "./commands/play.js";
9
9
  import { test } from "./commands/test.js";
10
+ import { review } from "./commands/review.js";
10
11
  import { paint } from "./format.js";
11
12
  import { CliError } from "./project.js";
12
13
  // Ludra strips types from game scripts with Node's built-in stripTypeScriptTypes, which warns that it is
@@ -21,8 +22,9 @@ const HELP = `ludra: the game engine built for AI agents
21
22
 
22
23
  Usage
23
24
  ludra new <folder> [--template <name>] Create a game project (templates: platformer, topdown, breakout)
24
- ludra check [folder] [--json] Validate the scene; every problem comes with a fix
25
+ ludra check [folder] [--json] Validate every scene; every problem comes with a fix
25
26
  ludra play [folder] [options] Simulate the game headlessly
27
+ --scene scenes/level-2.scene.json Scene to play (default: the entry scene in ludra.json)
26
28
  --inputs "right:1s, jump:0.4s" Scripted input. Actions: left right up down jump action wait
27
29
  --duration 3s Run length (default: inputs + 1s)
28
30
  --watch player,enemy Entities in the timeline (default: the players)
@@ -32,7 +34,9 @@ Usage
32
34
  --screenshot 1s,2.5s,end Save real rendered frames as PNGs (needs Chrome, Edge or Chromium)
33
35
  --out screenshots Folder for screenshots, relative to the project (default: screenshots)
34
36
  --json Machine-readable output
35
- ludra test [folder] [--json] Run tests/*.test.json
37
+ ludra test [folder] [--json] Run tests/*.test.json (a test can pick its "scene")
38
+ ludra review [folder] [--json] [--strict] Find what makes a game feel unfinished: untested mechanics,
39
+ silent events, default HUD, flat looks (--strict: exit 1 on issues)
36
40
  ludra components [name] [--json] Reference for every component and field
37
41
  ludra blocks [--json] Catalog of ready-made gameplay blocks
38
42
  ludra add <block> [folder] --at x,y Add a block: entities, scripts, art and effects
@@ -46,6 +50,7 @@ async function main(argv) {
46
50
  args: argv,
47
51
  allowPositionals: true,
48
52
  options: {
53
+ scene: { type: "string" },
49
54
  inputs: { type: "string" },
50
55
  duration: { type: "string" },
51
56
  watch: { type: "string" },
@@ -60,6 +65,7 @@ async function main(argv) {
60
65
  at: { type: "string" },
61
66
  id: { type: "string" },
62
67
  force: { type: "boolean" },
68
+ strict: { type: "boolean" },
63
69
  help: { type: "boolean", short: "h" },
64
70
  },
65
71
  });
@@ -77,6 +83,8 @@ async function main(argv) {
77
83
  return play(target ?? ".", values);
78
84
  case "test":
79
85
  return test(target ?? ".", values);
86
+ case "review":
87
+ return review(target ?? ".", values);
80
88
  case "components":
81
89
  return components(target, values);
82
90
  case "dev":
package/dist/project.d.ts CHANGED
@@ -21,9 +21,21 @@ export declare function loadProject(dir: string): Project;
21
21
  export interface SceneLoad {
22
22
  scene?: SceneDef;
23
23
  diagnostics: Diagnostic[];
24
+ /** The scene's path relative to the project, for messages. */
25
+ label: string;
24
26
  }
25
- /** Reads and validates the entry scene. JSON syntax errors become diagnostics too. */
26
- export declare function readScene(project: Project): SceneLoad;
27
+ /** Every scene in the project: the entry scene, then every .scene.json file under scenes/. */
28
+ export declare function listScenes(project: Project): string[];
29
+ /** Prefab files (prefabs/<name>.prefab.json) by name, plus problems reading them. */
30
+ export declare function readPrefabs(project: Project): {
31
+ prefabs: Record<string, unknown>;
32
+ diagnostics: Diagnostic[];
33
+ };
34
+ /**
35
+ * Reads and validates a scene (the entry scene by default), with the project's
36
+ * prefab files merged in. JSON syntax errors become diagnostics too.
37
+ */
38
+ export declare function readScene(project: Project, scenePath?: string): SceneLoad;
27
39
  /** Script files a game can use: those the scene references, plus everything in scripts/ (for world.spawn). */
28
40
  export declare function scriptPaths(project: Project, scene: SceneDef): string[];
29
41
  export interface Game {
@@ -34,12 +46,13 @@ export interface Game {
34
46
  * Reads the scene and imports its scripts. Node runs TypeScript scripts
35
47
  * directly (type stripping), so no build step is needed.
36
48
  */
37
- export declare function loadGame(project: Project): Promise<{
49
+ export declare function loadGame(project: Project, scenePath?: string): Promise<{
38
50
  game?: Game;
39
51
  diagnostics: Diagnostic[];
52
+ label: string;
40
53
  }>;
41
54
  /** Like `loadGame`, but stops with the diagnostics when anything is wrong. */
42
- export declare function requireGame(project: Project): Promise<Game>;
55
+ export declare function requireGame(project: Project, scenePath?: string): Promise<Game>;
43
56
  export declare function isInside(dir: string, path: string): boolean;
44
57
  /** Like `readScene`, but stops with the diagnostics when the scene has errors. */
45
- export declare function requireScene(project: Project): SceneDef;
58
+ export declare function requireScene(project: Project, scenePath?: string): SceneDef;