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
@@ -17,10 +17,11 @@
17
17
  * reconnect that never re-joins, a throttle that discards a payload, a batch
18
18
  * dropped on a closed socket.
19
19
  */
20
- import { existsSync, readFileSync } from 'node:fs';
20
+ import { existsSync } from 'node:fs';
21
21
  import { dirname, join, resolve } from 'node:path';
22
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
23
- import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
23
+ import { behaviorFiles, behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
24
+ import { readJson } from './_read-json.mjs';
24
25
 
25
26
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
26
27
 
@@ -37,6 +38,7 @@ if (argv.includes('--help') || argv.includes('-h')) {
37
38
  --seconds N simulated seconds (default 5)
38
39
  --seed N make the match reproducible (default 1)
39
40
  --behaviors FILE your Behavior subclasses (.ts works on bun / node >= 23.6)
41
+ comma-separate or repeat for several files
40
42
  --server FILE your Server class (default: server/src/server.ts if present)
41
43
  --scenes a=b.json a scene a NetworkSpawner names, repeatable
42
44
  --no-late-join skip the late-joining client
@@ -65,7 +67,7 @@ const { playMultiplayer, multiplayText, registerAllNodes } = await import(
65
67
  );
66
68
  registerAllNodes();
67
69
 
68
- const sceneJson = JSON.parse(readFileSync(scenePath, 'utf-8'));
70
+ const sceneJson = readJson(scenePath);
69
71
  const sceneDir = dirname(scenePath);
70
72
 
71
73
  // Without the game's own behaviours a scene that names one throws
@@ -73,9 +75,9 @@ const sceneDir = dirname(scenePath);
73
75
  // given them, and stub what is left. A stubbed behaviour still lets the
74
76
  // SCENE's replication run, which is what this command measures.
75
77
  const behaviorsFile = flag('--behaviors', null);
76
- if (behaviorsFile) {
78
+ for (const file of behaviorFiles(behaviorsFile)) {
77
79
  try {
78
- const mod = await import(pathToFileURL(resolve(behaviorsFile)).href);
80
+ const mod = await import(pathToFileURL(resolve(file)).href);
79
81
  const { registerBehavior, Behavior } = await import(
80
82
  pathToFileURL(join(PKG, 'dist', 'index.js')).href
81
83
  );
@@ -87,7 +89,7 @@ if (behaviorsFile) {
87
89
  } catch (e) {
88
90
  const why = e?.message ?? String(e);
89
91
  reexecUnderBun(import.meta.url, why);
90
- console.error(behaviorsHelp(behaviorsFile, why));
92
+ console.error(behaviorsHelp(file, why));
91
93
  process.exit(1);
92
94
  }
93
95
  }
@@ -105,7 +107,7 @@ const scenes = {};
105
107
  for (let i = 0; i < argv.length; i++) {
106
108
  if (argv[i] !== '--scenes') continue;
107
109
  const [key, file] = String(argv[i + 1] ?? '').split('=');
108
- if (key && file) scenes[key] = JSON.parse(readFileSync(resolve(sceneDir, file), 'utf-8'));
110
+ if (key && file) scenes[key] = readJson(resolve(sceneDir, file));
109
111
  }
110
112
  const spawnerScenes = new Set();
111
113
  const walk = (node) => {
@@ -120,7 +122,7 @@ for (const name of spawnerScenes) {
120
122
  for (const guess of [`${name}.scene.json`, `${name}.json`]) {
121
123
  const at = resolve(sceneDir, guess);
122
124
  if (existsSync(at)) {
123
- scenes[name] = JSON.parse(readFileSync(at, 'utf-8'));
125
+ scenes[name] = readJson(at);
124
126
  break;
125
127
  }
126
128
  }
@@ -182,7 +184,7 @@ try {
182
184
  lateJoin: !argv.includes('--no-late-join'),
183
185
  load: {
184
186
  stubMissingBehaviors: true,
185
- resolveScene: (p) => JSON.parse(readFileSync(resolve(sceneDir, p), 'utf-8')),
187
+ resolveScene: (p) => readJson(resolve(sceneDir, p)),
186
188
  },
187
189
  // Move the owner, or every replicated key holds its authored value and two
188
190
  // motionless clients agree about nothing in particular.
@@ -10,9 +10,11 @@
10
10
  * three commands that get you playing. Templates are real npm consumers —
11
11
  * no monorepo, no aliases.
12
12
  */
13
- import { cpSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { cpSync, existsSync, readdirSync, writeFileSync } from 'node:fs';
14
15
  import { basename, dirname, join, resolve } from 'node:path';
15
16
  import { fileURLToPath } from 'node:url';
17
+ import { readJson } from './_read-json.mjs';
16
18
 
17
19
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
18
20
  const TEMPLATES_DIR = join(PKG, 'templates-app');
@@ -26,6 +28,7 @@ const DESCRIPTIONS = {
26
28
  'side-scrolling platformer (the 2D flagship): tilemap level, coyote-time jump, follow cam, coins',
27
29
  'star-survivor':
28
30
  'survivors-like: waves that never stop, auto-attack, upgrades, a clock to outlast',
31
+ 'molehill-2d': 'played with the MOUSE: whack-a-mole — clickable nodes, no character, no keyboard',
29
32
  };
30
33
 
31
34
  /**
@@ -64,17 +67,46 @@ if (argv.includes('--list') || argv.includes('-l')) {
64
67
 
65
68
  let target;
66
69
  let template = 'beacon-isle-3d';
70
+ /*
71
+ * An unknown flag is a TYPO, not a request.
72
+ *
73
+ * This loop dropped anything it did not recognise, so `--tempalte platformer-2d`
74
+ * — and `--template=platformer-2d`, which is the form an agent types —
75
+ * scaffolded the default 3D game and exited 0. The funnel command silently
76
+ * building the wrong game is the most expensive silence in the package.
77
+ */
78
+ const usage =
79
+ 'usage: incanto-new <dir> [--template <name>] [--local] | --list\n' +
80
+ ' --local pin `file:` to the incanto this command is running from,\n' +
81
+ ' instead of the registry — for verifying a release candidate.\n' +
82
+ ' From a source checkout it PACKS first, because a `file:` pin at\n' +
83
+ ' a workspace package is not installable.';
84
+ const KNOWN_FLAGS = new Set(['--template', '-t', '--local', '--list', '-l', '--help', '-h']);
67
85
  for (let i = 0; i < argv.length; i++) {
68
- if (argv[i] === '--template' || argv[i] === '-t') template = argv[++i];
69
- else if (!argv[i].startsWith('-') && !target) target = argv[i];
86
+ const a = argv[i];
87
+ if (a === '--template' || a === '-t') template = argv[++i];
88
+ else if (a.startsWith('--template=')) template = a.slice('--template='.length);
89
+ else if (a === '--help' || a === '-h') {
90
+ console.log(usage);
91
+ process.exit(0);
92
+ } else if (a.startsWith('-') && !KNOWN_FLAGS.has(a)) {
93
+ console.error(`unknown flag '${a}'.\n${usage}`);
94
+ process.exit(1);
95
+ } else if (!a.startsWith('-')) {
96
+ if (target) {
97
+ console.error(`unexpected argument '${a}' — the template goes after --template.\n${usage}`);
98
+ process.exit(1);
99
+ }
100
+ target = a;
101
+ }
70
102
  }
71
103
 
72
104
  if (!target) {
73
- console.error(
74
- 'usage: incanto-new <dir> [--template <name>] [--local] | --list\n' +
75
- ' --local pin `file:` to the incanto this command is running from,\n' +
76
- ' instead of the registry for verifying a release candidate.',
77
- );
105
+ console.error(usage);
106
+ process.exit(1);
107
+ }
108
+ if (template === undefined || template === '') {
109
+ console.error(`--template needs a name.\n${usage}`);
78
110
  process.exit(1);
79
111
  }
80
112
 
@@ -93,11 +125,52 @@ if (existsSync(dest)) {
93
125
  cpSync(join(TEMPLATES_DIR, template), dest, { recursive: true });
94
126
 
95
127
  const pkgPath = join(dest, 'package.json');
96
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
128
+ const pkg = readJson(pkgPath);
97
129
  pkg.name = basename(dest)
98
130
  .toLowerCase()
99
131
  .replace(/[^a-z0-9-]/g, '-');
100
132
 
133
+ /**
134
+ * What `--local` should pin: the directory itself, or a tarball packed from it.
135
+ *
136
+ * Only a source checkout needs the pack — that is the one whose `package.json`
137
+ * still carries the monorepo's `catalog:`/`workspace:` devDependencies, which
138
+ * bun resolves through a `file:` dependency and cannot find outside the
139
+ * workspace.
140
+ */
141
+ function localPin(engineRoot, into) {
142
+ let manifest;
143
+ try {
144
+ manifest = readJson(join(engineRoot, 'package.json'));
145
+ } catch {
146
+ return engineRoot;
147
+ }
148
+ const unresolvable = Object.values(manifest.devDependencies ?? {}).some(
149
+ (v) => typeof v === 'string' && (v.startsWith('catalog:') || v.startsWith('workspace:')),
150
+ );
151
+ if (!unresolvable) return engineRoot;
152
+ // `npm pack` runs this package's `prepack`, which strips those devDeps —
153
+ // exactly the artifact `verify:tarball` installs and proves.
154
+ const out = spawnSync('npm', ['pack', '--pack-destination', into, '--silent'], {
155
+ cwd: engineRoot,
156
+ encoding: 'utf-8',
157
+ });
158
+ const name = String(out.stdout ?? '')
159
+ .trim()
160
+ .split('\n')
161
+ .pop();
162
+ if (out.status !== 0 || !name || !existsSync(join(into, name))) {
163
+ console.error(
164
+ `--local: could not pack the engine at ${engineRoot}, and pinning it directly would ` +
165
+ `produce a project bun cannot install (its devDependencies use the workspace-only ` +
166
+ `\`catalog:\` protocol).\n${(out.stderr ?? '').trim()}`,
167
+ );
168
+ process.exit(1);
169
+ }
170
+ console.log(` packed the engine → ${name}`);
171
+ return `./${name}`;
172
+ }
173
+
101
174
  /*
102
175
  * WHICH incanto this scaffold will install.
103
176
  *
@@ -113,7 +186,29 @@ pkg.name = basename(dest)
113
186
  */
114
187
  const ENGINE_ROOT = PKG;
115
188
  if (argv.includes('--local')) {
116
- pkg.dependencies = { ...pkg.dependencies, incanto: `file:${ENGINE_ROOT}` };
189
+ /*
190
+ * A `file:` pin at a SOURCE CHECKOUT is not installable, and the flag's whole
191
+ * purpose is to be installed.
192
+ *
193
+ * bun resolves a `file:` dependency's devDependencies, and this package's are
194
+ * monorepo declarations — four of them in bun's workspace-only `catalog:`
195
+ * protocol. So `bun install` on a `--local` scaffold pinned at
196
+ * `packages/engine` fails outright and leaves NO node_modules:
197
+ *
198
+ * error: @types/three@catalog: failed to resolve
199
+ * error: three@catalog: failed to resolve … and three more
200
+ *
201
+ * `prepack` already strips them for the published artifact, and
202
+ * `verify:tarball` proves THAT installs — so the fix is to pin what that
203
+ * step proves rather than the directory it is packed from. Six independent
204
+ * agents hit this before writing a line of game code; every one of them was
205
+ * running the bin out of a checkout, which is what an agent testing the
206
+ * engine does.
207
+ *
208
+ * An INSTALLED copy has no such devDeps, so it is pinned directly and nothing
209
+ * is packed.
210
+ */
211
+ pkg.dependencies = { ...pkg.dependencies, incanto: `file:${localPin(ENGINE_ROOT, dest)}` };
117
212
  }
118
213
  const pin = pkg.dependencies?.incanto ?? '(none)';
119
214
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
@@ -15,6 +15,9 @@ import { dirname, join, resolve } from 'node:path';
15
15
  * vector <action> <x> <y> analog direction ((0 0) clears)
16
16
  * key <code> down|up raw KeyboardEvent.code mouse <0|1|2> down|up
17
17
  * pointer <dx> <dy> look deltas wheel <dy>
18
+ * at <x> <y> | at <path> put the CURSOR somewhere (a node, or a point)
19
+ * click [left|middle|right] press and release there — a mouse game's whole
20
+ * input, and there was no way to say it
18
21
  * capture [file] current state as scene JSON (stdout or file)
19
22
  * describe one line per node, non-default props only
20
23
  * framing what the camera SEES: on/off screen, lights, overlaps
@@ -26,7 +29,8 @@ import { dirname, join, resolve } from 'node:path';
26
29
  */
27
30
  import { createInterface } from 'node:readline';
28
31
  import { fileURLToPath, pathToFileURL } from 'node:url';
29
- import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
32
+ import { behaviorFiles, behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
33
+ import { readJson } from './_read-json.mjs';
30
34
 
31
35
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
32
36
 
@@ -40,13 +44,19 @@ function parseArgs(argv) {
40
44
  };
41
45
  for (let i = 0; i < argv.length; i++) {
42
46
  const a = argv[i];
43
- if (a === '--behaviors') args.behaviors = argv[++i];
47
+ // Repeatable: behaviours legitimately live in more than one file.
48
+ if (a === '--behaviors') args.behaviors = [...(args.behaviors ?? []), argv[++i]];
44
49
  else if (a === '--seed') args.seed = Number(argv[++i]);
45
50
  else if (a === '--fixed-hz') args.fixedHz = Number(argv[++i]);
46
51
  else if (a === '--commands') args.commands = argv[++i];
47
52
  else if (a === '--help' || a === '-h') args.help = true;
48
53
  else if (a.startsWith('--')) {
54
+ // `invalid`, not `help`: printing usage and exiting 0 told an agent its
55
+ // typo was a request. `--runs=4`, `--behaviours`, `--no-replay` all landed
56
+ // here and looked like success — and with `--json` the machine path was
57
+ // literally silent (0 bytes on stdout).
49
58
  console.error(`unknown flag: ${a}`);
59
+ args.invalid = true;
50
60
  args.help = true;
51
61
  } else if (!args.scene) args.scene = a;
52
62
  }
@@ -59,7 +69,7 @@ if (args.help || !args.scene) {
59
69
 
60
70
  Headless play: stdin commands (step/press/release/vector/key/mouse/pointer/
61
71
  wheel/capture/describe/framing/logs/quit), JSON-line responses on stdout.`);
62
- process.exit(args.help && args.scene !== undefined ? 0 : 1);
72
+ process.exit(!args.invalid && args.help && args.scene !== undefined ? 0 : 1);
63
73
  }
64
74
 
65
75
  const out = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
@@ -69,20 +79,47 @@ const fail = (cmd, e) => {
69
79
  out({ ok: false, cmd, error: { code: e?.code ?? 'ERROR', message: e?.message ?? String(e) } });
70
80
  };
71
81
 
72
- const { createPlaySession } = await import(pathToFileURL(join(PKG, 'dist', 'test.js')).href);
82
+ const { createPlaySession, geometricPicker, geometricView, pointAtNode } = await import(
83
+ pathToFileURL(join(PKG, 'dist', 'test.js')).href
84
+ );
73
85
  const incanto = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
74
86
 
87
+ /**
88
+ * Let a behaviour ask where the cursor IS, not just what is under it.
89
+ *
90
+ * The renderer installs `toWorld`/`toScreen` so game logic can aim; there is no
91
+ * renderer here, so a game driven from this REPL could be clicked and never
92
+ * aimed. Same terms as the picker: only if the game brought none.
93
+ */
94
+ /**
95
+ * Has anything put the cursor somewhere yet?
96
+ *
97
+ * `click` used to fire regardless, so a plan whose `at` failed reported the aim
98
+ * false and the click true — and the click landed wherever the cursor had been
99
+ * left, which is (0, 0) at the start of a session.
100
+ */
101
+ let aimed = false;
102
+
103
+ const aimFrom = (session) => {
104
+ if (session.engine.toWorld) return;
105
+ const view = geometricView(() => session.scene);
106
+ session.engine.toWorld = view.toWorld;
107
+ session.engine.toScreen ??= view.toScreen;
108
+ session.engine.screenRay ??= view.screenRay;
109
+ };
110
+
75
111
  // --behaviors: import the consumer module (TS works on node >= 23.6 / bun)
76
112
  // and register every exported Behavior subclass under its export name.
77
113
  const behaviors = {};
78
- if (args.behaviors) {
114
+ const behaviorSources = behaviorFiles(args.behaviors);
115
+ for (const file of behaviorSources) {
79
116
  let mod;
80
117
  try {
81
- mod = await import(pathToFileURL(resolve(args.behaviors)).href);
118
+ mod = await import(pathToFileURL(resolve(file)).href);
82
119
  } catch (e) {
83
120
  reexecUnderBun(import.meta.url, e?.message ?? String(e));
84
121
  const reason =
85
- e?.code === 'ERR_MODULE_NOT_FOUND' && String(e.message).includes(resolve(args.behaviors))
122
+ e?.code === 'ERR_MODULE_NOT_FOUND' && String(e.message).includes(resolve(file))
86
123
  ? 'file not found'
87
124
  : (e?.message ?? String(e));
88
125
  out({
@@ -90,7 +127,7 @@ if (args.behaviors) {
90
127
  cmd: 'behaviors',
91
128
  error: {
92
129
  code: e?.code ?? 'BEHAVIORS_LOAD_FAILED',
93
- message: behaviorsHelp(args.behaviors, reason),
130
+ message: behaviorsHelp(file, reason),
94
131
  },
95
132
  });
96
133
  process.exit(1);
@@ -100,21 +137,20 @@ if (args.behaviors) {
100
137
  behaviors[name] = value;
101
138
  }
102
139
  }
103
- if (Object.keys(behaviors).length === 0) {
104
- console.error(`warning: no Behavior subclasses exported from ${args.behaviors}`);
105
- }
140
+ }
141
+ if (behaviorSources.length > 0 && Object.keys(behaviors).length === 0) {
142
+ console.error(`warning: no Behavior subclasses exported from ${behaviorSources.join(', ')}`);
106
143
  }
107
144
 
108
- const sceneJson = JSON.parse(readFileSync(resolve(args.scene), 'utf-8'));
145
+ const sceneJson = readJson(resolve(args.scene));
109
146
  let session;
110
147
  try {
111
148
  session = await createPlaySession(sceneJson, {
112
149
  behaviors,
113
- stubMissingBehaviors: !args.behaviors, // structure-only play without code
150
+ stubMissingBehaviors: behaviorSources.length === 0, // structure-only play without code
114
151
  seed: args.seed,
115
152
  fixedHz: args.fixedHz,
116
- resolveScene: (p) =>
117
- JSON.parse(readFileSync(resolve(dirname(resolve(args.scene)), p), 'utf-8')),
153
+ resolveScene: (p) => readJson(resolve(dirname(resolve(args.scene)), p)),
118
154
  });
119
155
  } catch (e) {
120
156
  fail('load', e);
@@ -127,7 +163,7 @@ out({
127
163
  scene: session.scene.name,
128
164
  dimension: session.scene.dimension ?? null,
129
165
  behaviors: Object.keys(behaviors),
130
- stubbed: !args.behaviors,
166
+ stubbed: behaviorSources.length === 0,
131
167
  });
132
168
 
133
169
  function handle(line) {
@@ -164,6 +200,81 @@ function handle(line) {
164
200
  session.engine.input.handlePointerMove(Number(parts[0]), Number(parts[1]));
165
201
  out({ ok: true, cmd });
166
202
  break;
203
+ /*
204
+ * Where the CURSOR is, and clicking it — the whole input of a match-3, a
205
+ * tower defense, a card game or a point-and-click.
206
+ *
207
+ * `pointer` is a look DELTA, and `mouse` presses a button at wherever the
208
+ * cursor is not. Headless there is also no renderer to raycast with, so
209
+ * `engine.picker` is null and `Clickable` is inert: this installs the
210
+ * geometric one on first use, the same picker `runScript`'s `click` step
211
+ * uses, so the two drivers answer alike.
212
+ */
213
+ case 'at': {
214
+ const where =
215
+ parts.length === 1
216
+ ? pointAtNode(session.scene, parts[0])
217
+ : [Number(parts[0]), Number(parts[1])];
218
+ if (!where || !Number.isFinite(where[0]) || !Number.isFinite(where[1])) {
219
+ out({
220
+ ok: false,
221
+ cmd,
222
+ error:
223
+ parts.length === 1
224
+ ? `at '${parts[0]}' — no such node, or it is not on screen`
225
+ : 'at needs two numbers (scene px) or one node path',
226
+ });
227
+ break;
228
+ }
229
+ session.engine.picker ??= geometricPicker(() => session.scene);
230
+ aimFrom(session);
231
+ session.engine.input.setPointerPosition(where[0], where[1]);
232
+ aimed = true;
233
+ out({ ok: true, cmd, at: where });
234
+ break;
235
+ }
236
+ case 'click': {
237
+ const button = { left: 0, middle: 1, right: 2 }[parts[0] ?? 'left'];
238
+ if (button === undefined) {
239
+ out({ ok: false, cmd, error: 'click takes nothing, or left|middle|right' });
240
+ break;
241
+ }
242
+ /*
243
+ * A click before anything aimed reported `ok: true`.
244
+ *
245
+ * The documented plan is `at %Mole; click; step 300`, and when the
246
+ * `at` fails — an ambiguous `%name`, a node off screen — the click that
247
+ * follows it clicked wherever the cursor had been left and said so with
248
+ * a green line. A caller reading `ok` per command sees one false and
249
+ * one true and concludes the click landed.
250
+ */
251
+ if (!aimed) {
252
+ out({
253
+ ok: false,
254
+ cmd,
255
+ error:
256
+ 'nothing has aimed the cursor yet — `at <node path>` or `at X Y` first. ' +
257
+ 'A click with no aim would land wherever the cursor was left.',
258
+ });
259
+ break;
260
+ }
261
+ session.engine.picker ??= geometricPicker(() => session.scene);
262
+ aimFrom(session);
263
+ const at = session.engine.input.pointerPosition();
264
+ const under = at ? session.engine.pickAt(at.x, at.y) : null;
265
+ const hit = under ? under.getPath() : null;
266
+ // Press, a step, release, a step: `Clickable` fires on the RELEASE over
267
+ // the node the press started on, and the frame between is where a game
268
+ // gets to show that it is being pressed.
269
+ session.engine.input.handleMouseButton(button, true);
270
+ session.step(16.7);
271
+ session.engine.input.handleMouseButton(button, false);
272
+ session.step(16.7);
273
+ // WHAT it landed on, not merely that it happened: the whole reason to
274
+ // aim is to hit something, and a caller cannot pick from out here.
275
+ out({ ok: true, cmd, t: Math.round(session.timeMs), hit });
276
+ break;
277
+ }
167
278
  case 'wheel':
168
279
  session.engine.input.handleWheel(Number(parts[0]));
169
280
  out({ ok: true, cmd });
@@ -217,6 +328,37 @@ if (args.commands) {
217
328
  // did nothing.
218
329
  const recording = text.trimStart().startsWith('{') ? JSON.parse(text) : null;
219
330
  if (recording?.replay) {
331
+ /*
332
+ * Is this a recording of THIS game?
333
+ *
334
+ * `.incanto/playtest` is keyed by `(outcome, seed)` and never cleaned, so
335
+ * the second playtest in a project leaves a stale `stuck-seed3.json` from a
336
+ * scene that no longer exists sitting beside the current one — and this
337
+ * replayed it against whatever scene it was pointed at and reported
338
+ * success. A replay of the wrong game is worse than no replay: it is a
339
+ * failure you cannot reproduce and an explanation that is not true.
340
+ *
341
+ * Recordings written before this carry no stamp and still play, which is
342
+ * the point of stamping rather than gating: an old file is not a wrong one.
343
+ */
344
+ const stamped = recording.scene;
345
+ const mine = sceneJson?.name;
346
+ if (stamped?.name && mine && stamped.name !== mine) {
347
+ out({
348
+ ok: false,
349
+ cmd: 'replay',
350
+ error: {
351
+ code: 'REPLAY_SCENE_MISMATCH',
352
+ message:
353
+ `this recording is of '${stamped.name}'${stamped.path ? ` (${stamped.path})` : ''} ` +
354
+ `and you pointed it at '${mine}'. Replays live in .incanto/playtest keyed by ` +
355
+ `outcome and seed, so an old one from another scene sits right beside the ` +
356
+ `current ones — check the file, or delete the directory and playtest again.`,
357
+ },
358
+ });
359
+ session.dispose();
360
+ process.exit(1);
361
+ }
220
362
  incanto.replay(session.engine, recording);
221
363
  out({
222
364
  ok: true,