@vgai/engine 0.5.21 → 0.5.23

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 (339) hide show
  1. package/README.md +6 -3
  2. package/dist/adapter/adapter-module.d.ts.map +1 -1
  3. package/dist/adapter/adapter-module.js +2 -4
  4. package/dist/adapter/adapter-surface.d.ts +1 -1
  5. package/dist/adapter/adapter-surface.js +1 -1
  6. package/dist/adapter/authoring.d.ts +19 -21
  7. package/dist/adapter/authoring.d.ts.map +1 -1
  8. package/dist/adapter/authoring.js +2 -2
  9. package/dist/adapter/binding.d.ts +341 -0
  10. package/dist/adapter/binding.d.ts.map +1 -0
  11. package/dist/adapter/binding.js +148 -0
  12. package/dist/adapter/colyseus-networking-adapter.d.ts.map +1 -1
  13. package/dist/adapter/colyseus-networking-adapter.js +23 -4
  14. package/dist/adapter/entrypoint-selection-readers.d.ts +24 -0
  15. package/dist/adapter/entrypoint-selection-readers.d.ts.map +1 -0
  16. package/dist/adapter/entrypoint-selection-readers.js +63 -0
  17. package/dist/adapter/entrypoint-selection-source.d.ts.map +1 -1
  18. package/dist/adapter/entrypoint-selection-source.js +1 -44
  19. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -1
  20. package/dist/adapter/finders/scenes-from-entrypoint-selection.js +1 -52
  21. package/dist/adapter/host-context.d.ts +46 -15
  22. package/dist/adapter/host-context.d.ts.map +1 -1
  23. package/dist/adapter/host-context.js +2 -9
  24. package/dist/adapter/index.d.ts +11 -12
  25. package/dist/adapter/index.d.ts.map +1 -1
  26. package/dist/adapter/index.js +9 -10
  27. package/dist/adapter/ingest/game-contract.d.ts +0 -3
  28. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  29. package/dist/adapter/ingest/game-contract.js +0 -3
  30. package/dist/adapter/ingest/scene-capture.d.ts +19 -23
  31. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  32. package/dist/adapter/ingest/scene-capture.js +143 -17
  33. package/dist/adapter/ingest/visible-capture-window.d.ts +34 -0
  34. package/dist/adapter/ingest/visible-capture-window.d.ts.map +1 -1
  35. package/dist/adapter/manifest-interpreter.d.ts +59 -0
  36. package/dist/adapter/manifest-interpreter.d.ts.map +1 -0
  37. package/dist/adapter/manifest-interpreter.js +50 -0
  38. package/dist/adapter/native-debug-module.d.ts +71 -3
  39. package/dist/adapter/native-debug-module.d.ts.map +1 -1
  40. package/dist/adapter/native-debug-module.js +146 -10
  41. package/dist/adapter/rapier-physics-adapter.d.ts +31 -1
  42. package/dist/adapter/rapier-physics-adapter.d.ts.map +1 -1
  43. package/dist/adapter/rapier-physics-adapter.js +50 -36
  44. package/dist/adapter/renderer-config.d.ts +3 -3
  45. package/dist/adapter/renderer-config.js +3 -3
  46. package/dist/adapter/root-adapter.d.ts +7 -7
  47. package/dist/adapter/root-adapter.js +4 -4
  48. package/dist/adapter/system-adapter.d.ts +45 -7
  49. package/dist/adapter/system-adapter.d.ts.map +1 -1
  50. package/dist/adapter/system-seam-contract.d.ts +3 -3
  51. package/dist/adapter/system-seam-contract.js +3 -3
  52. package/dist/adapter/system-slot.d.ts +168 -0
  53. package/dist/adapter/system-slot.d.ts.map +1 -0
  54. package/dist/adapter/system-slot.js +229 -0
  55. package/dist/adapter/transform.d.ts +11 -1
  56. package/dist/adapter/transform.d.ts.map +1 -1
  57. package/dist/ai/navigation.d.ts +17 -1
  58. package/dist/ai/navigation.d.ts.map +1 -1
  59. package/dist/ai/navigation.js +26 -5
  60. package/dist/animation/anim-graph-types.d.ts +11 -3
  61. package/dist/animation/anim-graph-types.d.ts.map +1 -1
  62. package/dist/animation/theatre-clock-binding.d.ts +4 -4
  63. package/dist/animation/theatre-clock-binding.js +4 -4
  64. package/dist/animation/xstate-animation-binding.d.ts.map +1 -1
  65. package/dist/animation/xstate-animation-binding.js +2 -2
  66. package/dist/animation/xstate-animation-meta.d.ts +13 -0
  67. package/dist/animation/xstate-animation-meta.d.ts.map +1 -1
  68. package/dist/animation/xstate-animation-meta.js +14 -0
  69. package/dist/asset-formats/index.d.ts +4 -7
  70. package/dist/asset-formats/index.d.ts.map +1 -1
  71. package/dist/asset-formats/index.js +4 -7
  72. package/dist/asset-formats/render-env.d.ts +5 -8
  73. package/dist/asset-formats/render-env.d.ts.map +1 -1
  74. package/dist/asset-formats/render-env.js +3 -6
  75. package/dist/asset-parse-error.d.ts +2 -5
  76. package/dist/asset-parse-error.d.ts.map +1 -1
  77. package/dist/asset-parse-error.js +2 -5
  78. package/dist/audio/bus-mixer.d.ts +101 -0
  79. package/dist/audio/bus-mixer.d.ts.map +1 -0
  80. package/dist/audio/bus-mixer.js +115 -0
  81. package/dist/canvas-react/index.d.ts +5 -12
  82. package/dist/canvas-react/index.d.ts.map +1 -1
  83. package/dist/canvas-react/index.js +5 -12
  84. package/dist/canvas-react/pixi-react-root-factory.d.ts +34 -21
  85. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  86. package/dist/canvas-react/pixi-react-root-factory.js +274 -36
  87. package/dist/core/countdown-timer.d.ts +118 -0
  88. package/dist/core/countdown-timer.d.ts.map +1 -0
  89. package/dist/core/countdown-timer.js +137 -0
  90. package/dist/core/deferred-commands.d.ts +127 -0
  91. package/dist/core/deferred-commands.d.ts.map +1 -0
  92. package/dist/core/deferred-commands.js +132 -0
  93. package/dist/core/frame-pacing.d.ts +1 -1
  94. package/dist/core/frame-pacing.js +2 -2
  95. package/dist/core/game-loop.d.ts +1 -3
  96. package/dist/core/game-loop.d.ts.map +1 -1
  97. package/dist/core/game-loop.js +1 -3
  98. package/dist/core/seeded-random.d.ts +1 -1
  99. package/dist/core/sim-clock.d.ts +19 -8
  100. package/dist/core/sim-clock.d.ts.map +1 -1
  101. package/dist/core/sim-clock.js +18 -7
  102. package/dist/core/types.d.ts +1 -3
  103. package/dist/core/types.d.ts.map +1 -1
  104. package/dist/defaults.d.ts +6 -14
  105. package/dist/defaults.d.ts.map +1 -1
  106. package/dist/defaults.js +6 -14
  107. package/dist/dev/instruments.d.ts +38 -11
  108. package/dist/dev/instruments.d.ts.map +1 -1
  109. package/dist/dev/instruments.js +81 -11
  110. package/dist/dev/register-render-vitals.d.ts +1 -4
  111. package/dist/dev/register-render-vitals.d.ts.map +1 -1
  112. package/dist/dev/register-render-vitals.js +1 -4
  113. package/dist/dev/render-debug-adapter.d.ts +2 -3
  114. package/dist/dev/render-debug-adapter.d.ts.map +1 -1
  115. package/dist/dev/render-debug-adapter.js +2 -3
  116. package/dist/dev/render-vitals.d.ts +3 -3
  117. package/dist/dev/render-vitals.js +3 -3
  118. package/dist/dev/webgl-frame-capture.d.ts +1 -1
  119. package/dist/dev/webgl-frame-capture.js +1 -1
  120. package/dist/index.d.ts +2 -5
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js +2 -4
  123. package/dist/input/host-pointer.d.ts +122 -0
  124. package/dist/input/host-pointer.d.ts.map +1 -0
  125. package/dist/input/host-pointer.js +160 -0
  126. package/dist/input/input-manager.d.ts +33 -5
  127. package/dist/input/input-manager.d.ts.map +1 -1
  128. package/dist/input/input-manager.js +48 -13
  129. package/dist/input/rebind-controller.d.ts.map +1 -1
  130. package/dist/input/rebind-controller.js +6 -8
  131. package/dist/input/schema.d.ts.map +1 -1
  132. package/dist/input/schema.js +56 -28
  133. package/dist/manifest/filename.d.ts.map +1 -1
  134. package/dist/manifest/filename.js +8 -12
  135. package/dist/manifest/load.d.ts +10 -10
  136. package/dist/manifest/load.d.ts.map +1 -1
  137. package/dist/manifest/load.js +9 -6
  138. package/dist/manifest/schema.d.ts +17 -21
  139. package/dist/manifest/schema.d.ts.map +1 -1
  140. package/dist/manifest/schema.js +45 -35
  141. package/dist/physics/collider-dimensions.d.ts +8 -12
  142. package/dist/physics/collider-dimensions.d.ts.map +1 -1
  143. package/dist/physics/collider-dimensions.js +8 -12
  144. package/dist/pixi/scene-capture.d.ts +11 -13
  145. package/dist/pixi/scene-capture.d.ts.map +1 -1
  146. package/dist/react/world-state.d.ts +1 -1
  147. package/dist/react/world-state.d.ts.map +1 -1
  148. package/dist/render/directional-shadow-fit.d.ts +88 -0
  149. package/dist/render/directional-shadow-fit.d.ts.map +1 -0
  150. package/dist/render/directional-shadow-fit.js +133 -0
  151. package/dist/render/environment-capture.d.ts +68 -0
  152. package/dist/render/environment-capture.d.ts.map +1 -0
  153. package/dist/render/environment-capture.js +90 -0
  154. package/dist/render/ibl-override-material.d.ts +97 -0
  155. package/dist/render/ibl-override-material.d.ts.map +1 -0
  156. package/dist/render/ibl-override-material.js +114 -0
  157. package/dist/render/light-camera-factory.d.ts +5 -7
  158. package/dist/render/light-camera-factory.d.ts.map +1 -1
  159. package/dist/render/light-camera-factory.js +8 -11
  160. package/dist/render/material-factory.js +1 -1
  161. package/dist/render/render-batch-system.d.ts.map +1 -1
  162. package/dist/render/render-features.d.ts +17 -6
  163. package/dist/render/render-features.d.ts.map +1 -1
  164. package/dist/render/render-features.js +0 -28
  165. package/dist/runtime/create-runtime.d.ts +34 -25
  166. package/dist/runtime/create-runtime.d.ts.map +1 -1
  167. package/dist/runtime/create-runtime.js +218 -93
  168. package/dist/runtime/debug-registry.d.ts +77 -8
  169. package/dist/runtime/debug-registry.d.ts.map +1 -1
  170. package/dist/runtime/debug-registry.js +3 -3
  171. package/dist/runtime/dev-build.d.ts +2 -2
  172. package/dist/runtime/dev-build.js +2 -2
  173. package/dist/runtime/game-input-seams.d.ts +51 -0
  174. package/dist/runtime/game-input-seams.d.ts.map +1 -0
  175. package/dist/runtime/game-input-seams.js +86 -0
  176. package/dist/runtime/game.d.ts +104 -40
  177. package/dist/runtime/game.d.ts.map +1 -1
  178. package/dist/runtime/game.js +119 -61
  179. package/dist/runtime/mount-game.d.ts +0 -16
  180. package/dist/runtime/mount-game.d.ts.map +1 -1
  181. package/dist/runtime/mount-game.js +26 -41
  182. package/dist/runtime/mount-manifest.d.ts +22 -14
  183. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  184. package/dist/runtime/mount-manifest.js +22 -20
  185. package/dist/runtime/playtest.d.ts +22 -0
  186. package/dist/runtime/playtest.d.ts.map +1 -0
  187. package/dist/runtime/playtest.js +12 -0
  188. package/dist/runtime/state-bridge.d.ts +2 -3
  189. package/dist/runtime/state-bridge.d.ts.map +1 -1
  190. package/dist/runtime/state-bridge.js +1 -0
  191. package/dist/setup/setup-audio.d.ts +12 -0
  192. package/dist/setup/setup-audio.d.ts.map +1 -1
  193. package/dist/setup/setup-audio.js +9 -10
  194. package/dist/setup/setup-renderer.d.ts +2 -25
  195. package/dist/setup/setup-renderer.d.ts.map +1 -1
  196. package/dist/setup/setup-renderer.js +2 -31
  197. package/dist/world3d-react/index.d.ts +15 -25
  198. package/dist/world3d-react/index.d.ts.map +1 -1
  199. package/dist/world3d-react/index.js +15 -25
  200. package/dist/world3d-react/r3f-root-factory.d.ts +28 -34
  201. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  202. package/dist/world3d-react/r3f-root-factory.js +403 -57
  203. package/dist/world3d-react/rapier-physics-bridge.d.ts +30 -17
  204. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  205. package/dist/world3d-react/rapier-physics-bridge.js +143 -58
  206. package/package.json +1 -1
  207. package/schemas/engine-capabilities.json +16 -14
  208. package/schemas/vgai-project.schema.json +35 -26
  209. package/src/adapter/adapter-module.ts +2 -4
  210. package/src/adapter/adapter-surface.ts +1 -1
  211. package/src/adapter/authoring.ts +19 -21
  212. package/src/adapter/binding.ts +468 -0
  213. package/src/adapter/colyseus-networking-adapter.ts +20 -5
  214. package/src/adapter/entrypoint-selection-readers.ts +66 -0
  215. package/src/adapter/entrypoint-selection-source.ts +1 -40
  216. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +1 -48
  217. package/src/adapter/host-context.ts +45 -15
  218. package/src/adapter/index.ts +23 -11
  219. package/src/adapter/ingest/game-contract.ts +0 -3
  220. package/src/adapter/ingest/scene-capture.ts +190 -39
  221. package/src/adapter/ingest/visible-capture-window.ts +35 -0
  222. package/src/adapter/manifest-interpreter.ts +64 -0
  223. package/src/adapter/native-debug-module.ts +248 -15
  224. package/src/adapter/rapier-physics-adapter.ts +76 -20
  225. package/src/adapter/renderer-config.ts +3 -3
  226. package/src/adapter/root-adapter.ts +7 -7
  227. package/src/adapter/system-adapter.ts +43 -8
  228. package/src/adapter/system-seam-contract.ts +3 -3
  229. package/src/adapter/system-slot.ts +291 -0
  230. package/src/adapter/transform.ts +18 -1
  231. package/src/ai/navigation.ts +30 -3
  232. package/src/animation/anim-graph-types.ts +11 -3
  233. package/src/animation/theatre-clock-binding.ts +4 -4
  234. package/src/animation/xstate-animation-binding.ts +2 -2
  235. package/src/animation/xstate-animation-meta.ts +16 -0
  236. package/src/asset-formats/index.ts +4 -7
  237. package/src/asset-formats/render-env.ts +3 -6
  238. package/src/asset-parse-error.ts +2 -5
  239. package/src/audio/bus-mixer.ts +161 -0
  240. package/src/canvas-react/index.ts +5 -21
  241. package/src/canvas-react/pixi-react-root-factory.tsx +301 -39
  242. package/src/core/countdown-timer.ts +188 -0
  243. package/src/core/deferred-commands.ts +174 -0
  244. package/src/core/frame-pacing.ts +2 -2
  245. package/src/core/game-loop.ts +1 -3
  246. package/src/core/seeded-random.ts +1 -1
  247. package/src/core/sim-clock.ts +19 -8
  248. package/src/core/types.ts +1 -3
  249. package/src/defaults.ts +6 -14
  250. package/src/dev/instruments.ts +108 -11
  251. package/src/dev/register-render-vitals.ts +1 -4
  252. package/src/dev/render-debug-adapter.ts +2 -3
  253. package/src/dev/render-vitals.ts +3 -3
  254. package/src/dev/webgl-frame-capture.ts +1 -1
  255. package/src/index.ts +2 -5
  256. package/src/input/host-pointer.ts +230 -0
  257. package/src/input/input-manager.ts +49 -13
  258. package/src/input/input-types.ts +1 -1
  259. package/src/input/rebind-controller.ts +6 -8
  260. package/src/input/schema.ts +216 -188
  261. package/src/manifest/filename.ts +8 -12
  262. package/src/manifest/load.ts +17 -13
  263. package/src/manifest/schema.ts +55 -47
  264. package/src/physics/collider-dimensions.ts +8 -12
  265. package/src/pixi/scene-capture.ts +12 -15
  266. package/src/react/world-state.tsx +1 -1
  267. package/src/render/directional-shadow-fit.ts +156 -0
  268. package/src/render/environment-capture.ts +102 -0
  269. package/src/render/ibl-override-material.ts +170 -0
  270. package/src/render/light-camera-factory.ts +8 -11
  271. package/src/render/material-factory.ts +1 -1
  272. package/src/render/render-batch-system.ts +14 -6
  273. package/src/render/render-features.ts +17 -35
  274. package/src/runtime/create-runtime.ts +270 -108
  275. package/src/runtime/debug-registry.ts +84 -8
  276. package/src/runtime/dev-build.ts +2 -2
  277. package/src/runtime/game-input-seams.ts +108 -0
  278. package/src/runtime/game.ts +231 -85
  279. package/src/runtime/mount-game.ts +31 -57
  280. package/src/runtime/mount-manifest.ts +47 -36
  281. package/src/runtime/playtest.ts +22 -0
  282. package/src/runtime/state-bridge.ts +3 -3
  283. package/src/setup/setup-audio.ts +21 -11
  284. package/src/setup/setup-renderer.ts +2 -61
  285. package/src/world3d-react/index.ts +15 -31
  286. package/src/world3d-react/r3f-root-factory.tsx +451 -60
  287. package/src/world3d-react/rapier-physics-bridge.tsx +154 -58
  288. package/dist/adapter/setup-three-root-adapter.d.ts +0 -86
  289. package/dist/adapter/setup-three-root-adapter.d.ts.map +0 -1
  290. package/dist/adapter/setup-three-root-adapter.js +0 -908
  291. package/dist/animation/clip-map.d.ts +0 -12
  292. package/dist/animation/clip-map.d.ts.map +0 -1
  293. package/dist/animation/clip-map.js +0 -31
  294. package/dist/asset-registry.d.ts +0 -38
  295. package/dist/asset-registry.d.ts.map +0 -1
  296. package/dist/asset-registry.js +0 -66
  297. package/dist/canvas-react/engine-bridge.d.ts +0 -45
  298. package/dist/canvas-react/engine-bridge.d.ts.map +0 -1
  299. package/dist/canvas-react/engine-bridge.js +0 -45
  300. package/dist/canvas-react/pixi-react-adapter.d.ts +0 -77
  301. package/dist/canvas-react/pixi-react-adapter.d.ts.map +0 -1
  302. package/dist/canvas-react/pixi-react-adapter.js +0 -294
  303. package/dist/canvas-react/world-context.d.ts +0 -98
  304. package/dist/canvas-react/world-context.d.ts.map +0 -1
  305. package/dist/canvas-react/world-context.js +0 -173
  306. package/dist/dev/debug-draw.d.ts +0 -24
  307. package/dist/dev/debug-draw.d.ts.map +0 -1
  308. package/dist/dev/debug-draw.js +0 -73
  309. package/dist/render/auto-batcher.d.ts +0 -34
  310. package/dist/render/auto-batcher.d.ts.map +0 -1
  311. package/dist/render/auto-batcher.js +0 -140
  312. package/dist/render/lod.d.ts +0 -13
  313. package/dist/render/lod.d.ts.map +0 -1
  314. package/dist/render/lod.js +0 -16
  315. package/dist/runtime/types.d.ts +0 -303
  316. package/dist/runtime/types.d.ts.map +0 -1
  317. package/dist/runtime/types.js +0 -1
  318. package/dist/world3d-react/engine-bridge.d.ts +0 -47
  319. package/dist/world3d-react/engine-bridge.d.ts.map +0 -1
  320. package/dist/world3d-react/engine-bridge.js +0 -73
  321. package/dist/world3d-react/r3f-adapter.d.ts +0 -58
  322. package/dist/world3d-react/r3f-adapter.d.ts.map +0 -1
  323. package/dist/world3d-react/r3f-adapter.js +0 -634
  324. package/dist/world3d-react/world-context.d.ts +0 -182
  325. package/dist/world3d-react/world-context.d.ts.map +0 -1
  326. package/dist/world3d-react/world-context.js +0 -235
  327. package/src/adapter/setup-three-root-adapter.ts +0 -1032
  328. package/src/animation/clip-map.ts +0 -34
  329. package/src/asset-registry.ts +0 -89
  330. package/src/canvas-react/engine-bridge.ts +0 -59
  331. package/src/canvas-react/pixi-react-adapter.tsx +0 -356
  332. package/src/canvas-react/world-context.ts +0 -253
  333. package/src/dev/debug-draw.ts +0 -80
  334. package/src/render/auto-batcher.ts +0 -168
  335. package/src/render/lod.ts +0 -17
  336. package/src/runtime/types.ts +0 -328
  337. package/src/world3d-react/engine-bridge.ts +0 -86
  338. package/src/world3d-react/r3f-adapter.tsx +0 -717
  339. package/src/world3d-react/world-context.ts +0 -358
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `Countdown` — a restartable interval the CALLER steps, with the two
3
+ * behaviours under a frame hitch that decide whether a game's event count is
4
+ * right.
5
+ *
6
+ * A respawn delay, a weapon cooldown, a wave spawner, a grace window: each is
7
+ * "count down from N seconds, tell me, maybe go again, and let me restart or
8
+ * stop you at any point". `core/sim-clock.ts` schedules a one-shot at an
9
+ * ABSOLUTE sim time — the right shape for "3 seconds from now, once" and the
10
+ * wrong one for a live interval that game code re-arms, reads
11
+ * ({@link Countdown.remaining} is what a HUD ring draws) and cancels.
12
+ *
13
+ * That gap used to be papered over in `sim-clock.ts`'s own header, which said
14
+ * an `after` re-arming itself was "three lines the game owns". Those three
15
+ * lines are wrong in a way nobody notices: re-arming schedules the next fire
16
+ * from the moment the callback RAN, so every long frame permanently lengthens
17
+ * the interval, and a game that hitches ten times has silently slowed its
18
+ * spawner. That is the drift {@link Countdown} exists to not have.
19
+ *
20
+ * ## It owns no clock, and that is the requirement rather than a shortcut
21
+ *
22
+ * There is no `setTimeout`, no `setInterval`, no `requestAnimationFrame` and no
23
+ * subscription anywhere in this module. A countdown advances only inside
24
+ * {@link Countdown.advance}, from seconds the caller already has — the fixed
25
+ * loop's own dt. This repo runs a fixed-step sim with display-rate
26
+ * presentation and can step a game deterministically; a countdown on wall-clock
27
+ * `setTimeout` would be a second, undeterministic timeline that no care
28
+ * downstream could re-sync. A countdown nobody advances does not fire —
29
+ * visibly, rather than drifting.
30
+ *
31
+ * ## The frame-hitch contract, which is the whole reason this is a module
32
+ *
33
+ * Both rules are about keeping the EVENT COUNT correct when `dt` is larger than
34
+ * the period, which is exactly when a game is already in trouble:
35
+ *
36
+ * - **Overshoot carries into the next period.** A 0.5 s repeating countdown
37
+ * advanced by 0.6 fires once and has 0.4 s left, not 0.5. Resetting to the
38
+ * full period instead would make one slow frame lengthen every interval
39
+ * after it — the drift above, one layer down.
40
+ * - **A single large step fires MORE THAN ONCE.** A 0.1 s countdown advanced
41
+ * by 0.35 fires three times and has 0.05 s left. Dropping the extras would
42
+ * silently slow a repeating countdown down whenever the frame was long, so a
43
+ * wave that should have spawned 3 enemies spawns 1 and the difficulty curve
44
+ * quietly depends on frame rate.
45
+ *
46
+ * Two consequences a caller must know about, both deliberate: `onElapsed` can
47
+ * be invoked several times within one `advance` call, and it runs INLINE
48
+ * (synchronously, inside `advance`) rather than on a microtask — so the caller
49
+ * always knows exactly which point of its frame the callback ran at.
50
+ *
51
+ * A one-shot countdown STOPS ITSELF BEFORE invoking `onElapsed`, so a callback
52
+ * that calls {@link Countdown.start} re-arms rather than fighting the stop.
53
+ * A callback that calls {@link Countdown.stop} ends the multi-fire loop
54
+ * immediately, which is what makes "fire once then disarm from inside the
55
+ * callback" work.
56
+ *
57
+ * ## Resource ownership
58
+ *
59
+ * **Owner:** whoever calls {@link createCountdown}. A countdown holds two
60
+ * numbers, a boolean and the `onElapsed` reference; it registers nothing,
61
+ * subscribes to nothing, and this module has no module-level state — so there
62
+ * is deliberately no `dispose()` and dropping the countdown drops everything it
63
+ * had. **Sharers:** none; a countdown is not shareable, because whoever
64
+ * advances it decides its timeline. **Teardown:** what DOES need unregistering
65
+ * is the countdown's slot in whatever set the owner advances each frame, and
66
+ * that is the owner's — the same way `SceneTree.addTimer` hands back a remover.
67
+ */
68
+
69
+ /** A restartable countdown. Build one with {@link createCountdown}. */
70
+ export interface Countdown {
71
+ /** Seconds it counts from. Reflects the last {@link Countdown.start} that
72
+ * passed a duration. */
73
+ readonly duration: number;
74
+ /** Does it re-arm itself after firing, or stop? */
75
+ readonly repeats: boolean;
76
+ /** Seconds left, or 0 when it is not running. */
77
+ readonly remaining: number;
78
+ /** Is it counting? */
79
+ readonly running: boolean;
80
+ /**
81
+ * Start, or RESTART a running countdown from the top. Passing `duration`
82
+ * overwrites {@link Countdown.duration} for this and every later start.
83
+ *
84
+ * @throws RangeError if `duration` is not a finite positive number of
85
+ * seconds. A non-positive period cannot fire and reads as a hung game
86
+ * rather than a misconfigured one, so it refuses at the call site.
87
+ */
88
+ start(duration?: number): void;
89
+ /** Stop counting. Invokes nothing. */
90
+ stop(): void;
91
+ /**
92
+ * Advance by `dt` SECONDS, invoking `onElapsed` once per completed period —
93
+ * possibly several times in one call. See the module header's frame-hitch
94
+ * contract.
95
+ */
96
+ advance(dt: number): void;
97
+ }
98
+
99
+ /** What {@link createCountdown} needs. */
100
+ export interface CountdownOptions {
101
+ /** Seconds to count from. Default 1. */
102
+ readonly duration?: number;
103
+ /** Re-arm after firing instead of stopping. Default `false` (one-shot). */
104
+ readonly repeats?: boolean;
105
+ /** Begin counting immediately instead of waiting for a `start()`. Default
106
+ * `false`. */
107
+ readonly autostart?: boolean;
108
+ /** Invoked inline, once per completed period. */
109
+ readonly onElapsed: () => void;
110
+ }
111
+
112
+ function assertDuration(value: number): void {
113
+ if (!Number.isFinite(value) || value <= 0) {
114
+ throw new RangeError(
115
+ `createCountdown: duration must be a finite, positive number of seconds, got ${String(value)}`,
116
+ );
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Build a countdown.
122
+ *
123
+ * ```ts
124
+ * const wave = createCountdown({ duration: 8, repeats: true, autostart: true, onElapsed: spawnWave });
125
+ * useFrame((_, dt) => wave.advance(dt)); // the caller owns the step
126
+ * ```
127
+ */
128
+ export function createCountdown(options: CountdownOptions): Countdown {
129
+ const onElapsed = options.onElapsed;
130
+ const repeats = options.repeats === true;
131
+ let duration = options.duration ?? 1;
132
+ let remaining = 0;
133
+ let running = false;
134
+
135
+ assertDuration(duration);
136
+
137
+ const countdown: Countdown = {
138
+ get duration(): number {
139
+ return duration;
140
+ },
141
+ get repeats(): boolean {
142
+ return repeats;
143
+ },
144
+ get remaining(): number {
145
+ return running ? remaining : 0;
146
+ },
147
+ get running(): boolean {
148
+ return running;
149
+ },
150
+
151
+ start(newDuration): void {
152
+ if (newDuration !== undefined) {
153
+ assertDuration(newDuration);
154
+ duration = newDuration;
155
+ }
156
+ remaining = duration;
157
+ running = true;
158
+ },
159
+
160
+ stop(): void {
161
+ running = false;
162
+ remaining = 0;
163
+ },
164
+
165
+ advance(dt): void {
166
+ if (!running) return;
167
+ remaining -= dt;
168
+ // The loop is the multi-fire rule; `running` in the condition is what
169
+ // lets a callback's own stop() end it. Module header, frame-hitch.
170
+ while (running && remaining <= 0) {
171
+ if (!repeats) {
172
+ // Disarm BEFORE invoking, so a callback that calls start() re-arms
173
+ // instead of being immediately stopped by this branch.
174
+ running = false;
175
+ remaining = 0;
176
+ onElapsed();
177
+ return;
178
+ }
179
+ // Carry the overshoot rather than resetting to the full period.
180
+ remaining += duration;
181
+ onElapsed();
182
+ }
183
+ },
184
+ };
185
+
186
+ if (options.autostart === true) countdown.start();
187
+ return countdown;
188
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `DeferredCommands` — work the caller runs at ONE point it chooses, plus the
3
+ * subject-keyed disposal that has to land after it.
4
+ *
5
+ * The hazard is not exotic and it is not a foreign engine's: it is what a
6
+ * Rapier world does to anything that mutates it from inside a callback. Disable
7
+ * a collider while the solver is iterating, or free the node a collision
8
+ * handler was dispatched ON, and the failure is either a corrupt step or
9
+ * Rapier's own "recursive use of an object ... unsafe aliasing in rust". The
10
+ * remedy is always the same shape — write down WHAT to do, do it once the frame
11
+ * is out of anybody's way — and every caller that needs it was previously
12
+ * hand-rolling an array.
13
+ *
14
+ * ## The one point is the CALLER's, deliberately
15
+ *
16
+ * There is no `queueMicrotask`, no `setTimeout(0)`, no `requestAnimationFrame`
17
+ * and no subscription anywhere in this module. Whoever builds the queue decides
18
+ * where {@link DeferredCommands.drain} runs — after physics, at the tail of a
19
+ * world's update, wherever the frame's quiet moment actually is. A queue nobody
20
+ * drains simply does not run its work, which is a visible failure rather than a
21
+ * silent reordering onto a timeline the game does not control.
22
+ *
23
+ * ## This is NOT `SimClock`, and `after(0)` is not a substitute
24
+ *
25
+ * `core/sim-clock.ts` schedules on SIM TIME: `after(0, fn)` scheduled mid-frame
26
+ * does land in that frame's tail flush (`runtime/game.ts` calls `flush(simT)`
27
+ * after every phase of every world), so the resemblance is real and worth
28
+ * naming. Three things make it the wrong tool for deferral:
29
+ *
30
+ * - **A paused world never flushes.** `flush` is inside `runFrameImpl`'s
31
+ * `advanced` guard, so a game that pauses between the defer and the drain
32
+ * holds the work forever. End-of-frame deferral has to run on the frame,
33
+ * not on the clock.
34
+ * - **No subject identity.** Deferring destruction needs "this object, once",
35
+ * and needs the two questions that fall out of it —
36
+ * {@link DeferredCommands.isDisposePending} (a handler must be able to skip
37
+ * a subject that is already going away) and
38
+ * {@link DeferredCommands.wasDisposed} (an owner sweeping its own indexes
39
+ * after the drain). A time-ordered timer set answers neither.
40
+ * - **No same-drain re-entry.** A sim timer scheduled during a flush is
41
+ * deliberately never due in that flush; a deferred command that defers one
42
+ * more command must still land this frame, or the second write arrives a
43
+ * frame after the first and the two are no longer atomic.
44
+ *
45
+ * ## Ordering, and why disposal goes last
46
+ *
47
+ * {@link DeferredCommands.drain} runs every deferred command first, re-draining
48
+ * until the queue is empty, and only then disposes every pending subject. That
49
+ * order is not cosmetic: a deferred write to a subject that is ALSO queued for
50
+ * disposal must still happen — it is what the caller asked for — and disposing
51
+ * first would make that write operate on a freed handle. A subject queued twice
52
+ * is disposed once, and the FIRST thunk wins (every caller for one subject
53
+ * passes the same one).
54
+ *
55
+ * The re-drain is bounded by {@link MAX_DRAIN_PASSES}. A command that defers
56
+ * itself forever is a bug in the caller, and a bounded loop names it in a
57
+ * millisecond instead of hanging the frame.
58
+ *
59
+ * ## Why disposal carries its own thunk
60
+ *
61
+ * Disposal is the one thing here that is not surface-neutral. A `PIXI.Container`
62
+ * ends with `removeFromParent()` + `destroy({ children })`; a `THREE.Object3D`
63
+ * ends by detaching and disposing the geometries and materials underneath it;
64
+ * a physics-backed entity has a Rapier body to pull first. This queue does none
65
+ * of that — it takes the thunk from whoever owns the surface and runs it at the
66
+ * drain. So the ORDERING lives here once and the surface knowledge stays where
67
+ * the surface is, which is what lets one queue serve a 2D world and a 3D one.
68
+ *
69
+ * ## Resource ownership
70
+ *
71
+ * **Owner:** whoever calls {@link createDeferredCommands}, and that owner is
72
+ * also the only caller of `drain()` — a queue is per-DRAINER, not per-process,
73
+ * so two mounted games are two queues and neither can see the other's work.
74
+ * This module registers nothing anywhere: there is no game-scoped slot (unlike
75
+ * `sim-clock.ts`, which needs one because every world on a Game shares one
76
+ * clock) and no module-level state at all.
77
+ * **Sharers:** the queued subjects, by reference, and only until the drain that
78
+ * disposes them.
79
+ * **Teardown:** dropping the queue. There is deliberately no `dispose()` —
80
+ * nothing here holds a handle, a listener or a timer, and a queue dropped with
81
+ * work still in it has simply not run that work, which is what discarding a
82
+ * frame means.
83
+ */
84
+
85
+ /**
86
+ * How many times {@link DeferredCommands.drain} re-drains a queue that keeps
87
+ * refilling itself before declaring the caller's own logic non-terminating.
88
+ *
89
+ * A bound that correct code cannot reach (a deferred command that defers one
90
+ * more, ten deep) while catching the infinite case immediately.
91
+ */
92
+ export const MAX_DRAIN_PASSES = 16;
93
+
94
+ /** The queue. Build one per drainer with {@link createDeferredCommands}. */
95
+ export interface DeferredCommands {
96
+ /**
97
+ * Run `command` at the next {@link DeferredCommands.drain}.
98
+ *
99
+ * ```ts
100
+ * // inside a collision handler, where the solver still owns the world:
101
+ * frame.defer(() => collider.setSensor(true));
102
+ * ```
103
+ */
104
+ defer(command: () => void): void;
105
+ /**
106
+ * Dispose `subject` at the next drain, after every deferred command has run.
107
+ * Queuing the same subject twice disposes it once — the first `dispose`
108
+ * thunk wins.
109
+ */
110
+ deferDispose(subject: object, dispose: () => void): void;
111
+ /**
112
+ * Is `subject` queued for disposal and not yet disposed? Read by a handler
113
+ * that must skip a subject already on its way out.
114
+ */
115
+ isDisposePending(subject: object): boolean;
116
+ /**
117
+ * Has THIS queue already disposed `subject`? Read after a drain by an owner
118
+ * sweeping its own indexes (a group, a registry, a spatial bucket) — "am I
119
+ * destroyed" is a surface-specific question that a plain `Object3D` has no
120
+ * answer to, so the queue is asked instead.
121
+ */
122
+ wasDisposed(subject: object): boolean;
123
+ /** Run every deferred command (re-draining), then dispose every pending
124
+ * subject. Called once per frame, by the owner, at the point it chose. */
125
+ drain(): void;
126
+ }
127
+
128
+ /** Build one queue. See the module header for who owns it and who drains it. */
129
+ export function createDeferredCommands(): DeferredCommands {
130
+ let commands: (() => void)[] = [];
131
+ const doomed = new Map<object, () => void>();
132
+ const disposed = new WeakSet<object>();
133
+
134
+ return {
135
+ defer(command): void {
136
+ commands.push(command);
137
+ },
138
+
139
+ deferDispose(subject, dispose): void {
140
+ if (!doomed.has(subject)) doomed.set(subject, dispose);
141
+ },
142
+
143
+ isDisposePending(subject): boolean {
144
+ return doomed.has(subject);
145
+ },
146
+
147
+ wasDisposed(subject): boolean {
148
+ return disposed.has(subject);
149
+ },
150
+
151
+ drain(): void {
152
+ let passes = 0;
153
+ while (commands.length > 0) {
154
+ if (++passes > MAX_DRAIN_PASSES) {
155
+ throw new Error(
156
+ `DeferredCommands.drain: still queueing more work after ${MAX_DRAIN_PASSES} passes. ` +
157
+ 'Something passed to defer() defers itself, so this drain would never terminate.',
158
+ );
159
+ }
160
+ const batch = commands;
161
+ commands = [];
162
+ for (const command of batch) command();
163
+ }
164
+
165
+ // Subjects last, and only after every deferred command has run — see the
166
+ // module header's ordering section.
167
+ for (const [subject, dispose] of doomed) {
168
+ dispose();
169
+ disposed.add(subject);
170
+ }
171
+ doomed.clear();
172
+ },
173
+ };
174
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Frame pacing — the accumulator/alpha arithmetic of the fixed-timestep loop,
3
- * extracted PURE (WO-P13, `docs/WORK.md` §2 P13).
3
+ * extracted PURE.
4
4
  *
5
5
  * `core/game-loop.ts` owns the browser side (rAF, `visibilitychange`,
6
6
  * `performance.now`); this module owns the arithmetic, so the interesting
@@ -108,7 +108,7 @@ export function paceFrame(
108
108
  const maxAccumulator = fixedDt * maxSubSteps;
109
109
 
110
110
  // Clamp large frame gaps (a slow frame, a debugger pause) BEFORE scaling by
111
- // timeScale — unchanged from the pre-WO-P13 loop.
111
+ // timeScale.
112
112
  const displayDt = Math.min(rawDt, maxAccumulator) * timeScale;
113
113
  let next = accumulator + displayDt;
114
114
  // Spiral-of-death guard: bound the accumulator regardless of timeScale or
@@ -9,9 +9,7 @@ const LOOP_STARVATION_MS = 1_000;
9
9
 
10
10
  /**
11
11
  * Fixed-timestep game loop with accumulator pattern — SIM at a fixed rate,
12
- * PRESENTATION at the display's rate (WO-P13, `docs/WORK.md` §2 P13, which
13
- * flipped the former D1 "render is fixed-rate too" decision this comment used
14
- * to state).
12
+ * PRESENTATION at the display's rate.
15
13
  *
16
14
  * Per real (rAF) frame:
17
15
  *
@@ -42,7 +42,7 @@ export const GAMEPLAY_STREAM = 'gameplay';
42
42
  export const DEFAULT_SEEDED_RANDOM_SEED = 0x9e3779b9;
43
43
 
44
44
  /**
45
- * The `ctx.random` surface (`GameContext.random`, `runtime/types.ts`).
45
+ * A game-scoped random surface.
46
46
  * Callable (draws from the `'gameplay'` stream), plus:
47
47
  * - `stream(name)` — an independent named generator, `[0, 1)` floats, same
48
48
  * call signature as `Math.random`/the bare `SeededRandom` call itself.
@@ -93,10 +93,9 @@
93
93
  * controller cancels everything a `setup()` scheduled:
94
94
  *
95
95
  * ```ts
96
- * export async function setup(ctx: GameContext): Promise<GameCleanup> {
97
- * const ac = new AbortController();
98
- * ctx.clock.after(3, () => spawnWave(), { signal: ac.signal });
99
- * return { dispose: () => ac.abort() }; // cancels timers AND listeners
96
+ * const ac = new AbortController();
97
+ * clock.after(3, () => spawnWave(), { signal: ac.signal });
98
+ * // on dispose: ac.abort() cancels timers AND listeners
100
99
  * }
101
100
  * ```
102
101
  *
@@ -105,8 +104,20 @@
105
104
  *
106
105
  * ## Deliberately absent
107
106
  *
108
- * - **No `every`/`repeat`/interval.** Nothing needs one, and `after`
109
- * re-arming itself is three lines the game owns.
107
+ * - **No `every`/`repeat`/interval that is `core/countdown-timer.ts`.**
108
+ * This header used to say an `after` re-arming itself was "three lines the
109
+ * game owns"; it is three lines that DRIFT, because re-arming schedules the
110
+ * next fire from the moment the callback ran, so every long frame
111
+ * permanently lengthens the interval. A repeating interval needs
112
+ * overshoot carry and multi-fire on a big step to keep its event count
113
+ * right, and it is stepped by its owner rather than by the clock. Both
114
+ * belong to a countdown object, not to a sim-time one-shot.
115
+ * - **No end-of-frame command queue — that is
116
+ * `core/deferred-commands.ts`.** `after(0, fn)` looks like one (the flush
117
+ * below does run at the tail of the frame that scheduled it) and is not:
118
+ * it holds the work forever across a pause, it has no subject identity, and
119
+ * a command it schedules during a flush waits a whole extra frame. See that
120
+ * module's header for all three.
110
121
  * - **No fiber kernel, no coroutine emulation, no `task.spawn`.** Calling an
111
122
  * `async function` *is* `task.spawn` — the language already has it.
112
123
  * - **No wall clock.** This module reads no system timer and creates no
@@ -158,7 +169,7 @@ export interface SimClock {
158
169
  /**
159
170
  * The runtime-facing half — NOT for game code. Split off the public
160
171
  * {@link SimClock} the same way `runtime/game.ts` splits `GameInternal` off
161
- * `Game`: `GameContext.clock` is typed as `SimClock`, so game code cannot
172
+ * `Game`: game code only ever sees the public `SimClock`, so it cannot
162
173
  * reach `flush`/`dispose`.
163
174
  */
164
175
  export interface SimClockInternal extends SimClock {
@@ -382,7 +393,7 @@ export function registerSimClock(owner: object, clock: SimClockInternal): void {
382
393
  * Returns the INTERNAL view because this registry is engine-only — but a
383
394
  * caller that RESOLVES a clock here is not its owner and must not call
384
395
  * `dispose()` on it (module header, ownership). Game code never reaches it at
385
- * all: `GameContext.clock` is typed as the public {@link SimClock}, which has
396
+ * all: it only ever sees the public {@link SimClock}, which has
386
397
  * neither `flush` nor `dispose`, the same way `Game` hides `GameInternal`. */
387
398
  export function getSimClock(owner: object): SimClockInternal | null {
388
399
  return clockByOwner.get(owner) ?? null;
package/src/core/types.ts CHANGED
@@ -72,9 +72,7 @@ export interface GameLoopConfig {
72
72
  */
73
73
  update: (dt: number) => void;
74
74
  /**
75
- * Called EXACTLY ONCE per real (rAF) frame — the PRESENTATION half
76
- * (WO-P13, `docs/WORK.md` §2 P13; this is the callback whose absence the
77
- * former D1 decision recorded).
75
+ * Called EXACTLY ONCE per real (rAF) frame — the PRESENTATION half.
78
76
  *
79
77
  * - `alpha` — `accumulator / fixedDt` after this frame's substeps were
80
78
  * consumed, in `[0, 1]`: how far presentation sits past the last
package/src/defaults.ts CHANGED
@@ -1,20 +1,12 @@
1
1
  /**
2
2
  * Single source of truth for default values the engine's factories read.
3
3
  *
4
- * WO-8 deleted SEVEN groups `transform`, `entity`, `audio`, `shadow`,
5
- * `animation`, `fog`, `environment` because every one of their readers was a
6
- * `.vscn.json` consumer (`entity-factory.ts`, `scene-loader.ts`,
7
- * `scene-sync.ts`, `scene-serializer.ts`, the per-section inspectors) and
8
- * `defaults-source-scan.test.ts` reported them with ZERO `DEFAULTS.<group>`
9
- * readers anywhere in engine/editor src or tests. That guard offers two remedies
10
- * — wire the group to the factory that reads it, or delete it if truly unused —
11
- * and there is no surviving factory to wire these to: `transform` restated
12
- * THREE's own Object3D constructor values, `entity.locked`/ `pivot` were
13
- * descriptor-only fields, and `fog`/`environment.envMapIntensity`/ `shadow` were
14
- * the `.vscn` `environment` block that `render-env.ts` explicitly did NOT carry
15
- * forward. Keeping them would have been exactly the dead documentation T1.13
16
- * created this guard to prevent.
17
- *
4
+ * A group belongs here only while a FACTORY reads it. A default with no
5
+ * reader is dead documentation: it looks authoritative, nothing consults it,
6
+ * and it drifts from the value the code actually uses. Restating a library's
7
+ * own constructor values (THREE's `Object3D` transform, say) is the same
8
+ * mistake in a different shape — the library is already the source of truth.
9
+ * So when a factory goes, its group goes with it.
18
10
  */
19
11
  export const DEFAULTS = {
20
12
  material: {
@@ -25,13 +25,13 @@
25
25
  * game registry.
26
26
  *
27
27
  * ── THE DOOR ────────────────────────────────────────────────────────────────
28
- * This module is IMPLEMENTATION ONLY. It publishes nothing itself: the dev-tools
29
- * capability's own registry adopts this list (`installDevToolsInstruments`) and
30
- * publishes each instrument through the SAME door every declaration in that
31
- * capability uses — one provider per reading (`instrument.timescale`), one
32
- * command per action (`instrument.timescale.set`), plus the `dev-tools` index
33
- * provider. That is deliberate and load-bearing: the registry IS the product,
34
- * so every reader of an instrument reaches the SAME registration.
28
+ * The HOST publishes these itself ({@link publishDevInstruments}, called by
29
+ * the mount install paths): one provider per reading
30
+ * (`instrument.timescale`), one command per action
31
+ * (`instrument.timescale.set`), plus the `instruments` index provider. The
32
+ * registry IS the product, so every reader of an instrument reaches the SAME
33
+ * registration and a game writes zero lines and mounts nothing to have
34
+ * them.
35
35
  *
36
36
  * ── DEGRADING ───────────────────────────────────────────────────────────────
37
37
  * An instrument whose subject this game does not have (no physics adapter ⇒ no
@@ -42,16 +42,17 @@
42
42
  *
43
43
  * ── RESOURCE OWNERSHIP, STATED ONCE ─────────────────────────────────────────
44
44
  * {@link createDevInstruments} owns everything it allocates: the frame-time
45
- * ring buffer and its ONE `game.onRenderStep` subscription. SHARER: the
46
- * dev-tools capability's `DevToolsBridge`, which creates one set per mounted
47
- * game. TEARDOWN: the returned {@link DevInstrumentSet.dispose} — the ONE path
48
- * that ends it. Nothing else here holds a resource; the time-scale, pause and
45
+ * ring buffer and its ONE `game.onRenderStep` subscription. SHARER: the host
46
+ * install path ({@link publishDevInstruments}), which creates one set per
47
+ * mounted game. TEARDOWN: the returned {@link DevInstrumentSet.dispose} — the
48
+ * ONE path that ends it. Nothing else here holds a resource; the time-scale, pause and
49
49
  * collider instruments only drive state that `Game` already owns.
50
50
  */
51
51
 
52
52
  import { z } from 'zod';
53
53
  import { nodeKeyedPhysics } from '../adapter/system-adapter';
54
54
  import { clampTimeScale, TIME_SCALE_RANGE } from '../core/frame-pacing';
55
+ import { getDebugRegistry } from '../runtime/debug-registry';
55
56
  import type { Game } from '../runtime/game';
56
57
 
57
58
  /** What shape an instrument's reading is, and what drives it. Data, not
@@ -304,3 +305,99 @@ export function createDevInstruments(game: Game): DevInstrumentSet {
304
305
  function now(): number {
305
306
  return typeof performance === 'undefined' ? Date.now() : performance.now();
306
307
  }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Host publication — the instruments' OWN door
311
+ // ---------------------------------------------------------------------------
312
+ //
313
+ // The HOST publishes these (the editor's mount install path), so every game
314
+ // gets them for literally zero lines — no capability, no bridge component, no
315
+ // mount. One provider per reading (`instrument.timescale`), one command per
316
+ // action (`instrument.timescale.set`), plus ONE index provider
317
+ // (`instruments`) carrying every descriptor with this instant's values, so an
318
+ // outside reader gets the whole panel in one round trip. Naming is unchanged
319
+ // from when the dev-tools capability published them, so every existing reader
320
+ // keeps working; only the publisher moved realms.
321
+
322
+ /** The index provider's name. */
323
+ export const INSTRUMENTS_PROVIDER = 'instruments';
324
+
325
+ /** The registry scope the host publishes under. Not a mounted world —
326
+ * instruments are game-scoped host furniture, like the hooks' `__react__`. */
327
+ const INSTRUMENTS_WORLD_ID = '__instruments__';
328
+
329
+ /** One instrument in the index: descriptor plus this instant's value. A
330
+ * throwing read is reported as itself, never swallowed. */
331
+ export interface PublishedInstrumentReading {
332
+ readonly name: string;
333
+ readonly id: string;
334
+ readonly label: string;
335
+ readonly hint: string;
336
+ readonly unit?: string;
337
+ readonly control: DevInstrumentControl;
338
+ readonly actions: readonly {
339
+ readonly name: string;
340
+ readonly id: string;
341
+ readonly label: string;
342
+ readonly hint: string;
343
+ }[];
344
+ readonly value: unknown;
345
+ readonly error?: string;
346
+ }
347
+
348
+ function readingOf(instrument: DevInstrument): PublishedInstrumentReading {
349
+ const base = {
350
+ name: `instrument.${instrument.id}`,
351
+ id: instrument.id,
352
+ label: instrument.label,
353
+ hint: instrument.hint,
354
+ ...(instrument.unit === undefined ? {} : { unit: instrument.unit }),
355
+ control: instrument.control,
356
+ actions: instrument.actions.map((action) => ({
357
+ name: `instrument.${instrument.id}.${action.id}`,
358
+ id: action.id,
359
+ label: action.label,
360
+ hint: action.hint,
361
+ })),
362
+ };
363
+ try {
364
+ return { ...base, value: instrument.read() };
365
+ } catch (error) {
366
+ return { ...base, value: null, error: error instanceof Error ? error.message : String(error) };
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Create this game's instrument set and register every door on its debug
372
+ * registry. Called once per mounted Game by the host install path; the
373
+ * returned disposer ends the set's one resource (the frame-time
374
+ * subscription) — the registrations die with the Game itself.
375
+ */
376
+ export function publishDevInstruments(game: Game): () => void {
377
+ const registry = getDebugRegistry(game);
378
+ if (!registry) {
379
+ throw new Error('Cannot publish dev instruments: mounted Game has no debug registry.');
380
+ }
381
+ const set = createDevInstruments(game);
382
+ const scope = registry.forRoot(INSTRUMENTS_WORLD_ID);
383
+ for (const instrument of set.instruments) {
384
+ scope.registerStateProvider(`instrument.${instrument.id}`, () => instrument.read(), {
385
+ tier: 'assisted',
386
+ });
387
+ for (const action of instrument.actions) {
388
+ scope.registerCommand(
389
+ `instrument.${instrument.id}.${action.id}`,
390
+ {
391
+ description: `${instrument.label}: ${action.label} — ${action.hint}`,
392
+ locus: 'client',
393
+ args: action.args,
394
+ },
395
+ (...values: unknown[]) => action.run(values),
396
+ );
397
+ }
398
+ }
399
+ scope.registerStateProvider(INSTRUMENTS_PROVIDER, () => set.instruments.map(readingOf), {
400
+ tier: 'assisted',
401
+ });
402
+ return () => set.dispose();
403
+ }
@@ -8,10 +8,7 @@
8
8
  *
9
9
  * Engine-owned and first-party, seeded by BOTH three-root mount paths — a game
10
10
  * writes nothing to get these, and there is no capability to install:
11
- * - `adapter/setup-three-root-adapter.ts` (the classic imperative
12
- * `setup(ctx)` world), at the same spot it seeds
13
- * `systemAdapters.renderDebug`;
14
- * - `world3d-react/r3f-adapter.tsx` (a TSX/R3F world — the shape EVERY
11
+ * - `world3d-react/r3f-root-factory.tsx` (a TSX/R3F world — the shape EVERY
15
12
  * scaffolded project's three root has), right after fiber's first commit
16
13
  * resolves the scene.
17
14
  * Both is the whole point and not redundancy: they are two independent mounts
@@ -3,11 +3,10 @@
3
3
  * the WebGL2 frame capture (`webgl-frame-capture.ts`), a live three.js scene,
4
4
  * and the render adapter's per-frame render pass.
5
5
  *
6
- * Split OUT of `setup-three-root-adapter.ts` so the capture/attribution/restore
6
+ * Split OUT of `world3d-react/r3f-root-factory.tsx` so the capture/attribution/restore
7
7
  * flow is unit-testable in a headless Node test with a mock GL context and a
8
8
  * plain scene — a real non-headless GPU mount (EffectComposer + WebGLRenderer)
9
- * cannot run under vitest here, the same reason the adapter suite drives
10
- * `setup(ctx)` only through hotReload on a data scene. `mount()` owns the
9
+ * cannot run under vitest here. `mount()` owns the
11
10
  * DECISION to construct this (only under a real WebGL2 context, never
12
11
  * headless — see `frameCaptureContextFor`); this module owns the BEHAVIOR.
13
12
  *