incanto 0.68.0 → 0.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/assets/catalog.json +9 -5
  2. package/bin/_behaviors-loader.mjs +22 -0
  3. package/bin/_read-json.mjs +28 -0
  4. package/bin/incanto-assets.mjs +19 -5
  5. package/bin/incanto-check.mjs +92 -15
  6. package/bin/incanto-editor.mjs +128 -5
  7. package/bin/incanto-env.mjs +3 -2
  8. package/bin/incanto-feel.mjs +24 -13
  9. package/bin/incanto-frame.mjs +8 -1
  10. package/bin/incanto-multiplay.mjs +11 -9
  11. package/bin/incanto-new.mjs +128 -5
  12. package/bin/incanto-play.mjs +158 -16
  13. package/bin/incanto-playtest.mjs +66 -23
  14. package/bin/incanto-serve.mjs +160 -0
  15. package/bin/incanto-skills.mjs +14 -2
  16. package/bin/incanto-verify.mjs +165 -44
  17. package/bin/incanto.mjs +4 -2
  18. package/dist/2d.d.ts +285 -36
  19. package/dist/2d.js +4 -4
  20. package/dist/3d.d.ts +158 -10
  21. package/dist/3d.js +8 -8
  22. package/dist/{agent8-CvsfVskX.js → agent8-CmNF01gA.js} +61 -8
  23. package/dist/{audio-player-DOrq7sP-.d.ts → audio-player-DaMxqfNE.d.ts} +33 -14
  24. package/dist/{behavior-DoFPYrgo.d.ts → behavior-DZExDn9o.d.ts} +809 -44
  25. package/dist/{create-game-IZIydDwI.js → create-game-Bwvh6q8A.js} +148 -61
  26. package/dist/{create-game-DbWtVTxD.js → create-game-C7ffQWW7.js} +103 -49
  27. package/dist/debug.d.ts +1 -1
  28. package/dist/debug.js +2 -3
  29. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  30. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  31. package/dist/editor.js +1066 -864
  32. package/dist/env.d.ts +1 -1
  33. package/dist/env.js +5 -3
  34. package/dist/{environment-presets-CybQXNqS.js → environment-presets-D6Q5BxeE.js} +299 -46
  35. package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
  36. package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
  37. package/dist/{gameplay-DM1eu_cV.js → gameplay-BfHkuzVb.js} +825 -221
  38. package/dist/gameplay.d.ts +187 -7
  39. package/dist/gameplay.js +1 -1
  40. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  41. package/dist/index.d.ts +156 -13
  42. package/dist/index.js +10 -12
  43. package/dist/json-CfTjpvW8.js +67 -0
  44. package/dist/{loader-CcB533FR.d.ts → loader-Cff09LMm.d.ts} +2 -2
  45. package/dist/net.d.ts +27 -3
  46. package/dist/net.js +2 -2
  47. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  48. package/dist/{physics-2d-B7Y6dPZO.js → physics-2d-CE0Qvy3V.js} +136 -11
  49. package/dist/{physics-3d-bG3n70Ky.js → physics-3d-CpH-2gn5.js} +104 -23
  50. package/dist/{teardown-D2NEmxPB.js → picking-CQJ_PJKh.js} +106 -14
  51. package/dist/react.d.ts +2 -2
  52. package/dist/react.js +2 -2
  53. package/dist/{register-3ta-2Xig.js → register-6DYnKZcy.js} +652 -831
  54. package/dist/{register-ibCjm-wH.js → register-Bkk0wSDB.js} +348 -30
  55. package/dist/{replay-CYvhVHHN.js → replay-DjAkAzMq.js} +224 -14
  56. package/dist/{replay-Dvn8aeBd.d.ts → replay-Dmw-PKQu.d.ts} +20 -3
  57. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  58. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  59. package/dist/{loader-BC4PNtJX.js → save-slots-BXVg148r.js} +4558 -2294
  60. package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
  61. package/dist/{split-screen-DhrSzZIB.d.ts → split-screen--k-XpBjr.d.ts} +36 -4
  62. package/dist/{split-screen-CYwDkbLF.js → split-screen-PL78oVXP.js} +159 -26
  63. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  64. package/dist/{src-D7RIqXYF.js → src-Cxfiv1Hg.js} +2 -17
  65. package/dist/test-iHYVUcDK.js +4036 -0
  66. package/dist/test.d.ts +542 -30
  67. package/dist/test.js +3 -3
  68. package/dist/touch-BnCyPA0G.js +519 -0
  69. package/dist/vite.d.ts +54 -3
  70. package/dist/vite.js +349 -17
  71. package/dist/{webgl-unavailable-N9nQqesw.js → webgl-unavailable-C8aDbGmR.js} +56 -1
  72. package/editor/assets/agent8-D0MS174y.js +1 -0
  73. package/editor/assets/{debug-BBhKuBNV.js → debug-BnXkKuYu.js} +2 -2
  74. package/editor/assets/index-CIu3uc3l.js +11046 -0
  75. package/editor/index.html +1 -1
  76. package/package.json +7 -16
  77. package/schemas/scene.schema.json +30 -3
  78. package/skills/incanto-3d-character.md +14 -1
  79. package/skills/incanto-3d-models.md +12 -0
  80. package/skills/incanto-assets.md +25 -3
  81. package/skills/incanto-audio.md +27 -5
  82. package/skills/incanto-behaviors-and-scripts.md +83 -6
  83. package/skills/incanto-building-2d-games.md +106 -8
  84. package/skills/incanto-building-3d-games.md +118 -6
  85. package/skills/incanto-editor.md +46 -7
  86. package/skills/incanto-environment.md +19 -1
  87. package/skills/incanto-game-feel.md +70 -0
  88. package/skills/incanto-gameplay-behaviors.md +121 -14
  89. package/skills/incanto-hud.md +128 -7
  90. package/skills/incanto-localization.md +13 -5
  91. package/skills/incanto-multiplayer.md +83 -3
  92. package/skills/incanto-node-reference.md +222 -58
  93. package/skills/incanto-performance.md +52 -0
  94. package/skills/incanto-physics-and-input.md +123 -24
  95. package/skills/incanto-playtesting.md +78 -2
  96. package/skills/incanto-save-slots.md +188 -6
  97. package/skills/incanto-scene-json-authoring.md +69 -12
  98. package/skills/incanto-verifying-your-game.md +292 -12
  99. package/skills/incanto-web-integration.md +28 -0
  100. package/skills/incanto-your-first-game.md +5 -2
  101. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  102. package/templates-app/beacon-isle-3d/package.json +2 -2
  103. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  104. package/templates-app/beacon-isle-3d/src/game.scene.json +103 -378
  105. package/templates-app/beacon-isle-3d/src/main.ts +24 -4
  106. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  107. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  108. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  109. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  110. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  111. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  112. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  113. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  114. package/templates-app/molehill-2d/index.html +73 -0
  115. package/templates-app/molehill-2d/package.json +23 -0
  116. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  117. package/templates-app/molehill-2d/src/game.scene.json +1255 -0
  118. package/templates-app/molehill-2d/src/main.ts +41 -0
  119. package/templates-app/molehill-2d/tsconfig.json +13 -0
  120. package/templates-app/molehill-2d/verify.ts +247 -0
  121. package/templates-app/molehill-2d/vite.config.ts +12 -0
  122. package/templates-app/platformer-2d/index.html +0 -23
  123. package/templates-app/platformer-2d/package.json +2 -2
  124. package/templates-app/platformer-2d/src/behaviors.ts +26 -16
  125. package/templates-app/platformer-2d/src/game.scene.json +143 -625
  126. package/templates-app/platformer-2d/src/main.ts +35 -13
  127. package/templates-app/platformer-2d/tsconfig.json +1 -1
  128. package/templates-app/star-survivor/package.json +2 -2
  129. package/templates-app/star-survivor/src/game.scene.json +41 -195
  130. package/templates-app/star-survivor/src/main.ts +28 -7
  131. package/templates-app/star-survivor/tsconfig.json +1 -1
  132. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  133. package/templates-app/tps-3d/package.json +2 -2
  134. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  135. package/templates-app/tps-3d/src/game.scene.json +78 -217
  136. package/templates-app/tps-3d/src/main.ts +39 -17
  137. package/templates-app/tps-3d/tsconfig.json +1 -1
  138. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  139. package/templates-app/village-quest-3d/package.json +2 -2
  140. package/templates-app/village-quest-3d/src/behaviors.ts +21 -0
  141. package/templates-app/village-quest-3d/src/grove.scene.json +54 -221
  142. package/templates-app/village-quest-3d/src/main.ts +24 -4
  143. package/templates-app/village-quest-3d/src/village.scene.json +199 -838
  144. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  145. package/templates-app/village-quest-3d/verify.ts +14 -1
  146. package/dist/duplicate-IWIqk0HJ.js +0 -22
  147. package/dist/json-CwwhxQgb.js +0 -36
  148. package/dist/registry-CF70EArN.js +0 -212
  149. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  150. package/dist/test-8hoHeRmo.js +0 -2340
  151. package/dist/touch-DEAmqGdf.js +0 -225
  152. package/editor/assets/agent8-BrrHOjMJ.js +0 -1
  153. package/editor/assets/index-eVd0BToA.js +0 -10958
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { dirname, join, resolve } from 'node:path';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, relative, resolve } from 'node:path';
4
4
  /**
5
5
  * incanto-playtest — the engine plays your game and tells you whether it works.
6
6
  *
@@ -23,26 +23,50 @@ import { dirname, join, resolve } from 'node:path';
23
23
  * Exit code 1 when no run reached a win, so CI can gate on it.
24
24
  */
