incanto 0.69.0 → 0.71.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 (147) hide show
  1. package/bin/_behaviors-loader.mjs +22 -0
  2. package/bin/_read-json.mjs +28 -0
  3. package/bin/incanto-assets.mjs +5 -4
  4. package/bin/incanto-check.mjs +58 -31
  5. package/bin/incanto-editor.mjs +128 -5
  6. package/bin/incanto-env.mjs +3 -2
  7. package/bin/incanto-feel.mjs +24 -13
  8. package/bin/incanto-frame.mjs +8 -1
  9. package/bin/incanto-multiplay.mjs +11 -9
  10. package/bin/incanto-new.mjs +105 -10
  11. package/bin/incanto-play.mjs +158 -16
  12. package/bin/incanto-playtest.mjs +66 -23
  13. package/bin/incanto-skills.mjs +14 -2
  14. package/bin/incanto-verify.mjs +121 -102
  15. package/bin/incanto.mjs +3 -2
  16. package/dist/2d.d.ts +296 -34
  17. package/dist/2d.js +4 -4
  18. package/dist/3d.d.ts +157 -9
  19. package/dist/3d.js +8 -8
  20. package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
  21. package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
  22. package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
  23. package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
  24. package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
  25. package/dist/debug.d.ts +1 -1
  26. package/dist/debug.js +2 -3
  27. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  28. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  29. package/dist/editor.js +1088 -882
  30. package/dist/env.d.ts +1 -1
  31. package/dist/env.js +5 -3
  32. package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
  33. package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
  34. package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
  35. package/dist/gameplay.d.ts +254 -10
  36. package/dist/gameplay.js +2 -2
  37. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  38. package/dist/index.d.ts +179 -13
  39. package/dist/index.js +10 -12
  40. package/dist/json-CfTjpvW8.js +67 -0
  41. package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
  42. package/dist/net.d.ts +27 -3
  43. package/dist/net.js +2 -2
  44. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  45. package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
  46. package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
  47. package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
  48. package/dist/react.d.ts +2 -2
  49. package/dist/react.js +2 -2
  50. package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
  51. package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
  52. package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
  53. package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
  54. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  55. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  56. package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
  57. package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
  58. package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
  59. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  60. package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
  61. package/dist/test-CmN54kWv.js +4264 -0
  62. package/dist/test.d.ts +639 -30
  63. package/dist/test.js +3 -3
  64. package/dist/touch-BnCyPA0G.js +519 -0
  65. package/dist/vite.d.ts +20 -37
  66. package/dist/vite.js +177 -7
  67. package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
  68. package/editor/assets/agent8-yLIEYHbd.js +1 -0
  69. package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
  70. package/editor/assets/index-DK9xMGpW.js +11046 -0
  71. package/editor/index.html +1 -1
  72. package/package.json +4 -14
  73. package/schemas/scene.schema.json +34 -3
  74. package/skills/incanto-3d-character.md +14 -1
  75. package/skills/incanto-3d-models.md +12 -0
  76. package/skills/incanto-assets.md +9 -1
  77. package/skills/incanto-audio.md +8 -2
  78. package/skills/incanto-behaviors-and-scripts.md +105 -7
  79. package/skills/incanto-building-2d-games.md +97 -10
  80. package/skills/incanto-building-3d-games.md +58 -4
  81. package/skills/incanto-editor.md +46 -7
  82. package/skills/incanto-environment.md +19 -1
  83. package/skills/incanto-game-feel.md +70 -0
  84. package/skills/incanto-gameplay-behaviors.md +172 -20
  85. package/skills/incanto-hud.md +142 -7
  86. package/skills/incanto-localization.md +13 -5
  87. package/skills/incanto-multiplayer.md +83 -3
  88. package/skills/incanto-node-reference.md +239 -59
  89. package/skills/incanto-performance.md +52 -0
  90. package/skills/incanto-physics-and-input.md +123 -24
  91. package/skills/incanto-playtesting.md +112 -2
  92. package/skills/incanto-save-slots.md +217 -7
  93. package/skills/incanto-scene-json-authoring.md +69 -12
  94. package/skills/incanto-verifying-your-game.md +207 -10
  95. package/skills/incanto-web-integration.md +28 -0
  96. package/skills/incanto-your-first-game.md +3 -1
  97. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  98. package/templates-app/beacon-isle-3d/package.json +2 -2
  99. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  100. package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
  101. package/templates-app/beacon-isle-3d/src/main.ts +11 -1
  102. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  103. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  104. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  105. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  106. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  107. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  108. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  109. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  110. package/templates-app/molehill-2d/index.html +73 -0
  111. package/templates-app/molehill-2d/package.json +23 -0
  112. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  113. package/templates-app/molehill-2d/src/game.scene.json +1205 -0
  114. package/templates-app/molehill-2d/src/main.ts +41 -0
  115. package/templates-app/molehill-2d/tsconfig.json +13 -0
  116. package/templates-app/molehill-2d/verify.ts +247 -0
  117. package/templates-app/molehill-2d/vite.config.ts +12 -0
  118. package/templates-app/platformer-2d/package.json +2 -2
  119. package/templates-app/platformer-2d/src/game.scene.json +178 -986
  120. package/templates-app/platformer-2d/src/main.ts +11 -1
  121. package/templates-app/platformer-2d/tsconfig.json +1 -1
  122. package/templates-app/star-survivor/package.json +2 -2
  123. package/templates-app/star-survivor/src/game.scene.json +43 -216
  124. package/templates-app/star-survivor/src/main.ts +11 -1
  125. package/templates-app/star-survivor/tsconfig.json +1 -1
  126. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  127. package/templates-app/tps-3d/package.json +2 -2
  128. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  129. package/templates-app/tps-3d/src/game.scene.json +66 -263
  130. package/templates-app/tps-3d/src/main.ts +13 -2
  131. package/templates-app/tps-3d/tsconfig.json +1 -1
  132. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  133. package/templates-app/village-quest-3d/package.json +2 -2
  134. package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
  135. package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
  136. package/templates-app/village-quest-3d/src/main.ts +11 -1
  137. package/templates-app/village-quest-3d/src/village.scene.json +202 -863
  138. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  139. package/templates-app/village-quest-3d/verify.ts +14 -1
  140. package/dist/duplicate-Dj4L-5AA.js +0 -22
  141. package/dist/json-CwwhxQgb.js +0 -36
  142. package/dist/registry-CF70EArN.js +0 -212
  143. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  144. package/dist/test-D6ISmuFE.js +0 -2383
  145. package/dist/touch-DEAmqGdf.js +0 -225
  146. package/editor/assets/agent8-BDvaZMQJ.js +0 -1
  147. package/editor/assets/index-CZIRr5EL.js +0 -10958
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { dirname, join, resolve } from 'node:path';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, relative, resolve } from 'node:path';
4
4
  /**
5
5
  * incanto-playtest — the engine plays your game and tells you whether it works.
6
6
  *
@@ -23,26 +23,50 @@ import { dirname, join, resolve } from 'node:path';
23
23
  * Exit code 1 when no run reached a win, so CI can gate on it.
24
24
  */
