incanto 0.67.0 → 0.69.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 (87) hide show
  1. package/assets/catalog.json +9 -5
  2. package/bin/_pick-server.mjs +48 -0
  3. package/bin/incanto-assets.mjs +14 -1
  4. package/bin/incanto-check.mjs +69 -5
  5. package/bin/incanto-frame.mjs +10 -25
  6. package/bin/incanto-logs.mjs +8 -18
  7. package/bin/incanto-new.mjs +29 -1
  8. package/bin/incanto-serve.mjs +160 -0
  9. package/bin/incanto-verify.mjs +40 -0
  10. package/bin/incanto.mjs +1 -0
  11. package/dist/2d.d.ts +10 -8
  12. package/dist/2d.js +6 -4
  13. package/dist/3d.d.ts +31 -7
  14. package/dist/3d.js +9 -7
  15. package/dist/{audio-player-BNUHo0JF.d.ts → audio-player-C0j2k-yB.d.ts} +80 -3
  16. package/dist/{behavior-ZnfzKDo3.d.ts → behavior-rm8vVzu_.d.ts} +145 -1
  17. package/dist/boot-failure-CKYrEcGF.js +47 -0
  18. package/dist/{create-game-FAXdHta3.js → create-game-CvJ1wSRP.js} +27 -27
  19. package/dist/{create-game-DMetd3vR.js → create-game-CwxEDlCm.js} +15 -11
  20. package/dist/debug.d.ts +1 -1
  21. package/dist/{duplicate-B8QnX2-r.js → duplicate-Dj4L-5AA.js} +1 -1
  22. package/dist/{editor-switch-DAvWQeld.d.ts → editor-switch-CnIOiyNJ.d.ts} +1 -1
  23. package/dist/editor.js +12 -6
  24. package/dist/env.d.ts +1 -1
  25. package/dist/{environment-presets-C-5g2pMh.js → environment-presets-BO-7XoFV.js} +58 -9
  26. package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
  27. package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
  28. package/dist/{gameplay-CGSv-9XR.js → gameplay-D1KixWK8.js} +175 -151
  29. package/dist/gameplay.d.ts +51 -3
  30. package/dist/gameplay.js +2 -2
  31. package/dist/index.d.ts +6 -6
  32. package/dist/index.js +8 -7
  33. package/dist/{loader-KZESOZmE.js → loader-DZE7B07H.js} +48 -7
  34. package/dist/{loader-DyT30ArB.d.ts → loader-DadV2QCT.d.ts} +2 -2
  35. package/dist/net.d.ts +2 -2
  36. package/dist/net.js +1 -1
  37. package/dist/{physics-2d-_B6zj3zU.js → physics-2d-CF5yyjbj.js} +2 -2
  38. package/dist/{physics-3d-i1IeAY0F.js → physics-3d-DbYaEcZy.js} +3 -3
  39. package/dist/react.d.ts +2 -2
  40. package/dist/react.js +1 -1
  41. package/dist/{register-vIxU3Xbv.js → register-DbVArsns.js} +19 -10
  42. package/dist/{register-Cs53CY7s.js → register-mmfugpaQ.js} +159 -6
  43. package/dist/{replay-BkpDV51O.js → replay-BEHLDcQV.js} +28 -5
  44. package/dist/{replay-D0GcEHYU.d.ts → replay-BQfkX4x7.d.ts} +38 -4
  45. package/dist/{schema-CFeioQRE.d.ts → schema-B6ugCV1Q.d.ts} +11 -0
  46. package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
  47. package/dist/{split-screen-DS8RaUV_.js → split-screen-CauMwu6y.js} +2 -2
  48. package/dist/{split-screen-DD5eYIgW.d.ts → split-screen-Cg76MCV5.d.ts} +3 -3
  49. package/dist/{src-BLlsGJW9.js → src-CYEu-_S9.js} +1 -1
  50. package/dist/{teardown-LB9cTnXJ.js → teardown-CMqFVxO9.js} +5 -4
  51. package/dist/{test-81uZ967a.js → test-D6ISmuFE.js} +73 -25
  52. package/dist/test.d.ts +17 -6
  53. package/dist/test.js +3 -3
  54. package/dist/vite.d.ts +70 -2
  55. package/dist/vite.js +176 -14
  56. package/dist/{rendering-options-ByAdi8PF.js → webgl-unavailable-Z5_g8x9H.js} +57 -2
  57. package/editor/assets/{agent8-C2jtNKbx.js → agent8-BDvaZMQJ.js} +1 -1
  58. package/editor/assets/{debug-DoU3KeqS.js → debug-CMULinBW.js} +1 -1
  59. package/editor/assets/{index-CNqHojcC.js → index-CZIRr5EL.js} +92 -92
  60. package/editor/index.html +1 -1
  61. package/package.json +4 -3
  62. package/schemas/scene.schema.json +4 -0
  63. package/skills/incanto-3d-character.md +1 -0
  64. package/skills/incanto-assets.md +16 -2
  65. package/skills/incanto-audio.md +65 -0
  66. package/skills/incanto-building-2d-games.md +66 -0
  67. package/skills/incanto-building-3d-games.md +97 -2
  68. package/skills/incanto-gameplay-behaviors.md +46 -2
  69. package/skills/incanto-node-reference.md +1 -0
  70. package/skills/incanto-physics-and-input.md +23 -0
  71. package/skills/incanto-save-slots.md +27 -0
  72. package/skills/incanto-scene-json-authoring.md +12 -1
  73. package/skills/incanto-verifying-your-game.md +96 -2
  74. package/skills/incanto-your-first-game.md +2 -1
  75. package/templates-app/beacon-isle-3d/package.json +1 -1
  76. package/templates-app/beacon-isle-3d/src/main.ts +21 -4
  77. package/templates-app/platformer-2d/index.html +0 -23
  78. package/templates-app/platformer-2d/package.json +1 -1
  79. package/templates-app/platformer-2d/src/behaviors.ts +26 -16
  80. package/templates-app/platformer-2d/src/game.scene.json +17 -27
  81. package/templates-app/platformer-2d/src/main.ts +32 -13
  82. package/templates-app/star-survivor/package.json +1 -1
  83. package/templates-app/star-survivor/src/main.ts +25 -7
  84. package/templates-app/tps-3d/package.json +1 -1
  85. package/templates-app/tps-3d/src/main.ts +34 -16
  86. package/templates-app/village-quest-3d/package.json +1 -1
  87. package/templates-app/village-quest-3d/src/main.ts +21 -4
