incanto 0.69.0 → 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/bin/_behaviors-loader.mjs +22 -0
  2. package/bin/_read-json.mjs +28 -0
  3. package/bin/incanto-assets.mjs +5 -4
  4. package/bin/incanto-check.mjs +58 -31
  5. package/bin/incanto-editor.mjs +128 -5
  6. package/bin/incanto-env.mjs +3 -2
  7. package/bin/incanto-feel.mjs +24 -13
  8. package/bin/incanto-frame.mjs +8 -1
  9. package/bin/incanto-multiplay.mjs +11 -9
  10. package/bin/incanto-new.mjs +105 -10
  11. package/bin/incanto-play.mjs +158 -16
  12. package/bin/incanto-playtest.mjs +66 -23
  13. package/bin/incanto-skills.mjs +14 -2
  14. package/bin/incanto-verify.mjs +121 -102
  15. package/bin/incanto.mjs +3 -2
  16. package/dist/2d.d.ts +296 -34
  17. package/dist/2d.js +4 -4
  18. package/dist/3d.d.ts +157 -9
  19. package/dist/3d.js +8 -8
  20. package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
  21. package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
  22. package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
  23. package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
  24. package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
  25. package/dist/debug.d.ts +1 -1
  26. package/dist/debug.js +2 -3
  27. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  28. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  29. package/dist/editor.js +1088 -882
  30. package/dist/env.d.ts +1 -1
  31. package/dist/env.js +5 -3
  32. package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
  33. package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
  34. package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
  35. package/dist/gameplay.d.ts +254 -10
  36. package/dist/gameplay.js +2 -2
  37. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  38. package/dist/index.d.ts +179 -13
  39. package/dist/index.js +10 -12
  40. package/dist/json-CfTjpvW8.js +67 -0
  41. package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
  42. package/dist/net.d.ts +27 -3
  43. package/dist/net.js +2 -2
  44. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  45. package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
  46. package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
  47. package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
  48. package/dist/react.d.ts +2 -2
  49. package/dist/react.js +2 -2
  50. package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
  51. package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
  52. package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
  53. package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
  54. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  55. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  56. package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
  57. package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
  58. package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
  59. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  60. package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
  61. package/dist/test-CmN54kWv.js +4264 -0
  62. package/dist/test.d.ts +639 -30
  63. package/dist/test.js +3 -3
  64. package/dist/touch-BnCyPA0G.js +519 -0
  65. package/dist/vite.d.ts +20 -37
  66. package/dist/vite.js +177 -7
  67. package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
  68. package/editor/assets/agent8-yLIEYHbd.js +1 -0
  69. package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
  70. package/editor/assets/index-DK9xMGpW.js +11046 -0
  71. package/editor/index.html +1 -1
  72. package/package.json +4 -14
  73. package/schemas/scene.schema.json +34 -3
  74. package/skills/incanto-3d-character.md +14 -1
  75. package/skills/incanto-3d-models.md +12 -0
  76. package/skills/incanto-assets.md +9 -1
  77. package/skills/incanto-audio.md +8 -2
  78. package/skills/incanto-behaviors-and-scripts.md +105 -7
  79. package/skills/incanto-building-2d-games.md +97 -10
  80. package/skills/incanto-building-3d-games.md +58 -4
  81. package/skills/incanto-editor.md +46 -7
  82. package/skills/incanto-environment.md +19 -1
  83. package/skills/incanto-game-feel.md +70 -0
  84. package/skills/incanto-gameplay-behaviors.md +172 -20
  85. package/skills/incanto-hud.md +142 -7
  86. package/skills/incanto-localization.md +13 -5
  87. package/skills/incanto-multiplayer.md +83 -3
  88. package/skills/incanto-node-reference.md +239 -59
  89. package/skills/incanto-performance.md +52 -0
  90. package/skills/incanto-physics-and-input.md +123 -24
  91. package/skills/incanto-playtesting.md +112 -2
  92. package/skills/incanto-save-slots.md +217 -7
  93. package/skills/incanto-scene-json-authoring.md +69 -12
  94. package/skills/incanto-verifying-your-game.md +207 -10
  95. package/skills/incanto-web-integration.md +28 -0
  96. package/skills/incanto-your-first-game.md +3 -1
  97. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  98. package/templates-app/beacon-isle-3d/package.json +2 -2
  99. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  100. package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
  101. package/templates-app/beacon-isle-3d/src/main.ts +11 -1
  102. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  103. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  104. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  105. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  106. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  107. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  108. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  109. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  110. package/templates-app/molehill-2d/index.html +73 -0
  111. package/templates-app/molehill-2d/package.json +23 -0
  112. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  113. package/templates-app/molehill-2d/src/game.scene.json +1205 -0
  114. package/templates-app/molehill-2d/src/main.ts +41 -0
  115. package/templates-app/molehill-2d/tsconfig.json +13 -0
  116. package/templates-app/molehill-2d/verify.ts +247 -0
  117. package/templates-app/molehill-2d/vite.config.ts +12 -0
  118. package/templates-app/platformer-2d/package.json +2 -2
  119. package/templates-app/platformer-2d/src/game.scene.json +178 -986
  120. package/templates-app/platformer-2d/src/main.ts +11 -1
  121. package/templates-app/platformer-2d/tsconfig.json +1 -1
  122. package/templates-app/star-survivor/package.json +2 -2
  123. package/templates-app/star-survivor/src/game.scene.json +43 -216
  124. package/templates-app/star-survivor/src/main.ts +11 -1
  125. package/templates-app/star-survivor/tsconfig.json +1 -1
  126. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  127. package/templates-app/tps-3d/package.json +2 -2
  128. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  129. package/templates-app/tps-3d/src/game.scene.json +66 -263
  130. package/templates-app/tps-3d/src/main.ts +13 -2
  131. package/templates-app/tps-3d/tsconfig.json +1 -1
  132. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  133. package/templates-app/village-quest-3d/package.json +2 -2
  134. package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
  135. package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
  136. package/templates-app/village-quest-3d/src/main.ts +11 -1
  137. package/templates-app/village-quest-3d/src/village.scene.json +202 -863
  138. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  139. package/templates-app/village-quest-3d/verify.ts +14 -1
  140. package/dist/duplicate-Dj4L-5AA.js +0 -22
  141. package/dist/json-CwwhxQgb.js +0 -36
  142. package/dist/registry-CF70EArN.js +0 -212
  143. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  144. package/dist/test-D6ISmuFE.js +0 -2383
  145. package/dist/touch-DEAmqGdf.js +0 -225
  146. package/editor/assets/agent8-BDvaZMQJ.js +0 -1
  147. package/editor/assets/index-CZIRr5EL.js +0 -10958
