lecodes-sdk 0.19.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 (367) hide show
  1. package/README.md +114 -0
  2. package/dist/global.d.ts +503 -0
  3. package/dist/host.d.ts +61 -0
  4. package/dist/inject.js +4730 -0
  5. package/dist/types/animate/animate.d.ts +20 -0
  6. package/dist/types/animate/bezier.d.ts +2 -0
  7. package/dist/types/animate/easings.d.ts +21 -0
  8. package/dist/types/canvas/Canvas.d.ts +168 -0
  9. package/dist/types/core/Aspect.d.ts +171 -0
  10. package/dist/types/core/InspectorUI.d.ts +88 -0
  11. package/dist/types/core/StateMachine.d.ts +81 -0
  12. package/dist/types/core/color.d.ts +13 -0
  13. package/dist/types/core/compWrite.d.ts +11 -0
  14. package/dist/types/core/events.d.ts +11 -0
  15. package/dist/types/core/fields.d.ts +58 -0
  16. package/dist/types/core/registry.d.ts +7 -0
  17. package/dist/types/core/signals.d.ts +29 -0
  18. package/dist/types/core/time.d.ts +31 -0
  19. package/dist/types/g2/Camera2D.d.ts +19 -0
  20. package/dist/types/g2/CharacterController2D.d.ts +134 -0
  21. package/dist/types/g2/Node2D.d.ts +91 -0
  22. package/dist/types/g2/OneWay2D.d.ts +24 -0
  23. package/dist/types/g2/Physics2D.d.ts +147 -0
  24. package/dist/types/g2/Scene2D.d.ts +72 -0
  25. package/dist/types/g2/Shape2D.d.ts +58 -0
  26. package/dist/types/g2/Sprite.d.ts +51 -0
  27. package/dist/types/g2/SpriteAnimation.d.ts +50 -0
  28. package/dist/types/g2/SpriteSheet.d.ts +69 -0
  29. package/dist/types/g2/Texture2D.d.ts +16 -0
  30. package/dist/types/g2/Tilemap.d.ts +20 -0
  31. package/dist/types/g2/Tileset.d.ts +32 -0
  32. package/dist/types/g2/Trigger2D.d.ts +20 -0
  33. package/dist/types/g2/autotile.d.ts +133 -0
  34. package/dist/types/g2/cells.d.ts +14 -0
  35. package/dist/types/g2/defineScene2d.d.ts +141 -0
  36. package/dist/types/g2/groups2d.d.ts +16 -0
  37. package/dist/types/g2/loop.d.ts +4 -0
  38. package/dist/types/g2/scenarios2d.d.ts +30 -0
  39. package/dist/types/g2/touch.d.ts +2 -0
  40. package/dist/types/gl/Camera.d.ts +79 -0
  41. package/dist/types/gl/CameraPlace.d.ts +26 -0
  42. package/dist/types/gl/CharacterController.d.ts +98 -0
  43. package/dist/types/gl/Gearbox.d.ts +86 -0
  44. package/dist/types/gl/Geometry.d.ts +39 -0
  45. package/dist/types/gl/IK.d.ts +53 -0
  46. package/dist/types/gl/InstancedMesh.d.ts +46 -0
  47. package/dist/types/gl/Light.d.ts +73 -0
  48. package/dist/types/gl/Lightmap.d.ts +85 -0
  49. package/dist/types/gl/Locomotion.d.ts +279 -0
  50. package/dist/types/gl/Material.d.ts +100 -0
  51. package/dist/types/gl/Mesh.d.ts +33 -0
  52. package/dist/types/gl/Model.d.ts +53 -0
  53. package/dist/types/gl/NavAgent.d.ts +99 -0
  54. package/dist/types/gl/NavMesh.d.ts +169 -0
  55. package/dist/types/gl/Node.d.ts +99 -0
  56. package/dist/types/gl/Noise.d.ts +20 -0
  57. package/dist/types/gl/Particles.d.ts +277 -0
  58. package/dist/types/gl/Physics.d.ts +91 -0
  59. package/dist/types/gl/Plane.d.ts +19 -0
  60. package/dist/types/gl/Ragdoll.d.ts +86 -0
  61. package/dist/types/gl/Ray.d.ts +8 -0
  62. package/dist/types/gl/Scene.d.ts +221 -0
  63. package/dist/types/gl/Shape.d.ts +122 -0
  64. package/dist/types/gl/Terrain.d.ts +280 -0
  65. package/dist/types/gl/Texture.d.ts +27 -0
  66. package/dist/types/gl/Trigger.d.ts +10 -0
  67. package/dist/types/gl/Vehicle.d.ts +191 -0
  68. package/dist/types/gl/Wheel.d.ts +95 -0
  69. package/dist/types/gl/animation/AnimationClip.d.ts +60 -0
  70. package/dist/types/gl/animation/Animator.d.ts +219 -0
  71. package/dist/types/gl/animation/Layer.d.ts +31 -0
  72. package/dist/types/gl/animation/Loop.d.ts +17 -0
  73. package/dist/types/gl/animation/Playback.d.ts +36 -0
  74. package/dist/types/gl/animation/core.d.ts +367 -0
  75. package/dist/types/gl/controls.d.ts +19 -0
  76. package/dist/types/gl/physicsEvents.d.ts +1 -0
  77. package/dist/types/gl/scenarios.d.ts +98 -0
  78. package/dist/types/gl/state.d.ts +5 -0
  79. package/dist/types/gl/terrainMesh.d.ts +46 -0
  80. package/dist/types/gl/touch.d.ts +2 -0
  81. package/dist/types/inject.d.ts +123 -0
  82. package/dist/types/math/Mathf.d.ts +39 -0
  83. package/dist/types/math/mat4.d.ts +66 -0
  84. package/dist/types/math/quat.d.ts +53 -0
  85. package/dist/types/math/vec.d.ts +129 -0
  86. package/dist/types/net/codec.d.ts +25 -0
  87. package/dist/types/net/core.d.ts +106 -0
  88. package/dist/types/net/index.d.ts +80 -0
  89. package/dist/types/net/replication.d.ts +101 -0
  90. package/dist/types/plugins/camera.d.ts +25 -0
  91. package/dist/types/plugins/geolocation.d.ts +39 -0
  92. package/dist/types/plugins/oauth.d.ts +25 -0
  93. package/dist/types/plugins/permission.d.ts +1 -0
  94. package/dist/types/plugins/push.d.ts +46 -0
  95. package/dist/types/plugins/qr.d.ts +21 -0
  96. package/dist/types/plugins/service.d.ts +33 -0
  97. package/dist/types/runtime/app.d.ts +67 -0
  98. package/dist/types/runtime/appEvents.d.ts +7 -0
  99. package/dist/types/runtime/channel.d.ts +1 -0
  100. package/dist/types/runtime/clipboard.d.ts +7 -0
  101. package/dist/types/runtime/datetime.d.ts +80 -0
  102. package/dist/types/runtime/device.d.ts +126 -0
  103. package/dist/types/runtime/fetch.d.ts +31 -0
  104. package/dist/types/runtime/files.d.ts +33 -0
  105. package/dist/types/runtime/input.d.ts +121 -0
  106. package/dist/types/runtime/media.d.ts +46 -0
  107. package/dist/types/runtime/misc.d.ts +11 -0
  108. package/dist/types/runtime/net.d.ts +15 -0
  109. package/dist/types/runtime/rpc.d.ts +44 -0
  110. package/dist/types/runtime/service.d.ts +1 -0
  111. package/dist/types/runtime/share.d.ts +2 -0
  112. package/dist/types/runtime/storage.d.ts +5 -0
  113. package/dist/types/runtime/touch.d.ts +56 -0
  114. package/dist/types/scene/defineScene.d.ts +296 -0
  115. package/dist/types/scene/editorPlugins.d.ts +82 -0
  116. package/dist/types/scene/gizmos.d.ts +56 -0
  117. package/dist/types/scene/grammar.d.ts +56 -0
  118. package/dist/types/scene/level.d.ts +98 -0
  119. package/dist/types/scene/material.d.ts +76 -0
  120. package/dist/types/server/auth/appConfig.d.ts +16 -0
  121. package/dist/types/server/auth/global.d.ts +56 -0
  122. package/dist/types/server/auth/models.d.ts +164 -0
  123. package/dist/types/server/auth/types.d.ts +56 -0
  124. package/dist/types/server/channel.d.ts +28 -0
  125. package/dist/types/server/context.d.ts +27 -0
  126. package/dist/types/server/db/defineDb.d.ts +36 -0
  127. package/dist/types/server/db/fields.d.ts +146 -0
  128. package/dist/types/server/db/index.d.ts +6 -0
  129. package/dist/types/server/db/marci/query.d.ts +326 -0
  130. package/dist/types/server/db/types.d.ts +163 -0
  131. package/dist/types/server/errors.d.ts +8 -0
  132. package/dist/types/server/inject.d.ts +5 -0
  133. package/dist/types/ui/NativeView.d.ts +38 -0
  134. package/dist/types/ui/UI.d.ts +27 -0
  135. package/dist/types/ui/UIBottomSheet.d.ts +71 -0
  136. package/dist/types/ui/UIButton.d.ts +30 -0
  137. package/dist/types/ui/UIContainer.d.ts +33 -0
  138. package/dist/types/ui/UIImage.d.ts +35 -0
  139. package/dist/types/ui/UIInput.d.ts +110 -0
  140. package/dist/types/ui/UIModal.d.ts +70 -0
  141. package/dist/types/ui/UINode.d.ts +363 -0
  142. package/dist/types/ui/UIPager.d.ts +123 -0
  143. package/dist/types/ui/UIPopover.d.ts +39 -0
  144. package/dist/types/ui/UIScreen.d.ts +57 -0
  145. package/dist/types/ui/UIScrollable.d.ts +40 -0
  146. package/dist/types/ui/UISpacer.d.ts +9 -0
  147. package/dist/types/ui/UITabs.d.ts +96 -0
  148. package/dist/types/ui/UIText.d.ts +19 -0
  149. package/dist/types/ui/UIVideo.d.ts +28 -0
  150. package/dist/types/ui/UIVirtualizedList.d.ts +97 -0
  151. package/dist/types/ui/UIWidget.d.ts +65 -0
  152. package/dist/types/ui/fonts.d.ts +9 -0
  153. package/dist/types/ui/presentable.d.ts +68 -0
  154. package/dist/types/ui/router.d.ts +40 -0
  155. package/dist/types/ui/theme.d.ts +26 -0
  156. package/dist/types.json +1 -0
  157. package/package.json +46 -0
  158. package/prompts/2d.md +395 -0
  159. package/prompts/3d-scene-files.md +113 -0
  160. package/prompts/3d-scene.md +119 -0
  161. package/prompts/3d.md +303 -0
  162. package/prompts/README.md +142 -0
  163. package/prompts/ar.md +109 -0
  164. package/prompts/canvas.md +27 -0
  165. package/prompts/compose.ts +46 -0
  166. package/prompts/concept.md +49 -0
  167. package/prompts/core-design.md +75 -0
  168. package/prompts/core.md +264 -0
  169. package/prompts/design.md +218 -0
  170. package/prompts/dist/2d-game.md +1530 -0
  171. package/prompts/dist/3d-app.md +1672 -0
  172. package/prompts/dist/ar-app.md +1548 -0
  173. package/prompts/dist/concept.md +49 -0
  174. package/prompts/dist/design.md +498 -0
  175. package/prompts/dist/ui-app.md +1134 -0
  176. package/prompts/index.md +37 -0
  177. package/prompts/intake-prompt.md +33 -0
  178. package/prompts/namer-prompt.md +12 -0
  179. package/prompts/router-prompt.md +50 -0
  180. package/prompts/select.ts +111 -0
  181. package/prompts/ui-design.md +203 -0
  182. package/prompts/ui.md +803 -0
  183. package/src/animate/animate.ts +238 -0
  184. package/src/animate/bezier.ts +138 -0
  185. package/src/animate/easings.ts +126 -0
  186. package/src/bridges.d.ts +1345 -0
  187. package/src/canvas/Canvas.ts +305 -0
  188. package/src/compile/__tests__/assetIconMacro.test.ts +219 -0
  189. package/src/compile/__tests__/assetMacro.test.ts +74 -0
  190. package/src/compile/__tests__/assetName.test.ts +55 -0
  191. package/src/compile/__tests__/compile.test.ts +287 -0
  192. package/src/compile/__tests__/detectEntry.test.ts +132 -0
  193. package/src/compile/__tests__/fontMacro.test.ts +199 -0
  194. package/src/compile/aspectMacro.ts +86 -0
  195. package/src/compile/assetIconMacro.ts +384 -0
  196. package/src/compile/assetMacro.ts +146 -0
  197. package/src/compile/assetName.ts +50 -0
  198. package/src/compile/bundler.ts +287 -0
  199. package/src/compile/compileProject.ts +127 -0
  200. package/src/compile/detectEntry.ts +128 -0
  201. package/src/compile/fontMacro.ts +459 -0
  202. package/src/compile/fontRegistry.ts +78 -0
  203. package/src/compile/header.ts +67 -0
  204. package/src/compile/index.ts +100 -0
  205. package/src/compile/libraryImports.ts +52 -0
  206. package/src/compile/liteMaterial.ts +247 -0
  207. package/src/compile/sceneEditor.ts +88 -0
  208. package/src/compile/serverSplit.ts +233 -0
  209. package/src/compile/serverTypes.ts +227 -0
  210. package/src/compile/sfnt.ts +98 -0
  211. package/src/compile/shaderSchema.ts +202 -0
  212. package/src/compile/shaderTargets.ts +77 -0
  213. package/src/compile/sourcemap.ts +25 -0
  214. package/src/core/Aspect.ts +568 -0
  215. package/src/core/InspectorUI.ts +212 -0
  216. package/src/core/StateMachine.ts +308 -0
  217. package/src/core/__tests__/stateMachine.test.ts +132 -0
  218. package/src/core/color.ts +66 -0
  219. package/src/core/compWrite.ts +42 -0
  220. package/src/core/events.ts +38 -0
  221. package/src/core/fields.ts +120 -0
  222. package/src/core/registry.ts +23 -0
  223. package/src/core/signals.ts +277 -0
  224. package/src/core/time.ts +84 -0
  225. package/src/g2/Camera2D.ts +40 -0
  226. package/src/g2/CharacterController2D.ts +276 -0
  227. package/src/g2/Node2D.ts +267 -0
  228. package/src/g2/OneWay2D.ts +66 -0
  229. package/src/g2/Physics2D.ts +346 -0
  230. package/src/g2/Scene2D.ts +209 -0
  231. package/src/g2/Shape2D.ts +259 -0
  232. package/src/g2/Sprite.ts +89 -0
  233. package/src/g2/SpriteAnimation.ts +171 -0
  234. package/src/g2/SpriteSheet.ts +166 -0
  235. package/src/g2/Texture2D.ts +47 -0
  236. package/src/g2/Tilemap.ts +41 -0
  237. package/src/g2/Tileset.ts +71 -0
  238. package/src/g2/Trigger2D.ts +77 -0
  239. package/src/g2/autotile.ts +433 -0
  240. package/src/g2/cells.ts +91 -0
  241. package/src/g2/defineScene2d.ts +381 -0
  242. package/src/g2/groups2d.ts +106 -0
  243. package/src/g2/loop.ts +50 -0
  244. package/src/g2/scenarios2d.ts +69 -0
  245. package/src/g2/touch.ts +83 -0
  246. package/src/gl/Camera.ts +160 -0
  247. package/src/gl/CameraPlace.ts +52 -0
  248. package/src/gl/CharacterController.ts +238 -0
  249. package/src/gl/Gearbox.ts +212 -0
  250. package/src/gl/Geometry.ts +279 -0
  251. package/src/gl/IK.ts +193 -0
  252. package/src/gl/InstancedMesh.ts +132 -0
  253. package/src/gl/Light.ts +135 -0
  254. package/src/gl/Lightmap.ts +402 -0
  255. package/src/gl/Locomotion.ts +498 -0
  256. package/src/gl/Material.ts +279 -0
  257. package/src/gl/Mesh.ts +83 -0
  258. package/src/gl/Model.ts +124 -0
  259. package/src/gl/NavAgent.ts +337 -0
  260. package/src/gl/NavMesh.ts +397 -0
  261. package/src/gl/Node.ts +350 -0
  262. package/src/gl/Noise.ts +30 -0
  263. package/src/gl/Particles.ts +676 -0
  264. package/src/gl/Physics.ts +222 -0
  265. package/src/gl/Plane.ts +53 -0
  266. package/src/gl/Ragdoll.ts +270 -0
  267. package/src/gl/Ray.ts +16 -0
  268. package/src/gl/Scene.ts +505 -0
  269. package/src/gl/Shape.ts +409 -0
  270. package/src/gl/Terrain.ts +1092 -0
  271. package/src/gl/Texture.ts +63 -0
  272. package/src/gl/Trigger.ts +45 -0
  273. package/src/gl/Vehicle.ts +473 -0
  274. package/src/gl/Wheel.ts +240 -0
  275. package/src/gl/animation/AnimationClip.ts +204 -0
  276. package/src/gl/animation/Animator.ts +329 -0
  277. package/src/gl/animation/Layer.ts +39 -0
  278. package/src/gl/animation/Loop.ts +31 -0
  279. package/src/gl/animation/Playback.ts +52 -0
  280. package/src/gl/animation/core.ts +670 -0
  281. package/src/gl/controls.ts +95 -0
  282. package/src/gl/physicsEvents.ts +20 -0
  283. package/src/gl/scenarios.ts +291 -0
  284. package/src/gl/state.ts +6 -0
  285. package/src/gl/terrainMesh.ts +219 -0
  286. package/src/gl/touch.ts +68 -0
  287. package/src/host.d.ts +61 -0
  288. package/src/inject.ts +203 -0
  289. package/src/math/Mathf.ts +118 -0
  290. package/src/math/mat4.ts +278 -0
  291. package/src/math/quat.ts +232 -0
  292. package/src/math/vec.ts +255 -0
  293. package/src/net/codec.ts +119 -0
  294. package/src/net/core.ts +384 -0
  295. package/src/net/index.ts +181 -0
  296. package/src/net/replication.ts +622 -0
  297. package/src/plugins/camera.ts +81 -0
  298. package/src/plugins/geolocation.ts +123 -0
  299. package/src/plugins/oauth.ts +61 -0
  300. package/src/plugins/permission.ts +7 -0
  301. package/src/plugins/push.ts +132 -0
  302. package/src/plugins/qr.ts +73 -0
  303. package/src/plugins/service.ts +47 -0
  304. package/src/runtime/app.ts +101 -0
  305. package/src/runtime/appEvents.ts +54 -0
  306. package/src/runtime/channel.ts +61 -0
  307. package/src/runtime/clipboard.ts +20 -0
  308. package/src/runtime/datetime.ts +329 -0
  309. package/src/runtime/device.ts +293 -0
  310. package/src/runtime/fetch.ts +77 -0
  311. package/src/runtime/files.ts +108 -0
  312. package/src/runtime/input.ts +175 -0
  313. package/src/runtime/media.ts +111 -0
  314. package/src/runtime/misc.ts +16 -0
  315. package/src/runtime/net.ts +36 -0
  316. package/src/runtime/rpc.ts +218 -0
  317. package/src/runtime/service.ts +83 -0
  318. package/src/runtime/share.ts +9 -0
  319. package/src/runtime/storage.ts +13 -0
  320. package/src/runtime/touch.ts +76 -0
  321. package/src/scene/defineScene.ts +1396 -0
  322. package/src/scene/editorPlugins.ts +110 -0
  323. package/src/scene/gizmos.ts +148 -0
  324. package/src/scene/grammar.ts +120 -0
  325. package/src/scene/level.ts +296 -0
  326. package/src/scene/material.ts +188 -0
  327. package/src/server/auth/appConfig.ts +12 -0
  328. package/src/server/auth/global.ts +80 -0
  329. package/src/server/auth/host.ts +318 -0
  330. package/src/server/auth/models.ts +83 -0
  331. package/src/server/auth/types.ts +50 -0
  332. package/src/server/channel.ts +56 -0
  333. package/src/server/context.ts +36 -0
  334. package/src/server/db/defineDb.ts +237 -0
  335. package/src/server/db/fields.ts +132 -0
  336. package/src/server/db/httpTransport.ts +93 -0
  337. package/src/server/db/index.ts +7 -0
  338. package/src/server/db/marci/query.ts +412 -0
  339. package/src/server/db/types.ts +202 -0
  340. package/src/server/errors.ts +12 -0
  341. package/src/server/host.ts +74 -0
  342. package/src/server/inject.ts +13 -0
  343. package/src/server/runtime.ts +133 -0
  344. package/src/server/validate.ts +87 -0
  345. package/src/ui/NativeView.ts +142 -0
  346. package/src/ui/UI.ts +39 -0
  347. package/src/ui/UIBottomSheet.ts +139 -0
  348. package/src/ui/UIButton.ts +101 -0
  349. package/src/ui/UIContainer.ts +60 -0
  350. package/src/ui/UIImage.ts +83 -0
  351. package/src/ui/UIInput.ts +185 -0
  352. package/src/ui/UIModal.ts +139 -0
  353. package/src/ui/UINode.ts +830 -0
  354. package/src/ui/UIPager.ts +362 -0
  355. package/src/ui/UIPopover.ts +100 -0
  356. package/src/ui/UIScreen.ts +123 -0
  357. package/src/ui/UIScrollable.ts +87 -0
  358. package/src/ui/UISpacer.ts +14 -0
  359. package/src/ui/UITabs.ts +236 -0
  360. package/src/ui/UIText.ts +51 -0
  361. package/src/ui/UIVideo.ts +88 -0
  362. package/src/ui/UIVirtualizedList.ts +241 -0
  363. package/src/ui/UIWidget.ts +127 -0
  364. package/src/ui/fonts.ts +13 -0
  365. package/src/ui/presentable.ts +117 -0
  366. package/src/ui/router.ts +132 -0
  367. package/src/ui/theme.ts +84 -0
