incanto 0.36.2 → 0.38.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 (57) hide show
  1. package/bin/incanto-feel.mjs +93 -0
  2. package/bin/incanto-playtest.mjs +136 -0
  3. package/dist/2d.d.ts +2 -2
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +44 -3
  6. package/dist/3d.js +4 -32
  7. package/dist/{behavior-DibCwrW7.d.ts → behavior-B5PJ32eu.d.ts} +106 -6
  8. package/dist/{create-game-D-StzrTj.js → create-game-C4t5gVhc.js} +5 -5
  9. package/dist/{create-game-DnOu2aAa.js → create-game-DqY2lMBc.js} +783 -689
  10. package/dist/debug.d.ts +1 -1
  11. package/dist/{duplicate-Cvb1BSca.js → duplicate-CRtihGmC.js} +1 -1
  12. package/dist/editor.js +21 -0
  13. package/dist/{environment-presets-fK9oyrrR.js → environment-presets-C0Axpfth.js} +75 -9
  14. package/dist/{gameplay-C0MUMSak.js → gameplay-CsJDUQh_.js} +2 -2
  15. package/dist/gameplay.d.ts +1 -1
  16. package/dist/gameplay.js +1 -1
  17. package/dist/index.d.ts +4 -50
  18. package/dist/index.js +5 -75
  19. package/dist/{loader-BqigZsfF.d.ts → loader-DSMdqasu.d.ts} +1 -1
  20. package/dist/{loader-DAZlgqeC.js → loader-r49nDwB4.js} +15 -0
  21. package/dist/net.d.ts +1 -1
  22. package/dist/net.js +3 -3
  23. package/dist/{pathfinding-B3QtdhvZ.d.ts → pathfinding-BrE5Uec6.d.ts} +1 -1
  24. package/dist/{physics-2d-B4v39hca.js → physics-2d-OfKb0PV_.js} +2 -2
  25. package/dist/{physics-3d-DFJVMYET.js → physics-3d-C8242nXr.js} +3 -3
  26. package/dist/react.d.ts +1 -1
  27. package/dist/react.js +1 -1
  28. package/dist/{register-DXwtIVqP.js → register-B4RRQbyq.js} +2 -2
  29. package/dist/{register-BHAwM4bK.js → register-Dm7nrd-E.js} +322 -34
  30. package/dist/{register-COGCNitr.js → register-DnB9m7Rl.js} +2 -2
  31. package/dist/replay-Cxrr-ApH.d.ts +86 -0
  32. package/dist/{audit-D7F3n3Nt.js → replay-DH0UOuZ-.js} +72 -2
  33. package/dist/test-CoDPWx-m.js +1404 -0
  34. package/dist/test.d.ts +113 -4
  35. package/dist/test.js +3 -3
  36. package/dist/vite.js +1 -1
  37. package/editor/assets/{agent8-DMvBVhe4.js → agent8-BtIlblPm.js} +1 -1
  38. package/editor/assets/{debug-BgAq55oC.js → debug-BvTmHXI7.js} +1 -1
  39. package/editor/assets/{index-Cy_QsKbh.js → index-BS01WWgM.js} +91 -91
  40. package/editor/index.html +1 -1
  41. package/package.json +3 -1
  42. package/schemas/scene.schema.json +318 -0
  43. package/skills/incanto-3d-character.md +35 -0
  44. package/skills/incanto-building-2d-games.md +6 -0
  45. package/skills/incanto-building-3d-games.md +6 -0
  46. package/skills/incanto-game-feel.md +76 -0
  47. package/skills/incanto-node-reference.md +51 -0
  48. package/skills/incanto-performance.md +212 -0
  49. package/skills/incanto-playtesting.md +143 -0
  50. package/skills/incanto-verifying-your-game.md +41 -0
  51. package/templates-app/beacon-isle-3d/package.json +1 -1
  52. package/templates-app/tps-3d/package.json +1 -1
  53. package/templates-app/village-quest-3d/package.json +1 -1
  54. package/templates-app/village-quest-3d/src/village.scene.json +3 -1
  55. package/templates-app/village-quest-3d/verify.ts +55 -5
  56. package/dist/errors-DGRtWlSx.d.ts +0 -39
  57. package/dist/test-X_xE6Yay.js +0 -642