25
25
  import { fileURLToPath, pathToFileURL } from 'node:url';
26
- import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
26
+ import { behaviorFiles, behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
27
+ import { readJson } from './_read-json.mjs';
27
28
 
28
29
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
30
+ const PKG_VERSION = readJson(join(PKG, 'package.json')).version;
31
+
32
+ /**
33
+ * A numeric flag with a missing or non-numeric value is a TYPO.
34
+ *
35
+ * `--runs $RUNS` with RUNS unset swallowed the NEXT flag as its value and gave
36
+ * `Number(undefined)` = NaN, which ran ZERO runs and then printed a confident
37
+ * report asserting the opposite of the truth about the scene — exit 0.
38
+ */
39
+ function num(raw, flag) {
40
+ const n = Number(raw);
41
+ if (raw === undefined || raw === '' || !Number.isFinite(n)) {
42
+ console.error(`${flag} needs a number, got ${raw === undefined ? 'nothing' : `'${raw}'`}`);
43
+ process.exit(1);
44
+ }
45
+ return n;
46
+ }
29
47
 
30
48
  function parseArgs(argv) {
31
49
  const args = { runs: 20, seconds: 60, seed: 1, out: '.incanto/playtest' };
32
50
  for (let i = 0; i < argv.length; i++) {
33
51
  const a = argv[i];
34
- if (a === '--behaviors') args.behaviors = argv[++i];
35
- else if (a === '--runs') args.runs = Number(argv[++i]);
36
- else if (a === '--seconds') args.seconds = Number(argv[++i]);
37
- else if (a === '--seed') args.seed = Number(argv[++i]);
38
- else if (a === '--fall-below') args.fallBelow = Number(argv[++i]);
39
- else if (a === '--reach-radius') args.reachRadius = Number(argv[++i]);
52
+ // Repeatable: behaviours legitimately live in more than one file.
53
+ if (a === '--behaviors') args.behaviors = [...(args.behaviors ?? []), argv[++i]];
54
+ else if (a === '--runs') args.runs = num(argv[++i], '--runs');
55
+ else if (a === '--seconds') args.seconds = num(argv[++i], '--seconds');
56
+ else if (a === '--seed') args.seed = num(argv[++i], '--seed');
57
+ else if (a === '--fall-below') args.fallBelow = num(argv[++i], '--fall-below');
58
+ else if (a === '--reach-radius') args.reachRadius = num(argv[++i], '--reach-radius');
40
59
  else if (a === '--out') args.out = argv[++i];
41
60
  else if (a === '--no-replays') args.noReplays = true;
42
61
  else if (a === '--json') args.json = true;
43
62
  else if (a === '--help' || a === '-h') args.help = true;
44
63
  else if (a.startsWith('--')) {
64
+ // `invalid`, not `help`: printing usage and exiting 0 told an agent its
65
+ // typo was a request. `--runs=4`, `--behaviours`, `--no-replay` all landed
66
+ // here and looked like success — and with `--json` the machine path was
67
+ // literally silent (0 bytes on stdout).
45
68
  console.error(`unknown flag: ${a}`);
69
+ args.invalid = true;
46
70
  args.help = true;
47
71
  } else if (!args.scene) args.scene = a;
48
72
  }
@@ -60,24 +84,28 @@ if (args.help || !args.scene) {
60
84
  3D, 1000 px under it in 2D — 2D counts DOWN the screen)
61
85
  --reach-radius R how close counts as "reached" (default 2 in 3D, 32 px in 2D)
62
86
  --behaviors FILE your Behavior subclasses (.ts works on node >= 23.6 / bun)
87
+ comma-separate or repeat for several files
63
88
  --out DIR where failing replays go (default .incanto/playtest)
64
89
  --no-replays report only, write nothing
65
90
  --json the full report as JSON instead of prose
66
91
 
67
- Exits 1 when no run reached a win.`);
68
- process.exit(args.help && args.scene !== undefined ? 0 : 1);
92
+ Exits 1 when a run THREW or left the world, or when a win is declared and no
93
+ run reached it. A scene that declares no win, or has nobody to drive, exits 0
94
+ the report marks those · rather than ✗, and the exit code says the same.`);
95
+ process.exit(!args.invalid && args.help && args.scene !== undefined ? 0 : 1);
69
96
  }