@@ -5,7 +5,7 @@
5
5
  "url": "incanto/assets/characters/2dbasic.png",
6
6
  "kind": "character",
7
7
  "bytes": 30499,
8
- "description": "2dbasic sprite sheet image.anything,base character. (frame size 192x192)",
8
+ "description": "2dbasic sprite sheet image.anything,base character.",
9
9
  "animation": "characters/2dbasic.json",
10
10
  "frameWidth": 111,
11
11
  "frameHeight": 83
@@ -144,7 +144,7 @@
144
144
  "url": "incanto/assets/characters/ghost.png",
145
145
  "kind": "character",
146
146
  "bytes": 22933,
147
- "description": "Ghost character with translucent appearance sprite sheet image (frame size 112x128)",
147
+ "description": "Ghost character with translucent appearance sprite sheet image",
148
148
  "animation": "characters/ghost.json",
149
149
  "frameWidth": 112,
150
150
  "frameHeight": 128
@@ -155,7 +155,7 @@
155
155
  "url": "incanto/assets/characters/goblin.png",
156
156
  "kind": "character",
157
157
  "bytes": 57994,
158
- "description": "Medieval goblin with torch sprite sheet image (frame size 192x192)",
158
+ "description": "Medieval goblin with torch sprite sheet image",
159
159
  "animation": "characters/goblin.json",
160
160
  "frameWidth": 192,
161
161
  "frameHeight": 192
@@ -286,7 +286,7 @@
286
286
  "url": "incanto/assets/characters/medieval-knight.png",
287
287
  "kind": "character",
288
288
  "bytes": 84367,