25
25
  import { fileURLToPath, pathToFileURL } from 'node:url';
26
- import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
26
+ import { behaviorFiles, behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
27
+ import { readJson } from './_read-json.mjs';
27
28
 
28
29
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
30
+ const PKG_VERSION = readJson(join(PKG, 'package.json')).version;
31
+
32
+ /**
33
+ * A numeric flag with a missing or non-numeric value is a TYPO.
34
+ *
35
+ * `--runs $RUNS` with RUNS unset swallowed the NEXT flag as its value and gave
36
+ * `Number(undefined)` = NaN, which ran ZERO runs and then printed a confident
37
+ * report asserting the opposite of the truth about the scene — exit 0.
38
+ */
39
+ function num(raw, flag) {
40
+ const n = Number(raw);
41
+ if (raw === undefined || raw === '' || !Number.isFinite(n)) {
42
+ console.error(`${flag} needs a number, got ${raw === undefined ? 'nothing' : `'${raw}'`}`);
43
+ process.exit(1);
44
+ }
45
+ return n;
46
+ }
29
47
 
30
48
  function parseArgs(argv) {
31
49
  const args = { runs: 20, seconds: 60, seed: 1, out: '.incanto/playtest' };
32
50
  for (let i = 0; i < argv.length; i++) {
33
51
  const a = argv[i];
34
- if (a === '--behaviors') args.behaviors = argv[++i];
35
- else if (a === '--runs') args.runs = Number(argv[++i]);
36
- else if (a === '--seconds') args.seconds = Number(argv[++i]);
37
- else if (a === '--seed') args.seed = Number(argv[++i]);
38
- else if (a === '--fall-below') args.fallBelow = Number(argv[++i]);
39
- else if (a === '--reach-radius') args.reachRadius = Number(argv[++i]);
52
+ // Repeatable: behaviours legitimately live in more than one file.
53
+ if (a === '--behaviors') args.behaviors = [...(args.behaviors ?? []), argv[++i]];
54
+ else if (a === '--runs') args.runs = num(argv[++i], '--runs');
55
+ else if (a === '--seconds') args.seconds = num(argv[++i], '--seconds');
56
+ else if (a === '--seed') args.seed = num(argv[++i], '--seed');
57
+ else if (a === '--fall-below') args.fallBelow = num(argv[++i], '--fall-below');
58
+ else if (a === '--reach-radius') args.reachRadius = num(argv[++i], '--reach-radius');
40
59
  else if (a === '--out') args.out = argv[++i];
41
60
  else if (a === '--no-replays') args.noReplays = true;
42
61
  else if (a === '--json') args.json = true;
43
62
  else if (a === '--help' || a === '-h') args.help = true;
44
63
  else if (a.startsWith('--')) {
64
+ // `invalid`, not `help`: printing usage and exiting 0 told an agent its
65
+ // typo was a request. `--runs=4`, `--behaviours`, `--no-replay` all landed
66
+ // here and looked like success — and with `--json` the machine path was
67
+ // literally silent (0 bytes on stdout).
45
68
  console.error(`unknown flag: ${a}`);
69
+ args.invalid = true;
46
70
  args.help = true;
47
71
  } else if (!args.scene) args.scene = a;
48
72
  }
@@ -60,24 +84,28 @@ if (args.help || !args.scene) {
60
84
  3D, 1000 px under it in 2D — 2D counts DOWN the screen)
61
85
  --reach-radius R how close counts as "reached" (default 2 in 3D, 32 px in 2D)
62
86
  --behaviors FILE your Behavior subclasses (.ts works on node >= 23.6 / bun)
87
+ comma-separate or repeat for several files
63
88
  --out DIR where failing replays go (default .incanto/playtest)
64
89
  --no-replays report only, write nothing
65
90
  --json the full report as JSON instead of prose
66
91
 
67
- Exits 1 when no run reached a win.`);
68
- process.exit(args.help && args.scene !== undefined ? 0 : 1);
92
+ Exits 1 when a run THREW or left the world, or when a win is declared and no
93
+ run reached it. A scene that declares no win, or has nobody to drive, exits 0
94
+ the report marks those · rather than ✗, and the exit code says the same.`);
95
+ process.exit(!args.invalid && args.help && args.scene !== undefined ? 0 : 1);
69
96
  }
