incanto 0.45.1 → 0.47.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 (38) hide show
  1. package/bin/incanto-check.mjs +5 -1
  2. package/bin/incanto-feel.mjs +5 -1
  3. package/bin/incanto-frame.mjs +57 -6
  4. package/bin/incanto-model.mjs +79 -10
  5. package/bin/incanto-playtest.mjs +5 -1
  6. package/bin/incanto-verify.mjs +214 -0
  7. package/dist/2d.d.ts +1 -1
  8. package/dist/2d.js +1 -1
  9. package/dist/3d.d.ts +90 -5
  10. package/dist/3d.js +92 -5
  11. package/dist/{create-game-DFRbG0Bj.js → create-game-CniOiWzN.js} +1 -1
  12. package/dist/{create-game-Gifrjhnz.js → create-game-D16MVIPO.js} +24 -5
  13. package/dist/{editor-switch-DVwIGZdK.d.ts → editor-switch-DAvWQeld.d.ts} +1 -1
  14. package/dist/editor.js +4674 -4346
  15. package/dist/{environment-presets-DUsBqvtk.js → environment-presets-D1b0ydTS.js} +5 -4
  16. package/dist/{frame-report-Ct8XgsmV.d.ts → frame-report-DZ70IY26.d.ts} +25 -0
  17. package/dist/{frame-report-Lr3VO24R.js → frame-report-njybhZon.js} +130 -3
  18. package/dist/index.js +1 -1
  19. package/dist/{physics-3d-DFqMtBUD.js → physics-3d-CSoGjM8P.js} +1 -1
  20. package/dist/react.js +1 -1
  21. package/dist/{src-DUxGnvq3.js → src-Ca3oV1fe.js} +1 -1
  22. package/dist/{teardown-yePMOE1K.js → teardown-BKTCzLek.js} +3 -2
  23. package/dist/{test-DD5scWfm.js → test-E4-otKqK.js} +54 -7
  24. package/dist/test.d.ts +55 -1
  25. package/dist/test.js +2 -2
  26. package/dist/vite.d.ts +34 -3
  27. package/dist/vite.js +104 -12
  28. package/editor/assets/{agent8-B25qJ5Wo.js → agent8-_007gPF8.js} +1 -1
  29. package/editor/assets/{debug-CUsz-pT8.js → debug-0DI_MJaq.js} +1 -1
  30. package/editor/assets/{index-3u3GVteP.js → index-B-6eYZEi.js} +173 -96
  31. package/editor/index.html +1 -1
  32. package/package.json +3 -2
  33. package/skills/incanto-3d-models.md +30 -0
  34. package/skills/incanto-editor.md +6 -1
  35. package/skills/incanto-verifying-your-game.md +60 -1
  36. package/templates-app/beacon-isle-3d/package.json +1 -1
  37. package/templates-app/tps-3d/package.json +1 -1
  38. package/templates-app/village-quest-3d/package.json +1 -1
@@ -106,4 +106,8 @@ if (args.json) {
106
106
  }
107
107
  console.log(`\n${results.length - failed.length}/${results.length} scene(s) valid`);
108
108
  }
109
- process.exit(failed.length === 0 ? 0 : 1);
109
+ // `exitCode`, never `process.exit()`: stdout to a PIPE is written
110
+ // asynchronously, and exiting discards whatever has not flushed. A --json
111
+ // report read by another program came back truncated — silently, and only
112
+ // when piped, which is the only way a program reads it.
113
+ process.exitCode = failed.length === 0 ? 0 : 1;
@@ -109,4 +109,8 @@ if (!args.json) {
109
109
  }
110
110
  }
111
111
 
