incanto 0.68.0 → 0.70.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 (153) hide show
  1. package/assets/catalog.json +9 -5
  2. package/bin/_behaviors-loader.mjs +22 -0
  3. package/bin/_read-json.mjs +28 -0
  4. package/bin/incanto-assets.mjs +19 -5
  5. package/bin/incanto-check.mjs +92 -15
  6. package/bin/incanto-editor.mjs +128 -5
  7. package/bin/incanto-env.mjs +3 -2
  8. package/bin/incanto-feel.mjs +24 -13
  9. package/bin/incanto-frame.mjs +8 -1
  10. package/bin/incanto-multiplay.mjs +11 -9
  11. package/bin/incanto-new.mjs +128 -5
  12. package/bin/incanto-play.mjs +158 -16
  13. package/bin/incanto-playtest.mjs +66 -23
  14. package/bin/incanto-serve.mjs +160 -0
  15. package/bin/incanto-skills.mjs +14 -2
  16. package/bin/incanto-verify.mjs +165 -44
  17. package/bin/incanto.mjs +4 -2
  18. package/dist/2d.d.ts +285 -36
  19. package/dist/2d.js +4 -4
  20. package/dist/3d.d.ts +158 -10
  21. package/dist/3d.js +8 -8
  22. package/dist/{agent8-CvsfVskX.js → agent8-CmNF01gA.js} +61 -8
  23. package/dist/{audio-player-DOrq7sP-.d.ts → audio-player-DaMxqfNE.d.ts} +33 -14
  24. package/dist/{behavior-DoFPYrgo.d.ts → behavior-DZExDn9o.d.ts} +809 -44
  25. package/dist/{create-game-IZIydDwI.js → create-game-Bwvh6q8A.js} +148 -61
  26. package/dist/{create-game-DbWtVTxD.js → create-game-C7ffQWW7.js} +103 -49
  27. package/dist/debug.d.ts +1 -1
  28. package/dist/debug.js +2 -3
  29. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  30. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  31. package/dist/editor.js +1066 -864
  32. package/dist/env.d.ts +1 -1
  33. package/dist/env.js +5 -3
  34. package/dist/{environment-presets-CybQXNqS.js → environment-presets-D6Q5BxeE.js} +299 -46
  35. package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
  36. package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
  37. package/dist/{gameplay-DM1eu_cV.js → gameplay-BfHkuzVb.js} +825 -221
  38. package/dist/gameplay.d.ts +187 -7
  39. package/dist/gameplay.js +1 -1
  40. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  41. package/dist/index.d.ts +156 -13
  42. package/dist/index.js +10 -12
  43. package/dist/json-CfTjpvW8.js +67 -0
  44. package/dist/{loader-CcB533FR.d.ts → loader-Cff09LMm.d.ts} +2 -2
  45. package/dist/net.d.ts +27 -3
  46. package/dist/net.js +2 -2
  47. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  48. package/dist/{physics-2d-B7Y6dPZO.js → physics-2d-CE0Qvy3V.js} +136 -11
  49. package/dist/{physics-3d-bG3n70Ky.js → physics-3d-CpH-2gn5.js} +104 -23
  50. package/dist/{teardown-D2NEmxPB.js → picking-CQJ_PJKh.js} +106 -14
  51. package/dist/react.d.ts +2 -2
  52. package/dist/react.js +2 -2
  53. package/dist/{register-3ta-2Xig.js → register-6DYnKZcy.js} +652 -831
  54. package/dist/{register-ibCjm-wH.js → register-Bkk0wSDB.js} +348 -30
  55. package/dist/{replay-CYvhVHHN.js → replay-DjAkAzMq.js} +224 -14
  56. package/dist/{replay-Dvn8aeBd.d.ts → replay-Dmw-PKQu.d.ts} +20 -3
  57. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  58. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  59. package/dist/{loader-BC4PNtJX.js → save-slots-BXVg148r.js} +4558 -2294
  60. package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
  61. package/dist/{split-screen-DhrSzZIB.d.ts → split-screen--k-XpBjr.d.ts} +36 -4
  62. package/dist/{split-screen-CYwDkbLF.js → split-screen-PL78oVXP.js} +159 -26
  63. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  64. package/dist/{src-D7RIqXYF.js → src-Cxfiv1Hg.js} +2 -17
  65. package/dist/test-iHYVUcDK.js +4036 -0
  66. package/dist/test.d.ts +542 -30
  67. package/dist/test.js +3 -3
  68. package/dist/touch-BnCyPA0G.js +519 -0
  69. package/dist/vite.d.ts +54 -3
  70. package/dist/vite.js +349 -17
  71. package/dist/{webgl-unavailable-N9nQqesw.js → webgl-unavailable-C8aDbGmR.js} +56 -1
  72. package/editor/assets/agent8-D0MS174y.js +1 -0
  73. package/editor/assets/{debug-BBhKuBNV.js → debug-BnXkKuYu.js} +2 -2
  74. package/editor/assets/index-CIu3uc3l.js +11046 -0
  75. package/editor/index.html +1 -1
  76. package/package.json +7 -16
  77. package/schemas/scene.schema.json +30 -3
  78. package/skills/incanto-3d-character.md +14 -1
  79. package/skills/incanto-3d-models.md +12 -0
  80. package/skills/incanto-assets.md +25 -3
  81. package/skills/incanto-audio.md +27 -5
  82. package/skills/incanto-behaviors-and-scripts.md +83 -6
  83. package/skills/incanto-building-2d-games.md +106 -8
  84. package/skills/incanto-building-3d-games.md +118 -6
  85. package/skills/incanto-editor.md +46 -7
  86. package/skills/incanto-environment.md +19 -1
  87. package/skills/incanto-game-feel.md +70 -0
  88. package/skills/incanto-gameplay-behaviors.md +121 -14
  89. package/skills/incanto-hud.md +128 -7
  90. package/skills/incanto-localization.md +13 -5
  91. package/skills/incanto-multiplayer.md +83 -3
  92. package/skills/incanto-node-reference.md +222 -58
  93. package/skills/incanto-performance.md +52 -0
  94. package/skills/incanto-physics-and-input.md +123 -24
  95. package/skills/incanto-playtesting.md +78 -2
  96. package/skills/incanto-save-slots.md +188 -6
  97. package/skills/incanto-scene-json-authoring.md +69 -12
  98. package/skills/incanto-verifying-your-game.md +292 -12
  99. package/skills/incanto-web-integration.md +28 -0
  100. package/skills/incanto-your-first-game.md +5 -2
  101. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  102. package/templates-app/beacon-isle-3d/package.json +2 -2
  103. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  104. package/templates-app/beacon-isle-3d/src/game.scene.json +103 -378
  105. package/templates-app/beacon-isle-3d/src/main.ts +24 -4
  106. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  107. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  108. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  109. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  110. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  111. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  112. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  113. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  114. package/templates-app/molehill-2d/index.html +73 -0
  115. package/templates-app/molehill-2d/package.json +23 -0
  116. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  117. package/templates-app/molehill-2d/src/game.scene.json +1255 -0
  118. package/templates-app/molehill-2d/src/main.ts +41 -0
  119. package/templates-app/molehill-2d/tsconfig.json +13 -0
  120. package/templates-app/molehill-2d/verify.ts +247 -0
  121. package/templates-app/molehill-2d/vite.config.ts +12 -0
  122. package/templates-app/platformer-2d/index.html +0 -23
  123. package/templates-app/platformer-2d/package.json +2 -2
  124. package/templates-app/platformer-2d/src/behaviors.ts +26 -16
  125. package/templates-app/platformer-2d/src/game.scene.json +143 -625
  126. package/templates-app/platformer-2d/src/main.ts +35 -13
  127. package/templates-app/platformer-2d/tsconfig.json +1 -1
  128. package/templates-app/star-survivor/package.json +2 -2
  129. package/templates-app/star-survivor/src/game.scene.json +41 -195
  130. package/templates-app/star-survivor/src/main.ts +28 -7
  131. package/templates-app/star-survivor/tsconfig.json +1 -1
  132. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  133. package/templates-app/tps-3d/package.json +2 -2
  134. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  135. package/templates-app/tps-3d/src/game.scene.json +78 -217
  136. package/templates-app/tps-3d/src/main.ts +39 -17
  137. package/templates-app/tps-3d/tsconfig.json +1 -1
  138. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  139. package/templates-app/village-quest-3d/package.json +2 -2
  140. package/templates-app/village-quest-3d/src/behaviors.ts +21 -0
  141. package/templates-app/village-quest-3d/src/grove.scene.json +54 -221
  142. package/templates-app/village-quest-3d/src/main.ts +24 -4
  143. package/templates-app/village-quest-3d/src/village.scene.json +199 -838
  144. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  145. package/templates-app/village-quest-3d/verify.ts +14 -1
  146. package/dist/duplicate-IWIqk0HJ.js +0 -22
  147. package/dist/json-CwwhxQgb.js +0 -36
  148. package/dist/registry-CF70EArN.js +0 -212
  149. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  150. package/dist/test-8hoHeRmo.js +0 -2340
  151. package/dist/touch-DEAmqGdf.js +0 -225
  152. package/editor/assets/agent8-BrrHOjMJ.js +0 -1
  153. package/editor/assets/index-eVd0BToA.js +0 -10958