@@ -0,0 +1,212 @@
1
+ ---
2
+ name: incanto-performance
3
+ description: Graphics settings the engine provides so a game does not have to invent them — quality tier (shadows/bloom/post/clouds), frame cap, resolution scale, and the one lever (antialiasing) that can only change at boot. They persist, they apply themselves, and the ones that can change live do. Use when supporting low-end devices or building a settings menu.
4
+ ---
5
+
6
+ # incanto — performance settings (quality tier, frame cap, resolution)
7
+
8
+ The settings a player expects to find in a graphics menu, provided by the engine so
9
+ your game does not have to invent them: **quality tier**, **frame cap**, **resolution
10
+ scale**. They persist, they apply themselves, and the ones that can change live do.
11
+
12
+ You do not implement any of this. You decide whether to expose it.
13
+
14
+ ---
15
+
16
+ ## The short version
17
+
18
+ ```ts
19
+ const game = await createGame3D({ canvas, scene });
20
+
21
+ // createGame3D has ALREADY done all of this:
22
+ // - picked a starting tier from the device, on a first visit only
23
+ // - read the saved tier at boot for antialiasing (see "boot vs live" below)
24
+ // - bound the tier, the frame cap and the resolution scale to the live game
25
+
26
+ game.engine.settings.set('quality', 'low'); // shadows/bloom/post drop THIS FRAME
27
+ game.engine.settings.set('maxFps', 30); // the loop slows down, and stays cool
28
+ game.engine.settings.set('renderScale', 0.7); // fewer pixels, same world
29
+ ```
30
+
31
+ Every one of those is written to the save store and comes back on the next launch.
32
+
33
+ ---
34
+
35
+ ## The four levers, and what each actually costs
36
+
37
+ | setting | values | costs | changes live |
38
+ | --- | --- | --- | --- |
39
+ | `quality` | `'low'` `'medium'` `'high'` | shadows, bloom, post, clouds | **yes** |
40
+ | `maxFps` | `0` (uncapped) or fps | nothing visual — battery and heat | **yes** |
41
+ | `renderScale` | `0.25`–`2` | image softness only | **yes** |
42
+ | antialiasing | follows the tier | jagged edges at `low` | **no — boot only** |
43
+
44
+ ### quality
45
+
46
+ `low` turns off shadows, bloom, post and clouds. `medium` keeps shadows but makes them
47
+ **static** (they render once instead of every frame — the measured 42% of a shadow's
48
+ cost is re-rendering it) and drops post. `high` is everything.
49
+
50
+ The tier is applied as an environment patch, so a scene that never had bloom does not
51
+ gain any at `high` — the tier can only take things away or restore what the scene asked
52
+ for.
53
+
54
+ ### maxFps
55
+
56
+ Gates the **whole frame**, not just the render: an uncapped loop on a 144 Hz phone runs
57
+ game logic 144 times a second that nobody will ever see. `0` means "as fast as the
58
+ display goes" and is the default — a cap nobody asked for is a downgrade.
59
+
60
+ `dt` still comes from the clock, so a capped game is not a slow game. Physics is fixed-
61
+ step and unaffected.
62
+
63
+ ### renderScale
64
+
65
+ The cheapest lever on a weak GPU. At `0.66` the fragment shaders run over **44%** of the
66
+ pixels; the image goes soft and nothing else changes. Compare that with dropping a
67
+ quality tier, which costs the shadows outright. On a phone that cannot hold 60, try
68
+ `renderScale` before `quality`.
69
+
70
+ It multiplies with the engine's automatic resolution governor rather than fighting it,
71
+ so a player who already scaled down does not get scaled down twice.
72
+
73
+ ---
74
+
75
+ ## Boot vs live — the one thing that cannot change while running
76
+
77
+ `antialias` is a **WebGL context attribute**. three fixes it when the context is
78
+ created, and there is no API anywhere that changes it afterwards. So:
79
+
80
+ - the tier's antialiasing is read **at boot** from the saved setting
81
+ - changing `quality` mid-game changes shadows/bloom/post immediately, and the
82
+ antialiasing on the **next launch**
83
+
84
+ If your settings menu wants to be honest about that, say "applies on restart" next to
85
+ antialiasing only. Everything else is immediate.
86
+
87
+ A scene's own `environment.rendering` and an explicit `pixelRatio` option both still win
88
+ over the tier — an author who pinned a value meant it.
89
+
90
+ ---
91
+
92
+ ## Detection runs once, and a choice is permanent
93
+
94
+ ```ts
95
+ engine.settings.autoQuality(); // first visit only — picks from the device
96
+ engine.settings.chooseQuality('high'); // a HUMAN chose; never auto-detect again
97
+ ```
98
+
99
+ `createGame3D` calls `autoQuality()` for you. Call `chooseQuality(tier)` — not
100
+ `set('quality', tier)` — from a settings menu, so re-detection on the next launch does
101
+ not silently undo it. (A separate `qualityChosen` flag is needed because a chosen tier
102
+ that happens to equal the detected one is otherwise indistinguishable from no choice.)
103
+
104
+ Device hints used: `hardwareConcurrency`, `deviceMemory`, and whether the pointer is
105
+ coarse. They are hints; a player's own choice always wins.
106
+
107
+ ---
108
+
109
+ ## The menu itself is three nodes
110
+
111
+ You do not build a graphics menu. You place it.
112
+
113
+ ```json
114
+ { "name": "Graphics", "type": "UiPanel", "props": { "anchor": "center" },
115
+ "children": [
116
+ { "name": "Quality", "type": "UiQualitySelect" },
117
+ { "name": "FrameCap", "type": "UiFrameCapSelect" },
118
+ { "name": "Resolution", "type": "UiRenderScaleSelect" }
119
+ ] }
120
+ ```
121
+
122
+ No script, no signal connections, no `Settings` knowledge. Each node reads the
123
+ setting it owns, writes it when the player picks, and follows a change made
124
+ anywhere else — so two menus (a title screen and a pause screen) can never
125
+ disagree.
126
+
127
+ | node | writes | reads as |
128
+ | --- | --- | --- |
129
+ | `UiQualitySelect` | `chooseQuality()` | Low / Medium / High |
130
+ | `UiFrameCapSelect` | `maxFps` + `engine.maxFps` | 30 / 60 / 120 / Unlimited |
131
+ | `UiRenderScaleSelect` | `renderScale` | 50% / 75% / 100% |
132
+
133
+ `UiQualitySelect` goes through **`chooseQuality`**, not `set('quality')`, so the
134
+ next launch's device detection cannot undo what a person picked.
135
+
136
+ Restrict or extend the choices with `options` — `"60,120,0"` on a game that is
137
+ unplayable at 30, `"0.5,0.75,1,1.5"` for supersampling on a strong GPU:
138
+
139
+ ```json
140
+ { "name": "FrameCap", "type": "UiFrameCapSelect", "props": { "options": "60,120,0" } }
141
+ ```
142
+
143
+ ### Localizing the labels
144
+
145
+ The option text is English unless the scene declares a string for it — a menu
146
+ reading `settings.quality.high` is worse than one reading `High`, so an
147
+ undeclared key falls back to English rather than showing the key:
148
+
149
+ ```json
150
+ "strings": {
151
+ "ko": {
152
+ "settings.quality.low": "낮음",
153
+ "settings.quality.medium": "보통",
154
+ "settings.quality.high": "높음",
155
+ "settings.frameCap.unlimited": "제한 없음"
156
+ }
157
+ }
158
+ ```
159
+
160
+ The `label` prop takes `@t:` like every other widget:
161
+ `{ "label": "@t:settings.quality" }`.
162
+
163
+ ---
164
+
165
+ ## Wiring your own menu
166
+
167
+ Each setting has a `bind*` that applies the saved value immediately and again on every
168
+ change, and returns an unsubscribe:
169
+
170
+ ```ts
171
+ const off = engine.settings.bindFrameCap((fps) => { engine.maxFps = fps; });
172
+ engine.settings.bindRenderScale((s) => renderer.setRenderScale(s));
173
+ engine.settings.bindQuality((patch) => setEnvironment3D(engine, patch));
174
+ ```
175
+
176
+ `createGame3D` already installs all three. You only reach for these when you build your
177
+ own game loop.
178
+
179
+ To read the current values for your UI:
180
+
181
+ ```ts
182
+ engine.settings.get('quality'); // 'low' | 'medium' | 'high'
183
+ engine.settings.get('maxFps'); // 0 = uncapped
184
+ engine.settings.get('renderScale'); // 1 = native
185
+ ```
186
+
187
+ ---
188
+
189
+ ## What NOT to do
190
+
191
+ - **Do not build your own frame limiter** with `setTimeout` around your update. The
192
+ engine's cap is deadline-based; a naive `if (now - last < interval) return` snaps a
193
+ 45 fps request to 30 on a 60 Hz display, which is worse than not capping.
194
+ - **Do not lower the quality tier to fix a slow scene you can fix properly.** The tier
195
+ is the player's lever. If your game is slow at `high` on a normal machine, run
196
+ `bunx incanto-check` and look at the triangle and draw-call counts first.
197
+ - **Do not offer antialiasing as a live toggle.** It cannot be one. See above.
198
+ - **Do not read `devicePixelRatio` yourself.** The renderer already clamps it to 2 and
199
+ scales it by the tier and the governor.
200
+
201
+ ---
202
+
203
+ ## Verifying
204
+
205
+ ```ts
206
+ const { fps, triangles, drawCalls } = game.stats();
207
+ ```
208
+
209
+ Set `maxFps` to 30 and confirm `fps` settles near 30 — if it settles near 20, the game
210
+ is slower than the cap and the cap is not what is limiting it.
211
+
212
+ See `incanto-verifying-your-game.md` for the full loop.
@@ -0,0 +1,143 @@
1
+ ---
2
+ name: incanto-playtesting
3
+ description: The engine plays your game and reports whether it works — bunx incanto-playtest runs seeded headless bot sessions and reports win rate, unreachable destinations, wires that never fire, falls out of the world, and whether anything can hurt the player. Failing runs come back as replays. Use after a level or game loop is in place, before handing the game back.
4
+ ---
5
+
6
+ # Playtesting — is it a game?
7
+
8
+ `incanto-check` answers *will it load*. This answers *is it a game*.
9
+
10
+ ```bash
11
+ bunx incanto-playtest src/game.scene.json --runs 20 --seconds 60
12
+ ```
13
+
14
+ ```
15
+ 20 runs × up to 60s
16
+
17
+ ✓ reached "won" in 17/20 median 38s
18
+ ✗ fell in 3/20 (last at y=-52.0)
19
+
20
+ never reached (1 of 6):
21
+ /Game/Vault/Chest
22
+
23
+ never fired (2 of 9):
24
+ /Game/Boss.died
25
+ /Game/QuestLog.completed
26
+
27
+ danger: the player took damage 0 times in 20 runs — nothing here can hurt you
28
+
29
+ replays: .incanto/playtest/fell-seed7.json
30
+ ```
31
+
32
+ Exits **1** when no run reached a win, so it gates CI.
33
+
34
+ ## What it does
35
+
36
+ A bot plays your game, headlessly, with real physics and a seeded RNG. It reads
37
+ the scene's own `input{}` block for its controls, so it can play a game it has
38
+ never seen. It holds a heading for a while rather than flipping a coin per
39
+ frame, and steers toward the part of the world it has visited least — enough to
40
+ find a door, not enough to solve a puzzle.
41
+
42
+ No renderer is involved, so twenty minutes of simulated play takes a second or
43
+ two.
44
+
45
+ ## What it reports, and where each answer comes from
46
+
47
+ | line | source |
48
+ |---|---|
49
+ | `reached "won"` | `GameFlow` state `'won'`, or a `won` signal (e.g. `ScoreKeeper`) |
50
+ | `lost` | `GameFlow` `'gameover'`, a `lost` signal, or the player's `Health.died` |
51
+ | `fell` | the player's world Y dropped below `--fall-below` (default: 50 under the spawn) |
52
+ | `error` | `stats().errors` went above zero — a behavior threw |
53
+ | `stuck` | none of the above before the clock ran out |
54
+ | `never reached` | destinations the bot never came within 2 units of |
55
+ | `never fired` | signals a `connections[]` entry listens to that never happened |
56
+ | `danger` | how many times the player's `Health` emitted `damaged` |
57
+
58
+ **Nothing here is a guess about your design.** The scene already declares what
59
+ winning is; this only listens.
60
+
61
+ ## Destinations are what YOU marked
62
+
63
+ A node counts as somewhere to go when it **runs a behavior**, **carries a
64
+ group**, or **is a trigger volume** (`Area2D`/`Area3D`). Decoration is silent —
65
+ keying off the tree alone reported 150 unreached nodes on one village scene and
66
+ 138 of them were roof beams.
67
+
68
+ So: if you want the playtester to care about something, give it a group.
69
+
70
+ ```json
71
+ { "name": "Chest", "type": "MeshInstance3D", "groups": ["loot"] }
72
+ ```
73
+
74
+ ## Reading the failures
75
+
76
+ **`never reached`** is the reachability question, and research on game QA puts
77
+ it as the single largest bug class: a place the level says you can go and the
78
+ geometry says you cannot. Check the jump height, the gap, the collider.
79
+
80
+ **`never fired`** means you drew a wire that does nothing. Either the signal is
81
+ unreachable or the connection is wrong.
82
+
83
+ **`nothing here can hurt you`** is a design report, not an error. Sometimes it
84
+ is exactly right (a walking simulator). Usually it means the hazards are not
85
+ wired up.
86
+
87
+ **`stuck` in every run** with no `won` at all usually means the scene declares
88
+ no win condition — add a `GameFlow` or a `ScoreKeeper` and the report becomes
89
+ far more useful.
90
+
91
+ ## Replays
92
+
93
+ A run that did not win is written out. Because the engine is deterministic
94
+ (seeded RNG, injected clock), a replay reproduces that run **bit-identically**:
95
+
96
+ ```bash
97
+ bunx incanto-play src/game.scene.json --seed 7 # then replay the recording
98
+ ```
99
+
100
+ One file per *kind* of failure — twenty identical "stuck" replays teach nothing
101
+ the first one does not.
102
+
103
+ ## Your behaviors
104
+
105
+ Without `--behaviors`, unregistered scripts are stubbed: the structure plays and
106
+ your game logic does not, which is fine for a reachability pass. To test the
107
+ real thing:
108
+
109
+ ```bash
110
+ bunx incanto-playtest src/game.scene.json --behaviors src/behaviors.ts
111
+ ```
112
+
113
+ ## From TypeScript
114
+
115
+ ```ts
116
+ import { playtest, playtestText, failingReplays } from 'incanto/test';
117
+
118
+ const report = await playtest(sceneJson, { runs: 20, seconds: 60 });
119
+ console.log(playtestText(report));
120
+ ```
121
+
122
+ `report.runs` carries per-run outcome, timing, reached paths, fired signals,
123
+ damage and the replay.
124
+
125
+ ## Pairs with the feel report
126
+
127
+ `incanto-playtest` answers *can it be finished*. `bunx incanto-feel` answers
128
+ *how does it move* — jump apex, turnaround, and the coyote/jump-buffer windows
129
+ measured rather than read. A level whose apex is 0.26 u and whose gaps are 2 u
130
+ apart shows up here as `never reached` and there as the reason why. See
131
+ `incanto-game-feel.md`.
132
+
133
+ ## What it is NOT
134
+
135
+ It does not know whether your game is fun. It reports reachability,
136
+ completability, coverage and danger. Taste is still yours.
137
+
138
+ ## Before you hand a game back
139
+
140
+ ```bash
141
+ bunx incanto-check src/*.scene.json # will it load
142
+ bunx incanto-playtest src/game.scene.json --runs 10 --seconds 45 # is it a game
143
+ ```
@@ -402,3 +402,44 @@ expect(player.position).toEqual(expected); // exact, not approximate
402
402
  Rules that make it hold (the engine's own rules anyway): use `engine.rng`
403
403
  never Math.random, dt/`engine.time` never Date.now. Gamepads replay through
404
404
  the ACTIONS they were bound to, not raw pad state.
405
+
406
+
407
+ ## The fifth question: is it a GAME?
408
+
409
+ The four signals above answer *will it load*, *did something throw*, *did the art
410
+ arrive* and *can the camera see it*. None of them answers whether the thing can
411
+ be played.
412
+
413
+ ```bash
414
+ bunx incanto-playtest src/game.scene.json --runs 20 --seconds 60
415
+ ```
416
+
417
+ A seeded bot plays it headlessly — reading the scene's own `input{}` for its
418
+ controls — and reports the win rate, destinations it could never reach, wires
419
+ that never fired, falls out of the world, and whether anything can hurt the
420
+ player at all. Failing runs come back as replays. Exits 1 when nothing ever won.
421
+
422
+ Read `incanto-playtesting.md` before shipping a level.
423
+
424
+
425
+ ## Do not teleport the player in a verify script
426
+
427
+ `player.position = [...]` between beats proves the quest LOGIC and quietly
428
+ assumes the thing most likely to be broken: that the player can get there. That
429
+ assumption is the largest single bug class in level design, and a verify script
430
+ that makes it is testing everything except the level.
431
+
432
+ Walk instead. In a camera-relative rig (`view: "free"`), CALIBRATE rather than
433
+ guess the heading — push one direction, measure the world displacement it
434
+ produced, and rotate every later aim by the difference:
435
+
436
+ ```ts
437
+ const before = at();
438
+ ctx.engine.input.setActionVector('move', 1, 0);
439
+ for (let i = 0; i < 20; i++) ctx.engine.step();
440
+ const inputToWorld = Math.atan2(at()[1] - before[1], at()[0] - before[0]);
441
+ // then aim: setActionVector('move', cos(want - inputToWorld), sin(want - inputToWorld))
442
+ ```
443
+
444
+ `examples/village-quest-3d/verify.ts` does exactly this and fails with
445
+ "the player cannot reach it on foot" when it cannot.
@@ -14,7 +14,7 @@
14
14
  "@dimforge/rapier2d-compat": "0.19.3",
15
15
  "@dimforge/rapier3d-compat": "0.19.3",
16
16
  "@pixiv/three-vrm": "^3.5.3",
17
- "incanto": "^0.36.2",
17
+ "incanto": "^0.38.0",
18
18
  "three": "^0.184.0"
19
19
  },