@@ -1,7 +1,23 @@
1
- import { D as handlerShadowWarnings, j as nodeRefWarnings } from "./loader-DZE7B07H.js";
2
- import { R as translationKey, k as HudWidgetBase } from "./register-mmfugpaQ.js";
3
- import { o as getNodeType } from "./registry-CF70EArN.js";
1
+ import { L as handlerShadowWarnings, R as sceneDimension, V as HudWidgetBase, Z as nodeRefWarnings } from "./save-slots-Bvuh2p_r.js";
2
+ import { N as translationKey } from "./register-DuVdzxbq.js";
3
+ import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
+ import { u as getNodeType } from "./touch-BnCyPA0G.js";
4
5
  //#region src/core/audit.ts
6
+ /**
7
+ * Behaviours whose ENTIRE observable effect is the signals they emit.
8
+ *
9
+ * Membership is the claim, so it is written down rather than guessed: a
10
+ * behaviour belongs here only if attaching it and wiring none of its signals
11
+ * leaves the game bit-identical. `Clickable` qualifies — it picks, it hovers,
12
+ * and it tells you; it changes nothing itself. `Pickup` does not (it frees
13
+ * itself), nor does `Health` (it holds the number), nor `Interactable` (it
14
+ * gates on range and shows a prompt).
15
+ */
16
+ const SIGNAL_ONLY = new Map([["Clickable", [
17
+ "clicked",
18
+ "hovered",
19
+ "unhovered"
20
+ ]]]);
5
21
  const ANIMATED_TYPES = new Set([
6
22
  "Particles2D",
7
23
  "Particles3D",
@@ -48,11 +64,14 @@ function isHudWidget(type) {
48
64
  }
49
65
  return false;
50
66
  }
51
- const LIGHT_TYPES = new Set([
52
- "DirectionalLight3D",
53
- "OmniLight3D",
54
- "SpotLight3D"
67
+ /** The node types that auto-find a terrain when their `terrain` path is empty. */
68
+ const DRAPERS = new Set([
69
+ "Foliage3D",
70
+ "Flowers3D",
71
+ "Tree3D",
72
+ "River3D"
55
73
  ]);
74
+ const LIGHT_TYPES = new Set(["DirectionalLight3D", "OmniLight3D"]);
56
75
  /**
57
76
  * Nodes whose gameplay answer comes from a CPU twin built out of `size`.
58
77
  *
@@ -62,6 +81,7 @@ const LIGHT_TYPES = new Set([
62
81
  */
63
82
  const WORLD_QUERY_TYPES = new Set([
64
83
  "Terrain3D",
84
+ "TileMap2D",
65
85
  "Water3D",
66
86
  "River3D"
67
87
  ]);
@@ -74,11 +94,21 @@ const WORLD_QUERY_TYPES = new Set([
74
94
  * A JSON walk cannot ask a behavior whether it serializes, so this is the list
75
95
  * of the ones that do. Your own behavior with a `serialize()` is caught at
76
96
  * runtime instead, by `captureState`.
97
+ *
98
+ * It is a CLOSED list beside an OPEN one — every engine `Behavior` overriding
99
+ * `serialize()` — and it drifted: `Currency` (a game's money) and `SavePoint`
100
+ * (how long the run has been going) both saved, and both audited clean on a
101
+ * node with no uid. `saveable-scripts.test.ts` reads the sources and fails when
102
+ * the two lists disagree again.
77
103
  */
78
104
  const SAVEABLE_SCRIPTS = new Set([
105
+ "Collector",
106
+ "Currency",
107
+ "DayNight",
79
108
  "Health",
109
+ "SavePoint",
80
110
  "ScoreKeeper",
81
- "Collector"
111
+ "WaveSpawner"
82
112
  ]);
83
113
  /** Does the scene header light anything by itself (sky, hdri, ambient, preset)? */
84
114
  function isLit(environment) {
@@ -123,7 +153,7 @@ function instancedScenes(corpus, resolveRef) {
123
153
  if (typeof ref === "string" && ref) instanced.add(resolveRef(from, ref));
124
154
  for (const child of node.children ?? []) walk(child, from);
125
155
  };
126
- for (const { path, json } of corpus) walk(json.root, path);
156
+ for (const { path, json } of corpus) walk(json?.root, path);
127
157
  return instanced;
128
158
  }
129
159
  function auditScene(scene, opts = {}) {
@@ -135,10 +165,15 @@ function auditScene(scene, opts = {}) {
135
165
  let lights = 0;
136
166
  /** Nodes that drive the current camera themselves — see the warning below. */
137
167
  const cameraDrivers = [];
168
+ const terrains = [];
169
+ const autoDrapers = [];
138
170
  const followedCameras = [];
139
171
  const needUid = [];
140
172
  /** Every `DamageOnContact.targetGroup` in the scene, with the node that set it. */
141
173
  const damageGroups = [];
174
+ /** Nodes whose script produces NOTHING but signals — see `SIGNAL_ONLY`. */
175
+ const silentSignals = [];
176
+ const groupRefs = [];
142
177
  const walk = (node, path, insideHud, staticRoot, transformed) => {
143
178
  const type = node.type ?? "";
144
179
  const props = node.props ?? {};
@@ -155,8 +190,14 @@ function auditScene(scene, opts = {}) {
155
190
  if (script === "FollowCamera") followedCameras.push(path);
156
191
  }
157
192
  if (type === "CharacterController3D") cameraDrivers.push(path);
193
+ if (type === "Terrain3D") terrains.push(path);
194
+ if (DRAPERS.has(type) && (props.terrain ?? "") === "") autoDrapers.push(path);
158
195
  if (LIGHT_TYPES.has(type)) lights += 1;
159
196
  if (BODY_TYPES.has(type) && transformed) warnings.push(`${path}: ${type} sits under '${transformed.path}', whose ${transformed.what} physics cannot compose — the collider will not line up with what is drawn. Keep body ancestors untransformed or translation-only, or put the body outside them.`);
197
+ if (script && SIGNAL_ONLY.has(script)) silentSignals.push({
198
+ path,
199
+ script
200
+ });
160
201
  if (script === "DamageOnContact") {
161
202
  const group = (node.script?.props)?.targetGroup;
162
203
  if (typeof group === "string" && group !== "") damageGroups.push({
@@ -164,6 +205,15 @@ function auditScene(scene, opts = {}) {
164
205
  group
165
206
  });
166
207
  }
208
+ for (const [name, prop] of [["Pickup", "collectorGroup"], ["Collector", "group"]]) {
209
+ if (script !== name) continue;
210
+ const group = (node.script?.props)?.[prop];
211
+ if (typeof group === "string" && group !== "") groupRefs.push({
212
+ path,
213
+ group,
214
+ what: `${name}.${prop}`
215
+ });
216
+ }
167
217
  if (WORLD_QUERY_TYPES.has(type) && transformed) warnings.push(`${path}: ${type} sits under '${transformed.path}', whose ${transformed.what} it cannot compose — the ground (or water) you can see would not be the ground you can stand on. Keep its ancestors untransformed or translation-only.`);
168
218
  if (BODY_TYPES.has(type)) {
169
219
  const collider = props.collider;
@@ -185,22 +235,52 @@ function auditScene(scene, opts = {}) {
185
235
  what: transformOf(root.props ?? {})
186
236
  } : null);
187
237
  if (needUid.length > 0) warnings.push(`these carry state a save keeps and have no uid to key it under, so the save comes back EMPTY and the load reports no problem: ${needUid.join(", ")}. Give each one a "uid" from newUid().`);
238
+ if (terrains.length > 1 && autoDrapers.length > 0) warnings.push(`${autoDrapers.join(", ")}: this scene has ${terrains.length} Terrain3Ds (${terrains.join(", ")}) and these do not say which one they grow on — with no \`terrain\` path they take the FIRST one in the tree, so the rest drape to the wrong height with nothing reported. Name it: "terrain": "%Name".`);
239
+ if (cameraDrivers.length > 1) warnings.push(`${cameraDrivers.join(", ")}: ${cameraDrivers.length} CharacterController3Ds drive the SAME camera (each one finds the first \`current\` camera in the tree), so the last of them wins every frame and the others have no view. For local co-op give each player a panel with \`createSplitScreen\` (one engine each); otherwise only one node may carry a controller.`);
188
240
  if (cameraDrivers.length > 0 && followedCameras.length > 0) warnings.push(`${followedCameras.join(", ")}: a FollowCamera writes this camera's position every frame, and so does the CharacterController3D at ${cameraDrivers.join(", ")} — the controller owns the camera in every view it has. Drop one of them: use the controller's own \`view\`/\`camDistance\`, or take the controller off the camera duty.`);
189
241
  if (cameras > 0 && currentCameras === 0) warnings.push("no camera has \"current\": true — the screen renders from a default view, probably not what you framed.");
190
242
  if (currentCameras > 1) warnings.push(`${currentCameras} cameras claim "current": true — only the first found wins.`);
191
243
  const isFragment = opts.instanced === true || scene.fragment === true;
192
- if (scene.dimension === "3d" && !isFragment && cameras === 0) warnings.push("this 3D scene has no camera — the screen renders from a default view, which is almost never the shot you want. Add a Camera3D with \"current\": true, or set \"fragment\": true in the scene header if this file is a prefab another scene drops in.");
193
- if (scene.dimension === "3d" && !isFragment && lights === 0 && !isLit(scene.environment)) warnings.push("nothing lights this 3D scene no light node, no environment sky/hdri, and no ambient intensity. It will render black. Add a DirectionalLight3D, or an \"environment\": { \"sky\": { \"type\": \"atmosphere\" } } / { \"preset\": \"...\" } or set \"fragment\": true if another scene lights this one.");
244
+ const is3d = sceneDimension(scene) === "3d";
245
+ if (is3d && !isFragment && cameras === 0) warnings.push("this 3D scene has no camera the screen renders from a default view, which is almost never the shot you want. Add a Camera3D with \"current\": true, or set \"fragment\": true in the scene header if this file is a prefab another scene drops in.");
246
+ if (is3d && !isFragment && lights === 0 && !isLit(scene.environment)) warnings.push("nothing lights this 3D scene — no light node, no environment sky/hdri, and no ambient intensity. It will render black. Add a DirectionalLight3D, or an \"environment\": { \"sky\": { \"type\": \"atmosphere\" } } / { \"preset\": \"...\" } — or set \"fragment\": true if another scene lights this one.");
247
+ if (silentSignals.length > 0) {
248
+ const heard = /* @__PURE__ */ new Set();
249
+ for (const conn of scene.connections ?? []) if (typeof conn.from === "string") heard.add(conn.from);
250
+ for (const { path, script } of silentSignals) {
251
+ const relative = path.slice(path.indexOf("/") + 1);
252
+ if (heard.has(relative) || heard.has(path) || heard.has(`/root/${relative}`)) continue;
253
+ const signals = SIGNAL_ONLY.get(script) ?? [];
254
+ warnings.push(`${path}: ${script} produces nothing but ${signals.map((x) => `'${x}'`).join("/")}, and no connection listens to any of them — so it can never do anything. Wire one, or listen in code with \`node.on('${signals[0]}', …)\`.`);
255
+ }
256
+ }
194
257
  if (damageGroups.length > 0) {
195
258
  const hurtable = hurtableGroups(root);
196
259
  for (const { path, group } of damageGroups) {
197
260
  if (hurtable.has(group)) continue;
198
- warnings.push(`${path}: DamageOnContact targets group '${group}', and nothing in this scene both carries a Health and is in that group — so this hazard can never damage anything. Groups that CAN be hurt here: [${[...hurtable].join(", ") || "none"}].`);
261
+ warnings.push(isFragment ? `${path}: DamageOnContact targets group '${group}', and no Health in THIS file is in that group — fine if the target lives in the host scene, a typo otherwise. Groups hurtable here: [${[...hurtable].join(", ") || "none"}].` : `${path}: DamageOnContact targets group '${group}', and nothing in this scene both carries a Health and is in that group — so this hazard can never damage anything. Groups that CAN be hurt here: [${[...hurtable].join(", ") || "none"}].`);
262
+ }
263
+ }
264
+ for (const { filter } of scene.connections ?? []) {
265
+ const group = filter?.group;
266
+ if (typeof group === "string" && group !== "") groupRefs.push({
267
+ path: "connections",
268
+ group,
269
+ what: "filter.group"
270
+ });
271
+ }
272
+ if (groupRefs.length > 0) {
273
+ const present = groupsInScene(root);
274
+ for (const { path, group, what } of groupRefs) {
275
+ if (present.has(group)) continue;
276
+ warnings.push(`${path}: ${what} names group '${group}', and no node in this scene is in it — so this never fires. Groups that exist here: [${[...present].join(", ") || "none"}].`);
199
277
  }
200
278
  }
201
- for (const warning of nodeRefWarnings(scene)) warnings.push(warning);
279
+ for (const warning of nodeRefWarnings(scene, { fragment: isFragment })) warnings.push(warning);
202
280
  for (const warning of handlerShadowWarnings(scene)) warnings.push(warning);
203
281
  for (const warning of auditStrings(scene, opts.declaredElsewhere)) warnings.push(warning);
282
+ for (const warning of auditInputBindings(scene)) warnings.push(warning);
283
+ for (const warning of auditKeyHints(scene, root)) warnings.push(warning);
204
284
  return warnings;
205
285
  }
206
286
  /**
@@ -216,6 +296,16 @@ function auditScene(scene, opts = {}) {
216
296
  * end), but the shape generalises: a hazard whose `targetGroup` nothing can
217
297
  * satisfy is wired, validated, and disarmed, and every check comes back green.
218
298
  */
299
+ /** Every group any node in the scene is in — the set a group REFERENCE can hit. */
300
+ function groupsInScene(root) {
301
+ const out = /* @__PURE__ */ new Set();
302
+ const walk = (node) => {
303
+ for (const g of node.groups ?? []) out.add(g);
304
+ for (const child of node.children ?? []) walk(child);
305
+ };
306
+ walk(root);
307
+ return out;
308
+ }
219
309
  function hurtableGroups(root) {
220
310
  const out = /* @__PURE__ */ new Set();
221
311
  const hasHealth = (node) => node.script?.name === "Health" || (node.children ?? []).some(hasHealth);
@@ -291,6 +381,125 @@ function auditStrings(scene, elsewhere) {
291
381
  }
292
382
  return out;
293
383
  }
384
+ /**
385
+ * One physical key, two actions.
386
+ *
387
+ * Found in the shipped `tps-3d` starter: `reload` and `restart` were both
388
+ * `KeyR`. Pressing R reloaded, because `restart` was never read by anything —
389
+ * the collision and the dead binding hid each other, and neither is visible to
390
+ * a loader that validates each action's own shape.
391
+ *
392
+ * A warning rather than an error: two actions on one key are legal and
393
+ * sometimes deliberate (an `interact` that also advances a dialogue), which is
394
+ * why the message says what will happen rather than refusing to load.
395
+ */
396
+ function auditInputBindings(scene) {
397
+ const input = scene.input;
398
+ if (!input || typeof input !== "object" || Array.isArray(input)) return [];
399
+ const byKey = /* @__PURE__ */ new Map();
400
+ const add = (code, action) => {
401
+ if (typeof code !== "string" || code === "") return;
402
+ const list = byKey.get(code) ?? [];
403
+ if (!list.includes(action)) list.push(action);
404
+ byKey.set(code, list);
405
+ };
406
+ for (const [action, decl] of Object.entries(input)) {
407
+ if (!decl || typeof decl !== "object" || Array.isArray(decl)) continue;
408
+ const keys = decl.keys;
409
+ if (Array.isArray(keys)) for (const code of keys) add(code, action);
410
+ else if (keys && typeof keys === "object") {
411
+ for (const codes of Object.values(keys)) if (Array.isArray(codes)) for (const code of codes) add(code, action);
412
+ }
413
+ }
414
+ const out = [];
415
+ for (const [code, actions] of byKey) {
416
+ if (actions.length < 2) continue;
417
+ out.push(`input: '${code}' is bound to ${actions.map((a) => `'${a}'`).join(" and ")} — both fire on the same press, so whichever one your code reads first wins and the other looks broken. Give one of them its own key.`);
418
+ }
419
+ return out;
420
+ }
421
+ /**
422
+ * How a HUD line spells a key, and how `input{}` spells the same key.
423
+ *
424
+ * Only the ones a player would ever type in a hint. A miss here is silence, not
425
+ * a false alarm: the check needs BOTH ends to be recognisable before it says
426
+ * anything.
427
+ */
428
+ const KEY_WORDS = {
429
+ esc: ["Escape"],
430
+ escape: ["Escape"],
431
+ enter: ["Enter", "NumpadEnter"],
432
+ return: ["Enter"],
433
+ space: ["Space"],
434
+ tab: ["Tab"],
435
+ shift: ["ShiftLeft", "ShiftRight"],
436
+ ctrl: ["ControlLeft", "ControlRight"],
437
+ alt: ["AltLeft", "AltRight"]
438
+ };
439
+ /** Every key code an action binds, however the declaration is shaped. */
440
+ function keysOfAction(decl) {
441
+ if (!decl || typeof decl !== "object" || Array.isArray(decl)) return [];
442
+ const keys = decl.keys;
443
+ if (Array.isArray(keys)) return keys.filter((k) => typeof k === "string");
444
+ if (keys && typeof keys === "object") {
445
+ const out = [];
446
+ for (const codes of Object.values(keys)) if (Array.isArray(codes)) {
447
+ for (const c of codes) if (typeof c === "string") out.push(c);
448
+ }
449
+ return out;
450
+ }
451
+ return [];
452
+ }
453
+ /**
454
+ * A HUD line that tells the player a key the input map does not bind.
455
+ *
456
+ * `tps-3d`'s pause menu read `Esc to resume · R to restart` for exactly three
457
+ * hours after `restart` was moved off `KeyR` (which `reload` already owned) —
458
+ * the binding moved and the sentence about it did not, which is the whole
459
+ * failure mode of any fact written down twice.
460
+ *
461
+ * Deliberately narrow. It fires only when a hint names BOTH a key this table
462
+ * knows and a word that is a DECLARED ACTION in the same scene, so "Esc to
463
+ * resume" says nothing (there is no `resume` action) and prose about a "T shape"
464
+ * says nothing. What is left is the case where the scene itself contains both
465
+ * halves and they disagree.
466
+ */
467
+ function auditKeyHints(scene, root) {
468
+ const input = scene.input;
469
+ if (!input || typeof input !== "object" || Array.isArray(input)) return [];
470
+ const actions = input;
471
+ const out = [];
472
+ const seen = /* @__PURE__ */ new Set();
473
+ const check = (path, text) => {
474
+ for (const m of text.matchAll(/\b([A-Za-z]{1,6})\s+to\s+([a-z]+)\b/g)) {
475
+ const word = (m[1] ?? "").toLowerCase();
476
+ const action = m[2] ?? "";
477
+ if (!(action in actions)) continue;
478
+ const wanted = KEY_WORDS[word] ?? (word.length === 1 ? [`Key${word.toUpperCase()}`] : null);
479
+ if (!wanted) continue;
480
+ const bound = keysOfAction(actions[action]);
481
+ if (bound.length === 0 || wanted.some((k) => bound.includes(k))) continue;
482
+ const line = `${path}: says "${m[0]}", and '${action}' is bound to [${bound.join(", ")}]`;
483
+ if (seen.has(line)) continue;
484
+ seen.add(line);
485
+ out.push(`${line} — the HUD is telling the player a key that does something else. Rebind the action, or fix the line.`);
486
+ }
487
+ };
488
+ const walk = (node, path) => {
489
+ const props = node.props ?? {};
490
+ for (const key of [
491
+ "text",
492
+ "format",
493
+ "label"
494
+ ]) {
495
+ const value = props[key];
496
+ if (typeof value === "string" && value !== "") check(`${path}.${key}`, value);
497
+ }
498
+ for (const child of node.children ?? []) walk(child, `${path}/${child.name ?? "?"}`);
499
+ };
500
+ walk(root, `/${root.name ?? "?"}`);
501
+ return out;
502
+ }
294
503
  //#endregion
295
504
  //#region src/core/replay.ts
296
505
  /**
@@ -377,7 +586,8 @@ function replay(engine, recording, opts) {
377
586
  }
378
587
  const at = recording.ticks[i];
379
588
  if (at === null) engine.step();
380
- else engine.tick(at);
589
+ else if (typeof at === "number" && Number.isFinite(at)) engine.tick(at);
590
+ else throw new IncantoError("BAD_FORMAT", `Replay tick ${i} is ${JSON.stringify(at)}, not a timestamp. A recording holds a finite millisecond value per frame, or null for a frame driven by step().`);
381
591
  opts?.onTick?.(i);
382
592
  }
383
593
  }
@@ -1,5 +1,5 @@
1
- import { b as Engine } from "./behavior-rm8vVzu_.js";
2
- import { s as JsonObject } from "./schema-B6ugCV1Q.js";
1
+ import { b as Engine } from "./behavior-Dcz0fr1S.js";
2
+ import { s as JsonObject } from "./rng-Bb-IutXB.js";
3
3
 
4
4
  //#region src/core/audit.d.ts
5
5
  /** Human-readable warnings (empty = clean). Pure JSON walk, no registry. */
@@ -59,7 +59,7 @@ declare function auditScene(scene: JsonObject, opts?: AuditOptions): string[];
59
59
  * Incanto fails loudly with machine-readable codes and messages that list valid
60
60
  * alternatives — AI agents self-correct on hard failures, not warnings.
61
61
  */
62
- type IncantoErrorCode = "AMBIGUOUS_HANDLER" | "BAD_FORMAT" | "BAD_NODE_PATH" | "DANGLING_CONNECTION" | "DUPLICATE_BEHAVIOR" | "DUPLICATE_NODE_TYPE" | "DUPLICATE_UNIQUE_NAME" | "DUPLICATE_UID" | "NODE_NOT_FOUND" | "PROP_TYPE_MISMATCH" | "TREE_VIOLATION" | "UNKNOWN_ANIMATION" | "UNKNOWN_ASSET" | "UNKNOWN_CONSTANT" | "UNKNOWN_BEHAVIOR" | "UNKNOWN_HANDLER" | "UNKNOWN_NODE_TYPE" | "UNKNOWN_PROP" | "UNKNOWN_SIGNAL" | "UNRESOLVED_INSTANCE" | "WEBGL_UNAVAILABLE";
62
+ type IncantoErrorCode = "AMBIGUOUS_HANDLER" | "BAD_FORMAT" | "BAD_NODE_PATH" | "DANGLING_CONNECTION" | "DUPLICATE_BEHAVIOR" | "DUPLICATE_NODE_TYPE" | "DUPLICATE_UNIQUE_NAME" | "DUPLICATE_UID" | "NETWORK_UNAVAILABLE" | "NODE_NOT_FOUND" | "PROP_TYPE_MISMATCH" | "TREE_VIOLATION" | "UNKNOWN_ANIMATION" | "UNKNOWN_ASSET" | "UNKNOWN_CONSTANT" | "UNKNOWN_BEHAVIOR" | "UNKNOWN_HANDLER" | "UNKNOWN_NODE_TYPE" | "UNKNOWN_PROP" | "UNKNOWN_SIGNAL" | "UNRESOLVED_INSTANCE" | "WEBGL_UNAVAILABLE";
63
63
  /**
64
64
  * Structured context mirrored from the prose message — tools and agents parse
65
65
  * THESE instead of regexing the message (which is for humans and may reword).
@@ -115,6 +115,23 @@ interface ReplayJson {
115
115
  */
116
116
  ticks: Array<number | null>;
117
117
  events: ReplayEvent[];
118
+ /**
119
+ * WHICH GAME this is a recording of — optional, and stamped by the tools.
120
+ *
121
+ * `.incanto/playtest` is keyed only by `(outcome, seed)` and never cleaned, so
122
+ * the second playtest in a project either left a stale replay of a scene that
123
+ * no longer exists beside the current one, or silently overwrote another
124
+ * scene's. Nothing downstream could tell: `incanto-play --commands` accepted
125
+ * any recording against any scene and reported success.
126
+ *
127
+ * Absent on an older recording, which still replays — this identifies, it
128
+ * does not gate.
129
+ */
130
+ scene?: {
131
+ /** The scene file this was recorded against, as the tool was given it. */path?: string; /** The scene's own `name`, which survives the file being moved. */
132
+ name?: string; /** The engine that recorded it, so a replay across a format change reads odd. */
133
+ engine?: string;
134
+ };
118
135
  }
119
136
  interface Recorder {
120
137
  /** Detach the taps and return the recording. */
@@ -14,26 +14,6 @@ declare function jsonEquals(a: JsonValue, b: JsonValue): boolean;
14
14
  */
15
15
  declare function jsonClone<T>(value: T): T;
16
16
  //#endregion
17
- //#region src/core/rng.d.ts
18
- /**
19
- * Deterministic seeded PRNG (mulberry32). Game logic should draw randomness
20
- * from `engine.rng` (or `this.rng` in a Behavior) instead of `Math.random()` —
21
- * a seeded engine then replays identically, which is what makes scripted
22
- * headless verification and multiplayer-adjacent determinism possible.
23
- */
24
- declare class Rng {
25
- private state;
26
- constructor(seed: number);
27
- /** Next float in [0, 1). */
28
- next(): number;
29
- /** Float in [min, max). */
30
- range(min: number, max: number): number;
31
- /** Integer in [min, max] — inclusive of BOTH bounds. */
32
- int(min: number, max: number): number;
33
- /** A uniformly random element. Empty arrays are a hard error. */
34
- pick<T>(items: readonly T[]): T;
35
- }
36
- //#endregion
37
17
  //#region src/core/scene/schema.d.ts
38
18
  /** Current scene file format version. Defaults are frozen per format version. */
39
19
  declare const SCENE_FORMAT: 1;
@@ -132,4 +112,41 @@ interface ConnectionJson {
132
112
  };
133
113
  }
134
114
  //#endregion
135
- export { Rng as a, JsonValue as c, jsonKind as d, SceneJson as i, jsonClone as l, NodeJson as n, JsonKind as o, SCENE_FORMAT as r, JsonObject as s, ConnectionJson as t, jsonEquals as u };
115
+ //#region src/core/rng.d.ts
116
+ /**
117
+ * Deterministic seeded PRNG (mulberry32). Game logic should draw randomness
118
+ * from `engine.rng` (or `this.rng` in a Behavior) instead of `Math.random()` —
119
+ * a seeded engine then replays identically, which is what makes scripted
120
+ * headless verification and multiplayer-adjacent determinism possible.
121
+ */
122
+ declare class Rng {
123
+ private state;
124
+ /**
125
+ * Where this stream has got to.
126
+ *
127
+ * A save that cannot carry this replays the seeded sequence from the top, so
128
+ * every "random" event after a load is the one the run opened with. Measured:
129
+ *
130
+ * ```
131
+ * continue WITHOUT save/load: rolls 10-13 = 0.2967 0.0860 0.7333 0.0400
132
+ * continue AFTER save/load : rolls 10-13 = 0.5628 0.3253 0.4690 0.8367
133
+ * ```
134
+ *
135
+ * and 0.5628 is not merely different — it is the FIRST roll a fresh run of
136
+ * that game makes. The state was private, so a game could not fix it either:
137
+ * `serialize()` had nothing to carry.
138
+ */
139
+ get position(): number;
140
+ set position(value: number);
141
+ constructor(seed: number);
142
+ /** Next float in [0, 1). */
143
+ next(): number;
144
+ /** Float in [min, max). */
145
+ range(min: number, max: number): number;
146
+ /** Integer in [min, max] — inclusive of BOTH bounds. */
147
+ int(min: number, max: number): number;
148
+ /** A uniformly random element. Empty arrays are a hard error. */
149
+ pick<T>(items: readonly T[]): T;
150
+ }
151
+ //#endregion
152
+ export { SceneJson as a, JsonValue as c, jsonKind as d, SCENE_FORMAT as i, jsonClone as l, ConnectionJson as n, JsonKind as o, NodeJson as r, JsonObject as s, Rng as t, jsonEquals as u };
@@ -8,6 +8,28 @@ import { t as IncantoError } from "./errors-BpWbnbb_.js";
8
8
  */
9
9
  var Rng = class {
10
10
  state;
11
+ /**
12
+ * Where this stream has got to.
13
+ *
14
+ * A save that cannot carry this replays the seeded sequence from the top, so
15
+ * every "random" event after a load is the one the run opened with. Measured:
16
+ *
17
+ * ```
18
+ * continue WITHOUT save/load: rolls 10-13 = 0.2967 0.0860 0.7333 0.0400
19
+ * continue AFTER save/load : rolls 10-13 = 0.5628 0.3253 0.4690 0.8367
20
+ * ```
21
+ *
22
+ * and 0.5628 is not merely different — it is the FIRST roll a fresh run of
23
+ * that game makes. The state was private, so a game could not fix it either:
24
+ * `serialize()` had nothing to carry.
25
+ */
26
+ get position() {
27
+ return this.state >>> 0;
28
+ }
29
+ set position(value) {
30
+ const next = value >>> 0;
31
+ this.state = next === 0 ? 2654435769 : next;
32
+ }
11
33
  constructor(seed) {
12
34
  this.state = seed >>> 0;
13
35
  if (this.state === 0) this.state = 2654435769;