70
97
 
71
- const { playtest, playtestText, failingReplays } = await import(
98
+ const { playtest, playtestText, playtestFailed, failingReplays } = await import(
72
99
  pathToFileURL(join(PKG, 'dist', 'test.js')).href
73
100
  );
74
101
  const incanto = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
75
102
 
76
103
  const behaviors = {};
77
- if (args.behaviors) {
104
+ const behaviorSources = behaviorFiles(args.behaviors);
105
+ for (const file of behaviorSources) {
78
106
  let mod;
79
107
  try {
80
- mod = await import(pathToFileURL(resolve(args.behaviors)).href);
108
+ mod = await import(pathToFileURL(resolve(file)).href);
81
109
  } catch (e) {
82
110
  const why = e?.message ?? String(e);
83
111
  // NODE CANNOT IMPORT THE PATTERN WE TEACH. Every template's behaviors.ts
@@ -94,7 +122,7 @@ if (args.behaviors) {
94
122
  // `village-quest-3d` starter, and it breaks for any author who splits their
95
123
  // behaviours across files — which is most of them, eventually.
96
124
  reexecUnderBun(import.meta.url, why);
97
- console.error(behaviorsHelp(args.behaviors, why));
125
+ console.error(behaviorsHelp(file, why));
98
126
  process.exit(1);
99
127
  }
100
128
  for (const [name, value] of Object.entries(mod)) {
@@ -102,13 +130,13 @@ if (args.behaviors) {
102
130
  behaviors[name] = value;
103
131
  }
104
132
  }
105
- if (Object.keys(behaviors).length === 0) {
106
- console.error(`warning: no Behavior subclasses exported from ${args.behaviors}`);
107
- }
133
+ }
134
+ if (behaviorSources.length > 0 && Object.keys(behaviors).length === 0) {
135
+ console.error(`warning: no Behavior subclasses exported from ${behaviorSources.join(', ')}`);
108
136
  }
109
137
 
110
138
  const scenePath = resolve(args.scene);
111
- const sceneJson = JSON.parse(readFileSync(scenePath, 'utf-8'));
139
+ const sceneJson = readJson(scenePath);
112
140
  const sceneDir = dirname(scenePath);
113
141
 
114
142
  let report;
@@ -118,11 +146,11 @@ try {
118
146
  seconds: args.seconds,
119
147
  seed: args.seed,
120
148
  behaviors,
121
- stubMissingBehaviors: !args.behaviors,
149
+ stubMissingBehaviors: behaviorSources.length === 0,
122
150
  ...(Number.isFinite(args.fallBelow) ? { fallBelow: args.fallBelow } : {}),
123
151
  ...(Number.isFinite(args.reachRadius) ? { reachRadius: args.reachRadius } : {}),
124
152
  // sub-scenes resolve relative to the scene file, as the loader does
125
- resolveScene: (p) => JSON.parse(readFileSync(resolve(sceneDir, p), 'utf-8')),
153
+ resolveScene: (p) => readJson(resolve(sceneDir, p)),
126
154
  });
127
155
  } catch (e) {
128
156
  console.error(`playtest failed: ${e?.message ?? e}`);
@@ -140,12 +168,27 @@ if (args.json) {
140
168
  if (!args.noReplays && !args.json) {
141
169
  const seen = new Set();
142
170
  const written = [];
171
+ /*
172
+ * Stamp WHICH GAME each recording is of.
173
+ *
174
+ * `.incanto/playtest` is keyed by `(outcome, seed)` and nothing else, and
175
+ * never cleaned. So the second playtest in a project either leaves a stale
176
+ * `stuck-seed3.json` from a scene that no longer exists sitting beside the
177
+ * current one, or silently overwrites another scene's — and
178
+ * `incanto-play --commands` happily replays any recording against any scene
179
+ * and reports success, because nothing in the file said what it was of.
180
+ */
181
+ const stamp = {
182
+ path: relative(process.cwd(), scenePath) || scenePath,
183
+ name: typeof sceneJson?.name === 'string' ? sceneJson.name : undefined,
184
+ engine: PKG_VERSION,
185
+ };
143
186
  for (const { seed, outcome, replay } of failingReplays(report)) {
144
187
  if (seen.has(outcome)) continue;
145
188
  seen.add(outcome);
146
189
  const file = join(resolve(args.out), `${outcome}-seed${seed}.json`);
147
190
  mkdirSync(dirname(file), { recursive: true });
148
- writeFileSync(file, JSON.stringify(replay, null, 2));
191
+ writeFileSync(file, JSON.stringify({ ...replay, scene: stamp }, null, 2));
149
192
  written.push(file);
150
193
  }
151
194
  if (written.length > 0) {
@@ -157,4 +200,4 @@ if (!args.noReplays && !args.json) {
157
200
  // asynchronously, and exiting discards whatever has not flushed. A --json
158
201
  // report read by another program came back truncated — silently, and only
159
202
  // when piped, which is the only way a program reads it.
160
- process.exitCode = report.runs.some((r) => r.outcome === 'won') ? 0 : 1;
203
+ process.exitCode = playtestFailed(report) ? 1 : 0;
@@ -22,8 +22,19 @@ function parseArgs(argv) {
22
22
  if (argv[i] === '--format') args.format = argv[++i];
23
23
  else if (argv[i] === '--out') args.out = argv[++i];
24
24
  else if (argv[i] === '--help' || argv[i] === '-h') args.help = true;
25
+ /*
26
+ * The bare word, because that is what everything tells you to type.
27
+ *
28
+ * `incanto-new`'s closing message — the last thing a scaffolded project
29
+ * prints, and the line that installs the skills the whole workflow depends
30
+ * on — said `bunx incanto-skills claude`, and that was an unknown argument.
31
+ * The flag form stays; this makes the printed line true.
32
+ */ else if (argv[i] === 'claude' || argv[i] === 'opencode') args.format = argv[i];
25
33
  else {
34
+ // Not `help`: printing usage and exiting 0 for a typo meant
35
+ // `incanto-skills --oops` installed ZERO skills and reported success.
26
36
  console.error(`unknown argument: ${argv[i]}`);
37
+ args.invalid = true;
27
38
  args.help = true;
28
39
  }
29
40
  }
@@ -32,7 +43,8 @@ function parseArgs(argv) {
32
43
 
33
44
  const args = parseArgs(process.argv.slice(2));
34
45
  if (args.help || !['claude', 'opencode'].includes(args.format)) {
35
- console.log(`Usage: npx incanto-skills [--format claude|opencode] [--out <dir>]
46
+ console.log(`Usage: npx incanto-skills [claude|opencode] [--out <dir>]
47
+ npx incanto-skills [--format claude|opencode] [--out <dir>]
36
48
 
37
49
  claude (default) Claude Code project skills:
38
50
  <out>/<skill-name>/SKILL.md (default out: .claude/skills)
@@ -40,7 +52,7 @@ if (args.help || !['claude', 'opencode'].includes(args.format)) {
40
52
  <out>/<skill-name>.md (default out: skills)
41
53
 
42
54
  Skills are read from the installed incanto package and match its version.`);
43
- process.exit(args.help ? 0 : 1);
55
+ process.exit(!args.invalid && args.help ? 0 : 1);
44
56
  }
45
57
 
46
58
  const outRoot = resolve(args.out ?? (args.format === 'claude' ? '.claude/skills' : 'skills'));
@@ -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 { feelsRung, firstLine, ladderText, ladderVerdict, saysRung, unmeasuredPlays } = 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']) {
@@ -190,6 +218,9 @@ const rungs = [];
190
218
  /** The playtest's JSON, shared by the `plays` and `feels` rungs. */
191
219
  let playtestReport = null;
192
220
 
221
+ /** Did the bot get the game MOVING? Set by the `plays` rung, read by `feels`. */
222
+ let botPlayed = true;
223
+
193
224
  // ---- plays ---------------------------------------------------------------
194
225
  if (rungs[0].status === 'pass') {
195
226
  const args = [scene, '--json', '--runs', '8'];
@@ -246,6 +277,8 @@ if (rungs[0].status === 'pass') {
246
277
  const DEFECT = new Set(stuckCounts ? ['error', 'fell', 'stuck'] : ['error', 'fell']);
247
278
  const defects = out?.runs?.filter((x) => DEFECT.has(x.outcome)) ?? [];
248
279
  const undrivable = total > 0 && defects.length === 0 && tally('stuck') === total;
280
+ // …and the `feels` rung below asks the same question, so it is answered once.
281
+ botPlayed = !undrivable;
249
282
  const count = (n, word) => n && `${n} ${word}`;
250
283
 
251
284
  if (total === 0) {
@@ -275,32 +308,50 @@ if (rungs[0].status === 'pass') {
275
308
  .join(', ') +
276
309
  ')' +
277
310
  (won > 0 ? ` — ${won} did finish it` : ''),
311
+ // The throw itself, not a command to go and find it. This rung used to
312
+ // say "read the throw: incanto-playtest …" — sending its reader to
313
+ // another tool for a line this JSON already carries.
314
+ notes: (out?.errorMessages ?? []).slice(0, 3),
278
315
  fix:
279
316
  tally('error') > 0
280
- ? `read the throw: \`incanto-playtest ${scene}${behaviors ? ` --behaviors ${behaviors}` : ''}\`, and \`engine.stats().errors\` in your own harness`
317
+ ? `fix the script named above, then re-run \`incanto-verify ${scene}${behaviors ? ` --behaviors ${behaviors}` : ''}\``
281
318
  : tally('fell') > 0
282
319
  ? `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
320
  : `nothing moved — check the input map and the controller: \`incanto-playtest ${scene}${behaviors ? ` --behaviors ${behaviors}` : ''}\``,
284
321
  });
285
- } else if (r.status === 0) {
322
+ } else if (won > 0) {
323
+ /*
324
+ * `r.status === 0` used to stand in for "it finished the game", and the
325
+ * exit code stopped meaning that: `incanto-playtest` now exits 0 for a
326
+ * scene with nobody to drive, because never being asked is not a failure.
327
+ * This rung read that 0 and reported
328
+ *
329
+ * ✓ plays — 0 of 8 seeded runs finished it
330
+ *
331
+ * on a scene with no character in it, right where it had been careful to
332
+ * say `? plays — nothing here is drivable`. The report has `runs`; ask it
333
+ * what happened rather than asking the child how it felt about it.
334
+ */
286
335
  rungs.push({
287
336
  name: 'plays',
288
337
  status: 'pass',
289
338
  summary: `${won} of ${total} seeded runs finished it`,
290
339
  });
291
340
  } else if (undrivable) {
292
- rungs.push({
293
- name: 'plays',
294
- status: 'unmeasured',
295
- 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`,
299
- 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\``,
303
- });
341
+ /*
342
+ * "Nobody to move" was said about games the bot had PLAYED.
343
+ *
344
+ * The driver has driven the mouse for a while, and this rung only ever
345
+ * asked whether a character could walk — so a flip puzzle whose every tile
346
+ * the bot clicked, whose `clicked` wires all fired and whose board changed
347
+ * state, was told to add a character controller. A whole class of games
348
+ * match-3, tower defense, cards, point-and-click, RTS, board games — has no
349
+ * character to add, and the advice was to add one anyway.
350
+ */
351
+ // The wording lives in `verify-ladder.ts` (pure, and tested there) —
352
+ // this branch owns only the fact that the bot could not WALK.
353
+ const said = unmeasuredPlays({ ...(out ?? {}), scene });
354
+ rungs.push({ name: 'plays', status: 'unmeasured', summary: said.summary, fix: said.fix });
304
355
  } else if (noGoal) {
305
356
  rungs.push({
306
357
  name: 'plays',
@@ -317,9 +368,22 @@ if (rungs[0].status === 'pass') {
317
368
  rungs.push({
318
369
  name: 'plays',
319
370
  status: 'unmeasured',
371
+ // The parenthetical is only worth printing when it has something in it.
372
+ // It listed `lost` and `unfinished` and nothing else, so a game whose
373
+ // runs all ended at a SCENE SWAP — every tally zero — reported
374
+ // `8 runs played without reaching a win ()`, an empty bracket where the
375
+ // reason should be. `swapped` IS the reason, and it is the one outcome
376
+ // here that explains itself.
320
377
  summary:
321
378
  `${total} runs played without reaching a win` +
322
- ` (${[count(tally('lost'), 'lost'), count(tally('unfinished'), 'ran out the clock')].filter(Boolean).join(', ')})`,
379
+ (() => {
380
+ const why = [
381
+ count(tally('lost'), 'lost'),
382
+ count(tally('unfinished'), 'ran out the clock'),
383
+ count(tally('swapped'), 'changed scene, where this bot stops measuring'),
384
+ ].filter(Boolean);
385
+ return why.length > 0 ? ` (${why.join(', ')})` : '';
386
+ })(),
323
387
  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
388
  });
325
389
  }
@@ -331,48 +395,10 @@ if (rungs[0].status === 'pass') {
331
395
  // Sound and effects, from the same playtest that just ran. A game whose
332
396
  // feedback is wired but never triggered plays perfectly and feels dead, and
333
397
  // every other rung here calls it healthy.
334
- {
335
- const fb = playtestReport?.feedback;
336
- const declared = (fb?.declaredAudio?.length ?? 0) + (fb?.declaredEffects?.length ?? 0);
337
- const fired = new Set([...(fb?.heard ?? []), ...(fb?.shown ?? [])]);
338
- if (!fb) {
339
- rungs.push({
340
- name: 'feels',
341
- status: 'skipped',
342
- summary: 'not run — the playtest did not report',
343
- });
344
- } else if (declared === 0) {
345
- rungs.push({
346
- name: 'feels',
347
- status: 'skipped',
348
- summary: 'this game declares no sound and no effects',
349
- });
350
- } else if (fired.size === 0) {
351
- rungs.push({
352
- name: 'feels',
353
- status: 'fail',
354
- summary: `${declared} sound/effect node(s) declared, and not one fired in any run`,
355
- fix: 'connect them — a signal to `play` on an AudioPlayer, or `replay()` on a one-shot; `engine.audio.recent()` and `engine.effects.recent()` say what did fire',
356
- });
357
- } else {
358
- const declaredPaths = [...(fb.declaredAudio ?? []), ...(fb.declaredEffects ?? [])];
359
- const silent = declaredPaths.filter((path) => !fired.has(path));
360
- // Of the nodes this rung is ABOUT, how many fired. `fired` also holds paths
361
- // nobody declared — a spawned clone sounds from its own path, a procedural
362
- // preset from wherever a behaviour played it — so using its size printed
363
- // arithmetic that cannot be true: `✓ feels — 12 of 9 fired`. The number has
364
- // to add up with the `silent` list beside it.
365
- const firedDeclared = declaredPaths.length - silent.length;
366
- rungs.push({
367
- 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}` : ''}`,
373
- });
374
- }
375
- }
398
+ // The wording lives in `verify-ladder.ts` (pure, and tested there).
399
+ // `undrivable` is the plays rung's own "the bot never got it moving", so the
400
+ // two never disagree about whether anything was played.
401
+ rungs.push(feelsRung(playtestReport?.feedback, { played: botPlayed }));
376
402
 
377
403
  // ---- agrees --------------------------------------------------------------
378
404
  // A desync is the purest silent failure this engine has: both clients run,
@@ -381,7 +407,7 @@ if (rungs[0].status === 'pass') {
381
407
  {
382
408
  const isMultiplayer = (() => {
383
409
  try {
384
- const doc = JSON.parse(readFileSync(scene, 'utf-8'));
410
+ const doc = readJson(scene);
385
411
  return Boolean(doc?.multiplayer);
386
412
  } catch {
387
413
  return false;
@@ -449,28 +475,27 @@ if (rungs[0].status === 'pass') {
449
475
  ? 'bring the preview window to the front — a hidden tab does not render, and a frame is captured inside a render'
450
476
  : 'start your dev server, open the page, and run this again — the pixels are in the browser',
451
477
  });
452
- } else if (report.hidden) {
478
+ } else if (report.hidden && report.droveFrames === 0) {
453
479
  /*
454
- * A hidden page is UNMEASURED, not passed.
480
+ * A hidden page cannot be DRIVEN, and that one is still unmeasured.
455
481
  *
456
- * A browser stops animating a page it considers hidden a window merely
457
- * covered by another counts and the back buffer keeps whatever it last
458
- * drew. Measured at one instant, same page:
482
+ * The rung used to refuse any hidden page, on the belief that its pixels
483
+ * were "whatever it last drew". They are not: `captureFrame` in both
484
+ * renderers waits ~120 ms for a loop that a hidden tab never runs, and then
485
+ * draws a frame itself. Measured — hide every node from the console and
486
+ * capture again: 2D `luminance 0.09 → 0.00 BLACK SCREEN`, 3D `0.64 → 0.96,
487
+ * subject 44.3% → 22.0%`.
459
488
  *
460
- * incanto-frame --do the page is hidden a frozen game (exit 1)
461
- * incanto-logs frame rate not measurablehidden
462
- * incanto-frame subject fills 17.5% of frame
463
- * incanto-verify ✓ draws subject fills 17.5%
464
- *
465
- * Two of six rungs reported ✓ off a stale buffer while the engine already
466
- * knew, and said elsewhere, that the page was drawing nothing. The skills
467
- * state the rule: an unmeasured rung is not a failure, and this is exactly
468
- * the unmeasured case.
489
+ * So a plain capture is current and measurable, and the ONLY thing a hidden
490
+ * page cannot do is advance which is exactly what a drive needs, and what
491
+ * `droveFrames: 0` reports. Refusing both cost this rung every measurement
492
+ * it could have made in the setup the whole toolchain is built for: a
493
+ * terminal in front and a browser behind it.
469
494
  */
470
495
  rungs.push({
471
496
  name: 'draws',
472
497
  status: 'unmeasured',
473
- summary: 'the page is HIDDEN — those pixels are whatever it last drew',
498
+ summary: 'the drive ran on a HIDDEN page 0 frames drawn, so nothing it did shows here',
474
499
  fix: 'bring the window to the front (one covered by another counts as hidden) and run this again',
475
500
  });
476
501
  } else if (report.black) {
@@ -503,6 +528,14 @@ if (rungs[0].status === 'pass') {
503
528
  if (report.uniform) {
504
529
  notes.push('one flat colour — the camera may be inside geometry, or only the sky is drawn');
505
530
  }
531
+ // Measured, not refused — but the reader still has to know the world was
532
+ // not moving while it was drawn.
533
+ if (report.hidden) {
534
+ notes.push(
535
+ 'the page is hidden, so the game is PAUSED — this frame was rendered for the ' +
536
+ 'capture, and shows the moment it froze',
537
+ );
538
+ }
506
539
  rungs.push({
507
540
  name: 'draws',
508
541
  status: 'pass',
@@ -528,21 +561,11 @@ if (rungs[0].status === 'pass') {
528
561
  summary: firstLine(r.stderr) ?? 'no page to ask',
529
562
  fix: 'open the preview page and run this again — the log buffer is in the browser',
530
563
  });
531
- } else if (report.ok) {
532
- rungs.push({ name: 'says', status: 'pass', summary: 'no errors, no failed assets' });
533
564
  } else {
534
- const asset = report.assetErrors?.[0];
535
- rungs.push({
536
- name: 'says',
537
- status: 'fail',
538
- // A game whose grass and water are fine still fails here when its player
539
- // never loaded — which is the case this rung exists for, and the one
540
- // `draws` calls healthy.
541
- summary: asset
542
- ? `asset ${asset.ref} never loaded: ${asset.error}`
543
- : (report.worst?.message ?? `${report.stats?.errors ?? 0} errors were swallowed`),
544
- fix: 'read it in full: `incanto-logs`',
545
- });
565
+ // What it MEANS lives in `verify-ladder` with the rest of the pure half —
566
+ // this rung was reading `ok` and dropping every warning the page reported,
567
+ // and there was nowhere to test that without a browser.
568
+ rungs.push(saysRung(report));
546
569
  }
547
570
  }
548
571
 
@@ -556,10 +579,6 @@ function safeJson(text) {
556
579
  return null;
557
580
  }
558
581
  }
559
- function firstLine(text) {
560
- const line = (text ?? '').split('\n').find((l) => l.trim());
561
- return line ? line.trim() : null;
562
- }
563
582
 
564
583
  const verdict = ladderVerdict(rungs);
565
584
  if (asJson) {
@@ -576,4 +595,4 @@ if (asJson) {
576
595
  // asynchronously, and exiting discards whatever has not flushed. A --json
577
596
  // report read by another program came back truncated — silently, and only
578
597
  // when piped, which is the only way a program reads it.
579
- process.exitCode = verdict.ok ? 0 : 1;
598
+ 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
 
@@ -50,7 +51,7 @@ const COMMANDS = {
50
51
 
51
52
  function version() {
52
53
  try {
53
- const pkg = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf-8'));
54
+ const pkg = readJson(join(HERE, '..', 'package.json'));
54
55
  return pkg.version ?? 'unknown';
55
56
  } catch {
56
57
  return 'unknown';