20
20
  "devDependencies": {
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.36.2",
16
+ "incanto": "^0.38.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.36.2",
16
+ "incanto": "^0.38.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {
@@ -3334,7 +3334,9 @@
3334
3334
  "run": "$anims/run",
3335
3335
  "fastRun": "$anims/runFast",
3336
3336
  "airborne": "$anims/jump"
3337
- }
3337
+ },
3338
+ "coyoteSeconds": 0.12,
3339
+ "jumpBufferSeconds": 0.15
3338
3340
  },
3339
3341
  "script": {
3340
3342
  "name": "SwordStrike"
@@ -26,6 +26,58 @@ interface Vec {
26
26
  position: number[];
27
27
  }
28
28
 
29
+ /**
30
+ * WALK there — do not teleport.
31
+ *
32
+ * This file used to set `player.position` at every quest beat, which proved the
33
+ * quest LOGIC and quietly assumed the thing most likely to be broken: that the
34
+ * player can actually get there. `incanto-playtest` exists because that
35
+ * assumption is the largest bug class in level design, and a verify script that
36
+ * makes it is demonstrating the wrong habit in the repo's own showcase.
37
+ *
38
+ * `move` is camera-relative in the `free` rig, so the heading is CALIBRATED
39
+ * rather than assumed: push one direction, measure the world displacement it
40
+ * produced, and rotate every later aim by the difference. That works whatever
41
+ * the camera is doing and needs no knowledge of the rig.
42
+ */
43
+ function walkTo(
44
+ ctx: { engine: Engine; getNode(path: string): unknown },
45
+ target: readonly [number, number],
46
+ opts: { within?: number; timeoutMs?: number; label?: string } = {},
47
+ ): void {
48
+ const within = opts.within ?? 1.2;
49
+ const budget = Math.round((opts.timeoutMs ?? 20000) / (1000 / 60));
50
+ const player = ctx.getNode('/root/Player') as unknown as Vec;
51
+ const at = (): [number, number] => [player.position[0] ?? 0, player.position[2] ?? 0];
52
+ const dist = (): number => Math.hypot(target[0] - at()[0], target[1] - at()[1]);
53
+
54
+ // calibrate: push +x in INPUT space and see where the world went
55
+ const before = at();
56
+ ctx.engine.input.setActionVector('move', 1, 0);
57
+ for (let i = 0; i < 20; i++) ctx.engine.step();
58
+ const after = at();
59
+ ctx.engine.input.setActionVector('move', 0, 0);
60
+ const moved = Math.hypot(after[0] - before[0], after[1] - before[1]);
61
+ if (moved < 0.05) throw new Error(`walkTo(${opts.label ?? ''}): the player cannot move at all`);
62
+ const inputToWorld = Math.atan2(after[1] - before[1], after[0] - before[0]);
63
+
64
+ for (let f = 0; f < budget && dist() > within; f++) {
65
+ const here = at();
66
+ const want = Math.atan2(target[1] - here[1], target[0] - here[0]);
67
+ const aim = want - inputToWorld;
68
+ ctx.engine.input.setActionVector('move', Math.cos(aim), Math.sin(aim));
69
+ ctx.engine.step();
70
+ }
71
+ ctx.engine.input.setActionVector('move', 0, 0);
72
+ for (let i = 0; i < 10; i++) ctx.engine.step();
73
+ if (dist() > within) {
74
+ throw new Error(
75
+ `walkTo(${opts.label ?? `${target[0]},${target[1]}`}): still ${dist().toFixed(1)} away ` +
76
+ `after ${opts.timeoutMs ?? 20000}ms — the player cannot reach it on foot`,
77
+ );
78
+ }
79
+ }
80
+
29
81
  const ok = (label: string, cond: boolean): void => {
30
82
  if (!cond) {
31
83
  console.error(`FAIL: ${label}`);
@@ -64,9 +116,8 @@ const ok = (label: string, cond: boolean): void => {
64
116
  label: 'talk to the Elder and accept the quest',
65
117
  do: (ctx) => {
66
118
  sceneName = () => ctx.engine.scene?.source?.name as string;
67
- const player = ctx.getNode('/root/Player') as unknown as Vec;
68
- player.position = [3, 1.4, 0.2]; // step up to the Elder
69
- for (let i = 0; i < 5; i++) ctx.engine.step();
119
+ // the Elder is at [3, 0, -2] with Interactable range 2.8
120
+ walkTo(ctx, [3, -2], { within: 2, label: 'the Elder' });
70
121
  ctx.engine.input.pressAction('interact');
71
122
  ctx.engine.step();
72
123
  ctx.engine.input.releaseAction('interact');
@@ -85,8 +136,7 @@ const ok = (label: string, cond: boolean): void => {
85
136
  atMs: 6000,
86
137
  label: 'north gate swaps to the grove (goToScene)',
87
138
  do: (ctx) => {
88
- const player = ctx.getNode('/root/Player') as unknown as Vec;
89
- player.position = [0, 1.5, -26.4]; // into the gate trigger
139
+ walkTo(ctx, [0, -26.4], { label: 'the north gate', timeoutMs: 30000 });
90
140
  for (let i = 0; i < 30 && sceneName() !== 'WolfGrove'; i++) ctx.engine.step();
91
141
  if (sceneName() !== 'WolfGrove') throw new Error(`still in ${sceneName()}`);
92
142
  },
@@ -1,39 +0,0 @@
1
- import { s as JsonObject } from "./schema-CFeioQRE.js";
2
-
3
- //#region src/core/audit.d.ts
4
- /** Human-readable warnings (empty = clean). Pure JSON walk, no registry. */
5
- declare function auditScene(scene: JsonObject): string[];
6
- //#endregion
7
- //#region src/core/errors.d.ts
8
- /**
9
- * Stable error codes for every hard failure in the engine.
10
- *
11
- * Incanto fails loudly with machine-readable codes and messages that list valid
12
- * alternatives — AI agents self-correct on hard failures, not warnings.
13
- */
14
- type IncantoErrorCode = "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";
15
- /**
16
- * Structured context mirrored from the prose message — tools and agents parse
17
- * THESE instead of regexing the message (which is for humans and may reword).
18
- */
19
- interface IncantoErrorDetails {
20
- /** Absolute path of the offending node in the scene being loaded ('/Root/Child'). */
21
- readonly path?: string;
22
- /** The offending node's uid, when the scene JSON declared one. */
23
- readonly uid?: string;
24
- /** The offending node's type name. */
25
- readonly nodeType?: string;
26
- /** The offending prop key. */
27
- readonly prop?: string;
28
- /** The offending signal name. */
29
- readonly signal?: string;
30
- /** The valid alternatives the message also lists — for programmatic self-correction. */
31
- readonly validOptions?: readonly string[];
32
- }
33
- declare class IncantoError extends Error {
34
- readonly code: IncantoErrorCode;
35
- readonly details: IncantoErrorDetails;
36
- constructor(code: IncantoErrorCode, message: string, details?: IncantoErrorDetails);
37
- }
38
- //#endregion
39
- export { auditScene as i, IncantoErrorCode as n, IncantoErrorDetails as r, IncantoError as t };