70
97
 
71
- const { playtest, playtestText, failingReplays } = await import(
98
+ const { playtest, playtestText, playtestFailed, failingReplays } = await import(
72
99
  pathToFileURL(join(PKG, 'dist', 'test.js')).href
73
100
  );
74
101
  const incanto = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
75
102
 
76
103
  const behaviors = {};
77
- if (args.behaviors) {
104
+ const behaviorSources = behaviorFiles(args.behaviors);
105
+ for (const file of behaviorSources) {
78
106
  let mod;
79
107
  try {
80
- mod = await import(pathToFileURL(resolve(args.behaviors)).href);
108
+ mod = await import(pathToFileURL(resolve(file)).href);
81
109
  } catch (e) {
82
110
  const why = e?.message ?? String(e);
83
111
  // NODE CANNOT IMPORT THE PATTERN WE TEACH. Every template's behaviors.ts
@@ -94,7 +122,7 @@ if (args.behaviors) {
94
122
  // `village-quest-3d` starter, and it breaks for any author who splits their
95
123
  // behaviours across files — which is most of them, eventually.
96
124
  reexecUnderBun(import.meta.url, why);
97
- console.error(behaviorsHelp(args.behaviors, why));
125
+ console.error(behaviorsHelp(file, why));
98
126
  process.exit(1);
99
127
  }
100
128
  for (const [name, value] of Object.entries(mod)) {
@@ -102,13 +130,13 @@ if (args.behaviors) {
102
130
  behaviors[name] = value;
103
131
  }
104
132
  }
105
- if (Object.keys(behaviors).length === 0) {
106
- console.error(`warning: no Behavior subclasses exported from ${args.behaviors}`);
107
- }
133
+ }
134
+ if (behaviorSources.length > 0 && Object.keys(behaviors).length === 0) {
135
+ console.error(`warning: no Behavior subclasses exported from ${behaviorSources.join(', ')}`);
108
136
  }
109
137
 
110
138
  const scenePath = resolve(args.scene);
111
- const sceneJson = JSON.parse(readFileSync(scenePath, 'utf-8'));
139
+ const sceneJson = readJson(scenePath);
112
140
  const sceneDir = dirname(scenePath);
113
141
 
114
142
  let report;
@@ -118,11 +146,11 @@ try {
118
146
  seconds: args.seconds,
119
147
  seed: args.seed,
120
148
  behaviors,
121
- stubMissingBehaviors: !args.behaviors,
149
+ stubMissingBehaviors: behaviorSources.length === 0,
122
150
  ...(Number.isFinite(args.fallBelow) ? { fallBelow: args.fallBelow } : {}),
123
151
  ...(Number.isFinite(args.reachRadius) ? { reachRadius: args.reachRadius } : {}),
124
152
  // sub-scenes resolve relative to the scene file, as the loader does
125
- resolveScene: (p) => JSON.parse(readFileSync(resolve(sceneDir, p), 'utf-8')),
153
+ resolveScene: (p) => readJson(resolve(sceneDir, p)),
126
154
  });
127
155
  } catch (e) {
128
156
  console.error(`playtest failed: ${e?.message ?? e}`);
@@ -140,12 +168,27 @@ if (args.json) {
140
168
  if (!args.noReplays && !args.json) {
141
169
  const seen = new Set();
142
170
  const written = [];
171
+ /*
172
+ * Stamp WHICH GAME each recording is of.
173
+ *
174
+ * `.incanto/playtest` is keyed by `(outcome, seed)` and nothing else, and
175
+ * never cleaned. So the second playtest in a project either leaves a stale
176
+ * `stuck-seed3.json` from a scene that no longer exists sitting beside the
177
+ * current one, or silently overwrites another scene's — and
178
+ * `incanto-play --commands` happily replays any recording against any scene
179
+ * and reports success, because nothing in the file said what it was of.
180
+ */
181
+ const stamp = {
182
+ path: relative(process.cwd(), scenePath) || scenePath,
183
+ name: typeof sceneJson?.name === 'string' ? sceneJson.name : undefined,
184
+ engine: PKG_VERSION,
185
+ };
143
186
  for (const { seed, outcome, replay } of failingReplays(report)) {
144
187
  if (seen.has(outcome)) continue;
145
188
  seen.add(outcome);
146
189
  const file = join(resolve(args.out), `${outcome}-seed${seed}.json`);
147
190
  mkdirSync(dirname(file), { recursive: true });
148
- writeFileSync(file, JSON.stringify(replay, null, 2));
191
+ writeFileSync(file, JSON.stringify({ ...replay, scene: stamp }, null, 2));
149
192
  written.push(file);
150
193
  }
151
194
  if (written.length > 0) {
@@ -157,4 +200,4 @@ if (!args.noReplays && !args.json) {
157
200
  // asynchronously, and exiting discards whatever has not flushed. A --json
158
201
  // report read by another program came back truncated — silently, and only
159
202
  // when piped, which is the only way a program reads it.
160
- process.exitCode = report.runs.some((r) => r.outcome === 'won') ? 0 : 1;
203
+ process.exitCode = playtestFailed(report) ? 1 : 0;
@@ -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
+ });
@@ -22,8 +22,19 @@ function parseArgs(argv) {
22
22
  if (argv[i] === '--format') args.format = argv[++i];
23
23
  else if (argv[i] === '--out') args.out = argv[++i];
24
24
  else if (argv[i] === '--help' || argv[i] === '-h') args.help = true;
25
+ /*
26
+ * The bare word, because that is what everything tells you to type.
27
+ *
28
+ * `incanto-new`'s closing message — the last thing a scaffolded project
29
+ * prints, and the line that installs the skills the whole workflow depends
30
+ * on — said `bunx incanto-skills claude`, and that was an unknown argument.
31
+ * The flag form stays; this makes the printed line true.
32
+ */ else if (argv[i] === 'claude' || argv[i] === 'opencode') args.format = argv[i];
25
33
  else {
34
+ // Not `help`: printing usage and exiting 0 for a typo meant
35
+ // `incanto-skills --oops` installed ZERO skills and reported success.
26
36
  console.error(`unknown argument: ${argv[i]}`);
37
+ args.invalid = true;
27
38
  args.help = true;
28
39
  }
29
40
  }
@@ -32,7 +43,8 @@ function parseArgs(argv) {
32
43
 
33
44
  const args = parseArgs(process.argv.slice(2));
34
45
  if (args.help || !['claude', 'opencode'].includes(args.format)) {
35
- console.log(`Usage: npx incanto-skills [--format claude|opencode] [--out <dir>]
46
+ console.log(`Usage: npx incanto-skills [claude|opencode] [--out <dir>]
47
+ npx incanto-skills [--format claude|opencode] [--out <dir>]
36
48
 
37
49
  claude (default) Claude Code project skills:
38
50
  <out>/<skill-name>/SKILL.md (default out: .claude/skills)
@@ -40,7 +52,7 @@ if (args.help || !['claude', 'opencode'].includes(args.format)) {
40
52
  <out>/<skill-name>.md (default out: skills)
41
53
 
42
54
  Skills are read from the installed incanto package and match its version.`);
43
- process.exit(args.help ? 0 : 1);
55
+ process.exit(!args.invalid && args.help ? 0 : 1);
44
56
  }
45
57
 
46
58
  const outRoot = resolve(args.out ?? (args.format === 'claude' ? '.claude/skills' : 'skills'));