112
- process.exit(report.player ? 0 : 1);
112
+ // `exitCode`, never `process.exit()`: stdout to a PIPE is written
113
+ // asynchronously, and exiting discards whatever has not flushed. A --json
114
+ // report read by another program came back truncated — silently, and only
115
+ // when piped, which is the only way a program reads it.
116
+ process.exitCode = report.player ? 0 : 1;
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
3
  /**
4
4
  * incanto-frame — what is actually on screen, as numbers.
5
5
  *
@@ -37,6 +37,8 @@ const flag = (name) => {
37
37
  return next && !next.startsWith('-') ? next : '';
38
38
  };
39
39
  const gridArg = flag('--grid');
40
+ const outArg = flag('--out');
41
+ const sizeArg = flag('--size');
40
42
  const remember = flag('--remember');
41
43
  const diff = flag('--diff');
42
44
  const threshold = flag('--threshold');
@@ -45,6 +47,8 @@ if (args.includes('--help') || args.includes('-h')) {
45
47
 
46
48
  --json the full report as JSON instead of prose
47
49
  --grid WxH cells across and down (default 16x9)
50
+ --out FILE also write the frame as a PNG you can look at
51
+ --size N longest side of that PNG (default 512)
48
52
  --port N skip discovery and use this port
49
53
 
50
54
  --remember [L] keep this frame as the baseline named L (default "last")
@@ -52,7 +56,14 @@ if (args.includes('--help') || args.includes('-h')) {
52
56
  --threshold N a cell has changed when it moves this far, 0..255 (default 3)
53
57
 
54
58
  Run your game's dev server and open the preview page first: the pixels live in
55
- the browser, not in the dev server.
59
+ the browser, not in the dev server. Nothing headless is involved and no browser
60
+ is installed: the page that is already open does the rendering.
61
+
62
+ The report says whether anything was drawn, where the subject is and how much of
63
+ the frame it fills. When that is not enough, look:
64
+
65
+ incanto-frame --out shot.png # then open it, or read it
66
+
56
67
 
57
68
  The report of ONE frame has a floor: a one-pixel seam is a fraction of a level
58
69
  once a cell is averaged, and nothing can tell it apart from a thin rope in the
@@ -138,6 +149,7 @@ if (!found) {
138
149
 
139
150
  const params = new URLSearchParams();
140
151
  if (gridArg !== null) params.set('grid', gridArg);
152
+ if (outArg) params.set('image', sizeArg || '512');
141
153
  if (remember !== null) params.set('remember', remember);
142
154
  if (diff !== null) params.set('diff', diff);
143
155
  if (threshold) params.set('threshold', threshold);
@@ -147,10 +159,22 @@ const res = await fetch(`http://${found.host}:${found.port}/__incanto/frame${q ?
147
159
  });
148
160
  const body = await res.json().catch(() => null);
149
161
 
162
+ if (body?.error === 'page-not-drawing') {
163
+ console.error(
164
+ `the page on :${found.port} is connected but not drawing.\n` +
165
+ ' A frame is captured inside a render, and a browser stops rendering a tab it\n' +
166
+ ' considers hidden — including a window covered by another one. Bring the\n' +
167
+ ' preview to the front and run this again.',
168
+ );
169
+ process.exit(1);
170
+ }
150
171
  if (res.status === 504 || body?.error === 'no-page-connected') {
151
172
  console.error(
152
- `the dev server is running on :${found.port}, but no page is connected.\n` +
153
- ' Open the preview in a browser — the pixels are there, not in the server.',
173
+ `the dev server is running on :${found.port}, but no page answered.\n` +
174
+ ' Open the preview in a browser — the pixels are there, not in the server.\n' +
175
+ ' If it IS open: a window covered by another one counts as hidden, and a\n' +
176
+ ' hidden tab gets no animation frames — such a page never finishes loading\n' +
177
+ ' and cannot draw. Bring it to the front.',
154
178
  );
155
179
  process.exit(1);
156
180
  }
@@ -165,10 +189,33 @@ if (report.error) {
165
189
  process.exit(1);
166
190
  }
167
191
 
192
+ /** The picture, if one was asked for. Written before anything is printed, so a
193
+ * report that mentions the file is a report whose file exists. */
194
+ let wroteImage = null;
195
+ if (outArg) {
196
+ const dataUrl = typeof report.image === 'string' ? report.image : null;
197
+ const base64 = dataUrl?.slice(dataUrl.indexOf(',') + 1);
198
+ if (!base64) {
199
+ console.error(
200
+ 'the page returned no image — it is running an engine older than this CLI,\n' +
201
+ ' or its renderer cannot capture frames.',
202
+ );
203
+ } else {
204
+ writeFileSync(outArg, Buffer.from(base64, 'base64'));
205
+ wroteImage = outArg;
206
+ }
207
+ }
208
+
168
209
  if (asJson) {
169
- process.stdout.write(`${JSON.stringify(body, null, 2)}\n`);
210
+ // The base64 image is for the file, not for a terminal — the path is the
211
+ // useful part and a megabyte of it in a pipe is not.
212
+ const { image: _image, ...rest } = report;
213
+ process.stdout.write(
214
+ `${JSON.stringify({ ...body, report: rest, ...(wroteImage ? { image: wroteImage } : {}) }, null, 2)}\n`,
215
+ );
170
216
  } else {
171
217
  const lines = [frameText(report)];
218
+ if (wroteImage) lines.push(`wrote ${wroteImage} — open it, or read it`);
172
219
  if (body.diffError === 'no-baseline') {
173
220
  lines.push(
174
221
  `no baseline named "${diff || 'last'}" — run \`incanto-frame --remember${diff ? ` ${diff}` : ''}\` first,\n` +
@@ -183,6 +230,10 @@ if (asJson) {
183
230
  process.stdout.write(`${lines.join('\n')}\n`);
184
231
  }
185
232
  // A missing baseline is a usage error, not a picture: fail so a script notices.
186
- process.exit(report.black || body.diffError ? 1 : 0);
233
+ // `exitCode`, never `process.exit()`: stdout to a PIPE is written
234
+ // asynchronously, and exiting discards whatever has not flushed. A --json
235
+ // report read by another program came back truncated — silently, and only
236
+ // when piped, which is the only way a program reads it.
237
+ process.exitCode = report.black || body.diffError ? 1 : 0;
187
238
 
188
239
  void parseProcNetTcp; // re-exported for tests; referenced so bundlers keep it
@@ -9,14 +9,25 @@
9
9
  *
10
10
  * Bounding boxes come from glTF POSITION accessor min/max transformed through
11
11
  * each node's TRS chain — for skinned meshes that is the BIND pose.
12
+ *
13
+ * A URL works anywhere a path does. Finding an asset is the agent8 library's
14
+ * job (its vector search matches meaning, which no filename filter can); this
15
+ * answers the question that search cannot — what will this file DO in a scene.
12
16
  */
13
17
  import { readFileSync } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
+
21
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
22
+ const { modelVerdict, verdictText } = await import(pathToFileURL(join(PKG, 'dist', '3d.js')).href);
14
23
 
15
24
  function parseArgs(argv) {
16
- const args = { file: undefined, json: false };
17
- for (const a of argv) {
25
+ const args = { file: undefined, json: false, key: undefined };
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
18
28
  if (a === '--json') args.json = true;
19
29
  else if (a === '--help' || a === '-h') args.help = true;
30
+ else if (a === '--key') args.key = argv[++i];
20
31
  else if (!a.startsWith('-') && !args.file) args.file = a;
21
32
  else {
22
33
  console.error(`unknown argument: ${a}`);
@@ -26,19 +37,49 @@ function parseArgs(argv) {
26
37
  return args;
27
38
  }
28
39
 
40
+ const isUrl = (s) => /^https?:\/\//i.test(s ?? '');
41
+
42
+ /** A key from a url or path: `.../goblin_king.glb` → `goblin_king`. */
43
+ function keyFrom(source) {
44
+ const file = (source.split(/[?#]/)[0] ?? '').split('/').pop() ?? '';
45
+ return (
46
+ file
47
+ .replace(/\.[^.]+$/, '')
48
+ .replace(/[^a-zA-Z0-9_-]+/g, '_')
49
+ .replace(/_{2,}/g, '_')
50
+ .replace(/^_+|_+$/g, '')
51
+ .toLowerCase() || 'model'
52
+ );
53
+ }
54
+
29
55
  const args = parseArgs(process.argv.slice(2));
30
56
  if (args.help || args.error || !args.file) {
31
- console.log(`Usage: npx incanto-model <file.glb|.gltf|.vrm> [--json]
57
+ console.log(`Usage: npx incanto-model <file.glb|.gltf|.vrm|URL> [--json] [--key NAME]
32
58
 
33
59
  Reports hierarchy, mesh stats, scene bounding box, animations, and VRM meta —
34
- the numbers you need to size and place a model in a scene.`);
60
+ the numbers you need to size and place a model in a scene — then says what the
61
+ file will DO in an incanto scene:
62
+
63
+ will the 3d/animations clips play on it (bone names decide, and a mismatch
64
+ keeps the bind pose with only a console warning)
65
+ is it a character's size, or authored in centimetres
66
+ does it stand on its own origin, or will a node at [0,0,0] put it in the sky
67
+
68
+ and prints the scene JSON to paste. A URL works anywhere a path does, so an
69
+ asset the agent8 library found can be checked before it is used.`);
35
70
  process.exit(args.help && !args.error ? 0 : 1);
36
71
  }
37
72
 
38
73
  // ---- container parsing --------------------------------------------------------------
39
74
 
40
- function readGltfJson(file) {
41
- const data = readFileSync(file);
75
+ async function bytesOf(source) {
76
+ if (!isUrl(source)) return readFileSync(source);
77
+ const res = await fetch(source);
78
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${source}`);
79
+ return Buffer.from(await res.arrayBuffer());
80
+ }
81
+
82
+ function readGltfJson(data) {
42
83
  if (data.length >= 12 && data.readUInt32LE(0) === 0x46546c67) {
43
84
  // GLB container: header(12) then chunks [len][type][payload]
44
85
  let offset = 12;
@@ -186,11 +227,19 @@ function analyze(json) {
186
227
  if (node.skin !== undefined) entry.skinned = true;
187
228
  nodes.push(entry);
188
229
  const mesh = node.mesh !== undefined ? meshes[node.mesh] : null;
230
+ // A SKINNED mesh is placed by its joint matrices, not by the node it hangs
231
+ // off — the node's own TRS is ignored at draw time and is routinely a
232
+ // hundredth-scale leftover from the export. Measured on the stock
233
+ // `base-model.glb`: transforming through the chain reported a 1.5-unit
234
+ // character as 0.003 units tall, because its root carries scale 0.01. Take
235
+ // the vertices as authored for those, and the transformed corners for
236
+ // everything else, where the chain IS the placement.
237
+ const identity = node.skin !== undefined;
189
238
  if (mesh?.localMin && mesh.localMax) {
190
239
  for (const x of [mesh.localMin[0], mesh.localMax[0]]) {
191
240
  for (const y of [mesh.localMin[1], mesh.localMax[1]]) {
192
241
  for (const z of [mesh.localMin[2], mesh.localMax[2]]) {
193
- const p = transformPoint(mat, [x, y, z]);
242
+ const p = identity ? [x, y, z] : transformPoint(mat, [x, y, z]);
194
243
  sceneMin = sceneMin ? sceneMin.map((v, i) => Math.min(v, p[i])) : [...p];
195
244
  sceneMax = sceneMax ? sceneMax.map((v, i) => Math.max(v, p[i])) : [...p];
196
245
  }
@@ -246,21 +295,30 @@ function analyze(json) {
246
295
  // ---- output --------------------------------------------------------------------------
247
296
 
248
297
  let report;
298
+ let verdict;
249
299
  try {
250
- const json = readGltfJson(args.file);
300
+ const data = await bytesOf(args.file);
301
+ const json = readGltfJson(data);
251
302
  if (!json.asset?.version) throw new Error('missing glTF asset header');
252
303
  report = {
253
304
  file: args.file,
254
- format: readFileSync(args.file).readUInt32LE(0) === 0x46546c67 ? 'glb' : 'gltf',
305
+ format: data.readUInt32LE(0) === 0x46546c67 ? 'glb' : 'gltf',
255
306
  ...analyze(json),
256
307
  };
308
+ verdict = modelVerdict(
309
+ { ...report, meshes: report.meshes.length },
310
+ {
311
+ url: isUrl(args.file) ? args.file : args.file,
312
+ key: args.key ?? keyFrom(args.file),
313
+ },
314
+ );
257
315
  } catch (error) {
258
316
  console.error(`not a glTF/GLB/VRM file: ${String(error.message ?? error)}`);
259
317
  process.exit(1);
260
318
  }
261
319
 
262
320
  if (args.json) {
263
- console.log(JSON.stringify(report, null, 2));
321
+ console.log(JSON.stringify({ ...report, verdict }, null, 2));
264
322
  } else {
265
323
  const out = [];
266
324
  out.push(`${report.file} (${report.format}${report.vrm ? ` · VRM ${report.vrm.spec}` : ''})`);
@@ -292,5 +350,16 @@ if (args.json) {
292
350
  for (const anim of report.animations) {
293
351
  out.push(` anim ${anim.name}: ${anim.duration}s`);
294
352
  }
353
+ // The verdict is the part you act on, so it goes last — closest to the
354
+ // prompt, where a reader stops.
355
+ out.push('', verdictText(verdict));
356
+ if (verdict.sceneJson) {
357
+ const { assets, node } = verdict.sceneJson;
358
+ out.push(
359
+ '',
360
+ 'paste into your scene:',
361
+ JSON.stringify(node ? { assets, node } : { assets }, null, 2),
362
+ );
363
+ }
295
364
  console.log(out.join('\n'));
296
365
  }
@@ -133,4 +133,8 @@ if (!args.noReplays && !args.json) {
133
133
  }
134
134
  }
135
135
 
136
- process.exit(report.runs.some((r) => r.outcome === 'won') ? 0 : 1);
136
+ // `exitCode`, never `process.exit()`: stdout to a PIPE is written
137
+ // asynchronously, and exiting discards whatever has not flushed. A --json
138
+ // report read by another program came back truncated — silently, and only
139
+ // when piped, which is the only way a program reads it.
140
+ process.exitCode = report.runs.some((r) => r.outcome === 'won') ? 0 : 1;
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * incanto-verify — the whole ladder, one command, one next action.
4
+ *
5
+ * bunx incanto-verify # finds your scene
6
+ * bunx incanto-verify src/game.scene.json --behaviors src/behaviors.ts
7
+ *
8
+ * The rungs already existed and were used one at a time, because using them
9
+ * together means knowing three things nobody writes down: the order, that the
10
+ * first red rung makes the ones above it meaningless, and that "no dev server"
11
+ * is a question unasked rather than a failing game.
12
+ *
13
+ * loads incanto-check the scene is legal and every asset resolves
14
+ * plays incanto-playtest a seeded run can actually finish it
15
+ * draws incanto-frame the GPU drew something, and the subject is in shot
16
+ *
17
+ * Exit 1 when a rung FAILED. An unmeasured rung is not a failure — it prints
18
+ * what to arrange, and says so in the summary.
19
+ */
20
+ import { spawnSync } from 'node:child_process';
21
+ import { existsSync, readdirSync, statSync } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
+ import { fileURLToPath, pathToFileURL } from 'node:url';
24
+
25
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
26
+ const { ladderText, ladderVerdict } = await import(
27
+ pathToFileURL(join(PKG, 'dist', 'test.js')).href
28
+ );
29
+
30
+ const argv = process.argv.slice(2);
31
+ const asJson = argv.includes('--json');
32
+ const flag = (name) => {
33
+ const i = argv.indexOf(name);
34
+ return i >= 0 ? argv[i + 1] : null;
35
+ };
36
+ if (argv.includes('--help') || argv.includes('-h')) {
37
+ console.log(`Usage: incanto-verify [scene.json] [--behaviors FILE] [--json]
38
+
39
+ Walks the whole ladder and tells you the ONE thing to do next:
40
+
41
+ loads the scene is legal and every asset resolves (incanto-check)
42
+ plays a seeded run can actually finish it (incanto-playtest)
43
+ draws the GPU drew something and the subject is in shot (incanto-frame)
44
+
45
+ Without a scene it looks for one *.scene.json under the current directory.
46
+ "draws" needs a dev server with the page open; without one it is reported as
47
+ NOT MEASURED rather than failed — a missing measurement is not a broken game.`);
48
+ process.exit(0);
49
+ }
50
+
51
+ /** The scene to verify: the one named, or the only one that can be found. */
52
+ function findScene() {
53
+ const named = argv.find((a) => !a.startsWith('-') && a.endsWith('.json'));
54
+ if (named) return named;
55
+ const found = [];
56
+ const walk = (dir, depth) => {
57
+ if (depth > 4 || found.length > 8) return;
58
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
59
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
60
+ const full = join(dir, entry.name);
61
+ if (entry.isDirectory()) walk(full, depth + 1);
62
+ else if (entry.name.endsWith('.scene.json')) found.push(full);
63
+ }
64
+ };
65
+ try {
66
+ walk(process.cwd(), 0);
67
+ } catch {
68
+ /* unreadable tree — the caller can always name the file */
69
+ }
70
+ return { scene: found.length === 1 ? found[0] : null, candidates: found };
71
+ }
72
+
73
+ const bin = (name) => join(PKG, 'bin', `incanto-${name}.mjs`);
74
+ const run = (name, args) =>
75
+ spawnSync(process.execPath, [bin(name), ...args], {
76
+ encoding: 'utf-8',
77
+ timeout: 180_000,
78
+ // A playtest report carries a replay per losing run; the 1MB default
79
+ // truncates it and a truncated report is indistinguishable from a tool
80
+ // that would not start.
81
+ maxBuffer: 64 * 1024 * 1024,
82
+ });
83
+
84
+ const { scene, candidates } = findScene();
85
+ if (!scene) {
86
+ const report = ladderVerdict([], { candidates });
87
+ console.log(asJson ? JSON.stringify(report, null, 2) : ladderText(report));
88
+ process.exit(0);
89
+ }
90
+ if (!existsSync(scene) || !statSync(scene).isFile()) {
91
+ console.error(`no such scene: ${scene}`);
92
+ process.exit(1);
93
+ }
94
+
95
+ const behaviors = flag('--behaviors');
96
+ const rungs = [];
97
+
98
+ // ---- loads ---------------------------------------------------------------
99
+ {
100
+ const r = run('check', [scene, '--json']);
101
+ const out = safeJson(r.stdout);
102
+ const problems = out?.files?.flatMap((f) => f.problems ?? []) ?? [];
103
+ rungs.push(
104
+ r.status === 0
105
+ ? { name: 'loads', status: 'pass', summary: 'the scene is legal and its assets resolve' }
106
+ : {
107
+ name: 'loads',
108
+ status: 'fail',
109
+ summary: problems[0]?.why ?? firstLine(r.stdout || r.stderr) ?? 'incanto-check failed',
110
+ fix: `fix that, then run again — nothing above this rung means anything until it is green (\`incanto-check ${scene}\` for the detail)`,
111
+ },
112
+ );
113
+ }
114
+
115
+ // ---- plays ---------------------------------------------------------------
116
+ if (rungs[0].status === 'pass') {
117
+ const args = [scene, '--json', '--runs', '8'];
118
+ if (behaviors) args.push('--behaviors', behaviors);
119
+ const r = run('playtest', args);
120
+ const out = safeJson(r.stdout);
121
+ const won = out?.runs?.filter((x) => x.outcome === 'won').length ?? 0;
122
+ const total = out?.runs?.length ?? 0;
123
+ // A scene that declares no win is not a scene that cannot be won: a
124
+ // walkabout template has no end, and calling that a failure sends its author
125
+ // hunting a bug that was never there.
126
+ const noGoal = out && out.declaresWin === false;
127
+ rungs.push(
128
+ r.status === 0
129
+ ? { name: 'plays', status: 'pass', summary: `${won} of ${total} seeded runs finished it` }
130
+ : noGoal
131
+ ? {
132
+ name: 'plays',
133
+ status: 'unmeasured',
134
+ summary: `nothing declares a win — ${total} runs played without error, and there was no end to reach`,
135
+ fix: 'if it is meant to be finishable, emit `won` (GameFlow, ScoreKeeper, or your own behaviour)',
136
+ }
137
+ : {
138
+ name: 'plays',
139
+ status: 'fail',
140
+ summary:
141
+ total > 0 ? `no run finished it (${total} tried)` : 'the playtest could not run',
142
+ fix: behaviors
143
+ ? `see which runs stalled and where: \`incanto-playtest ${scene} --behaviors ${behaviors}\``
144
+ : `run it with your behaviours — without them the structure plays and your game logic does not: \`incanto-playtest ${scene} --behaviors src/behaviors.ts\``,
145
+ },
146
+ );
147
+ } else {
148
+ rungs.push({ name: 'plays', status: 'skipped', summary: 'not run — the scene does not load' });
149
+ }
150
+
151
+ // ---- draws ---------------------------------------------------------------
152
+ {
153
+ const r = run('frame', ['--json']);
154
+ const out = safeJson(r.stdout);
155
+ const report = out?.report;
156
+ if (!report) {
157
+ rungs.push({
158
+ name: 'draws',
159
+ status: 'unmeasured',
160
+ summary: firstLine(r.stderr) ?? 'no dev server with a page open',
161
+ fix: /not drawing/.test(r.stderr ?? '')
162
+ ? 'bring the preview window to the front — a hidden tab does not render, and a frame is captured inside a render'
163
+ : 'start your dev server, open the page, and run this again — the pixels are in the browser',
164
+ });
165
+ } else if (report.black) {
166
+ rungs.push({
167
+ name: 'draws',
168
+ status: 'fail',
169
+ summary: 'BLACK SCREEN — nothing was drawn',
170
+ fix: 'no light, no current camera, or nothing in view: `incanto-frame --out shot.png` and look',
171
+ });
172
+ } else if (!report.subject) {
173
+ rungs.push({
174
+ name: 'draws',
175
+ status: 'fail',
176
+ summary: 'nothing but background — the camera is pointed away from the scene',
177
+ fix: 'check the camera: `incanto-frame --out shot.png` and look at what it is aimed at',
178
+ });
179
+ } else {
180
+ rungs.push({
181
+ name: 'draws',
182
+ status: 'pass',
183
+ summary: `subject fills ${(report.subject.coverage * 100).toFixed(1)}% of the frame`,
184
+ });
185
+ }
186
+ }
187
+
188
+ /**
189
+ * The JSON in `text`, past whatever a dependency printed first. Rapier's
190
+ * deprecation notice lands on stdout ahead of the report, and treating that as
191
+ * "the tool could not run" is how this reported a healthy scene as broken.
192
+ */
193
+ function safeJson(text) {
194
+ const raw = text ?? '';
195
+ const start = raw.indexOf('{');
196
+ if (start < 0) return null;
197
+ try {
198
+ return JSON.parse(raw.slice(start));
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+ function firstLine(text) {
204
+ const line = (text ?? '').split('\n').find((l) => l.trim());
205
+ return line ? line.trim() : null;
206
+ }
207
+
208
+ const verdict = ladderVerdict(rungs);
209
+ console.log(asJson ? JSON.stringify({ scene, ...verdict }, null, 2) : ladderText(verdict));
210
+ // `exitCode`, never `process.exit()`: stdout to a PIPE is written
211
+ // asynchronously, and exiting discards whatever has not flushed. A --json
212
+ // report read by another program came back truncated — silently, and only
213
+ // when piped, which is the only way a program reads it.
214
+ process.exitCode = verdict.ok ? 0 : 1;
package/dist/2d.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Et as Node, P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-62q0HWBO.js";
2
- import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DVwIGZdK.js";
2
+ import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DAvWQeld.js";
3
3
  import { i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
4
4
  import { t as LoadSceneOptions } from "./loader-CeyU_bm1.js";
5
5
  import { t as ParticleSim } from "./particle-sim-BzJ1yxoE.js";
package/dist/2d.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
2
- import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-DFRbG0Bj.js";
2
+ import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-CniOiWzN.js";
3
3
  import { _ as RigidBody2D, a as parseCells, c as ColorRect2D, d as AnimatedSprite2D, f as Sprite2D, g as PhysicsBody2D, h as CharacterBody2D, i as mergeSolidRects, l as CharacterController2D, m as Area2D, n as UILayer, o as Particles2D, p as Joint2D, r as TileMap2D, s as Label, t as registerNodes2D, u as Camera2D, v as StaticBody2D, y as Node2D } from "./register-R2JTnIMw.js";
4
4
  import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-BmgXBNDB.js";
5
5
  //#region src/2d/library-sprite.ts
package/dist/3d.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { D as QualityCaps, Et as Node, P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-62q0HWBO.js";
2
- import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DVwIGZdK.js";
3
- import { i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
2
+ import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DAvWQeld.js";
3
+ import { i as SceneJson$1, s as JsonObject } from "./schema-CFeioQRE.js";
4
4
  import { t as LoadSceneOptions } from "./loader-CeyU_bm1.js";
5
5
  import { t as ParticleSim } from "./particle-sim-BzJ1yxoE.js";
6
- import { a as GridCell, c as diffText, d as frameText, i as FrameStatsOptions, l as frameSignature, n as FrameSignature, o as SIGNATURE_GRID, r as FrameStats, s as diffSignatures, t as FrameDiff, u as frameStats } from "./frame-report-Ct8XgsmV.js";
6
+ import { a as GridCell, c as diffText, d as frameText, i as FrameStatsOptions, l as frameSignature, n as FrameSignature, o as SIGNATURE_GRID, r as FrameStats, s as diffSignatures, t as FrameDiff, u as frameStats } from "./frame-report-DZ70IY26.js";
7
7
  import { n as PathGrid, s as SpatialPose } from "./pathfinding-C49JSNNq.js";
8
8
  import { AnimationClip, AnimationMixer, BufferGeometry, Color, DirectionalLight, Group, InstancedMesh, Mesh, MeshPhysicalMaterial, Object3D, PerspectiveCamera, Scene, ShaderMaterial, Texture, Vector3, WebGLRenderer } from "three";
9
9
  import { VRM } from "@pixiv/three-vrm";
@@ -633,7 +633,7 @@ interface Game3D {
633
633
  * make "edit the scene" mean "edit wherever the simulation happens to be",
634
634
  * so the source is kept beside the game and handed over unchanged.
635
635
  */
636
- sourceJson: SceneJson;
636
+ sourceJson: SceneJson$1;
637
637
  renderer: GameRenderer;
638
638
  physics: Physics3D | null;
639
639
  /**
@@ -931,6 +931,91 @@ declare class Environment3D {
931
931
  */
932
932
  declare function setEnvironment3D(engine: Engine, patch: JsonObject): void;
933
933
  //#endregion
934
+ //#region src/3d/model-verdict.d.ts
935
+ /**
936
+ * What a GLB will DO in an incanto scene, judged from what is in the file.
937
+ *
938
+ * Finding an asset is somebody else's job and they do it better: the agent8
939
+ * service has a vector search over the same library, and matching "a stocky
940
+ * dwarf blacksmith" to a URL is not something a filename filter should attempt.
941
+ * What no search can answer is whether the thing it found will work HERE —
942
+ *
943
+ * will the library's animation clips play on it? (bone NAMES decide, and a
944
+ * rig that does not match keeps its bind pose with only a console warning)
945
+ * how tall is it? (a model authored in centimetres dwarfs the scene)
946
+ * does it stand on its own origin? (`monster_trumble_mega` sits ten units
947
+ * above its root, so a node at [0,0,0] puts it in the sky — `targetHeight`
948
+ * normalises SIZE, not position, which cost a day of this repo's time)
949
+ *
950
+ * Every one of those is in the file, and every one of them was learned the
951
+ * expensive way. Pure arithmetic over an already-parsed report: no three, no
952
+ * network, no GPU.
953
+ */
954
+ /** The parts of `incanto-model`'s report this judges. */
955
+ interface ModelFacts {
956
+ nodes: Array<{
957
+ name: string;
958
+ }>;
959
+ bbox: {
960
+ size: [number, number, number];
961
+ center: [number, number, number];
962
+ min: [number, number, number];
963
+ max: [number, number, number];
964
+ } | null;
965
+ animations: Array<{
966
+ name: string;
967
+ duration: number;
968
+ }>;
969
+ /** Meshes in the file. Zero means there is nothing to draw — see `kind`. */
970
+ meshes: number;
971
+ /** glTF skins. Zero means a prop, not a character. */
972
+ skins: number;
973
+ vrm: {
974
+ humanBones: number;
975
+ } | null;
976
+ }
977
+ type Rig = "mixamorig" | "vrm-humanoid" | "other" | "none";
978
+ interface SceneJson {
979
+ assets: Record<string, {
980
+ type: "model" | "animation";
981
+ url: string;
982
+ }>;
983
+ /** Null for a clip: it is played BY a model, it is not one. */
984
+ node: {
985
+ name: string;
986
+ type: "ModelInstance3D";
987
+ props: Record<string, unknown>;
988
+ } | null;
989
+ }
990
+ interface ModelVerdict {
991
+ /**
992
+ * What the file IS. A clip is a `.glb` with a rig and no mesh — declared as a
993
+ * model it renders nothing at all, with no error, and every other signal
994
+ * about it says "character".
995
+ */
996
+ kind: "model" | "animation";
997
+ rig: Rig;
998
+ /** The `3d/animations` shelf plays on this as-is. */
999
+ takesLibraryClips: boolean;
1000
+ /** Height in the file's own units (0 when there is no geometry to measure). */
1001
+ height: number;
1002
+ /** Where the model sits relative to its own origin: bottom-centre, in units. */
1003
+ originOffset: [number, number, number];
1004
+ /** Standing on its own origin — a node at [0,0,0] puts its feet on the floor. */
1005
+ groundedAtOrigin: boolean;
1006
+ /** Clip names baked into the file itself (playable by name, no asset entry). */
1007
+ embeddedClips: string[];
1008
+ sceneJson: SceneJson | null;
1009
+ }
1010
+ interface VerdictTarget {
1011
+ url: string;
1012
+ /** Scene asset key. Also names the node. */
1013
+ key: string;
1014
+ }
1015
+ declare function modelVerdict(facts: ModelFacts, target?: VerdictTarget): ModelVerdict;
1016
+ /** The verdict as sentences: what to do, and what will bite if you do not. */
1017
+ declare function verdictText(v: ModelVerdict): string;
1018
+ //#endregion
934
1019
  //#region src/3d/nodes/billboard-3d.d.ts
935
1020
  /** Valid `Billboard3D.mode` values — drives the prop options AND validateJson. */
936
1021
  declare const BILLBOARD_GROUP_MODES: readonly ["screen", "y", "none"];
@@ -3621,4 +3706,4 @@ declare function createNavDebugNode(nav: TerrainNav, opts?: {
3621
3706
  name?: string;
3622
3707
  }): InstancedMesh3D;
3623
3708
  //#endregion
3624
- export { Area3D, AssetStore3D, type BedSampler, Billboard3D, type BillboardGroupMode, BoneAttachment3D, BoneLookAt3D, Camera3D, CharacterBody3D, CharacterController3D, type CreateGame3DOptions, DEFAULT_TERRAIN_TEXTURE_BASE, DirectionalLight3D, type DownhillTraceOptions, Environment3D, type Environment3DConfig, DENSITY_PRESETS as FLOWER_DENSITY_PRESETS, FLOWER_VARIETIES, type FlowerVariety, Flowers3D, type FogEnvironment, Foliage3D, type FoliageKind, type FoliageStyle, type FrameDiff, type FrameSignature, type FrameStats, type FrameStatsOptions, type Game3D, type GridCell, type HeightSampler, type Heightmap, type HeightmapOptions, InstancedMesh3D, Joint3D, type JointType3D, LoftMesh3D, type LoftSection, MeshInstance3D, type MeshKind, type MeshMaterialProps, type ModelEntry, ModelInstance3D, Node3D, OmniLight3D, Particles3D, Physics3D, type Physics3DOptions, PhysicsBody3D, QUARTER_PITCH, type RenderContext3D, type RenderHook3D, Renderer3D, type Renderer3DOptions, type RigView, RigidBody3D, type Ripple, River3D, type RiverCarveOptions, type RiverCoverageGap, type RiverHit, type RiverRing, type RiverRingOptions, SIGNATURE_GRID, type ShadowsEnvironment, type SkyEnvironment, StaticBody3D, type SunConsumer3D, type SyncOptions, type SyncResult, TERRAIN_THEMES, Terrain3D, type TerrainLayer, type TerrainNav, type TerrainNavOptions, type TerrainTheme, TextureCache3D, type TextureSpec, Trail3D, Tree3D, type TreeTier, type TreeType, VOXEL_PALETTE, type VoxelBlock, VoxelGrid3D, WATER_CUTOUT_MAX, WATER_MAX_RIPPLES, Water3D, WaterCutout3D, acquireOwnTexture, acquireTexture, buildHeightmap, buildRiverRings, buildTerrainNav, cameraRelative, createGame3D, createNavDebugNode, diffSignatures, diffText, enablePhysics3D, findRiverCoverageGaps, frameSignature, frameStats, frameText, horizonColorFromSky, keyboardIntensity, movementState, parseEnvironment3D, projectToRiver, registerNodes3D, resolveFlowerDensity, rigPose, riverCarveChannels, riverStepFor, setEnvironment3D, smoothCourse, splatWeights, sunDirectionFromElevationAzimuth, sunDirectionFromSky, syncTree, terrainThemeLayers, traceDownhillPath };
3709
+ export { Area3D, AssetStore3D, type BedSampler, Billboard3D, type BillboardGroupMode, BoneAttachment3D, BoneLookAt3D, Camera3D, CharacterBody3D, CharacterController3D, type CreateGame3DOptions, DEFAULT_TERRAIN_TEXTURE_BASE, DirectionalLight3D, type DownhillTraceOptions, Environment3D, type Environment3DConfig, DENSITY_PRESETS as FLOWER_DENSITY_PRESETS, FLOWER_VARIETIES, type FlowerVariety, Flowers3D, type FogEnvironment, Foliage3D, type FoliageKind, type FoliageStyle, type FrameDiff, type FrameSignature, type FrameStats, type FrameStatsOptions, type Game3D, type GridCell, type HeightSampler, type Heightmap, type HeightmapOptions, InstancedMesh3D, Joint3D, type JointType3D, LoftMesh3D, type LoftSection, MeshInstance3D, type MeshKind, type MeshMaterialProps, type ModelEntry, type ModelFacts, ModelInstance3D, type ModelVerdict, Node3D, OmniLight3D, Particles3D, Physics3D, type Physics3DOptions, PhysicsBody3D, QUARTER_PITCH, type RenderContext3D, type RenderHook3D, Renderer3D, type Renderer3DOptions, type Rig, type RigView, RigidBody3D, type Ripple, River3D, type RiverCarveOptions, type RiverCoverageGap, type RiverHit, type RiverRing, type RiverRingOptions, SIGNATURE_GRID, type SceneJson, type ShadowsEnvironment, type SkyEnvironment, StaticBody3D, type SunConsumer3D, type SyncOptions, type SyncResult, TERRAIN_THEMES, Terrain3D, type TerrainLayer, type TerrainNav, type TerrainNavOptions, type TerrainTheme, TextureCache3D, type TextureSpec, Trail3D, Tree3D, type TreeTier, type TreeType, VOXEL_PALETTE, type VoxelBlock, VoxelGrid3D, WATER_CUTOUT_MAX, WATER_MAX_RIPPLES, Water3D, WaterCutout3D, acquireOwnTexture, acquireTexture, buildHeightmap, buildRiverRings, buildTerrainNav, cameraRelative, createGame3D, createNavDebugNode, diffSignatures, diffText, enablePhysics3D, findRiverCoverageGaps, frameSignature, frameStats, frameText, horizonColorFromSky, keyboardIntensity, modelVerdict, movementState, parseEnvironment3D, projectToRiver, registerNodes3D, resolveFlowerDensity, rigPose, riverCarveChannels, riverStepFor, setEnvironment3D, smoothCourse, splatWeights, sunDirectionFromElevationAzimuth, sunDirectionFromSky, syncTree, terrainThemeLayers, traceDownhillPath, verdictText };