@@ -19,23 +19,20 @@
19
19
  * what to arrange, and says so in the summary.
20
20
  */
21
21
  import { spawnSync } from 'node:child_process';
22
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
22
+ import { existsSync, readdirSync, statSync } from 'node:fs';
23
23
  import { dirname, join, relative } from 'node:path';
24
24
  import { fileURLToPath, pathToFileURL } from 'node:url';
25
+ import { readJson } from './_read-json.mjs';
25
26
 
26
27
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
27
- const { ladderText, ladderVerdict } = await import(
28
+ const { firstLine, ladderText, ladderVerdict, saysRung } = await import(
28
29
  pathToFileURL(join(PKG, 'dist', 'test.js')).href
29
30
  );
30
31
 
31
32
  const argv = process.argv.slice(2);
32
33
  const asJson = argv.includes('--json');
33
- const flag = (name) => {
34
- const i = argv.indexOf(name);
35
- return i >= 0 ? argv[i + 1] : null;
36
- };
37
34
  if (argv.includes('--help') || argv.includes('-h')) {
38
- console.log(`Usage: incanto verify [scene.json] [--behaviors FILE] [--json]
35
+ console.log(`Usage: incanto verify [scene.json] [--behaviors FILE[,FILE...]] [--json]
39
36
 
40
37
  Walks the whole ladder and tells you the ONE thing to do next:
41
38
 
@@ -111,7 +108,26 @@ const { scene, candidates } = findScene();
111
108
  if (!scene) {
112
109
  const report = ladderVerdict([], { candidates });
113
110
  console.log(asJson ? JSON.stringify(report, null, 2) : ladderText(report));
114
- process.exit(0);
111
+ /*
112
+ * Exit 2, not 0.
113
+ *
114
+ * "Unmeasured is not a failure" is the rule for a RUNG — the game is fine and
115
+ * a question could not be asked. This is different: nothing was measured at
116
+ * ALL, and a CI step whose whole job is `incanto-verify` went green having
117
+ * looked at nothing:
118
+ *
119
+ * 2 scenes here, and guessing between them would verify the wrong one:
120
+ * src/game.scene.json
121
+ * src/title.scene.json
122
+ * $ echo $?
123
+ * 0
124
+ *
125
+ * A distinct code rather than 1, so a script can still tell "your game is
126
+ * broken" from "tell me WHICH game" — and `verdict.measured` says the same
127
+ * thing to anything reading `--json`. Multi-scene is the shape of almost
128
+ * every real game, so this is not an edge case for long.
129
+ */
130
+ process.exit(2);
115
131
  }
116
132
  if (!existsSync(scene) || !statSync(scene).isFile()) {
117
133
  console.error(`no such scene: ${scene}`);
@@ -129,8 +145,20 @@ if (!existsSync(scene) || !statSync(scene).isFile()) {
129
145
  * path to a file sitting right next to the scene it had already found.
130
146
  */
131
147
  function findBehaviors(sceneFile) {
132
- const named = flag('--behaviors');
133
- if (named) return { file: named, guessed: false };
148
+ /*
149
+ * Every `--behaviors`, not the first one.
150
+ *
151
+ * The flag is repeatable in the bins this spawns, and reading only
152
+ * `argv.indexOf` would have silently dropped every file after the first —
153
+ * the CLI accepting an argument and then not using it, which is the shape
154
+ * this round keeps finding. Joined with commas, which is the other form the
155
+ * children take.
156
+ */
157
+ const named = [];
158
+ for (let i = 0; i < argv.length; i++) {
159
+ if (argv[i] === '--behaviors' && argv[i + 1]) named.push(argv[++i]);
160
+ }
161
+ if (named.length > 0) return { file: named.join(','), guessed: false };
134
162
  const near = dirname(sceneFile);
135
163
  for (const dir of [near, join(near, '..'), process.cwd(), join(process.cwd(), 'src')]) {
136
164
  for (const base of ['behaviors', 'behaviours']) {
@@ -275,31 +303,62 @@ if (rungs[0].status === 'pass') {
275
303
  .join(', ') +
276
304
  ')' +
277
305
  (won > 0 ? ` — ${won} did finish it` : ''),
306
+ // The throw itself, not a command to go and find it. This rung used to
307
+ // say "read the throw: incanto-playtest …" — sending its reader to
308
+ // another tool for a line this JSON already carries.
309
+ notes: (out?.errorMessages ?? []).slice(0, 3),
278
310
  fix:
279
311
  tally('error') > 0
280
- ? `read the throw: \`incanto-playtest ${scene}${behaviors ? ` --behaviors ${behaviors}` : ''}\`, and \`engine.stats().errors\` in your own harness`
312
+ ? `fix the script named above, then re-run \`incanto-verify ${scene}${behaviors ? ` --behaviors ${behaviors}` : ''}\``
281
313
  : tally('fell') > 0
282
314
  ? `the player left the world with nothing catching them — give the level a floor, walls, or a respawn (\`incanto-playtest ${scene}${behaviors ? ` --behaviors ${behaviors}` : ''}\` prints where)`
283
315
  : `nothing moved — check the input map and the controller: \`incanto-playtest ${scene}${behaviors ? ` --behaviors ${behaviors}` : ''}\``,
284
316
  });
285
- } else if (r.status === 0) {
317
+ } else if (won > 0) {
318
+ /*
319
+ * `r.status === 0` used to stand in for "it finished the game", and the
320
+ * exit code stopped meaning that: `incanto-playtest` now exits 0 for a
321
+ * scene with nobody to drive, because never being asked is not a failure.
322
+ * This rung read that 0 and reported
323
+ *
324
+ * ✓ plays — 0 of 8 seeded runs finished it
325
+ *
326
+ * on a scene with no character in it, right where it had been careful to
327
+ * say `? plays — nothing here is drivable`. The report has `runs`; ask it
328
+ * what happened rather than asking the child how it felt about it.
329
+ */
286
330
  rungs.push({
287
331
  name: 'plays',
288
332
  status: 'pass',
289
333
  summary: `${won} of ${total} seeded runs finished it`,
290
334
  });
291
335
  } else if (undrivable) {
336
+ /*
337
+ * "Nobody to move" was said about games the bot had PLAYED.
338
+ *
339
+ * The driver has driven the mouse for a while, and this rung only ever
340
+ * asked whether a character could walk — so a flip puzzle whose every tile
341
+ * the bot clicked, whose `clicked` wires all fired and whose board changed
342
+ * state, was told to add a character controller. A whole class of games —
343
+ * match-3, tower defense, cards, point-and-click, RTS, board games — has no
344
+ * character to add, and the advice was to add one anyway.
345
+ */
346
+ const clicks = (out?.runs ?? []).reduce((n, r) => n + (r.clicks ?? 0), 0);
292
347
  rungs.push({
293
348
  name: 'plays',
294
349
  status: 'unmeasured',
295
350
  summary:
296
- out?.hasPlayer === false
297
- ? `nothing here is drivable — ${total} runs had nobody to move`
298
- : `${total} runs went nowhere, and your behaviours were not loaded — that is most of what moves a character`,
351
+ clicks > 0
352
+ ? `played with the MOUSE — ${clicks} click(s) across ${total} runs, and no win to reach`
353
+ : out?.hasPlayer === false
354
+ ? `nothing here is drivable — ${total} runs had nobody to move and nothing to click`
355
+ : `${total} runs went nowhere, and your behaviours were not loaded — that is most of what moves a character`,
299
356
  fix:
300
- out?.hasPlayer === false
301
- ? 'give the player a character controller, the `player` group, or the name Player otherwise no seeded run can play this'
302
- : `name them: \`incanto-verify ${scene} --behaviors src/behaviors.ts\``,
357
+ clicks > 0
358
+ ? 'a mouse game is played, not walked emit `won`/`lost` and this rung can pass'
359
+ : out?.hasPlayer === false
360
+ ? 'give the player a character controller, the `player` group, or the name Player — or `Clickable` on what the cursor drives'
361
+ : `name them: \`incanto-verify ${scene} --behaviors src/behaviors.ts\``,
303
362
  });
304
363
  } else if (noGoal) {
305
364
  rungs.push({
@@ -317,9 +376,22 @@ if (rungs[0].status === 'pass') {
317
376
  rungs.push({
318
377
  name: 'plays',
319
378
  status: 'unmeasured',
379
+ // The parenthetical is only worth printing when it has something in it.
380
+ // It listed `lost` and `unfinished` and nothing else, so a game whose
381
+ // runs all ended at a SCENE SWAP — every tally zero — reported
382
+ // `8 runs played without reaching a win ()`, an empty bracket where the
383
+ // reason should be. `swapped` IS the reason, and it is the one outcome
384
+ // here that explains itself.
320
385
  summary:
321
386
  `${total} runs played without reaching a win` +
322
- ` (${[count(tally('lost'), 'lost'), count(tally('unfinished'), 'ran out the clock')].filter(Boolean).join(', ')})`,
387
+ (() => {
388
+ const why = [
389
+ count(tally('lost'), 'lost'),
390
+ count(tally('unfinished'), 'ran out the clock'),
391
+ count(tally('swapped'), 'changed scene, where this bot stops measuring'),
392
+ ].filter(Boolean);
393
+ return why.length > 0 ? ` (${why.join(', ')})` : '';
394
+ })(),
323
395
  fix: 'nothing here is broken — a win that takes skill or a sequence is out of reach of random play. Judge it with a scripted run: `bun run verify`, or `runScript` from `incanto/test`',
324
396
  });
325
397
  }
@@ -363,13 +435,36 @@ if (rungs[0].status === 'pass') {
363
435
  // arithmetic that cannot be true: `✓ feels — 12 of 9 fired`. The number has
364
436
  // to add up with the `silent` list beside it.
365
437
  const firedDeclared = declaredPaths.length - silent.length;
438
+ /*
439
+ * A rung that exists because "wired but never triggered plays perfectly and
440
+ * feels dead, and every other rung here calls it healthy" — and which then
441
+ * called it healthy itself. `fired.size === 0` failed and ANYTHING else
442
+ * passed, so the flagship starter reported
443
+ *
444
+ * ✓ feels — 13 of 26 fired — silent: /Game/Ward1/ChimeSfx …
445
+ *
446
+ * A tick next to thirteen emitters nobody heard is the same claim the rung
447
+ * was written to stop.
448
+ *
449
+ * It is not a FAILURE either: a random bot does not light three wards, so a
450
+ * win chime it never reached is not a broken wire. That is exactly what
451
+ * `unmeasured` means in this ladder — the tool did not get to look — and it
452
+ * is the one status that cannot be read as approval.
453
+ */
366
454
  rungs.push({
367
455
  name: 'feels',
368
- status: 'pass',
369
- summary:
370
- silent.length === 0
371
- ? `every one of the ${declared} sound/effect nodes fired`
372
- : `${firedDeclared} of ${declared} fired — silent: ${silent.slice(0, 3).join(', ')}${silent.length > 3 ? ` +${silent.length - 3}` : ''}`,
456
+ ...(silent.length === 0
457
+ ? { status: 'pass', summary: `every one of the ${declared} sound/effect nodes fired` }
458
+ : {
459
+ status: 'unmeasured',
460
+ summary:
461
+ `${firedDeclared} of ${declared} fired — silent: ${silent.slice(0, 3).join(', ')}` +
462
+ `${silent.length > 3 ? ` +${silent.length - 3}` : ''}`,
463
+ fix:
464
+ 'a silent emitter is either a broken wire or a moment random play never ' +
465
+ 'reached — the two look identical from here. Reach it in a scripted run ' +
466
+ '(`runScript` from `incanto/test`), and this rung can tell you which.',
467
+ }),
373
468
  });
374
469
  }
375
470
  }
@@ -381,7 +476,7 @@ if (rungs[0].status === 'pass') {
381
476
  {
382
477
  const isMultiplayer = (() => {
383
478
  try {
384
- const doc = JSON.parse(readFileSync(scene, 'utf-8'));
479
+ const doc = readJson(scene);
385
480
  return Boolean(doc?.multiplayer);
386
481
  } catch {
387
482
  return false;
@@ -449,6 +544,30 @@ if (rungs[0].status === 'pass') {
449
544
  ? 'bring the preview window to the front — a hidden tab does not render, and a frame is captured inside a render'
450
545
  : 'start your dev server, open the page, and run this again — the pixels are in the browser',
451
546
  });
547
+ } else if (report.hidden) {
548
+ /*
549
+ * A hidden page is UNMEASURED, not passed.
550
+ *
551
+ * A browser stops animating a page it considers hidden — a window merely
552
+ * covered by another counts — and the back buffer keeps whatever it last
553
+ * drew. Measured at one instant, same page:
554
+ *
555
+ * incanto-frame --do the page is hidden … a frozen game (exit 1)
556
+ * incanto-logs frame rate not measurable — hidden
557
+ * incanto-frame subject fills 17.5% of frame
558
+ * incanto-verify ✓ draws — subject fills 17.5%
559
+ *
560
+ * Two of six rungs reported ✓ off a stale buffer while the engine already
561
+ * knew, and said elsewhere, that the page was drawing nothing. The skills
562
+ * state the rule: an unmeasured rung is not a failure, and this is exactly
563
+ * the unmeasured case.
564
+ */
565
+ rungs.push({
566
+ name: 'draws',
567
+ status: 'unmeasured',
568
+ summary: 'the page is HIDDEN — those pixels are whatever it last drew',
569
+ fix: 'bring the window to the front (one covered by another counts as hidden) and run this again',
570
+ });
452
571
  } else if (report.black) {
453
572
  rungs.push({
454
573
  name: 'draws',
@@ -464,10 +583,26 @@ if (rungs[0].status === 'pass') {
464
583
  fix: 'check the camera: `incanto-frame --out shot.png` and look at what it is aimed at',
465
584
  });
466
585
  } else {
586
+ /*
587
+ * A rung that PASSED can still have something to say, and this one was
588
+ * dropping it. `incanto-frame` prints `CLIPPED by the frame edge` and
589
+ * `the subject fills almost nothing` — real observations about a shot that
590
+ * is technically non-black — and the ladder replaced all of it with one
591
+ * coverage number. The `loads` rung has carried its tool's warnings since
592
+ * the day a scene described as "It will render black" came back green.
593
+ */
594
+ const notes = [];
595
+ if (report.subject.clipped) {
596
+ notes.push('the subject is CLIPPED by the frame edge — part of it is off screen');
597
+ }
598
+ if (report.uniform) {
599
+ notes.push('one flat colour — the camera may be inside geometry, or only the sky is drawn');
600
+ }
467
601
  rungs.push({
468
602
  name: 'draws',
469
603
  status: 'pass',
470
604
  summary: `subject fills ${(report.subject.coverage * 100).toFixed(1)}% of the frame`,
605
+ ...(notes.length > 0 ? { notes } : {}),
471
606
  });
472
607
  }
473
608
  }
@@ -488,21 +623,11 @@ if (rungs[0].status === 'pass') {
488
623
  summary: firstLine(r.stderr) ?? 'no page to ask',
489
624
  fix: 'open the preview page and run this again — the log buffer is in the browser',
490
625
  });
491
- } else if (report.ok) {
492
- rungs.push({ name: 'says', status: 'pass', summary: 'no errors, no failed assets' });
493
626
  } else {
494
- const asset = report.assetErrors?.[0];
495
- rungs.push({
496
- name: 'says',
497
- status: 'fail',
498
- // A game whose grass and water are fine still fails here when its player
499
- // never loaded — which is the case this rung exists for, and the one
500
- // `draws` calls healthy.
501
- summary: asset
502
- ? `asset ${asset.ref} never loaded: ${asset.error}`
503
- : (report.worst?.message ?? `${report.stats?.errors ?? 0} errors were swallowed`),
504
- fix: 'read it in full: `incanto-logs`',
505
- });
627
+ // What it MEANS lives in `verify-ladder` with the rest of the pure half —
628
+ // this rung was reading `ok` and dropping every warning the page reported,
629
+ // and there was nowhere to test that without a browser.
630
+ rungs.push(saysRung(report));
506
631
  }
507
632
  }
508
633
 
@@ -516,10 +641,6 @@ function safeJson(text) {
516
641
  return null;
517
642
  }
518
643
  }
519
- function firstLine(text) {
520
- const line = (text ?? '').split('\n').find((l) => l.trim());
521
- return line ? line.trim() : null;
522
- }
523
644
 
524
645
  const verdict = ladderVerdict(rungs);
525
646
  if (asJson) {
@@ -536,4 +657,4 @@ if (asJson) {
536
657
  // asynchronously, and exiting discards whatever has not flushed. A --json
537
658
  // report read by another program came back truncated — silently, and only
538
659
  // when piped, which is the only way a program reads it.
539
- process.exitCode = verdict.ok ? 0 : 1;
660
+ process.exitCode = verdict.measured ? (verdict.ok ? 0 : 1) : 2;
package/bin/incanto.mjs CHANGED
@@ -23,9 +23,10 @@
23
23
  * the SAME runtime that started this one, which matters because `--behaviors`
24
24
  * loads your TypeScript and bun and node disagree about what they can import.
25
25
  */
26
- import { readFileSync } from 'node:fs';
26
+
27
27
  import { dirname, join } from 'node:path';
28
28
  import { fileURLToPath, pathToFileURL } from 'node:url';
29
+ import { readJson } from './_read-json.mjs';
29
30
 
30
31
  const HERE = dirname(fileURLToPath(import.meta.url));
31
32
 
@@ -40,6 +41,7 @@ const COMMANDS = {
40
41
  feel: ['incanto-feel.mjs', 'measure jump height, run speed, facing'],
41
42
  frame: ['incanto-frame.mjs', 'what the running page is drawing right now'],
42
43
  logs: ['incanto-logs.mjs', 'what the running page has been saying'],
44
+ serve: ['incanto-serve.mjs', 'serve a BUILT game so the tools can measure it'],
43
45
  editor: ['incanto-editor.mjs', 'the visual scene composer'],
44
46
  model: ['incanto-model.mjs', "a GLB's real bounds, animations and rig"],
45
47
  assets: ['incanto-assets.mjs', 'the built-in art and audio catalog'],
@@ -49,7 +51,7 @@ const COMMANDS = {
49
51
 
50
52
  function version() {
51
53
  try {
52
- const pkg = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf-8'));
54
+ const pkg = readJson(join(HERE, '..', 'package.json'));
53
55
  return pkg.version ?? 'unknown';
54
56
  } catch {
55
57
  return 'unknown';