incanto 0.66.0 → 0.68.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 (96) hide show
  1. package/bin/_behaviors-loader.mjs +87 -0
  2. package/bin/_pick-server.mjs +48 -0
  3. package/bin/incanto-check.mjs +16 -2
  4. package/bin/incanto-editor.mjs +39 -2
  5. package/bin/incanto-feel.mjs +4 -1
  6. package/bin/incanto-frame.mjs +37 -3
  7. package/bin/incanto-logs.mjs +23 -3
  8. package/bin/incanto-multiplay.mjs +50 -1
  9. package/bin/incanto-play.mjs +3 -1
  10. package/bin/incanto-playtest.mjs +3 -25
  11. package/bin/incanto-verify.mjs +15 -2
  12. package/dist/2d.d.ts +44 -7
  13. package/dist/2d.js +6 -4
  14. package/dist/3d.d.ts +112 -16
  15. package/dist/3d.js +8 -6
  16. package/dist/{audio-player-_UAcHxnC.d.ts → audio-player-DOrq7sP-.d.ts} +62 -4
  17. package/dist/{behavior-BXNLfIJk.d.ts → behavior-DoFPYrgo.d.ts} +117 -2
  18. package/dist/boot-failure-CKYrEcGF.js +47 -0
  19. package/dist/{create-game-DYJCIzO0.js → create-game-DbWtVTxD.js} +91 -29
  20. package/dist/{create-game-Dd7H4bJV.js → create-game-IZIydDwI.js} +16 -10
  21. package/dist/debug.d.ts +1 -1
  22. package/dist/debug.js +3 -2
  23. package/dist/{duplicate-CqSAtdrh.js → duplicate-IWIqk0HJ.js} +1 -1
  24. package/dist/{editor-switch-DAvWQeld.d.ts → editor-switch-CnIOiyNJ.d.ts} +1 -1
  25. package/dist/editor.js +18 -7
  26. package/dist/env.d.ts +1 -1
  27. package/dist/{environment-presets-BlPsEmq6.js → environment-presets-CybQXNqS.js} +100 -11
  28. package/dist/{gameplay-D1RADWu3.js → gameplay-DM1eu_cV.js} +46 -5
  29. package/dist/gameplay.d.ts +75 -6
  30. package/dist/gameplay.js +2 -2
  31. package/dist/index.d.ts +52 -8
  32. package/dist/index.js +9 -7
  33. package/dist/{loader-lQDCwNag.js → loader-BC4PNtJX.js} +173 -1
  34. package/dist/{loader-Cga7FVP4.d.ts → loader-CcB533FR.d.ts} +2 -2
  35. package/dist/net.d.ts +2 -2
  36. package/dist/net.js +1 -1
  37. package/dist/{physics-2d-Cji5A6sX.js → physics-2d-B7Y6dPZO.js} +29 -2
  38. package/dist/{physics-3d-BkHjwJgI.js → physics-3d-bG3n70Ky.js} +30 -3
  39. package/dist/react.d.ts +2 -2
  40. package/dist/react.js +1 -1
  41. package/dist/{register-BmuqYTiY.js → register-3ta-2Xig.js} +148 -22
  42. package/dist/{register-D0CxCveZ.js → register-ibCjm-wH.js} +3 -3
  43. package/dist/{replay-IsZbNu6d.js → replay-CYvhVHHN.js} +28 -4
  44. package/dist/{replay-BHoB6fCU.d.ts → replay-Dvn8aeBd.d.ts} +38 -4
  45. package/dist/{schema-CFeioQRE.d.ts → schema-B6ugCV1Q.d.ts} +11 -0
  46. package/dist/{split-screen-D_i7GRcY.js → split-screen-CYwDkbLF.js} +2 -2
  47. package/dist/{split-screen-eJUFjhi1.d.ts → split-screen-DhrSzZIB.d.ts} +3 -3
  48. package/dist/{src-BTLbXFPZ.js → src-D7RIqXYF.js} +1 -1
  49. package/dist/{teardown-byR9USax.js → teardown-D2NEmxPB.js} +2 -2
  50. package/dist/{test-DgrD0jHD.js → test-8hoHeRmo.js} +140 -32
  51. package/dist/test.d.ts +9 -7
  52. package/dist/test.js +3 -3
  53. package/dist/{touch-DESwnpOc.js → touch-DEAmqGdf.js} +30 -186
  54. package/dist/vite.d.ts +0 -7
  55. package/dist/vite.js +13 -3
  56. package/dist/webgl-unavailable-N9nQqesw.js +228 -0
  57. package/editor/assets/{agent8-9N-Pd_YS.js → agent8-BrrHOjMJ.js} +1 -1
  58. package/editor/assets/debug-BBhKuBNV.js +3 -0
  59. package/editor/assets/{index-CeDhIPTC.js → index-eVd0BToA.js} +92 -92
  60. package/editor/index.html +1 -1
  61. package/package.json +1 -1
  62. package/schemas/scene.schema.json +5 -1
  63. package/skills/incanto-3d-character.md +1 -0
  64. package/skills/incanto-audio.md +49 -0
  65. package/skills/incanto-building-2d-games.md +37 -0
  66. package/skills/incanto-building-3d-games.md +56 -0
  67. package/skills/incanto-editor.md +5 -1
  68. package/skills/incanto-environment.md +2 -2
  69. package/skills/incanto-gameplay-behaviors.md +29 -2
  70. package/skills/incanto-hud.md +14 -1
  71. package/skills/incanto-multiplayer.md +14 -0
  72. package/skills/incanto-node-reference.md +2 -1
  73. package/skills/incanto-performance.md +35 -0
  74. package/skills/incanto-physics-and-input.md +23 -0
  75. package/skills/incanto-playtesting.md +15 -1
  76. package/skills/incanto-save-slots.md +21 -1
  77. package/skills/incanto-scene-json-authoring.md +12 -1
  78. package/skills/incanto-verifying-your-game.md +19 -0
  79. package/skills/incanto-your-first-game.md +4 -4
  80. package/templates-app/beacon-isle-3d/package.json +1 -1
  81. package/templates-app/beacon-isle-3d/src/game.scene.json +38 -19
  82. package/templates-app/beacon-isle-3d/src/main.ts +8 -1
  83. package/templates-app/platformer-2d/package.json +1 -1
  84. package/templates-app/platformer-2d/src/game.scene.json +99 -53
  85. package/templates-app/platformer-2d/src/main.ts +8 -1
  86. package/templates-app/star-survivor/package.json +1 -1
  87. package/templates-app/star-survivor/src/game.scene.json +31 -17
  88. package/templates-app/star-survivor/src/main.ts +8 -1
  89. package/templates-app/tps-3d/package.json +1 -1
  90. package/templates-app/tps-3d/src/game.scene.json +14 -8
  91. package/templates-app/tps-3d/src/main.ts +8 -1
  92. package/templates-app/village-quest-3d/package.json +1 -1
  93. package/templates-app/village-quest-3d/src/grove.scene.json +20 -11
  94. package/templates-app/village-quest-3d/src/main.ts +8 -1
  95. package/templates-app/village-quest-3d/src/village.scene.json +10 -7
  96. package/editor/assets/debug-CkbJICYp.js +0 -3
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Load a `--behaviors` file the way the author WROTE it, from any bin.
3
+ *
4
+ * Every template's `behaviors.ts` does `import gameJson from './game.scene.json'`
5
+ * — the documented way to read your own scene — and node's type stripping
6
+ * refuses a JSON import without `with { type: 'json' }`. Same for an
7
+ * extensionless relative import (`from './quest'`), which is what the tsconfig
8
+ * this package scaffolds asks for. Vite reads both. bun reads both. The bins'
9
+ * shebang is node.
10
+ *
11
+ * `incanto-playtest` grew a bun re-exec for exactly this and **the other four
12
+ * bins that take `--behaviors` never got it**: `feel`, `play`, `verify` and
13
+ * `multiplay` all died on the shipped `village-quest-3d`, and the only
14
+ * invocation the docs give (`incanto-verifying-your-game.md`) is the failing
15
+ * form. Worse, `verify:templates` — the gate step added specifically to run a
16
+ * template under node's resolver — runs `incanto-playtest`, the one bin whose
17
+ * fallback hides the failure. The step built to catch this class was
18
+ * neutralised by the thing it was built to catch.
19
+ *
20
+ * So the fallback lives here, once, and every bin uses it. Set
21
+ * `INCANTO_NO_BUN_REEXEC=1` to forbid it — that is how the gate exercises the
22
+ * resolver a user actually gets.
23
+ */
24
+ import { spawnSync } from 'node:child_process';
25
+
26
+ /**
27
+ * Is this node refusing the file, rather than the file being wrong?
28
+ *
29
+ * Matched against the SPECIFIER where node gives us one. Node words the
30
+ * extensionless failure as `Cannot find module '<resolved path>' imported from
31
+ * '<importer>'`, and a check that reads the importer collapses it into
32
+ * "(file not found)" naming the file that plainly exists — which is what the
33
+ * author sees and cannot act on.
34
+ */
35
+ export function nodeCannotReadIt(why) {
36
+ return (
37
+ /import attribute|Unknown file extension|Cannot find module|ERR_MODULE_NOT_FOUND|ERR_UNSUPPORTED_[A-Z_]+/i.test(
38
+ String(why),
39
+ ) ||
40
+ // Node's type stripping ERASES types; it does not transform. Anything that
41
+ // needs a transform is refused by name, and the wording is nothing like the
42
+ // resolver's. Measured on the shipped `racing-3d` example:
43
+ //
44
+ // x TypeScript parameter property is not supported in strip-only mode
45
+ //
46
+ // `constructor(private root: Node3D)` is ordinary TypeScript that vite and
47
+ // bun both compile, and the bin gave up with "Pass the file that exports
48
+ // your Behavior subclasses" — telling the author their correct file was
49
+ // wrong. Enums and namespaces are refused the same way.
50
+ /strip-only mode|is not supported in strip|only mode|TypeScript parameter property|Unsupported TypeScript/i.test(
51
+ String(why),
52
+ )
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Re-run this bin under bun, once, if node is the reason it failed.
58
+ *
59
+ * Never returns when it re-execs. Returns false when it did not (already bun,
60
+ * already re-execed, forbidden, no bun on PATH, or a real error in the file).
61
+ */
62
+ export function reexecUnderBun(entryUrl, why) {
63
+ if (!nodeCannotReadIt(why)) return false;
64
+ if (process.versions.bun) return false;
65
+ if (process.env.INCANTO_BUN_REEXEC || process.env.INCANTO_NO_BUN_REEXEC) return false;
66
+ if (spawnSync('bun', ['--version'], { stdio: 'ignore' }).status !== 0) return false;
67
+ const here = new URL(entryUrl).pathname;
68
+ const again = spawnSync('bun', [here, ...process.argv.slice(2)], {
69
+ stdio: 'inherit',
70
+ env: { ...process.env, INCANTO_BUN_REEXEC: '1' },
71
+ });
72
+ process.exit(again.status ?? 1);
73
+ }
74
+
75
+ /** The sentence to print when the re-exec was not available. */
76
+ export function behaviorsHelp(file, why) {
77
+ return (
78
+ `could not load --behaviors '${file}' (${why}).` +
79
+ (nodeCannotReadIt(why)
80
+ ? '\n\nThis is usually node refusing your file rather than a problem with it:' +
81
+ "\nnode cannot import JSON from a .ts module without `with { type: 'json' }`," +
82
+ '\nand it will not resolve an extensionless relative import. Both are what' +
83
+ '\nthe scaffolded tsconfig asks you to write. Run it with bun, which reads' +
84
+ '\nthe file as written — prefix any incanto command with `bunx --bun`.'
85
+ : '\nPass the file that exports your Behavior subclasses, e.g. src/behaviors.ts.')
86
+ );
87
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Which of the dev servers that answered is THIS project's — the one decision
3
+ * `incanto-frame` and `incanto-logs` must never get wrong, in one place.
4
+ *
5
+ * They each had their own copy, and each copy had the same hatch: when exactly
6
+ * one server answered and it did not say whose it was, take it anyway ("an
7
+ * older incanto that predates the identity field"). A server that will not
8
+ * identify itself is the ONE kind the check cannot clear, so the hatch let
9
+ * through precisely what it was written to stop. Measured in an empty temp
10
+ * directory with one foreign page listening, on a scene with two nodes and no
11
+ * assets:
12
+ *
13
+ * ✓ draws — subject fills 37.5% of the frame
14
+ * ✗ says — asset …/NO-SUCH-TEXTURE.jpg never loaded: 404
15
+ *
16
+ * A ✓ measured off another game, and a ✗ naming a texture that appears nowhere
17
+ * in this project. It surfaced in this repo as an intermittent failure of
18
+ * `incanto-verify.test.ts`, which is a gate that lies in both directions.
19
+ *
20
+ * The real dev server has sent `root` since 0.41 (`frame-endpoint.test.ts`
21
+ * pins it), so nothing is lost; `--port` still names a server on purpose.
22
+ */
23
+
24
+ /** Is `body.root` this directory, an ancestor of it, or inside it? */
25
+ export function sameProject(body, cwd) {
26
+ if (!body?.root) return false;
27
+ const root = String(body.root);
28
+ return root === cwd || cwd.startsWith(`${root}/`) || root.startsWith(`${cwd}/`);
29
+ }
30
+
31
+ /**
32
+ * `{ pick }` when one of them is ours, `{ refusal }` when some answered and
33
+ * none is, `{}` when nothing answered at all (the caller says "no dev server").
34
+ */
35
+ export function pickServer(answered, cwd, what) {
36
+ const mine = answered.filter((a) => sameProject(a, cwd));
37
+ if (mine.length > 0) return { pick: mine[0] };
38
+ if (answered.length === 0) return {};
39
+ const listed = answered
40
+ .map((a) => ` :${a.port} ${a.project || '(unnamed)'} ${a.root ?? "doesn't say"}`)
41
+ .join('\n');
42
+ return {
43
+ refusal:
44
+ `${answered.length} incanto dev server(s) are running and none of them is this ` +
45
+ `directory (${cwd}):\n${listed}\n\n${what}\n` +
46
+ "Start this project's dev server, or pass --port.",
47
+ };
48
+ }
@@ -155,7 +155,9 @@ if (files.length === 0) {
155
155
  process.exit(1);
156
156
  }
157
157
 
158
- const { auditScene, validateScene } = await import(pathToFileURL(join(DIST, 'test.js')).href);
158
+ const { auditScene, instancedScenes, validateScene } = await import(
159
+ pathToFileURL(join(DIST, 'test.js')).href
160
+ );
159
161
 
160
162
  /**
161
163
  * Every `strings` table in the corpus, unioned.
@@ -176,9 +178,16 @@ const declaredElsewhere = {};
176
178
  * part of its project it can.
177
179
  */
178
180
  const declaredAssets = {};
181
+ /**
182
+ * Every scene some OTHER scene embeds — the only honest way to tell a fragment
183
+ * from a first draft. Without it a 3D scene with no camera and no light checked
184
+ * CLEAN, because "has no camera" was doing double duty as "is a fragment".
185
+ */
186
+ const corpus = [];
179
187
  for (const file of files) {
180
188
  try {
181
189
  const doc = JSON.parse(readFileSync(file, 'utf-8'));
190
+ corpus.push({ path: resolve(file), json: doc });
182
191
  for (const [locale, entries] of Object.entries(doc?.strings ?? {})) {
183
192
  if (typeof entries !== 'object' || entries === null) continue;
184
193
  declaredElsewhere[locale] = { ...(declaredElsewhere[locale] ?? {}), ...entries };
@@ -191,6 +200,8 @@ for (const file of files) {
191
200
  }
192
201
  }
193
202
 
203
+ const instanced = instancedScenes(corpus, (from, ref) => resolve(dirname(from), ref));
204
+
194
205
  const results = files.map((file) => {
195
206
  let json;
196
207
  try {
@@ -207,7 +218,10 @@ const results = files.map((file) => {
207
218
  return {
208
219
  file,
209
220
  ok: true,
210
- warnings: [...auditScene(json, { declaredElsewhere }), ...missingArt(file, json)],
221
+ warnings: [
222
+ ...auditScene(json, { declaredElsewhere, instanced: instanced.has(resolve(file)) }),
223
+ ...missingArt(file, json),
224
+ ],
211
225
  };
212
226
  }
213
227
  return {
@@ -200,6 +200,29 @@ async function proxyLibrary(req, res, url) {
200
200
  }
201
201
  }
202
202
 
203
+ /**
204
+ * What kind of project is this — read from the scenes already in it.
205
+ *
206
+ * A 3D game's new scene must not be born 2D, and the only thing that knows
207
+ * which is which is what is already on disk. Returns null for an empty project,
208
+ * where there is genuinely nothing to go on.
209
+ */
210
+ function projectDimension() {
211
+ let d2 = 0;
212
+ let d3 = 0;
213
+ for (const entry of discoverScenes()) {
214
+ try {
215
+ const json = JSON.parse(readFileSync(join(ROOT, entry.rel ?? entry), 'utf8'));
216
+ if (json?.dimension === '3d') d3 += 1;
217
+ else if (json?.dimension === '2d') d2 += 1;
218
+ } catch {
219
+ // not a scene, or unreadable — the scene browser already says so
220
+ }
221
+ }
222
+ if (d3 === 0 && d2 === 0) return null;
223
+ return d3 > d2 ? '3d' : '2d';
224
+ }
225
+
203
226
  function discoverScenes() {
204
227
  const found = [];
205
228
  const walk = (dir, depth) => {
@@ -418,12 +441,26 @@ const server = createServer((req, res) => {
418
441
  return send(res, 409, JSON.stringify({ error: `already exists: ${wanted}` }));
419
442
  }
420
443
  const name = basename(abs).replace(/\.scene\.json$|\.json$/, '') || 'Scene';
444
+ /*
445
+ * A 3D project's new scene must not be born 2D.
446
+ *
447
+ * This hardcoded `dimension: '2d'` / `Node2D` and ignored `body`
448
+ * entirely, so every scene the editor created in a 3D game was the
449
+ * wrong kind — and node types are IMMUTABLE by design, so the halfway
450
+ * state (a 3D header over a Node2D root) is easy to reach through the
451
+ * header's dimension select and is written back with a 200.
452
+ *
453
+ * Asked for explicitly, or guessed from the project: whatever the
454
+ * sibling scenes are is overwhelmingly what the next one should be.
455
+ */
456
+ const asked = body?.dimension === '3d' || body?.dimension === '2d' ? body.dimension : null;
457
+ const dimension = asked ?? projectDimension() ?? '2d';
421
458
  const scene = {
422
459
  format: 1,
423
460
  type: 'scene',
424
- dimension: '2d',
461
+ dimension,
425
462
  name,
426
- root: { name, type: 'Node2D' },
463
+ root: { name, type: dimension === '3d' ? 'Node3D' : 'Node2D' },
427
464
  };
428
465
  try {
429
466
  mkdirSync(dirname(abs), { recursive: true });
@@ -21,6 +21,7 @@ import { dirname, join, resolve } from 'node:path';
21
21
  * wrong for most games.
22
22
  */
23
23
  import { fileURLToPath, pathToFileURL } from 'node:url';
24
+ import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
24
25
 
25
26
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
26
27
 
@@ -63,7 +64,9 @@ if (args.behaviors) {
63
64
  try {
64
65
  mod = await import(pathToFileURL(resolve(args.behaviors)).href);
65
66
  } catch (e) {
66
- console.error(`could not load --behaviors '${args.behaviors}' (${e?.message ?? e}).`);
67
+ const why = e?.message ?? String(e);
68
+ reexecUnderBun(import.meta.url, why);
69
+ console.error(behaviorsHelp(args.behaviors, why));
67
70
  process.exit(1);
68
71
  }
69
72
  for (const [name, value] of Object.entries(mod)) {
@@ -20,6 +20,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
20
20
  import { createRequire } from 'node:module';
21
21
  import { dirname, join } from 'node:path';
22
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
23
+ import { pickServer } from './_pick-server.mjs';
23
24
 
24
25
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
25
26
  const { listeningPorts, parseProcNetTcp } = await import(
@@ -150,11 +151,28 @@ const discovered = explicit ? [explicit] : await listeningPorts({ read });
150
151
  const ports = explicit ? discovered : [...new Set([...bsdListeningPorts(), ...discovered])];
151
152
 
152
153
  let found = null;
154
+ /**
155
+ * WHOSE dev server answered.
156
+ *
157
+ * This scans a port range, so before the ping carried identity, whoever
158
+ * answered first won — and a passing number from ANOTHER project silently
159
+ * overrode this one's failing one. Measured: project A on :5188 with a real
160
+ * page reported "the subject fills almost nothing (0.3%)", while `incanto
161
+ * verify` scanning from the same directory reported `✓ draws — 37.5%`,
162
+ * measured on project B. Corroboration from the other direction: four scenes
163
+ * across two unrelated projects all reported 22.6%.
164
+ *
165
+ * So: prefer the server rooted at THIS directory. If several answer and none is
166
+ * ours, refuse and list them — the same refusal `verify` already makes when a
167
+ * project has several scenes and guessing would verify the wrong one.
168
+ */
169
+ const answered = [];
153
170
  // BOTH hosts, deliberately: vite binds to ::1 on some machines and 127.0.0.1 on
154
171
  // others, and a probe of the wrong one reports "no dev server" while the page
155
172
  // is plainly on screen.
156
173
  const HOSTS = ['localhost', '127.0.0.1'];
157
- outer: for (const port of ports) {
174
+ const CWD = process.cwd();
175
+ for (const port of ports) {
158
176
  for (const host of HOSTS) {
159
177
  try {
160
178
  const res = await fetch(`http://${host}:${port}/__incanto/ping`, {
@@ -163,8 +181,8 @@ outer: for (const port of ports) {
163
181
  if (!res.ok) continue;
164
182
  const body = await res.json();
165
183
  if (body?.incanto) {
166
- found = { host, port, ...body };
167
- break outer;
184
+ answered.push({ host, port, ...body });
185
+ break; // one host per port is enough
168
186
  }
169
187
  } catch {
170
188
  // not listening, not ours, or not answering — either way, next
@@ -172,6 +190,22 @@ outer: for (const port of ports) {
172
190
  }
173
191
  }
174
192
 
193
+ if (explicit) {
194
+ found = answered[0] ?? null;
195
+ } else {
196
+ const { pick, refusal } = pickServer(
197
+ answered,
198
+ CWD,
199
+ "Measuring one project on another project's page reports a number that has\n" +
200
+ 'nothing to do with this game.',
201
+ );
202
+ if (pick) found = pick;
203
+ else if (refusal) {
204
+ console.error(refusal);
205
+ process.exit(1);
206
+ }
207
+ }
208
+
175
209
  if (!found) {
176
210
  console.error(
177
211
  'no incanto dev server found.\n' +
@@ -21,6 +21,7 @@ import { readFileSync } from 'node:fs';
21
21
  import { createRequire } from 'node:module';
22
22
  import { dirname, join } from 'node:path';
23
23
  import { fileURLToPath, pathToFileURL } from 'node:url';
24
+ import { pickServer } from './_pick-server.mjs';
24
25
 
25
26
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
26
27
  const { listeningPorts } = await import(pathToFileURL(join(PKG, 'dist', 'vite.js')).href);
@@ -75,7 +76,12 @@ const discovered = explicit ? [explicit] : await listeningPorts({ read });
75
76
  const ports = explicit ? discovered : [...new Set([...bsdListeningPorts(), ...discovered])];
76
77
 
77
78
  let found = null;
78
- outer: for (const port of ports) {
79
+ // Whose dev server answered see incanto-frame.mjs for the measurement that
80
+ // made this necessary: a scan takes whoever replies first, and a foreign
81
+ // project's page answered both the frame and the logs request.
82
+ const answered = [];
83
+ const CWD = process.cwd();
84
+ for (const port of ports) {
79
85
  for (const host of ['localhost', '127.0.0.1']) {
80
86
  try {
81
87
  const res = await fetch(`http://${host}:${port}/__incanto/ping`, {
@@ -84,14 +90,28 @@ outer: for (const port of ports) {
84
90
  if (!res.ok) continue;
85
91
  const body = await res.json();
86
92
  if (body?.incanto) {
87
- found = { host, port };
88
- break outer;
93
+ answered.push({ host, port, ...body });
94
+ break;
89
95
  }
90
96
  } catch {
91
97
  // not listening, not ours, or not answering — next
92
98
  }
93
99
  }
94
100
  }
101
+ if (explicit) {
102
+ found = answered[0] ? { host: answered[0].host, port: answered[0].port } : null;
103
+ } else {
104
+ const { pick, refusal } = pickServer(
105
+ answered,
106
+ CWD,
107
+ "Reading another project's log buffer would report its errors as yours.",
108
+ );
109
+ if (pick) found = { host: pick.host, port: pick.port };
110
+ else if (refusal) {
111
+ console.error(refusal);
112
+ process.exit(1);
113
+ }
114
+ }
95
115
 
96
116
  if (!found) {
97
117
  console.error(
@@ -20,6 +20,7 @@
20
20
  import { existsSync, readFileSync } 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
24
 
24
25
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
25
26
 
@@ -36,6 +37,7 @@ if (argv.includes('--help') || argv.includes('-h')) {
36
37
  --seconds N simulated seconds (default 5)
37
38
  --seed N make the match reproducible (default 1)
38
39
  --behaviors FILE your Behavior subclasses (.ts works on bun / node >= 23.6)
40
+ --server FILE your Server class (default: server/src/server.ts if present)
39
41
  --scenes a=b.json a scene a NetworkSpawner names, repeatable
40
42
  --no-late-join skip the late-joining client
41
43
  --json machine-readable report
@@ -83,7 +85,9 @@ if (behaviorsFile) {
83
85
  }
84
86
  }
85
87
  } catch (e) {
86
- console.error(`could not load --behaviors '${behaviorsFile}' (${e?.message ?? e}).`);
88
+ const why = e?.message ?? String(e);
89
+ reexecUnderBun(import.meta.url, why);
90
+ console.error(behaviorsHelp(behaviorsFile, why));
87
91
  process.exit(1);
88
92
  }
89
93
  }
@@ -122,11 +126,56 @@ for (const name of spawnerScenes) {
122
126
  }
123
127
  }
124
128
 
129
+ /**
130
+ * Your REAL server class, so `agrees` measures the game you ship.
131
+ *
132
+ * `playMultiplayer` has always accepted a `server`, and this bin never passed
133
+ * one — so the rung ran every match against a bare loopback kernel with no
134
+ * remote functions. Measured: with no server `roomState` came back `{}`, with
135
+ * one it came back `{"matchMs": 5983.3}`; a `manager.call('claimRelic')` threw
136
+ * `BAD_FORMAT: Loopback kernel has no remote function 'claimRelic'` and the
137
+ * report STILL said `ok=true errors=0 problems=[]`. Moving `server/src/server.ts`
138
+ * out of the tree changed nothing, which is the whole finding: a ✓ that could
139
+ * not have been a ✗.
140
+ *
141
+ * Found where the templates put it, or named with `--server`.
142
+ */
143
+ let server;
144
+ const serverFlag = flag('--server', null);
145
+ const serverGuesses = serverFlag
146
+ ? [resolve(serverFlag)]
147
+ : ['server/src/server.ts', 'server/src/server.js', 'src/server.ts'].map((g) =>
148
+ resolve(sceneDir, '..', '..', g),
149
+ );
150
+ for (const at of serverGuesses) {
151
+ if (!existsSync(at)) continue;
152
+ try {
153
+ const mod = await import(pathToFileURL(at).href);
154
+ server = mod.Server ?? mod.default;
155
+ if (server) {
156
+ if (!argv.includes('--json')) console.log(`· server: ${at} (${server.name || 'default'})`);
157
+ break;
158
+ }
159
+ } catch (e) {
160
+ // A server that cannot be loaded must not read as a game that has none.
161
+ console.error(
162
+ `could not load the server at '${at}' (${e?.message ?? e}). ` +
163
+ 'The agreement check would then measure a game without its own rules; refusing.',
164
+ );
165
+ process.exit(1);
166
+ }
167
+ }
168
+ if (serverFlag && !server) {
169
+ console.error(`--server '${serverFlag}' exports neither \`Server\` nor a default.`);
170
+ process.exit(1);
171
+ }
172
+
125
173
  let report;
126
174
  try {
127
175
  report = await playMultiplayer({
128
176
  scene: sceneJson,
129
177
  scenes,
178
+ ...(server ? { server } : {}),
130
179
  players: Number(flag('--players', 2)) || 2,
131
180
  seconds: Number(flag('--seconds', 5)) || 5,
132
181
  seed: Number(flag('--seed', 1)) || 1,
@@ -26,6 +26,7 @@ import { dirname, join, resolve } from 'node:path';
26
26
  */
27
27
  import { createInterface } from 'node:readline';
28
28
  import { fileURLToPath, pathToFileURL } from 'node:url';
29
+ import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
29
30
 
30
31
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
31
32
 
@@ -79,6 +80,7 @@ if (args.behaviors) {
79
80
  try {
80
81
  mod = await import(pathToFileURL(resolve(args.behaviors)).href);
81
82
  } catch (e) {
83
+ reexecUnderBun(import.meta.url, e?.message ?? String(e));
82
84
  const reason =
83
85
  e?.code === 'ERR_MODULE_NOT_FOUND' && String(e.message).includes(resolve(args.behaviors))
84
86
  ? 'file not found'
@@ -88,7 +90,7 @@ if (args.behaviors) {
88
90
  cmd: 'behaviors',
89
91
  error: {
90
92
  code: e?.code ?? 'BEHAVIORS_LOAD_FAILED',
91
- message: `could not load --behaviors '${args.behaviors}' (${reason}). Pass the file that exports your Behavior subclasses, e.g. src/behaviors.ts.`,
93
+ message: behaviorsHelp(args.behaviors, reason),
92
94
  },
93
95
  });
94
96
  process.exit(1);
@@ -23,6 +23,7 @@ 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
27
 
27
28
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
28
29
 
@@ -92,31 +93,8 @@ if (args.behaviors) {
92
93
  // and vite resolve it. It broke `incanto verify` on the shipped
93
94
  // `village-quest-3d` starter, and it breaks for any author who splits their
94
95
  // behaviours across files — which is most of them, eventually.
95
- const nodeCannotReadIt = /import attribute|Unknown file extension|Cannot find module/i.test(
96
- why,
97
- );
98
- if (nodeCannotReadIt && !process.versions.bun && !process.env.INCANTO_BUN_REEXEC) {
99
- const { spawnSync } = await import('node:child_process');
100
- const hasBun = spawnSync('bun', ['--version'], { stdio: 'ignore' }).status === 0;
101
- if (hasBun) {
102
- const here = fileURLToPath(import.meta.url);
103
- const again = spawnSync('bun', [here, ...process.argv.slice(2)], {
104
- stdio: 'inherit',
105
- env: { ...process.env, INCANTO_BUN_REEXEC: '1' },
106
- });
107
- process.exit(again.status ?? 1);
108
- }
109
- }
110
- console.error(
111
- `could not load --behaviors '${args.behaviors}' (${why}).` +
112
- (nodeCannotReadIt
113
- ? '\n\nThis is usually node refusing your file rather than a problem with it:' +
114
- "\nnode cannot import JSON from a .ts module without `with { type: 'json' }`," +
115
- '\nand it will not resolve an extensionless relative import. Both are what' +
116
- '\nthe scaffolded tsconfig asks you to write. Run it with bun, which reads' +
117
- '\nthe file as written: `bunx incanto playtest <scene> --behaviors <file>`.'
118
- : '\nPass the file that exports your Behavior subclasses, e.g. src/behaviors.ts.'),
119
- );
96
+ reexecUnderBun(import.meta.url, why);
97
+ console.error(behaviorsHelp(args.behaviors, why));
120
98
  process.exit(1);
121
99
  }
122
100
  for (const [name, value] of Object.entries(mod)) {
@@ -84,6 +84,19 @@ function findScene() {
84
84
  }
85
85
 
86
86
  const bin = (name) => join(PKG, 'bin', `incanto-${name}.mjs`);
87
+
88
+ /**
89
+ * `--port`, passed straight through to the page-reading rungs.
90
+ *
91
+ * `draws` and `says` ask a dev server for the browser's answer, and discovery
92
+ * scans a port range — so with two projects running, this one measured the
93
+ * OTHER one's page and reported its pass. `incanto-frame`/`incanto-logs` now
94
+ * refuse a foreign server, and this makes the escape hatch they document
95
+ * reachable from the rung that actually runs them.
96
+ */
97
+ const PORT = process.argv.includes('--port')
98
+ ? ['--port', String(process.argv[process.argv.indexOf('--port') + 1])]
99
+ : [];
87
100
  const run = (name, args) =>
88
101
  spawnSync(process.execPath, [bin(name), ...args], {
89
102
  encoding: 'utf-8',
@@ -424,7 +437,7 @@ if (rungs[0].status === 'pass') {
424
437
 
425
438
  // ---- draws ---------------------------------------------------------------
426
439
  {
427
- const r = run('frame', ['--json']);
440
+ const r = run('frame', ['--json', ...PORT]);
428
441
  const out = safeJson(r.stdout);
429
442
  const report = out?.report;
430
443
  if (!report) {
@@ -466,7 +479,7 @@ if (rungs[0].status === 'pass') {
466
479
  */
467
480
  // ---- says ---------------------------------------------------------------
468
481
  {
469
- const r = run('logs', ['--json']);
482
+ const r = run('logs', ['--json', ...PORT]);
470
483
  const report = safeJson(r.stdout);
471
484
  if (!report) {
472
485
  rungs.push({
package/dist/2d.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, jt as Node, n as BehaviorCtor, w as GameStats } from "./behavior-BXNLfIJk.js";
2
- import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DAvWQeld.js";
3
- import { i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
1
+ import { c as showBootFailure, i as ParticleSim, o as ParticleView, r as SpatialPose, s as isWebGLAvailable } from "./audio-player-DOrq7sP-.js";
2
+ import { P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, jt as Node, n as BehaviorCtor, w as GameStats, x as EngineOptions } from "./behavior-DoFPYrgo.js";
3
+ import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-CnIOiyNJ.js";
4
+ import { i as SceneJson, s as JsonObject } from "./schema-B6ugCV1Q.js";
4
5
  import { r as FrameStats } from "./frame-report-DCnHFmto.js";
5
- import { t as LoadSceneOptions } from "./loader-Cga7FVP4.js";
6
+ import { t as LoadSceneOptions } from "./loader-CcB533FR.js";
6
7
  import { n as AnimationEntry } from "./sprite-animation-CMr6f1K2.js";
7
- import { i as ParticleSim, o as ParticleView, r as SpatialPose } from "./audio-player-_UAcHxnC.js";
8
8
  import { Group, Mesh, Object3D, Scene, Texture } from "three";
9
9
  import * as RapierNs from "@dimforge/rapier2d-compat";
10
10
 
@@ -279,6 +279,16 @@ declare class Physics2D {
279
279
  /** Who is currently inside each sensor/body, maintained from the event drain. */
280
280
  private readonly overlaps;
281
281
  private trackOverlap;
282
+ /**
283
+ * Forget every overlap whose node has left the tree.
284
+ *
285
+ * The lazy drop inside `overlapping()` only cleans the SETS of a key someone
286
+ * asks about, and only when they ask. This clears the KEYS, which is where the
287
+ * dead nodes were pinned.
288
+ */
289
+ /** Drop one node from the overlap map, both as a key and as a member. */
290
+ private forgetOverlaps;
291
+ private pruneOverlaps;
282
292
  /** The bodies currently overlapping `node`. Freed nodes are dropped on read. */
283
293
  overlapping(node: PhysicsBody2D): PhysicsBody2D[];
284
294
  /**
@@ -386,6 +396,18 @@ interface CreateGame2DOptions {
386
396
  createElement(tag: string): HTMLElement;
387
397
  };
388
398
  seed?: number;
399
+ /**
400
+ * Namespace this game's persisted settings — volume, language, quality tier.
401
+ *
402
+ * They are keyed `incanto:<namespace>:<key>` in the browser's storage and
403
+ * defaulted to `settings` for everyone, so two Incanto games on one domain
404
+ * shared one options screen. Measured: game A's player sets `renderScale
405
+ * 0.25, muted, quality low`; game B, whose source never mentions settings,
406
+ * boots at a 480x232 drawing buffer and silent.
407
+ *
408
+ * `SaveSlots(namespace)` already takes one for exactly this reason.
409
+ */
410
+ settings?: EngineOptions["settings"];
389
411
  fixedHz?: number;
390
412
  /** Renderer extras (pixelRatio, antialias, custom asset store). */
391
413
  pixelRatio?: number;
@@ -425,7 +447,22 @@ interface Game2DRenderer extends GameRenderer {
425
447
  }
426
448
  interface Game2D {
427
449
  engine: Engine;
428
- scene: Scene$1;
450
+ /**
451
+ * The LIVE scene — a getter over `engine.scene`, not a snapshot.
452
+ *
453
+ * This used to be the scene captured at boot. `Engine.setScene` calls
454
+ * `this._scene?.root.free()`, so after any swap the old handle was not merely
455
+ * stale, it was EMPTIED — and a single-scene game reaches that through
456
+ * `flow.restart()`, which `incanto-hud.md` wires to a Start button. Measured:
457
+ *
458
+ * before swap game.scene.root.children = 2 engine.scene = 2
459
+ * after swap game.scene.root.children = 0 engine.scene = 2
460
+ * game.scene.root.getNode('Flow') -> No node at 'Flow'. Children here: [].
461
+ *
462
+ * `game.engine.scene` always worked and three skills use it, so there were
463
+ * two handles with one name and no note saying which was which.
464
+ */
465
+ get scene(): Scene$1;
429
466
  /**
430
467
  * What the last drawn frame actually looks like, as numbers.
431
468
  *
@@ -1210,4 +1247,4 @@ declare function parseCells(cells: readonly (string | readonly number[])[], lege
1210
1247
  */
1211
1248
  declare function mergeSolidRects(grid: readonly (readonly number[])[], solid: Set<number>): TileRect[];
1212
1249
  //#endregion
1213
- export { AnimatedSprite2D, Area2D, type AssetStatus, AssetStore2D, Camera2D, CharacterBody2D, CharacterController2D, ColorRect2D, type CreateGame2DOptions, type Game2D, Joint2D, type JointType2D, Label, Node2D, Particles2D, Physics2D, type Physics2DOptions, PhysicsBody2D, Renderer2D, type Renderer2DOptions, type ResolvedSpriteTexture, RigidBody2D, type SheetInfo, Sprite2D, type SpriteFromLibraryResult, StaticBody2D, type Sync2DResult, type TextureLoadCallbacks, TileMap2D, type TileRect, type UIAnchor, UILayer, createGame2D, enablePhysics2D, mergeSolidRects, parseCells, registerNodes2D, spriteFromLibraryMeta, syncTree2D };
1250
+ export { AnimatedSprite2D, Area2D, type AssetStatus, AssetStore2D, Camera2D, CharacterBody2D, CharacterController2D, ColorRect2D, type CreateGame2DOptions, type Game2D, Joint2D, type JointType2D, Label, Node2D, Particles2D, Physics2D, type Physics2DOptions, PhysicsBody2D, Renderer2D, type Renderer2DOptions, type ResolvedSpriteTexture, RigidBody2D, type SheetInfo, Sprite2D, type SpriteFromLibraryResult, StaticBody2D, type Sync2DResult, type TextureLoadCallbacks, TileMap2D, type TileRect, type UIAnchor, UILayer, createGame2D, enablePhysics2D, isWebGLAvailable, mergeSolidRects, parseCells, registerNodes2D, showBootFailure, spriteFromLibraryMeta, syncTree2D };