289
- "description": "(frame size 192x192) Using a medieval-themed SD(Super Deformed) knight sprite sheet image, you can apply idle, move, and attack animations, among others.",
289
+ "description": "Using a medieval-themed SD(Super Deformed) knight sprite sheet image, you can apply idle, move, and attack animations, among others.",
290
290
  "animation": "characters/medieval-knight.json",
291
291
  "frameWidth": 192,
292
292
  "frameHeight": 192
@@ -297,7 +297,11 @@
297
297
  "url": "incanto/assets/tiles/minecraft-tiles.png",
298
298
  "kind": "tile",
299
299
  "bytes": 10511,
300
- "description": "Minecraft-themed tiles sprite sheet image (frame size 16x16)"
300
+ "description": "Minecraft-themed tiles sprite sheet image",
301
+ "frameWidth": 16,
302
+ "frameHeight": 16,
303
+ "columns": 5,
304
+ "tiles": 25
301
305
  },
302
306
  {
303
307
  "name": "monster-died",
@@ -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
+ }
@@ -40,8 +40,21 @@ if (command === 'list') {
40
40
  } else {
41
41
  for (const e of entries) {
42
42
  const anim = e.animation ? ' [animated]' : '';
43
+ /*
44
+ * The GRID, from the fields rather than the prose.
45
+ *
46
+ * `list` printed only the description, and a description carrying a frame
47
+ * size is a second source of truth that drifts: `2dbasic` said
48
+ * "(frame size 192x192)" against a real grid of 111x83 — 666x664 does not
49
+ * divide by 192 — and the wrong number was the first one an agent read.
50
+ * The sizes live in `frameWidth`/`frameHeight` now, and the prose says
51
+ * what the art IS.
52
+ */
53
+ const grid = e.frameWidth
54
+ ? ` ${e.frameWidth}×${e.frameHeight}${e.tiles ? ` (${e.tiles} tiles)` : ''}`
55
+ : '';
43
56
  console.log(
44
- `${e.name.padEnd(20)} ${e.kind.padEnd(10)}${anim.padEnd(11)} ${e.description.slice(0, 80)}`,
57
+ `${e.name.padEnd(20)} ${e.kind.padEnd(10)}${anim.padEnd(11)}${grid.padEnd(16)} ${e.description.slice(0, 64)}`,
45
58
  );
46
59
  }
47
60
  console.log(`\n${entries.length} built-in asset(s) — incanto-assets info <name> for details`);
@@ -18,14 +18,27 @@ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node
18
18
  import { createRequire } from 'node:module';
19
19
  import { dirname, join, resolve } from 'node:path';
20
20
  import { fileURLToPath, pathToFileURL } from 'node:url';
21
+ import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
21
22
 
22
23
  const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist');
23
24
 
24
25
  function parseArgs(argv) {
25
- const args = { files: [], json: false, strict: false, help: false, invalid: false };
26
- for (const a of argv) {
26
+ const args = {
27
+ files: [],
28
+ json: false,
29
+ strict: false,
30
+ help: false,
31
+ invalid: false,
32
+ behaviors: null,
33
+ };
34
+ // Indexed, because `--behaviors` takes the NEXT argument. A `for…of` with a
35
+ // hand-rolled `++i` reads fine and throws `i is not defined` at runtime,
36
+ // which is a flag that exists in the help and cannot be used.
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const a = argv[i];
27
39
  if (a === '--json') args.json = true;
28
40
  else if (a === '--strict-behaviors') args.strict = true;
41
+ else if (a === '--behaviors') args.behaviors = argv[++i];
29
42
  else if (a === '--strict-warnings') args.strictWarnings = true;
30
43
  else if (a === '--help' || a === '-h') args.help = true;
31
44
  else if (a.startsWith('--')) {
@@ -38,7 +51,7 @@ function parseArgs(argv) {
38
51
 
39
52
  const args = parseArgs(process.argv.slice(2));
40
53
  if (args.help || args.invalid) {
41
- console.log(`Usage: npx incanto check [files|dirs...] [--json] [--strict-behaviors] [--strict-warnings]
54
+ console.log(`Usage: npx incanto check [files|dirs...] [--json] [--behaviors FILE] [--strict-behaviors] [--strict-warnings]
42
55
 
43
56
  Validates *.scene.json files headlessly with the installed engine.
44
57
  With no arguments, scans the current directory recursively
@@ -155,7 +168,33 @@ if (files.length === 0) {
155
168
  process.exit(1);
156
169
  }
157
170
 
158
- const { auditScene, validateScene } = await import(pathToFileURL(join(DIST, 'test.js')).href);
171
+ const { auditScene, instancedScenes, validateScene } = await import(
172
+ pathToFileURL(join(DIST, 'test.js')).href
173
+ );
174
+
175
+ /** Your Behavior subclasses, when you name a file — same contract as the other bins. */
176
+ const behaviors = {};
177
+ if (args.behaviors) {
178
+ const { Behavior } = await import(pathToFileURL(join(DIST, 'index.js')).href);
179
+ let mod;
180
+ try {
181
+ mod = await import(pathToFileURL(resolve(args.behaviors)).href);
182
+ } catch (e) {
183
+ const why = e?.message ?? String(e);
184
+ // node cannot import the pattern this package teaches (a JSON import
185
+ // without an attribute, an extensionless relative path); bun can. Same
186
+ // hand-off as `incanto-playtest`.
187
+ reexecUnderBun(import.meta.url, why);
188
+ console.error(behaviorsHelp(args.behaviors, why));
189
+ process.exit(1);
190
+ }
191
+ for (const [name, value] of Object.entries(mod)) {
192
+ if (typeof value === 'function' && value.prototype instanceof Behavior) behaviors[name] = value;
193
+ }
194
+ if (Object.keys(behaviors).length === 0) {
195
+ console.error(`warning: no Behavior subclasses exported from ${args.behaviors}`);
196
+ }
197
+ }
159
198
 
160
199
  /**
161
200
  * Every `strings` table in the corpus, unioned.
@@ -176,9 +215,16 @@ const declaredElsewhere = {};
176
215
  * part of its project it can.
177
216
  */
178
217
  const declaredAssets = {};
218
+ /**
219
+ * Every scene some OTHER scene embeds — the only honest way to tell a fragment
220
+ * from a first draft. Without it a 3D scene with no camera and no light checked
221
+ * CLEAN, because "has no camera" was doing double duty as "is a fragment".
222
+ */
223
+ const corpus = [];
179
224
  for (const file of files) {
180
225
  try {
181
226
  const doc = JSON.parse(readFileSync(file, 'utf-8'));
227
+ corpus.push({ path: resolve(file), json: doc });
182
228
  for (const [locale, entries] of Object.entries(doc?.strings ?? {})) {
183
229
  if (typeof entries !== 'object' || entries === null) continue;
184
230
  declaredElsewhere[locale] = { ...(declaredElsewhere[locale] ?? {}), ...entries };
@@ -191,6 +237,8 @@ for (const file of files) {
191
237
  }
192
238
  }
193
239
 
240
+ const instanced = instancedScenes(corpus, (from, ref) => resolve(dirname(from), ref));
241
+
194
242
  const results = files.map((file) => {
195
243
  let json;
196
244
  try {
@@ -200,6 +248,19 @@ const results = files.map((file) => {
200
248
  }
201
249
  const res = validateScene(json, {
202
250
  strictBehaviors: args.strict,
251
+ /*
252
+ * `validateScene` has always taken `behaviors`; this bin had no way to pass
253
+ * any. So `--strict-behaviors` could only ever FAIL on a project with a
254
+ * behaviour of its own — which is every project past its first hour:
255
+ *
256
+ * [UNKNOWN_BEHAVIOR] Unknown behavior 'Hunter'. … Did you forget
257
+ * registerBehavior('Hunter', Hunter)?
258
+ * 0/1 scene(s) valid EXIT=1
259
+ *
260
+ * A flag whose only outcome is failure is a flag nobody uses, and the check
261
+ * it gates — a script name that resolves nowhere — is worth having.
262
+ */
263
+ behaviors,
203
264
  declaredAssets,
204
265
  resolveScene: (p) => JSON.parse(readFileSync(resolve(dirname(file), p), 'utf-8')),
205
266
  });
@@ -207,7 +268,10 @@ const results = files.map((file) => {
207
268
  return {
208
269
  file,
209
270
  ok: true,
210
- warnings: [...auditScene(json, { declaredElsewhere }), ...missingArt(file, json)],
271
+ warnings: [
272
+ ...auditScene(json, { declaredElsewhere, instanced: instanced.has(resolve(file)) }),
273
+ ...missingArt(file, json),
274
+ ],
211
275
  };
212
276
  }
213
277
  return {
@@ -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(
@@ -171,11 +172,6 @@ const answered = [];
171
172
  // is plainly on screen.
172
173
  const HOSTS = ['localhost', '127.0.0.1'];
173
174
  const CWD = process.cwd();
174
- const sameProject = (b) => {
175
- if (!b?.root) return false;
176
- const r = String(b.root);
177
- return r === CWD || CWD.startsWith(`${r}/`) || r.startsWith(`${CWD}/`);
178
- };
179
175
  for (const port of ports) {
180
176
  for (const host of HOSTS) {
181
177
  try {
@@ -197,26 +193,15 @@ for (const port of ports) {
197
193
  if (explicit) {
198
194
  found = answered[0] ?? null;
199
195
  } else {
200
- const mine = answered.filter(sameProject);
201
- if (mine.length > 0) {
202
- found = mine[0];
203
- } else if (answered.length === 1 && answered[0].root === undefined) {
204
- // An older dev server that predates the identity field. Take it, but say so.
205
- found = answered[0];
206
- console.error(
207
- the dev server on :${found.port} does not report which project it is ` +
208
- '(older incanto) — if this reads the wrong game, pass --port.',
209
- );
210
- } else if (answered.length > 0) {
211
- console.error(
212
- `${answered.length} incanto dev server(s) are running and NONE of them is this ` +
213
- `directory (${CWD}):\n` +
214
- answered
215
- .map((a) => ` :${a.port} ${a.project || '(unnamed)'} ${a.root ?? '?'}`)
216
- .join('\n') +
217
- "\n\nMeasuring one project on another project's page reports a number that has\n" +
218
- "nothing to do with this game. Start this project's dev server, or pass --port.",
219
- );
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);
220
205
  process.exit(1);
221
206
  }
222
207
  }
@@ -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);
@@ -80,11 +81,6 @@ let found = null;
80
81
  // project's page answered both the frame and the logs request.
81
82
  const answered = [];
82
83
  const CWD = process.cwd();
83
- const sameProject = (b) => {
84
- if (!b?.root) return false;
85
- const r = String(b.root);
86
- return r === CWD || CWD.startsWith(`${r}/`) || r.startsWith(`${CWD}/`);
87
- };
88
84
  for (const port of ports) {
89
85
  for (const host of ['localhost', '127.0.0.1']) {
90
86
  try {
@@ -105,20 +101,14 @@ for (const port of ports) {
105
101
  if (explicit) {
106
102
  found = answered[0] ? { host: answered[0].host, port: answered[0].port } : null;
107
103
  } else {
108
- const mine = answered.filter(sameProject);
109
- const pick =
110
- mine[0] ?? (answered.length === 1 && answered[0].root === undefined ? answered[0] : null);
104
+ const { pick, refusal } = pickServer(
105
+ answered,
106
+ CWD,
107
+ "Reading another project's log buffer would report its errors as yours.",
108
+ );
111
109
  if (pick) found = { host: pick.host, port: pick.port };
112
- else if (answered.length > 0) {
113
- console.error(
114
- `${answered.length} incanto dev server(s) are running and none is this directory ` +
115
- `(${CWD}):\n` +
116
- answered
117
- .map((a) => ` :${a.port} ${a.project || '(unnamed)'} ${a.root ?? '?'}`)
118
- .join('\n') +
119
- "\n\nReading another project's log buffer would report its errors as yours.\n" +
120
- "Start this project's dev server, or pass --port.",
121
- );
110
+ else if (refusal) {
111
+ console.error(refusal);
122
112
  process.exit(1);
123
113
  }
124
114
  }
@@ -70,7 +70,11 @@ for (let i = 0; i < argv.length; i++) {
70
70
  }
71
71
 
72
72
  if (!target) {
73
- console.error('usage: incanto-new <dir> [--template <name>] | --list');
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
+ );
74
78
  process.exit(1);
75
79
  }
76
80
 
@@ -93,10 +97,30 @@ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
93
97
  pkg.name = basename(dest)
94
98
  .toLowerCase()
95
99
  .replace(/[^a-z0-9-]/g, '-');
100
+
101
+ /*
102
+ * WHICH incanto this scaffold will install.
103
+ *
104
+ * The template pins `^<version>`, so `bun install` resolves the REGISTRY build
105
+ * — even when the scaffolder itself came from a local tarball. Someone
106
+ * verifying a release candidate that shares its version with a published one
107
+ * then measures the published artifact for an hour and never sees a difference:
108
+ * the version matches, the install succeeds, and a `main.ts` that typechecks
109
+ * against the candidate does not typecheck against the registry copy.
110
+ *
111
+ * `--local` pins the package this bin is running from, and either way the pin
112
+ * is printed. A silent resolution is the part that cost the hour.
113
+ */
114
+ const ENGINE_ROOT = PKG;
115
+ if (argv.includes('--local')) {
116
+ pkg.dependencies = { ...pkg.dependencies, incanto: `file:${ENGINE_ROOT}` };
117
+ }
118
+ const pin = pkg.dependencies?.incanto ?? '(none)';
96
119
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
97
120
 
98
121
  console.log(`
99
122
  ${template} → ${target}
123
+ incanto ${pin}
100
124
 
101
125
  cd ${target}
102
126
  bun install # or: npm install
@@ -104,6 +128,10 @@ console.log(`
104
128
  bun run verify # prove it plays, headlessly
105
129
  bun run check # validate the scene JSON
106
130
 
131
+ When it plays, ship it:
132
+ bun run build # the artifact a player gets
133
+ bunx incanto-serve # serve dist/ so verify/frame/logs can see the BUILT game
134
+
107
135
  Vibe-coding? Install the agent skills too:
108
136
  bunx incanto-skills claude # or: opencode
109
137
  `);
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ /**
4
+ * Serve a BUILT game, and let the inspect tools see it.
5
+ *
6
+ * `incanto-frame` and `incanto-logs` ask a dev server, which asks the page over
7
+ * vite's HMR channel. A production build has no HMR, so nothing could look at
8
+ * the artifact an author actually deploys:
9
+ *
10
+ * $ node …/incanto-logs.mjs --port 8791 # dist/ on a static server
11
+ * no incanto dev server found.
12
+ * $ incanto-verify src/game.scene.json
13
+ * ? draws — no incanto dev server found.
14
+ * ? says — no incanto dev server found.
15
+ *
16
+ * The boot path is the only code a BUILD adds over a SCENE — `main.ts`, the
17
+ * loading overlay, the asset base path, everything after `createGame*` resolves
18
+ * — and it was the one thing no rung could reach.
19
+ *
20
+ * This serves `dist/` as plain static files (no bundler, no transform) and adds
21
+ * exactly two things: the `/__incanto/*` endpoints the CLIs already speak, and
22
+ * one inline script injected into `index.html` that relays them to the page.
23
+ * Nothing is added to the files on disk, so what you measure is what you ship.
24
+ *
25
+ * bunx incanto-serve # ./dist on :4180
26
+ * bunx incanto-serve build --port 5050
27
+ * bunx incanto-verify # now measures the BUILT game
28
+ */
29
+ import { createServer } from 'node:http';
30
+ import { createRequire } from 'node:module';
31
+ import { extname, join, normalize, resolve, sep } from 'node:path';
32
+ import { fileURLToPath, pathToFileURL } from 'node:url';
33
+
34
+ const PKG = resolve(fileURLToPath(import.meta.url), '../..');
35
+ const DIST = join(PKG, 'dist');
36
+ if (!existsSync(join(DIST, 'vite.js'))) {
37
+ console.error('incanto: the package is not built (dist/vite.js missing).');
38
+ process.exit(1);
39
+ }
40
+ const { createStaticInspectHost, serveFrameEndpoints } = await import(
41
+ pathToFileURL(join(DIST, 'vite.js')).href
42
+ );
43
+ const VERSION = createRequire(import.meta.url)('../package.json').version;
44
+
45
+ const args = process.argv.slice(2);
46
+ if (args.includes('--help') || args.includes('-h')) {
47
+ console.error(`Usage: incanto-serve [dir] [--port N] [--host H]
48
+
49
+ Serve a BUILT game (default ./dist) so \`incanto-frame\`, \`incanto-logs\` and
50
+ \`incanto-verify\` can measure the artifact you actually deploy. Files are served
51
+ byte-for-byte; the inspect client is injected into index.html at serve time and
52
+ never written to disk.`);
53
+ process.exit(0);
54
+ }
55
+ const flag = (name, fallback) => {
56
+ const at = args.indexOf(name);
57
+ return at >= 0 && args[at + 1] ? args[at + 1] : fallback;
58
+ };
59
+ const port = Number(flag('--port', '4180'));
60
+ const host = flag('--host', '127.0.0.1');
61
+ const dir = resolve(
62
+ args.find(
63
+ (a) =>
64
+ !a.startsWith('--') &&
65
+ args[args.indexOf(a) - 1] !== '--port' &&
66
+ args[args.indexOf(a) - 1] !== '--host',
67
+ ) ?? 'dist',
68
+ );
69
+
70
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
71
+ console.error(
72
+ `incanto-serve: no such directory: ${dir}\n` +
73
+ ' Build first (`bun run build`), or name the output directory.',
74
+ );
75
+ process.exit(1);
76
+ }
77
+ if (!existsSync(join(dir, 'index.html'))) {
78
+ console.error(
79
+ `incanto-serve: ${dir} has no index.html — that is not a built game.\n` +
80
+ ' Point this at the directory your bundler wrote.',
81
+ );
82
+ process.exit(1);
83
+ }
84
+
85
+ const inspect = createStaticInspectHost(process.cwd());
86
+ serveFrameEndpoints(inspect, VERSION);
87
+
88
+ const TYPES = {
89
+ '.html': 'text/html; charset=utf-8',
90
+ '.js': 'text/javascript; charset=utf-8',
91
+ '.mjs': 'text/javascript; charset=utf-8',
92
+ '.css': 'text/css; charset=utf-8',
93
+ '.json': 'application/json; charset=utf-8',
94
+ '.png': 'image/png',
95
+ '.jpg': 'image/jpeg',
96
+ '.jpeg': 'image/jpeg',
97
+ '.webp': 'image/webp',
98
+ '.gif': 'image/gif',
99
+ '.svg': 'image/svg+xml',
100
+ '.ico': 'image/x-icon',
101
+ '.glb': 'model/gltf-binary',
102
+ '.gltf': 'model/gltf+json',
103
+ '.mp3': 'audio/mpeg',
104
+ '.ogg': 'audio/ogg',
105
+ '.wav': 'audio/wav',
106
+ '.wasm': 'application/wasm',
107
+ '.woff2': 'font/woff2',
108
+ '.ttf': 'font/ttf',
109
+ };
110
+
111
+ /** Inject the relay ahead of everything, so it exists before the game boots. */
112
+ function withInspectClient(html) {
113
+ const tag = `<script>${inspect.clientScript()}</script>`;
114
+ if (html.includes('</head>')) return html.replace('</head>', `${tag}</head>`);
115
+ return tag + html;
116
+ }
117
+
118
+ const server = createServer((req, res) => {
119
+ const url = (req.url ?? '/').split('?')[0] ?? '/';
120
+ if (url.startsWith('/__incanto/')) {
121
+ // `originalUrl` is what the endpoints read for the query string; connect
122
+ // strips the mount path off `url` and these were written against that.
123
+ req.originalUrl = req.url;
124
+ if (inspect.handleInspect(req, res)) return;
125
+ res.statusCode = 404;
126
+ res.end('{}');
127
+ return;
128
+ }
129
+ // A path outside the served directory is a traversal attempt, not a typo.
130
+ const rel = normalize(decodeURIComponent(url)).replace(/^(\.\.[/\\])+/, '');
131
+ let file = join(dir, rel);
132
+ if (!file.startsWith(dir + sep) && file !== dir) file = dir;
133
+ if (!existsSync(file) || statSync(file).isDirectory()) file = join(dir, 'index.html');
134
+
135
+ const type = TYPES[extname(file)] ?? 'application/octet-stream';
136
+ if (file.endsWith('index.html')) {
137
+ const html = withInspectClient(readFileSync(file, 'utf8'));
138
+ res.statusCode = 200;
139
+ res.setHeader('content-type', TYPES['.html']);
140
+ res.end(html);
141
+ return;
142
+ }
143
+ res.statusCode = 200;
144
+ res.setHeader('content-type', type);
145
+ res.end(readFileSync(file));
146
+ });
147
+
148
+ server.on('error', (e) => {
149
+ if (e?.code === 'EADDRINUSE') {
150
+ console.error(`incanto-serve: port ${port} is already in use — pass --port N.`);
151
+ process.exit(1);
152
+ }
153
+ throw e;
154
+ });
155
+
156
+ server.listen(port, host, () => {
157
+ console.log(`incanto-serve ${dir}`);
158
+ console.log(` http://${host}:${port}/`);
159
+ console.log(' open it, then run `incanto-verify` / `incanto-frame` / `incanto-logs`.');
160
+ });
@@ -449,6 +449,30 @@ if (rungs[0].status === 'pass') {
449
449
  ? 'bring the preview window to the front — a hidden tab does not render, and a frame is captured inside a render'
450
450
  : 'start your dev server, open the page, and run this again — the pixels are in the browser',
451
451
  });
452
+ } else if (report.hidden) {
453
+ /*
454
+ * A hidden page is UNMEASURED, not passed.
455
+ *
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:
459
+ *
460
+ * incanto-frame --do the page is hidden … a frozen game (exit 1)
461
+ * incanto-logs frame rate not measurable — hidden
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.
469
+ */
470
+ rungs.push({
471
+ name: 'draws',
472
+ status: 'unmeasured',
473
+ summary: 'the page is HIDDEN — those pixels are whatever it last drew',
474
+ fix: 'bring the window to the front (one covered by another counts as hidden) and run this again',
475
+ });
452
476
  } else if (report.black) {
453
477
  rungs.push({
454
478
  name: 'draws',
@@ -464,10 +488,26 @@ if (rungs[0].status === 'pass') {
464
488
  fix: 'check the camera: `incanto-frame --out shot.png` and look at what it is aimed at',
465
489
  });
466
490
  } else {
491
+ /*
492
+ * A rung that PASSED can still have something to say, and this one was
493
+ * dropping it. `incanto-frame` prints `CLIPPED by the frame edge` and
494
+ * `the subject fills almost nothing` — real observations about a shot that
495
+ * is technically non-black — and the ladder replaced all of it with one
496
+ * coverage number. The `loads` rung has carried its tool's warnings since
497
+ * the day a scene described as "It will render black" came back green.
498
+ */
499
+ const notes = [];
500
+ if (report.subject.clipped) {
501
+ notes.push('the subject is CLIPPED by the frame edge — part of it is off screen');
502
+ }
503
+ if (report.uniform) {
504
+ notes.push('one flat colour — the camera may be inside geometry, or only the sky is drawn');
505
+ }
467
506
  rungs.push({
468
507
  name: 'draws',
469
508
  status: 'pass',
470
509
  summary: `subject fills ${(report.subject.coverage * 100).toFixed(1)}% of the frame`,
510
+ ...(notes.length > 0 ? { notes } : {}),
471
511
  });
472
512
  }
473
513
  }
package/bin/incanto.mjs CHANGED
@@ -40,6 +40,7 @@ const COMMANDS = {
40
40
  feel: ['incanto-feel.mjs', 'measure jump height, run speed, facing'],
41
41
  frame: ['incanto-frame.mjs', 'what the running page is drawing right now'],
42
42
  logs: ['incanto-logs.mjs', 'what the running page has been saying'],
43
+ serve: ['incanto-serve.mjs', 'serve a BUILT game so the tools can measure it'],
43
44
  editor: ['incanto-editor.mjs', 'the visual scene composer'],
44
45
  model: ['incanto-model.mjs', "a GLB's real bounds, animations and rig"],
45
46
  assets: ['incanto-assets.mjs', 'the built-in art and audio catalog'],