@@ -0,0 +1,212 @@
1
+ // The scene editor's immediate-mode inspector protocol (docs/scene-editor-plan.md, phase 9).
2
+ //
3
+ // Custom editor UI never renders into the editor panel directly — the live data (aspect instances,
4
+ // nodes, `model.anim.clips`, generated meshes) lives in the SCENE WORLD (the compiled bundle), on
5
+ // the other side of the controller. So a custom view is an imgui-style function running in the
6
+ // world: it re-runs on every change/event and emits a WIDGET LIST (plain data); the editor panel is
7
+ // a dumb renderer of that list; user events `{ id, value }` come back and trigger the next run —
8
+ // `ui.button()` returns `true` on the run that consumes the click.
9
+ //
10
+ // class Road extends Aspect<'road'> {
11
+ // from: Node | null = null
12
+ // to: Node | null = null
13
+ // width = 2
14
+ // static editor = { rebuild: true }
15
+ // static inspector(ui: InspectorUI, road: Road) {
16
+ // ui.auto() // the inferred fields, as usual
17
+ // if (road.from && road.to) ui.info(`${road.generated.children.length} pieces`)
18
+ // else ui.warn('Assign both endpoints')
19
+ // if (ui.button('Shuffle')) road.rebuild()
20
+ // }
21
+ // }
22
+ //
23
+ // BINDING RULE: a field whose key is a declared aspect field (a public class field) is DOC-BOUND —
24
+ // the editor persists edits to the scene file through its normal commit/undo path. Any other key is
25
+ // EDITOR STATE — transient, kept on this InspectorUI instance across runs, never written to a file
26
+ // (the animation card's clip choice, a tool's brush size). One vocabulary, two lifetimes.
27
+
28
+ import type { FieldDescriptor, FieldEditor } from "./fields"
29
+
30
+ /** One user interaction coming back from the editor panel: the widget's id (+ value for fields). */
31
+ export type InspectorEvent = { id: string, value?: unknown }
32
+
33
+ export type InspectorFieldWidget = {
34
+ kind: "field"
35
+ id: string
36
+ /** The bound key — an aspect field (doc-bound) or an editor-state key. */
37
+ key: string
38
+ label: string
39
+ editor: FieldEditor | undefined
40
+ value: unknown
41
+ /** True = persisted to the scene file by the editor; false = transient editor state. */
42
+ doc: boolean
43
+ /** True = emitted by `ui.auto()` — the editor may overlay its syntactic hints (options/node). */
44
+ auto?: boolean
45
+ min?: number
46
+ max?: number
47
+ step?: number
48
+ options?: readonly (string | number)[]
49
+ }
50
+
51
+ export type InspectorWidget =
52
+ | InspectorFieldWidget
53
+ | { kind: "button", id: string, label: string }
54
+ | { kind: "toolButton", id: string, label: string, tool: string }
55
+ | { kind: "header", id: string, label: string }
56
+ | { kind: "info" | "warn", id: string, text: string }
57
+
58
+ type FieldOpts = {
59
+ label?: string
60
+ min?: number
61
+ max?: number
62
+ step?: number
63
+ /** Default for EDITOR-STATE keys (doc keys default from the class field). */
64
+ value?: unknown
65
+ }
66
+
67
+ /**
68
+ * The widget builder handed to `static inspector(ui, aspect)` (and to editor windows/tools later).
69
+ * One instance lives per card and persists across runs — it carries the card's editor state.
70
+ */
71
+ export class InspectorUI {
72
+ /** @internal This run's emitted widgets. */
73
+ _widgets: InspectorWidget[] = []
74
+ /** @internal Transient editor state (non-doc field keys), kept across runs. */
75
+ _state: Record<string, unknown> = {}
76
+ /** @internal The aspect entry's doc props (live reference — the runner keeps it current). */
77
+ _props: Record<string, unknown> = {}
78
+ /** @internal Keys that are declared aspect fields → doc-bound. Empty for node/window cards. */
79
+ _docKeys = new Set<string>()
80
+ /** @internal Field descriptors for auto() + doc-key defaults (describeFields output). */
81
+ _fields: FieldDescriptor[] = []
82
+ /** @internal The unconsumed button event of the current run. */
83
+ _event: InspectorEvent | null = null
84
+
85
+ // ---- fields (each emits one widget AND returns the current value) ---------------------------
86
+
87
+ /** Numeric field (min/max/step render as a slider-style scrub range). */
88
+ number(key: string, opts?: FieldOpts): number {
89
+ const v = this._field("number", key, opts)
90
+ return typeof v === "number" ? v : 0
91
+ }
92
+ /** Alias of `number` — give it min/max/step and the editor renders a scrubable range. */
93
+ slider(key: string, opts?: FieldOpts): number { return this.number(key, opts) }
94
+
95
+ text(key: string, opts?: FieldOpts): string {
96
+ const v = this._field("text", key, opts)
97
+ return typeof v === "string" ? v : ""
98
+ }
99
+
100
+ color(key: string, opts?: FieldOpts): string {
101
+ const v = this._field("color", key, opts)
102
+ return typeof v === "string" ? v : "#ffffff"
103
+ }
104
+
105
+ switch(key: string, opts?: FieldOpts): boolean {
106
+ return this._field("switch", key, opts) === true
107
+ }
108
+
109
+ /** Dropdown — `options` may be computed fresh every run (dynamic lists come free). */
110
+ select(key: string, options: readonly (string | number)[], opts?: FieldOpts): string | number {
111
+ const v = this._field("select", key, { ...opts, value: opts?.value ?? options[0] }, options)
112
+ return (typeof v === "string" || typeof v === "number") && options.includes(v) ? v : options[0]
113
+ }
114
+
115
+ vec2(key: string, opts?: FieldOpts): [number, number] { return this._vec("vec2", key, 2, opts) as [number, number] }
116
+ vec3(key: string, opts?: FieldOpts): [number, number, number] { return this._vec("vec3", key, 3, opts) as [number, number, number] }
117
+ vec4(key: string, opts?: FieldOpts): [number, number, number, number] { return this._vec("vec4", key, 4, opts) as [number, number, number, number] }
118
+
119
+ /** Scene-node reference (`ref()` fields): name dropdown + pick-in-viewport. Returns the RAW doc
120
+ * value (`{ $ref }` marker or null) — read the resolved live node off the aspect instance. */
121
+ node(key: string, opts?: FieldOpts): unknown {
122
+ return this._field("node", key, opts)
123
+ }
124
+
125
+ /** Project-asset path (the editor renders a dropdown of the project's GLBs). "" = none picked. */
126
+ asset(key: string, opts?: FieldOpts): string {
127
+ const v = this._field("asset", key, { ...opts, value: opts?.value ?? "" })
128
+ return typeof v === "string" ? v : ""
129
+ }
130
+
131
+ // ---- everything else --------------------------------------------------------------------------
132
+
133
+ /** True on the run that consumes this button's click — do the action right there. */
134
+ button(label: string, opts?: { id?: string }): boolean {
135
+ const id = opts?.id ?? `b:${label}`
136
+ this._widgets.push({ kind: "button", id, label })
137
+ if (this._event?.id === id) {
138
+ this._event = null
139
+ return true
140
+ }
141
+ return false
142
+ }
143
+
144
+ /** A toggle that activates/deactivates the named viewport tool (`registerEditorTool`). The
145
+ * editor owns the active-tool state — the click never round-trips into the scene world. */
146
+ toolButton(label: string, tool: string): void {
147
+ this._widgets.push({ kind: "toolButton", id: `t:${tool}`, label, tool })
148
+ }
149
+
150
+ header(label: string): void { this._widgets.push({ kind: "header", id: `h:${label}`, label }) }
151
+ info(text: string): void { this._widgets.push({ kind: "info", id: `i:${this._widgets.length}`, text }) }
152
+ warn(text: string): void { this._widgets.push({ kind: "warn", id: `w:${this._widgets.length}`, text }) }
153
+
154
+ /** Emit the inferred field widgets (all declared aspect fields, or just the named ones) — the
155
+ * zero-ceremony baseline; append custom widgets around it. */
156
+ auto(...keys: string[]): void {
157
+ for (const f of this._fields) {
158
+ if (keys.length > 0 && !keys.includes(f.key)) continue
159
+ const value = this._props[f.key] !== undefined ? this._props[f.key] : f.value
160
+ this._widgets.push({
161
+ kind: "field", id: `f:${f.key}`, key: f.key, label: f.label, editor: f.editor,
162
+ value, doc: true, auto: true, min: f.min, max: f.max, step: f.step, options: f.options,
163
+ })
164
+ }
165
+ }
166
+
167
+ // ---- internals ----------------------------------------------------------------------------
168
+
169
+ private _vec(editor: FieldEditor, key: string, size: number, opts?: FieldOpts): number[] {
170
+ const v = this._field(editor, key, opts)
171
+ const arr = Array.isArray(v) ? v : []
172
+ return Array.from({ length: size }, (_, i) => (typeof arr[i] === "number" ? arr[i] as number : 0))
173
+ }
174
+
175
+ private _field(
176
+ editor: FieldEditor, key: string, opts?: FieldOpts, options?: readonly (string | number)[],
177
+ ): unknown {
178
+ const doc = this._docKeys.has(key)
179
+ const fallback = doc ? this._fields.find((f) => f.key === key)?.value : opts?.value
180
+ const stored = doc ? this._props[key] : this._state[key]
181
+ const value = stored !== undefined ? stored : fallback
182
+ this._widgets.push({
183
+ kind: "field", id: `f:${key}`, key, label: opts?.label ?? key, editor, value, doc,
184
+ min: opts?.min, max: opts?.max, step: opts?.step, options,
185
+ })
186
+ return value
187
+ }
188
+
189
+ /**
190
+ * @internal One immediate-mode pass: deliver `event`, run `fn`, return the widget list.
191
+ * Editor-state field events are applied here; DOC-key field events must be applied by the
192
+ * caller beforehand (they go through the scene loader's prop machinery — rebuilds, ref
193
+ * resolution); button events are consumed by the matching `button()` call during the run.
194
+ * A throwing inspector logs and shows the error as a warn line — it can't take the editor down.
195
+ */
196
+ _run(fn: (ui: this) => void, event?: InspectorEvent): InspectorWidget[] {
197
+ if (event && event.id.startsWith("f:") && !this._docKeys.has(event.id.slice(2))) {
198
+ this._state[event.id.slice(2)] = event.value
199
+ event = undefined
200
+ }
201
+ this._event = event ?? null
202
+ this._widgets = []
203
+ try {
204
+ fn(this)
205
+ } catch (e) {
206
+ console.error("[scene] inspector render failed:", e)
207
+ this.warn(String((e as Error)?.message ?? e))
208
+ }
209
+ this._event = null
210
+ return this._widgets
211
+ }
212
+ }
@@ -0,0 +1,308 @@
1
+ // A hierarchical state machine over a plain context object — the one place a game keeps "what am I
2
+ // doing right now": an enemy brain, a weapon (ready → fire → cooldown → reload), a game mode
3
+ // (warmup → round → over), a door. Declarative: the whole machine is ONE table you can read top
4
+ // to bottom, log, and later show in an inspector; the machine knows nothing about aspects, nodes
5
+ // or the network, so it runs wherever its owner ticks it (an `updateFixed` for anything that must
6
+ // match another machine's, `update` for presentation).
7
+ //
8
+ // const Guard = defineStates<Bot>({
9
+ // initial: 'patrol',
10
+ // on: { hit: 'flinch' }, // from ANY state
11
+ // to: { dead: (b) => b.hp <= 0 }, // checked in every state, first
12
+ // states: {
13
+ // patrol: { update: (b, dt) => b.walk(dt), to: { combat: (b) => b.target !== null } },
14
+ // combat: {
15
+ // initial: 'engage',
16
+ // to: { search: (b) => b.lostFor > 4 }, // leaves whichever child is active
17
+ // states: {
18
+ // engage: { update: (b) => b.approach(), to: { shoot: (b) => b.visible && b.dist < 12 } },
19
+ // shoot: { enter: (b) => b.fire = true, exit: (b) => b.fire = false, to: { engage: (b) => !b.visible } },
20
+ // },
21
+ // },
22
+ // search: { after: [6, 'patrol'] }, // a timed transition
23
+ // flinch: { after: [0.2, '$back'] }, // '$back' = the state we came from
24
+ // dead: { enter: (b) => b.die() },
25
+ // },
26
+ // })
27
+ // const fsm = new StateMachine(Guard, bot)
28
+ // fsm.step(dt) // once per tick: guards (outer states first), then updates
29
+ // fsm.send('hit') // an event: the innermost state that handles it wins
30
+ // fsm.state // 'combat.shoot' fsm.is('combat') → true
31
+ // fsm.on('change', (to, from) => console.log(`[fsm] ${from} → ${to}`))
32
+ //
33
+ // Semantics, in order, per `step(dt)`:
34
+ // 1. every active state's clock advances (`t` = seconds in that state);
35
+ // 2. transitions are tested OUTERMOST first — the root's `to` (any-state guards), then each
36
+ // active state's `to` guards (first true wins) and its `after` timer; a taken transition is
37
+ // re-tested from the new state (a pass-through state chains), at most 8 hops per step;
38
+ // 3. `update` runs outermost → innermost; an update that RETURNS a state name transitions
39
+ // there and ends the pass (the states it entered update next step).
40
+ // A transition exits states innermost-first up to the common ancestor and enters the new branch
41
+ // outermost-first, descending into `initial` children; the target state's own name (or an
42
+ // ancestor's) re-enters it. Names resolve relative to the declaring state (siblings, then each
43
+ // ancestor's siblings), or as a dotted path from the root; unknown names throw when the machine is
44
+ // built, so a typo is a startup error, not a silent no-op. `send` looks innermost → outermost
45
+ // (the root's `on` = any state) and returns whether someone handled it.
46
+
47
+ export type StateGuard<C> = (ctx: C, t: number) => boolean
48
+ export type StateHandler<C> = (ctx: C, ...args: any[]) => string | void
49
+
50
+ export interface StateDef<C> {
51
+ /** The child entered by default when this (compound) state is entered. */
52
+ initial?: string
53
+ /** Child states — this becomes a compound state; only a leaf is ever "the" state. */
54
+ states?: Record<string, StateDef<C>>
55
+ /** Entered (from = the previous leaf's path, '' at start). */
56
+ enter?: (ctx: C, from: string) => void
57
+ /** Left (to = the next leaf's path). */
58
+ exit?: (ctx: C, to: string) => void
59
+ /** Every step while active, outer states first; `t` = seconds in this state. Return a state name
60
+ * to transition. */
61
+ update?: (ctx: C, dt: number, t: number) => string | void
62
+ /** Guarded transitions, tested every step before `update` — key = target, first true wins. */
63
+ to?: Record<string, StateGuard<C>>
64
+ /** Leave for `target` after `seconds` in this state. */
65
+ after?: [seconds: number, target: string]
66
+ /** Event handlers for `send(name, …args)`: a target name, or a function returning one (or nothing). */
67
+ on?: Record<string, string | StateHandler<C>>
68
+ }
69
+
70
+ export interface StatesDef<C> {
71
+ initial: string
72
+ states: Record<string, StateDef<C>>
73
+ /** Any-state guards — tested first, in every state. */
74
+ to?: Record<string, StateGuard<C>>
75
+ /** Any-state event handlers — the fallback when no active state handles the event. */
76
+ on?: Record<string, string | StateHandler<C>>
77
+ /** Any-state update, runs before the active states' own. */
78
+ update?: (ctx: C, dt: number, t: number) => string | void
79
+ }
80
+
81
+ export type StateMachineEvents = {
82
+ /** After a transition completed (both are leaf paths; `from` = '' for the initial entry). */
83
+ change: (to: string, from: string) => void
84
+ }
85
+
86
+ /** Identity helper — names the context type once so every callback is typed. */
87
+ export const defineStates = <C>(def: StatesDef<C>): StatesDef<C> => def
88
+
89
+ const BACK = "$back"
90
+ const MAX_HOPS = 8
91
+
92
+ interface SNode<C> {
93
+ name: string
94
+ path: string
95
+ def: StateDef<C>
96
+ parent: SNode<C> | null
97
+ children: Map<string, SNode<C>>
98
+ initial: SNode<C> | null
99
+ depth: number
100
+ /** seconds in this state (valid while active) */
101
+ t: number
102
+ /** resolved `to` targets in declaration order */
103
+ guards: Array<[SNode<C>, StateGuard<C>]>
104
+ after: [number, SNode<C> | typeof BACK] | null
105
+ /** resolution cache for names returned at runtime */
106
+ resolved: Map<string, SNode<C> | typeof BACK>
107
+ }
108
+
109
+ export class StateMachine<C> {
110
+ readonly ctx: C
111
+ private readonly root: SNode<C>
112
+ private readonly byPath = new Map<string, SNode<C>>()
113
+ /** the active branch, root's child (index 0) → leaf */
114
+ private chain: SNode<C>[] = []
115
+ private prev: SNode<C> | null = null
116
+ private listeners: Array<(to: string, from: string) => void> = []
117
+ private changed = 0
118
+
119
+ constructor(def: StatesDef<C>, ctx: C) {
120
+ this.ctx = ctx
121
+ this.root = this.build("", "", def as StateDef<C>, null, 0)
122
+ // second pass: resolve names (every state exists now)
123
+ for (const n of this.byPath.values()) this.link(n)
124
+ this.link(this.root)
125
+ if (!this.root.initial) throw new Error("StateMachine: `initial` is required")
126
+ this.enterBranch([], this.leafOf(this.root.initial), "")
127
+ }
128
+
129
+ /** The active leaf's path ('combat.shoot'). */
130
+ get state(): string { return this.chain[this.chain.length - 1]?.path ?? "" }
131
+ /** The leaf we came from ('' before the first transition). */
132
+ get previous(): string { return this.prev?.path ?? "" }
133
+ /** Seconds in the active leaf. */
134
+ get time(): number { return this.chain[this.chain.length - 1]?.t ?? 0 }
135
+ /** Every active state's path, outermost first. */
136
+ get branch(): string[] { return this.chain.map((n) => n.path) }
137
+
138
+ /** Is `name` (a state name or dotted path) active — as the leaf OR as one of its ancestors? */
139
+ is(name: string): boolean {
140
+ for (const n of this.chain) if (n.name === name || n.path === name) return true
141
+ return false
142
+ }
143
+ /** Seconds in the named active state (0 when it is not active). */
144
+ timeIn(name: string): number {
145
+ for (const n of this.chain) if (n.name === name || n.path === name) return n.t
146
+ return 0
147
+ }
148
+
149
+ /** Force a transition (a spawn, a reset from outside the table). */
150
+ go(name: string): void {
151
+ const leaf = this.chain[this.chain.length - 1] ?? this.root
152
+ this.transition(this.resolve(leaf, name))
153
+ }
154
+
155
+ /** Deliver an event. Innermost active state first, the root's `on` last. True if handled. */
156
+ send(event: string, ...args: unknown[]): boolean {
157
+ for (let i = this.chain.length - 1; i >= 0; i--) {
158
+ const n = this.chain[i]!
159
+ const h = n.def.on?.[event]
160
+ if (h === undefined) continue
161
+ const target = typeof h === "string" ? h : h(this.ctx, ...args)
162
+ if (typeof target === "string") this.transition(this.resolve(n, target))
163
+ return true
164
+ }
165
+ const h = this.root.def.on?.[event]
166
+ if (h === undefined) return false
167
+ const target = typeof h === "string" ? h : h(this.ctx, ...args)
168
+ if (typeof target === "string") this.transition(this.resolve(this.chain[this.chain.length - 1] ?? this.root, target))
169
+ return true
170
+ }
171
+
172
+ /** One tick: clocks, guards (outer first, chained), then updates (outer first). */
173
+ step(dt: number): void {
174
+ for (const n of this.chain) n.t += dt
175
+ this.root.t += dt
176
+ for (let hop = 0; hop < MAX_HOPS; hop++) {
177
+ const target = this.findTransition()
178
+ if (target === null) break
179
+ this.transition(target)
180
+ }
181
+ const mark = this.changed
182
+ const r0 = this.root.def.update?.(this.ctx, dt, this.root.t)
183
+ if (typeof r0 === "string") { this.transition(this.resolve(this.chain[this.chain.length - 1]!, r0)); return }
184
+ if (this.changed !== mark) return
185
+ const chain = this.chain.slice()
186
+ for (const n of chain) {
187
+ const r = n.def.update?.(this.ctx, dt, n.t)
188
+ if (typeof r === "string") { this.transition(this.resolve(n, r)); return }
189
+ if (this.changed !== mark) return // an update transitioned through send()/go()
190
+ }
191
+ }
192
+
193
+ /** Listen for transitions. */
194
+ on(event: "change", fn: StateMachineEvents["change"]): this { this.listeners.push(fn); return this }
195
+ off(event: "change", fn: StateMachineEvents["change"]): this {
196
+ const i = this.listeners.indexOf(fn)
197
+ if (i >= 0) this.listeners.splice(i, 1)
198
+ return this
199
+ }
200
+
201
+ // ---- building ----
202
+ private build(name: string, path: string, def: StateDef<C>, parent: SNode<C> | null, depth: number): SNode<C> {
203
+ const node: SNode<C> = { name, path, def, parent, children: new Map(), initial: null, depth, t: 0, guards: [], after: null, resolved: new Map() }
204
+ if (path) this.byPath.set(path, node)
205
+ if (def.states) {
206
+ for (const [childName, childDef] of Object.entries(def.states)) {
207
+ if (childName.includes(".") || childName.startsWith("$")) throw new Error(`StateMachine: bad state name '${childName}'`)
208
+ node.children.set(childName, this.build(childName, path ? `${path}.${childName}` : childName, childDef, node, depth + 1))
209
+ }
210
+ const initial = def.initial ?? Object.keys(def.states)[0]!
211
+ const child = node.children.get(initial)
212
+ if (!child) throw new Error(`StateMachine: '${path || "root"}' has no child '${initial}' for initial`)
213
+ node.initial = child
214
+ } else if (def.initial) {
215
+ throw new Error(`StateMachine: '${path}' names an initial child but has no states`)
216
+ }
217
+ return node
218
+ }
219
+
220
+ private link(n: SNode<C>): void {
221
+ if (n.def.to) for (const [target, guard] of Object.entries(n.def.to)) n.guards.push([this.resolveStrict(n, target), guard])
222
+ if (n.def.after) n.after = [n.def.after[0], this.resolve(n, n.def.after[1])]
223
+ if (n.def.on) for (const h of Object.values(n.def.on)) if (typeof h === "string") this.resolve(n, h)
224
+ }
225
+
226
+ private resolveStrict(from: SNode<C>, name: string): SNode<C> {
227
+ const r = this.resolve(from, name)
228
+ if (r === BACK) throw new Error(`StateMachine: '${BACK}' is not allowed as a guard target of '${from.path}'`)
229
+ return r
230
+ }
231
+
232
+ /** Relative first (siblings of `from`, then of each ancestor), then a dotted path from the root. */
233
+ private resolve(from: SNode<C>, name: string): SNode<C> | typeof BACK {
234
+ const cached = from.resolved.get(name)
235
+ if (cached) return cached
236
+ let found: SNode<C> | typeof BACK | undefined
237
+ if (name === BACK) found = BACK
238
+ else {
239
+ for (let n: SNode<C> | null = from; n && !found; n = n.parent) {
240
+ const scope = n.parent ?? this.root
241
+ found = scope.children.get(name)
242
+ if (!found && n === from) found = n.children.get(name) // a compound state naming its own child
243
+ }
244
+ if (!found) found = this.byPath.get(name)
245
+ }
246
+ if (!found) throw new Error(`StateMachine: unknown state '${name}' (from '${from.path || "root"}')`)
247
+ from.resolved.set(name, found)
248
+ return found
249
+ }
250
+
251
+ // ---- running ----
252
+ /** Guards never re-enter a state that is already active (a `dead` guard staying true does not
253
+ * re-enter `dead` every step) — only an explicit `go()` / event does that. */
254
+ private findTransition(): SNode<C> | typeof BACK | null {
255
+ const ctx = this.ctx
256
+ for (const [target, guard] of this.root.guards) if (!this.active(target) && guard(ctx, this.root.t)) return target
257
+ for (const n of this.chain) {
258
+ for (const [target, guard] of n.guards) if (!this.active(target) && guard(ctx, n.t)) return target
259
+ if (n.after && n.t >= n.after[0] && (n.after[1] === BACK || !this.active(n.after[1]))) return n.after[1]
260
+ }
261
+ return null
262
+ }
263
+
264
+ private active(n: SNode<C>): boolean { return this.chain[n.depth - 1] === n }
265
+
266
+ private leafOf(n: SNode<C>): SNode<C> {
267
+ while (n.initial) n = n.initial
268
+ return n
269
+ }
270
+
271
+ private transition(target: SNode<C> | typeof BACK): void {
272
+ const current = this.chain[this.chain.length - 1] ?? null
273
+ let node: SNode<C>
274
+ if (target === BACK) {
275
+ if (!this.prev) return
276
+ node = this.prev
277
+ } else node = target
278
+ const leaf = this.leafOf(node)
279
+ // the new branch (root excluded)
280
+ const next: SNode<C>[] = []
281
+ for (let n: SNode<C> | null = leaf; n && n !== this.root; n = n.parent) next.unshift(n)
282
+ // common prefix; a target that is the current leaf or one of its ancestors re-enters itself
283
+ let common = 0
284
+ while (common < this.chain.length && common < next.length && this.chain[common] === next[common]) common++
285
+ if (node !== this.root && common >= node.depth && this.chain[node.depth - 1] === node) common = node.depth - 1
286
+ const from = current?.path ?? ""
287
+ const to = leaf.path
288
+ for (let i = this.chain.length - 1; i >= common; i--) this.chain[i]!.def.exit?.(this.ctx, to)
289
+ this.prev = current
290
+ this.enterBranch(this.chain.slice(0, common), leaf, from, next)
291
+ }
292
+
293
+ private enterBranch(kept: SNode<C>[], leaf: SNode<C>, from: string, next?: SNode<C>[]): void {
294
+ if (!next) {
295
+ next = []
296
+ for (let n: SNode<C> | null = leaf; n && n !== this.root; n = n.parent) next.unshift(n)
297
+ }
298
+ this.chain = kept.slice()
299
+ this.changed++
300
+ for (let i = kept.length; i < next.length; i++) {
301
+ const n = next[i]!
302
+ n.t = 0
303
+ this.chain.push(n)
304
+ n.def.enter?.(this.ctx, from)
305
+ }
306
+ for (const fn of this.listeners.slice()) fn(leaf.path, from)
307
+ }
308
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { StateMachine, defineStates } from "../StateMachine"
3
+
4
+ type Bot = { hp: number; target: boolean; visible: boolean; dist: number; lost: number; fire: boolean; log: string[] }
5
+ const makeBot = (): Bot => ({ hp: 100, target: false, visible: false, dist: 99, lost: 0, fire: false, log: [] })
6
+
7
+ const Guard = defineStates<Bot>({
8
+ initial: "patrol",
9
+ on: { hit: "flinch" },
10
+ to: { dead: (b) => b.hp <= 0 },
11
+ states: {
12
+ patrol: { enter: (b) => b.log.push("enter patrol"), exit: (b) => b.log.push("exit patrol"), to: { combat: (b) => b.target } },
13
+ combat: {
14
+ initial: "engage",
15
+ enter: (b) => b.log.push("enter combat"),
16
+ exit: (b) => b.log.push("exit combat"),
17
+ to: { search: (b) => b.lost > 4 },
18
+ states: {
19
+ engage: { enter: (b) => b.log.push("enter engage"), exit: (b) => b.log.push("exit engage"), to: { shoot: (b) => b.visible && b.dist < 12 } },
20
+ shoot: {
21
+ enter: (b) => { b.fire = true; b.log.push("enter shoot") },
22
+ exit: (b) => { b.fire = false; b.log.push("exit shoot") },
23
+ to: { engage: (b) => !b.visible },
24
+ on: { hit: (b) => (b.hp < 30 ? "combat.engage" : undefined) },
25
+ },
26
+ },
27
+ },
28
+ search: { after: [6, "patrol"], enter: (b) => b.log.push("enter search") },
29
+ flinch: { after: [0.2, "$back"], enter: (b) => b.log.push("enter flinch"), exit: (b) => b.log.push("exit flinch") },
30
+ dead: { enter: (b) => b.log.push("enter dead") },
31
+ },
32
+ })
33
+
34
+ const DT = 1 / 60
35
+
36
+ describe("StateMachine", () => {
37
+ test("enters the initial state and follows guards into nested states", () => {
38
+ const bot = makeBot()
39
+ const fsm = new StateMachine(Guard, bot)
40
+ expect(fsm.state).toBe("patrol")
41
+ expect(bot.log).toEqual(["enter patrol"])
42
+ fsm.step(DT)
43
+ expect(fsm.state).toBe("patrol")
44
+ bot.target = true
45
+ fsm.step(DT)
46
+ expect(fsm.state).toBe("combat.engage")
47
+ expect(bot.log.slice(-3)).toEqual(["exit patrol", "enter combat", "enter engage"])
48
+ expect(fsm.is("combat")).toBe(true)
49
+ expect(fsm.is("engage")).toBe(true)
50
+ expect(fsm.is("patrol")).toBe(false)
51
+ })
52
+
53
+ test("a sibling transition exits and enters only the leaves", () => {
54
+ const bot = makeBot()
55
+ const fsm = new StateMachine(Guard, bot)
56
+ bot.target = true
57
+ fsm.step(DT)
58
+ bot.visible = true
59
+ bot.dist = 5
60
+ fsm.step(DT)
61
+ expect(fsm.state).toBe("combat.shoot")
62
+ expect(bot.fire).toBe(true)
63
+ expect(bot.log.slice(-2)).toEqual(["exit engage", "enter shoot"])
64
+ })
65
+
66
+ test("events: innermost handler wins, the root's `on` is the fallback, $back restores the previous leaf", () => {
67
+ const bot = makeBot()
68
+ const fsm = new StateMachine(Guard, bot)
69
+ bot.target = true; fsm.step(DT)
70
+ bot.visible = true; bot.dist = 5; fsm.step(DT)
71
+ // handled by shoot (returns undefined → stays), the root's handler is NOT consulted
72
+ expect(fsm.send("hit")).toBe(true)
73
+ expect(fsm.state).toBe("combat.shoot")
74
+ bot.hp = 20
75
+ expect(fsm.send("hit")).toBe(true)
76
+ expect(fsm.state).toBe("combat.engage")
77
+ // engage has no handler → root → flinch, leaving combat
78
+ bot.visible = false
79
+ fsm.send("hit")
80
+ expect(fsm.state).toBe("flinch")
81
+ expect(bot.log.slice(-3)).toEqual(["exit engage", "exit combat", "enter flinch"])
82
+ for (let i = 0; i < 13; i++) fsm.step(DT)
83
+ expect(fsm.state).toBe("combat.engage")
84
+ expect(bot.log.slice(-3)).toEqual(["exit flinch", "enter combat", "enter engage"])
85
+ expect(fsm.previous).toBe("flinch")
86
+ })
87
+
88
+ test("compound guards, timed transitions, clocks", () => {
89
+ const bot = makeBot()
90
+ const fsm = new StateMachine(Guard, bot)
91
+ bot.target = true; fsm.step(DT)
92
+ bot.lost = 5
93
+ fsm.step(DT)
94
+ expect(fsm.state).toBe("search")
95
+ bot.target = false; bot.lost = 0
96
+ for (let i = 0; i < 361; i++) fsm.step(DT)
97
+ expect(fsm.state).toBe("patrol")
98
+ expect(fsm.time).toBeLessThan(0.05)
99
+ })
100
+
101
+ test("go() re-enters the current state; guards never re-enter an active state", () => {
102
+ const bot = makeBot()
103
+ const fsm = new StateMachine(Guard, bot)
104
+ bot.log.length = 0
105
+ fsm.go("patrol")
106
+ expect(bot.log).toEqual(["exit patrol", "enter patrol"])
107
+ bot.hp = 0
108
+ bot.target = true
109
+ const changes: string[] = []
110
+ fsm.on("change", (to, from) => changes.push(`${from}>${to}`))
111
+ fsm.step(DT) // the any-state `dead` guard beats patrol→combat
112
+ expect(fsm.state).toBe("dead")
113
+ fsm.step(DT); fsm.step(DT)
114
+ expect(changes).toEqual(["patrol>dead"]) // no dead>dead re-entries while the guard stays true
115
+ })
116
+
117
+ test("unknown targets throw when the machine is built", () => {
118
+ expect(() => new StateMachine(defineStates<{}>({ initial: "a", states: { a: { to: { nowhere: () => true } } } }), {})).toThrow(/unknown state 'nowhere'/)
119
+ expect(() => new StateMachine(defineStates<{}>({ initial: "zzz", states: { a: {} } }), {})).toThrow()
120
+ })
121
+
122
+ test("pass-through states chain within one step; update may return a target", () => {
123
+ const chain = new StateMachine(defineStates<{}>({ initial: "a", states: { a: { to: { b: () => true } }, b: { to: { c: () => true } }, c: {} } }), {})
124
+ chain.step(0)
125
+ expect(chain.state).toBe("c")
126
+ const upd = new StateMachine(defineStates<{ n: number }>({ initial: "a", states: { a: { update: (c) => (++c.n >= 3 ? "b" : undefined) }, b: {} } }), { n: 0 })
127
+ upd.step(0); upd.step(0)
128
+ expect(upd.state).toBe("a")
129
+ upd.step(0)
130
+ expect(upd.state).toBe("b")
131
+ })
132
+ })