@vgai/editor 0.2.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 (519) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +35 -0
  3. package/dist/a/b/c/test-nested.json +1 -0
  4. package/dist/assets/CCDIKSolver-Dz-ysxZF.js +1 -0
  5. package/dist/assets/SelectionBox-BXsv_6aH.js +1 -0
  6. package/dist/assets/browser-project-scripts-DOru4L1x.js +2 -0
  7. package/dist/assets/browserAll-CEkWYlFk.js +1 -0
  8. package/dist/assets/collision-world-DniMB0qq.glb +0 -0
  9. package/dist/assets/create-runtime-DYIWr3va.js +1 -0
  10. package/dist/assets/data-asset-Bb9sIM7i.js +5 -0
  11. package/dist/assets/esbuild-BHljloGq.wasm +0 -0
  12. package/dist/assets/fake-three-BMTQ9rl_.js +1 -0
  13. package/dist/assets/game-8SDZPPgH.js +1 -0
  14. package/dist/assets/game-BI111o5f.js +10 -0
  15. package/dist/assets/game-CevorxyV.js +1 -0
  16. package/dist/assets/game-Di1H0gFj.js +1 -0
  17. package/dist/assets/game-Di7XlywD.js +1 -0
  18. package/dist/assets/game-DjnCaw8R.js +1 -0
  19. package/dist/assets/game-Q9A0aXSV.js +1 -0
  20. package/dist/assets/game-pt_1lQLv.js +1 -0
  21. package/dist/assets/game-source-CvidN7AB.js +1 -0
  22. package/dist/assets/game-xsOho_wg.js +83 -0
  23. package/dist/assets/index-Bp5cecD6.js +12 -0
  24. package/dist/assets/index-CPVV6pjw.js +1 -0
  25. package/dist/assets/index-CpOnYyeD.js +213 -0
  26. package/dist/assets/index-Cs8xwv3e.js +1 -0
  27. package/dist/assets/index-DL7RcQ0n.js +27 -0
  28. package/dist/assets/index-Dr8H_6Rl.js +1 -0
  29. package/dist/assets/index-_bCaipUk.js +13650 -0
  30. package/dist/assets/ingest-siblings-Dz8eEqrV.js +1 -0
  31. package/dist/assets/mount-manifest-Bhk0ohsl.js +1 -0
  32. package/dist/assets/recast-navigation.wasm-compat-CvP4UGK6.js +1 -0
  33. package/dist/assets/scene-query-BivAHhJu.js +1 -0
  34. package/dist/assets/stats.module--VATS4Kh.js +1 -0
  35. package/dist/assets/webworkerAll-BLrH0F6F.js +1 -0
  36. package/dist/assets/xstate-animation-binding-1NGLmw6y.js +1 -0
  37. package/dist/assets/xstate.esm-BgUD33nl.js +3 -0
  38. package/dist/index.html +37 -0
  39. package/dist/ingest/racing-game/UPSTREAM.md +68 -0
  40. package/dist/ingest/racing-game/a/Inter-Black.woff +0 -0
  41. package/dist/ingest/racing-game/a/Inter-Black.woff2 +0 -0
  42. package/dist/ingest/racing-game/a/Inter-BlackItalic.woff +0 -0
  43. package/dist/ingest/racing-game/a/Inter-BlackItalic.woff2 +0 -0
  44. package/dist/ingest/racing-game/a/Inter-Bold.woff +0 -0
  45. package/dist/ingest/racing-game/a/Inter-Bold.woff2 +0 -0
  46. package/dist/ingest/racing-game/a/Inter-BoldItalic.woff +0 -0
  47. package/dist/ingest/racing-game/a/Inter-BoldItalic.woff2 +0 -0
  48. package/dist/ingest/racing-game/a/Inter-ExtraBold.woff +0 -0
  49. package/dist/ingest/racing-game/a/Inter-ExtraBold.woff2 +0 -0
  50. package/dist/ingest/racing-game/a/Inter-ExtraBoldItalic.woff +0 -0
  51. package/dist/ingest/racing-game/a/Inter-ExtraBoldItalic.woff2 +0 -0
  52. package/dist/ingest/racing-game/a/Inter-ExtraLight.woff +0 -0
  53. package/dist/ingest/racing-game/a/Inter-ExtraLight.woff2 +0 -0
  54. package/dist/ingest/racing-game/a/Inter-ExtraLightItalic.woff +0 -0
  55. package/dist/ingest/racing-game/a/Inter-ExtraLightItalic.woff2 +0 -0
  56. package/dist/ingest/racing-game/a/Inter-Italic.woff +0 -0
  57. package/dist/ingest/racing-game/a/Inter-Italic.woff2 +0 -0
  58. package/dist/ingest/racing-game/a/Inter-Light.woff +0 -0
  59. package/dist/ingest/racing-game/a/Inter-Light.woff2 +0 -0
  60. package/dist/ingest/racing-game/a/Inter-LightItalic.woff +0 -0
  61. package/dist/ingest/racing-game/a/Inter-LightItalic.woff2 +0 -0
  62. package/dist/ingest/racing-game/a/Inter-Medium.woff +0 -0
  63. package/dist/ingest/racing-game/a/Inter-Medium.woff2 +0 -0
  64. package/dist/ingest/racing-game/a/Inter-MediumItalic.woff +0 -0
  65. package/dist/ingest/racing-game/a/Inter-MediumItalic.woff2 +0 -0
  66. package/dist/ingest/racing-game/a/Inter-Regular.woff +0 -0
  67. package/dist/ingest/racing-game/a/Inter-Regular.woff2 +0 -0
  68. package/dist/ingest/racing-game/a/Inter-SemiBold.woff +0 -0
  69. package/dist/ingest/racing-game/a/Inter-SemiBold.woff2 +0 -0
  70. package/dist/ingest/racing-game/a/Inter-SemiBoldItalic.woff +0 -0
  71. package/dist/ingest/racing-game/a/Inter-SemiBoldItalic.woff2 +0 -0
  72. package/dist/ingest/racing-game/a/Inter-Thin.woff +0 -0
  73. package/dist/ingest/racing-game/a/Inter-Thin.woff2 +0 -0
  74. package/dist/ingest/racing-game/a/Inter-ThinItalic.woff +0 -0
  75. package/dist/ingest/racing-game/a/Inter-ThinItalic.woff2 +0 -0
  76. package/dist/ingest/racing-game/a/Inter-italic.var.woff2 +0 -0
  77. package/dist/ingest/racing-game/a/Inter-roman.var.woff2 +0 -0
  78. package/dist/ingest/racing-game/a/Inter.var.woff2 +0 -0
  79. package/dist/ingest/racing-game/a/index.css +1 -0
  80. package/dist/ingest/racing-game/images/bronze.png +0 -0
  81. package/dist/ingest/racing-game/images/gold.png +0 -0
  82. package/dist/ingest/racing-game/images/silver.png +0 -0
  83. package/dist/ingest/racing-game/index.html +15 -0
  84. package/dist/ingest/racing-game/models/chassis-draco.glb +0 -0
  85. package/dist/ingest/racing-game/models/track-draco.glb +0 -0
  86. package/dist/ingest/racing-game/models/wheel-draco.glb +0 -0
  87. package/dist/ingest/racing-game/rg-entry.js +1 -0
  88. package/dist/ingest/racing-game/sounds/accelerate.mp3 +0 -0
  89. package/dist/ingest/racing-game/sounds/boost.mp3 +0 -0
  90. package/dist/ingest/racing-game/sounds/crash.mp3 +0 -0
  91. package/dist/ingest/racing-game/sounds/engine.mp3 +0 -0
  92. package/dist/ingest/racing-game/sounds/honk.mp3 +0 -0
  93. package/dist/ingest/racing-game/sounds/tire-brake.mp3 +0 -0
  94. package/dist/ingest/racing-game/sounds/train.mp3 +0 -0
  95. package/dist/ingest/racing-game/sounds/water.mp3 +0 -0
  96. package/dist/ingest/racing-game/textures/cursor.svg +3 -0
  97. package/dist/ingest/racing-game/textures/dikhololo_night_1k.hdr +0 -0
  98. package/dist/ingest/racing-game/textures/heightmap_1024.png +0 -0
  99. package/dist/ingest/racing-game/textures/mask.svg +11 -0
  100. package/dist/ingest/racing-game/textures/no_sound.png +0 -0
  101. package/dist/ingest/racing-game/three-r139.module.js +50617 -0
  102. package/dist/ingest/racing-game/vendor.js +179 -0
  103. package/dist/ingest/racing-game/vgai.game.json +20 -0
  104. package/dist/ingest/simcity/UPSTREAM.md +65 -0
  105. package/dist/ingest/simcity/favicon.ico +0 -0
  106. package/dist/ingest/simcity/fonts/Bebas-Regular.otf +0 -0
  107. package/dist/ingest/simcity/fonts/Digitalt.otf +0 -0
  108. package/dist/ingest/simcity/fonts/Stanberry.ttf +0 -0
  109. package/dist/ingest/simcity/icons/bulldozer-color.png +0 -0
  110. package/dist/ingest/simcity/icons/bulldozer.png +0 -0
  111. package/dist/ingest/simcity/icons/calendar.png +0 -0
  112. package/dist/ingest/simcity/icons/factory-color.png +0 -0
  113. package/dist/ingest/simcity/icons/factory.png +0 -0
  114. package/dist/ingest/simcity/icons/house-color.png +0 -0
  115. package/dist/ingest/simcity/icons/house.png +0 -0
  116. package/dist/ingest/simcity/icons/job.png +0 -0
  117. package/dist/ingest/simcity/icons/office.png +0 -0
  118. package/dist/ingest/simcity/icons/pause-color.png +0 -0
  119. package/dist/ingest/simcity/icons/pause.png +0 -0
  120. package/dist/ingest/simcity/icons/person.png +0 -0
  121. package/dist/ingest/simcity/icons/play-color.png +0 -0
  122. package/dist/ingest/simcity/icons/play.png +0 -0
  123. package/dist/ingest/simcity/icons/power-color.png +0 -0
  124. package/dist/ingest/simcity/icons/power-line-color.png +0 -0
  125. package/dist/ingest/simcity/icons/power.png +0 -0
  126. package/dist/ingest/simcity/icons/road-color.png +0 -0
  127. package/dist/ingest/simcity/icons/road.png +0 -0
  128. package/dist/ingest/simcity/icons/select-color.png +0 -0
  129. package/dist/ingest/simcity/icons/select.png +0 -0
  130. package/dist/ingest/simcity/icons/store-color.png +0 -0
  131. package/dist/ingest/simcity/index.html +94 -0
  132. package/dist/ingest/simcity/index.js +58 -0
  133. package/dist/ingest/simcity/main.css +233 -0
  134. package/dist/ingest/simcity/models/armored-truck.glb +0 -0
  135. package/dist/ingest/simcity/models/building-block-4floor-corner.glb +0 -0
  136. package/dist/ingest/simcity/models/building-block-4floor-short.glb +0 -0
  137. package/dist/ingest/simcity/models/building-block-5floor.glb +0 -0
  138. package/dist/ingest/simcity/models/building-burger-joint.glb +0 -0
  139. package/dist/ingest/simcity/models/building-cafe.glb +0 -0
  140. package/dist/ingest/simcity/models/building-casino.glb +0 -0
  141. package/dist/ingest/simcity/models/building-cinema.glb +0 -0
  142. package/dist/ingest/simcity/models/building-house-block-big.glb +0 -0
  143. package/dist/ingest/simcity/models/building-house-family-large.glb +0 -0
  144. package/dist/ingest/simcity/models/building-house-family-small.glb +0 -0
  145. package/dist/ingest/simcity/models/building-office-balcony.glb +0 -0
  146. package/dist/ingest/simcity/models/building-office-big.glb +0 -0
  147. package/dist/ingest/simcity/models/building-office-pyramid.glb +0 -0
  148. package/dist/ingest/simcity/models/building-office-tall.glb +0 -0
  149. package/dist/ingest/simcity/models/building-office.glb +0 -0
  150. package/dist/ingest/simcity/models/building-restaurant.glb +0 -0
  151. package/dist/ingest/simcity/models/building-skyscraper.glb +0 -0
  152. package/dist/ingest/simcity/models/car-ambulance-pickup.glb +0 -0
  153. package/dist/ingest/simcity/models/car-baywatch.glb +0 -0
  154. package/dist/ingest/simcity/models/car-hippie-van.glb +0 -0
  155. package/dist/ingest/simcity/models/car-passenger-race.glb +0 -0
  156. package/dist/ingest/simcity/models/car-passenger.glb +0 -0
  157. package/dist/ingest/simcity/models/car-police.glb +0 -0
  158. package/dist/ingest/simcity/models/car-taxi.glb +0 -0
  159. package/dist/ingest/simcity/models/car-tow-truck.glb +0 -0
  160. package/dist/ingest/simcity/models/car-truck-dump.glb +0 -0
  161. package/dist/ingest/simcity/models/car-veteran.glb +0 -0
  162. package/dist/ingest/simcity/models/construction-small.glb +0 -0
  163. package/dist/ingest/simcity/models/data-center.glb +0 -0
  164. package/dist/ingest/simcity/models/industry-factory-old.glb +0 -0
  165. package/dist/ingest/simcity/models/industry-factory.glb +0 -0
  166. package/dist/ingest/simcity/models/industry-refinery.glb +0 -0
  167. package/dist/ingest/simcity/models/industry-warehouse.glb +0 -0
  168. package/dist/ingest/simcity/models/power_line_pole_modified.glb +0 -0
  169. package/dist/ingest/simcity/models/tile-plain_grass.glb +0 -0
  170. package/dist/ingest/simcity/models/tile-road-curve.glb +0 -0
  171. package/dist/ingest/simcity/models/tile-road-end.glb +0 -0
  172. package/dist/ingest/simcity/models/tile-road-intersection-t.glb +0 -0
  173. package/dist/ingest/simcity/models/tile-road-intersection.glb +0 -0
  174. package/dist/ingest/simcity/models/tile-road-straight.glb +0 -0
  175. package/dist/ingest/simcity/models/truck.glb +0 -0
  176. package/dist/ingest/simcity/statusIcons/no-power.png +0 -0
  177. package/dist/ingest/simcity/statusIcons/no-road-access.png +0 -0
  178. package/dist/ingest/simcity/textures/base.png +0 -0
  179. package/dist/ingest/simcity/textures/grid.png +0 -0
  180. package/dist/ingest/simcity/textures/specular.png +0 -0
  181. package/dist/ingest/simcity/three-r155.module.js +51466 -0
  182. package/dist/ingest/simcity/vgai.game.json +20 -0
  183. package/dist/ingest/tanks/UPSTREAM.md +69 -0
  184. package/dist/ingest/tanks/index.html +214 -0
  185. package/dist/ingest/tanks/models/pixelTank.fbx +0 -0
  186. package/dist/ingest/tanks/tanks-entry.js +92 -0
  187. package/dist/ingest/tanks/three-r170.module.js +54571 -0
  188. package/dist/ingest/tanks/vgai.game.json +20 -0
  189. package/dist/inputmaps/default.inputmap.json +41 -0
  190. package/dist/jsm/libs/draco/gltf/draco_decoder.js +33 -0
  191. package/dist/jsm/libs/draco/gltf/draco_decoder.wasm +0 -0
  192. package/dist/jsm/libs/draco/gltf/draco_wasm_wrapper.js +116 -0
  193. package/dist/scenes/untitled.vscn.json +29 -0
  194. package/dist/textures/waternormals.jpg +0 -0
  195. package/dist-server/packaged.mjs +18155 -0
  196. package/package.json +36 -0
  197. package/server/asset-library-routes.ts +833 -0
  198. package/server/canonical-path.ts +50 -0
  199. package/server/cloud-asset-catalog.ts +101 -0
  200. package/server/dev.ts +350 -0
  201. package/server/editor-server.ts +1537 -0
  202. package/server/editor-sse.ts +73 -0
  203. package/server/game-globals-shadow.ts +58 -0
  204. package/server/local-asset-catalog.ts +163 -0
  205. package/server/packaged.ts +571 -0
  206. package/server/prod.ts +75 -0
  207. package/server/project-optimize-deps-entries.ts +149 -0
  208. package/server/project-view.ts +65 -0
  209. package/server/server-utils.ts +430 -0
  210. package/server/session-registry.ts +93 -0
  211. package/src/EditorContext.tsx +87 -0
  212. package/src/achieved-tier.ts +126 -0
  213. package/src/action-registry.ts +268 -0
  214. package/src/adapter-resolver.ts +1858 -0
  215. package/src/asset-events.ts +100 -0
  216. package/src/authoring/active-adapter.ts +98 -0
  217. package/src/authoring/active-systems.ts +50 -0
  218. package/src/authoring/boundary-authoring-adapter.ts +178 -0
  219. package/src/authoring/composite-authoring-adapter.ts +891 -0
  220. package/src/authoring/design-time-layers.ts +776 -0
  221. package/src/authoring/edit-mode-authoring.ts +643 -0
  222. package/src/authoring/embed-only-adapter.ts +57 -0
  223. package/src/authoring/ephemeral-persistence.ts +26 -0
  224. package/src/authoring/eyedropper-session.ts +69 -0
  225. package/src/authoring/first-party-hierarchy-menu.ts +74 -0
  226. package/src/authoring/first-party-inspector/AddSectionMenu.tsx +118 -0
  227. package/src/authoring/first-party-inspector/EnvironmentSection.tsx +550 -0
  228. package/src/authoring/first-party-inspector/HeaderSection.tsx +329 -0
  229. package/src/authoring/first-party-inspector/is-first-party-selection.ts +44 -0
  230. package/src/authoring/first-party-inspector/register.tsx +108 -0
  231. package/src/authoring/ingest-authoring-adapter.ts +475 -0
  232. package/src/authoring/ingest-capture.ts +94 -0
  233. package/src/authoring/ingest-game-adapter.ts +291 -0
  234. package/src/authoring/ingest-iframe-adapter.ts +38 -0
  235. package/src/authoring/ingest-iframe-reachable-adapter.ts +401 -0
  236. package/src/authoring/ingest-overlay.ts +193 -0
  237. package/src/authoring/layered-pick.ts +87 -0
  238. package/src/authoring/mount-failure-report.ts +112 -0
  239. package/src/authoring/overlay-report.ts +52 -0
  240. package/src/authoring/project-scenes.ts +111 -0
  241. package/src/authoring/react-dom-authoring-adapter.ts +795 -0
  242. package/src/authoring/react-inspector-section.tsx +1041 -0
  243. package/src/authoring/react-world-authoring-adapter.ts +2400 -0
  244. package/src/authoring/root-activation.ts +61 -0
  245. package/src/authoring/scene-ui-layer.ts +485 -0
  246. package/src/authoring/story-overlay.ts +48 -0
  247. package/src/authoring/ui-authoring-adapter.ts +325 -0
  248. package/src/authoring/ui-hierarchy-menu.ts +40 -0
  249. package/src/authoring/ui-inspector-sections.tsx +156 -0
  250. package/src/authoring/undo-timeline.ts +145 -0
  251. package/src/authoring/vgai-scene-authoring-adapter.ts +746 -0
  252. package/src/authoring/viewport-pick-context.ts +31 -0
  253. package/src/authoring/viewport-raycast.ts +80 -0
  254. package/src/authoring/world-overlay.ts +76 -0
  255. package/src/authoring/world-pan-state.ts +115 -0
  256. package/src/authoring/world-session-state.ts +102 -0
  257. package/src/authoring/world2d-authoring-adapter.ts +313 -0
  258. package/src/browser-external-watch.ts +98 -0
  259. package/src/browser-play-entry.ts +112 -0
  260. package/src/browser-project-scripts.ts +112 -0
  261. package/src/browser-transpile.ts +246 -0
  262. package/src/browser-watch-reload.ts +100 -0
  263. package/src/command-listener.ts +326 -0
  264. package/src/components/AlignToolbar.tsx +189 -0
  265. package/src/components/AppRoot.tsx +73 -0
  266. package/src/components/AssetBrowser.tsx +502 -0
  267. package/src/components/BottomPanel.tsx +326 -0
  268. package/src/components/BuildPanel.tsx +455 -0
  269. package/src/components/CameraInfo.tsx +56 -0
  270. package/src/components/CommandPalette.tsx +265 -0
  271. package/src/components/ConsolePanel.tsx +272 -0
  272. package/src/components/DataPanel.tsx +1268 -0
  273. package/src/components/DefaultEditorLayout.tsx +509 -0
  274. package/src/components/FolderTree.tsx +179 -0
  275. package/src/components/GameHierarchy.tsx +1349 -0
  276. package/src/components/Inspector.tsx +671 -0
  277. package/src/components/InspectorToolSection.tsx +74 -0
  278. package/src/components/MountFailureBanner.tsx +110 -0
  279. package/src/components/NetworkSection.tsx +109 -0
  280. package/src/components/OnlineAssetBrowser.tsx +448 -0
  281. package/src/components/OverlayReportBanner.tsx +122 -0
  282. package/src/components/PlayBar.tsx +356 -0
  283. package/src/components/PrefabSaveDialog.tsx +201 -0
  284. package/src/components/ProjectHeader.tsx +174 -0
  285. package/src/components/ProjectScreen.tsx +1004 -0
  286. package/src/components/RightPanel.tsx +166 -0
  287. package/src/components/RootActivateDialog.tsx +105 -0
  288. package/src/components/SaveDialog.tsx +197 -0
  289. package/src/components/SaveStatus.tsx +69 -0
  290. package/src/components/StatsOverlay.tsx +35 -0
  291. package/src/components/ToolHost.tsx +80 -0
  292. package/src/components/Toolbar.tsx +307 -0
  293. package/src/components/ViewportOverlay.tsx +234 -0
  294. package/src/components/ViewportPanel.tsx +369 -0
  295. package/src/components/WorldContextMenu.tsx +257 -0
  296. package/src/components/WorldSelectionOverlay.tsx +2003 -0
  297. package/src/components/WorldTextEditor.tsx +86 -0
  298. package/src/components/asset-viewers/AssetViewer.tsx +38 -0
  299. package/src/components/asset-viewers/AudioViewer.tsx +149 -0
  300. package/src/components/asset-viewers/GenericJsonViewer.tsx +77 -0
  301. package/src/components/asset-viewers/ImageViewer.tsx +65 -0
  302. package/src/components/asset-viewers/InputMapViewer.tsx +206 -0
  303. package/src/components/asset-viewers/ModelViewer.tsx +180 -0
  304. package/src/components/asset-viewers/OnlineAssetDetail.tsx +236 -0
  305. package/src/components/data-csv.ts +301 -0
  306. package/src/components/entity-name-commit.ts +18 -0
  307. package/src/components/inspector-property-grouping.ts +65 -0
  308. package/src/components/inspector-widgets/AlignmentGrid.tsx +188 -0
  309. package/src/components/inspector-widgets/BorderEditor.tsx +305 -0
  310. package/src/components/inspector-widgets/ColorPicker.tsx +548 -0
  311. package/src/components/inspector-widgets/FilterEditor.tsx +108 -0
  312. package/src/components/inspector-widgets/FontPicker.tsx +196 -0
  313. package/src/components/inspector-widgets/GradientEditor.tsx +277 -0
  314. package/src/components/inspector-widgets/ScrubbableInput.tsx +152 -0
  315. package/src/components/inspector-widgets/ShadowEditor.tsx +332 -0
  316. package/src/components/inspector-widgets/color-utils.ts +202 -0
  317. package/src/components/inspector-widgets/gallery-main.tsx +167 -0
  318. package/src/components/inspector-widgets/index.ts +22 -0
  319. package/src/components/inspector-widgets/shared.tsx +135 -0
  320. package/src/components/inspectors/AnimationSection.tsx +143 -0
  321. package/src/components/inspectors/AudioSection.tsx +215 -0
  322. package/src/components/inspectors/BoneAttachmentSection.tsx +82 -0
  323. package/src/components/inspectors/CameraSection.tsx +105 -0
  324. package/src/components/inspectors/ComponentsSection.tsx +358 -0
  325. package/src/components/inspectors/JointsSection.tsx +255 -0
  326. package/src/components/inspectors/LightSection.tsx +116 -0
  327. package/src/components/inspectors/MaterialSection.tsx +1189 -0
  328. package/src/components/inspectors/MeshSection.tsx +99 -0
  329. package/src/components/inspectors/NavigationSection.tsx +58 -0
  330. package/src/components/inspectors/ParticleSection.tsx +681 -0
  331. package/src/components/inspectors/PhysicsSection.tsx +326 -0
  332. package/src/components/inspectors/PostProcessingSection.tsx +1290 -0
  333. package/src/components/inspectors/RenderingSection.tsx +109 -0
  334. package/src/components/inspectors/ShadowSection.tsx +84 -0
  335. package/src/components/inspectors/SplineSection.tsx +191 -0
  336. package/src/components/inspectors/TransformSection.tsx +191 -0
  337. package/src/components/inspectors/json-schema-fields.ts +281 -0
  338. package/src/components/inspectors/multi-edit-utils.ts +49 -0
  339. package/src/components/inspectors/zod-fields.ts +184 -0
  340. package/src/components/primitives/ColorInput.tsx +109 -0
  341. package/src/components/primitives/JsonInput.tsx +98 -0
  342. package/src/components/primitives/NumberInput.tsx +164 -0
  343. package/src/components/primitives/Panel.tsx +68 -0
  344. package/src/components/primitives/SectionHeader.tsx +45 -0
  345. package/src/components/primitives/Tooltip.tsx +82 -0
  346. package/src/components/primitives/Vec3Input.tsx +62 -0
  347. package/src/components/world-overlay-gestures.ts +1438 -0
  348. package/src/doctor/data-checks.ts +285 -0
  349. package/src/doctor/report.ts +386 -0
  350. package/src/editor-api.ts +700 -0
  351. package/src/editor-console.ts +194 -0
  352. package/src/editor-hotkeys.ts +441 -0
  353. package/src/editor-layers.ts +2 -0
  354. package/src/editor-mode.ts +28 -0
  355. package/src/editor-store.ts +2225 -0
  356. package/src/editor-viewport.ts +1924 -0
  357. package/src/entity-factory.ts +221 -0
  358. package/src/game-globals-prelude.ts +27 -0
  359. package/src/gated-globals.ts +131 -0
  360. package/src/generated/setup-by-example.generated.ts +53 -0
  361. package/src/gizmo-registry.ts +608 -0
  362. package/src/handle-system.ts +221 -0
  363. package/src/hierarchy-menu-registry.ts +70 -0
  364. package/src/hierarchy-rows.ts +73 -0
  365. package/src/hosted-example.ts +160 -0
  366. package/src/hotkeys.ts +142 -0
  367. package/src/ingest/discovery-react.ts +132 -0
  368. package/src/ingest/discovery.ts +205 -0
  369. package/src/ingest/discovery2d.ts +204 -0
  370. package/src/ingest/esm-entry-resolution.ts +102 -0
  371. package/src/ingest/games/bloom-composer/game.js +99 -0
  372. package/src/ingest/games/bloom-composer/vgai.game.json +21 -0
  373. package/src/ingest/games/embed-bundle/embed.html +10 -0
  374. package/src/ingest/games/embed-bundle/vgai.game.json +21 -0
  375. package/src/ingest/games/games-fps/assets/collision-world.glb +0 -0
  376. package/src/ingest/games/games-fps/game.js +479 -0
  377. package/src/ingest/games/games-fps/vgai.game.json +24 -0
  378. package/src/ingest/games/iframe-reachable/game-source.js +58 -0
  379. package/src/ingest/games/iframe-reachable/vgai.game.json +21 -0
  380. package/src/ingest/games/interactive-cubes/game.js +139 -0
  381. package/src/ingest/games/interactive-cubes/vgai.game.json +21 -0
  382. package/src/ingest/games/mismatched-three/fake-three.js +38 -0
  383. package/src/ingest/games/mismatched-three/game.js +10 -0
  384. package/src/ingest/games/mismatched-three/vgai.game.json +22 -0
  385. package/src/ingest/games/own-three-bundled/game.js +43 -0
  386. package/src/ingest/games/own-three-bundled/vgai.game.json +21 -0
  387. package/src/ingest/games/pointerlock-maze/game.js +288 -0
  388. package/src/ingest/games/pointerlock-maze/vgai.game.json +22 -0
  389. package/src/ingest/games-2d/_fixtures/first-party-scene.scn2d.json +124 -0
  390. package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +25 -0
  391. package/src/ingest/games-2d/bunnymark/game.ts +88 -0
  392. package/src/ingest/games-2d/bunnymark/vgai.game.json +21 -0
  393. package/src/ingest/games-2d/flappy/game.ts +148 -0
  394. package/src/ingest/games-2d/flappy/vgai.game.json +21 -0
  395. package/src/ingest/games-2d/flappy-pixi/vgai.game.json +25 -0
  396. package/src/ingest/games-2d/puzzling-potions/vgai.game.json +25 -0
  397. package/src/ingest/games-2d/tilemap-camera/game.ts +115 -0
  398. package/src/ingest/games-2d/tilemap-camera/vgai.game.json +21 -0
  399. package/src/ingest/games-react/react-rpg/entry.tsx +185 -0
  400. package/src/ingest/games-react/react-rpg/shims.d.ts +26 -0
  401. package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +129 -0
  402. package/src/ingest/games-react/react-rpg/vgai.game.json +21 -0
  403. package/src/ingest/host-namespace-registry.ts +196 -0
  404. package/src/ingest/types.ts +127 -0
  405. package/src/ingest-hierarchy-rows.ts +207 -0
  406. package/src/ingest-mode.ts +2092 -0
  407. package/src/ingest-siblings.ts +466 -0
  408. package/src/initial-scene.ts +194 -0
  409. package/src/inspector-section-registry.ts +117 -0
  410. package/src/interactive-edit-scope.ts +34 -0
  411. package/src/main.tsx +23 -0
  412. package/src/manifest-project.ts +43 -0
  413. package/src/model-thumbnail.ts +156 -0
  414. package/src/module-mode.ts +513 -0
  415. package/src/navmesh-handler.ts +94 -0
  416. package/src/packaged-runtime.ts +49 -0
  417. package/src/play-mode.ts +1148 -0
  418. package/src/playground/main.ts +206 -0
  419. package/src/project-manager.ts +173 -0
  420. package/src/scene-diff.ts +112 -0
  421. package/src/scene-serializer.ts +144 -0
  422. package/src/scene-sync.ts +587 -0
  423. package/src/scene-ui-mode/active-scene-ui.ts +58 -0
  424. package/src/scene-ui-mode/scene-ui-palette-panel.tsx +152 -0
  425. package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +29 -0
  426. package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +74 -0
  427. package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +49 -0
  428. package/src/scene-ui-mode/ui-prefab-io.ts +76 -0
  429. package/src/script-loader.ts +143 -0
  430. package/src/storage/fsa-storage.ts +55 -0
  431. package/src/storage/handle-storage.ts +150 -0
  432. package/src/storage/handle-store.ts +116 -0
  433. package/src/storage/http-storage.ts +137 -0
  434. package/src/storage/index.ts +147 -0
  435. package/src/storage/mem-storage.ts +135 -0
  436. package/src/storage/opfs-storage.ts +25 -0
  437. package/src/storage/paths.ts +26 -0
  438. package/src/storage/seed.ts +106 -0
  439. package/src/storage/types.ts +110 -0
  440. package/src/tool-loader.ts +291 -0
  441. package/src/ui-editor/inspector.tsx +563 -0
  442. package/src/ui-editor/node-ops.ts +266 -0
  443. package/src/ui-editor/overlay.tsx +380 -0
  444. package/src/ui-editor/palette.tsx +114 -0
  445. package/src/ui-editor/react-store.ts +78 -0
  446. package/src/ui-editor/timeline-panel.tsx +443 -0
  447. package/src/ui-editor/ui-edit-store.ts +552 -0
  448. package/src/ui-editor/ui-prefab.ts +95 -0
  449. package/src/ui-source/inspect.ts +544 -0
  450. package/src/ui-source/oid-transform.ts +133 -0
  451. package/src/ui-source/source-write-backend.ts +203 -0
  452. package/src/ui-source/tailwind-map.ts +174 -0
  453. package/src/ui-source/writer.ts +1175 -0
  454. package/src/widgets/index.ts +73 -0
  455. package/template/.claude/skills/editor/SKILL.md +252 -0
  456. package/template/CLAUDE.md +512 -0
  457. package/template/LICENSE +30 -0
  458. package/template/emit-schemas.ts +42 -0
  459. package/template/index.html +17 -0
  460. package/template/package.json +53 -0
  461. package/template/project.json +9 -0
  462. package/template/public/audio/ambient-arena.wav +0 -0
  463. package/template/public/audio/ambient-music.wav +0 -0
  464. package/template/public/audio/ambient-nature.wav +0 -0
  465. package/template/public/audio/ambient-wind.wav +0 -0
  466. package/template/public/inputmaps/default.inputmap.json +41 -0
  467. package/template/public/jsm/libs/draco/gltf/draco_decoder.js +33 -0
  468. package/template/public/jsm/libs/draco/gltf/draco_decoder.wasm +0 -0
  469. package/template/public/jsm/libs/draco/gltf/draco_encoder.js +33 -0
  470. package/template/public/jsm/libs/draco/gltf/draco_wasm_wrapper.js +116 -0
  471. package/template/public/scenes/default.vscn.json +43 -0
  472. package/template/public/scenes/untitled.vscn.json +29 -0
  473. package/template/public/textures/waternormals.jpg +0 -0
  474. package/template/server/colyseus-setup.ts +85 -0
  475. package/template/server/main.ts +34 -0
  476. package/template/server/rooms/arena-room.ts +420 -0
  477. package/template/server/rooms/game-room.ts +162 -0
  478. package/template/server/rooms.ts +15 -0
  479. package/template/server/tsconfig.json +19 -0
  480. package/template/src/data/README.md +40 -0
  481. package/template/src/data/assets.ts +31 -0
  482. package/template/src/data/tuning.data.json +5 -0
  483. package/template/src/data/tuning.schema.json +21 -0
  484. package/template/src/data/tuning.schema.ts +34 -0
  485. package/template/src/data/tuning.ts +24 -0
  486. package/template/src/main.ts +157 -0
  487. package/template/src/scripts/components/data-spinner.ts +45 -0
  488. package/template/src/scripts/components/scene-camera.ts +40 -0
  489. package/template/src/scripts/main.ts +94 -0
  490. package/template/src/scripts/registry.ts +46 -0
  491. package/template/src/tools/README.md +14 -0
  492. package/template/src/tools/example.tool.tsx +91 -0
  493. package/template/src/tools/spin.tool.tsx +101 -0
  494. package/template/src/ui/Button.tsx +27 -0
  495. package/template/src/ui/DefaultHud.tsx +40 -0
  496. package/template/src/ui/KeyHint.tsx +25 -0
  497. package/template/src/ui/Panel.tsx +52 -0
  498. package/template/src/ui/RebindPanel.tsx +129 -0
  499. package/template/src/ui/Slider.tsx +28 -0
  500. package/template/src/ui/StatusDot.tsx +22 -0
  501. package/template/src/ui/Toggle.tsx +19 -0
  502. package/template/src/ui/game-state.tsx +37 -0
  503. package/template/src/ui/index.ts +7 -0
  504. package/template/src/ui/mount-default-hud.tsx +19 -0
  505. package/template/src/ui/mount-ui.ts +15 -0
  506. package/template/tsconfig.json +36 -0
  507. package/template/validate-asset-content.ts +78 -0
  508. package/template/validate-manifest.ts +55 -0
  509. package/template/validate-scenes.ts +211 -0
  510. package/template/vgai.game.json +21 -0
  511. package/template/vite.config.ts +128 -0
  512. package/vite-plugin-example-assets.ts +60 -0
  513. package/vite-plugin-game-globals.ts +46 -0
  514. package/vite-plugin-game-static.ts +71 -0
  515. package/vite-plugin-project-game-static.ts +84 -0
  516. package/vite-plugin-react-game-static.ts +98 -0
  517. package/vite-plugin-react-world-provider.ts +81 -0
  518. package/vite-plugin-react-world-runtime.ts +76 -0
  519. package/vite-plugin-ui-oid.ts +561 -0
@@ -0,0 +1,1858 @@
1
+ /**
2
+ * Adapter resolver — T3.3 slice 2 (docs/CLI-ON-FOLDER-DESIGN.md §1D), extended
3
+ * in T6.1 slice 2 (docs/MULTI-WORLD-DESIGN.md §1.D/§3 slice 2) for `default-pixi`
4
+ * + the multi-world resolution surface, in T6.1 slice 4 (§1.E/§3 slice 4)
5
+ * for `ingest-pixi` (world2d silo retirement), in T6.2 (docs/REACT-WORLD-
6
+ * DESIGN.md §1.B/§3 slice 1) for `default-react`, in T3.4 slice 1
7
+ * (docs/ADAPTER-AUTHORING-DESIGN.md §1.A/§1.C) for `{ module }` (custom
8
+ * adapter modules) + achieved-tier verification, and in Track N task N1
9
+ * (docs/REACT-INGEST-LANDING-DESIGN.md D-N2/D-N3/D-N4) for `ingest-react`
10
+ * (vendored native-React game ingestion — the deduped-only mount).
11
+ *
12
+ * Turns one manifest world (`ResolvedWorldEntry`, from `@engine/manifest/load`)
13
+ * into a mountable {@link GameAdapter} (or, for a pixijs world, a structurally
14
+ * distinct {@link Pixi2DGameAdapter} — see the default-pixi section below).
15
+ * Lives editor-side, not in the engine package: it needs `/@fs/` dynamic-import
16
+ * mechanics (the same route `loadProjectScripts` uses, `./script-loader.ts`),
17
+ * and the engine's manifest loader deliberately has no fs/vite dependency
18
+ * (`manifest/load.ts`'s no-fs discipline — the pure identity-resolution half
19
+ * stays importable anywhere, including Node CLI code; this file is the
20
+ * instantiation half).
21
+ *
22
+ * Coverage in this slice (per the design docs' staging):
23
+ * - `default-three` -> a configured `VgaiSceneGameAdapter`, WITH
24
+ * `componentRegistry` threaded from the project's `src/scripts/registry.ts`
25
+ * (closes the T7.2 finding: `sceneData`/`scenePath` never passed a
26
+ * registry, so scene-authored NAMED components were unreachable through
27
+ * those two `VgaiSceneConfig` branches).
28
+ * - `default-pixi` -> a configured `PixiSceneGameAdapter` (T6.1 slice 2, see
29
+ * `resolveDefaultPixiAdapter` below for the `scene`/`entry` split and the
30
+ * `entry`-for-pixi adjudication).
31
+ * - `ingest-three` -> see `resolveIngestDescriptor`'s doc comment below for
32
+ * the shape landed on and the structural finding it reports.
33
+ * - `ingest-pixi` -> see `resolveIngest2DDescriptor`'s doc comment below (T6.1
34
+ * slice 4) — same two-layer shape as ingest-three (descriptor builder +
35
+ * a named-stub `GameAdapter`; the real mount is `ingest-mode.ts`'s
36
+ * `enterIngestModeFromManifestWorld2D`).
37
+ * - `default-react` -> a `ReactWorldAdapter` (T6.2, see
38
+ * `resolveDefaultReactAdapter` below for the `entry`-only rule — `scene`
39
+ * is a named error, D4 — and the two engine-seam findings its doc
40
+ * comment reports).
41
+ * - `ingest-react` -> also a `ReactWorldAdapter` (Track N, N1 —
42
+ * docs/REACT-INGEST-LANDING-DESIGN.md D-N2/D-N3, see
43
+ * `resolveIngestReactAdapter` below): same T6.2 mount machinery as
44
+ * `default-react`, restricted to the `'deduped'` strategy rung (a named
45
+ * error for any other strategy) with `entry` required (the D-N2 host-shim
46
+ * module) — and, per D-N8, no authoring adapter anywhere in v1.
47
+ * - `{ module }` -> `/@fs/`-imports the game folder's declared adapter module
48
+ * (path-guarded, shape-checked) — see `resolveModuleAdapter` below (T3.4
49
+ * slice 1). Achieved-tier verification (post-mount, against the actual
50
+ * `MountedGame` surface) lives in `./achieved-tier.ts`, shared with the
51
+ * ingest embed-only degradation path's retrofit (`ingest-mode.ts`).
52
+ *
53
+ * `resolveAllWorlds` (T6.1 slice 2, extended T6.2 for react) is the
54
+ * multi-world sibling of `resolveWorldAdapter`: it resolves every world in a
55
+ * `ResolvedGameManifest` into slice-1 `WorldMountSpec`s, enforcing the v1
56
+ * scope rule for manifests with more than one world (docs/MULTI-WORLD-
57
+ * DESIGN.md §3 slice 4, docs/REACT-WORLD-DESIGN.md §3 for react's admission).
58
+ */
59
+
60
+ import type { GameAdapter } from '@engine/adapter';
61
+ import { VgaiSceneGameAdapter } from '@engine/adapter';
62
+ import { assertNever } from '@engine/adapter/world-kind';
63
+ import { resolveUrl } from '@engine/loader';
64
+ import type { ResolvedGameManifest, ResolvedWorldEntry } from '@engine/manifest/load';
65
+ import { GameProvider as EngineGameProvider } from '@engine/react/game-state';
66
+ import type {
67
+ MountedReactGame,
68
+ Pixi2DGameAdapter,
69
+ ReactWorldAdapter,
70
+ ReactWorldHost,
71
+ WorldMountSpec,
72
+ } from '@engine/runtime/create-runtime';
73
+ import type { Game } from '@engine/runtime/game';
74
+ import type { MountEntry } from '@engine/runtime/mount-manifest';
75
+ import type { EditorPreview } from '@engine/runtime/types';
76
+ import type { ComponentRegistry } from '@engine/scene/component-registry';
77
+ import { SceneParseError } from '@engine/scene/parse';
78
+ import type { IngestGame2D } from '@engine/world2d/ingest2d';
79
+ import { PixiSceneGameAdapter, type World2DSetupFn } from '@engine/world2d/pixi-game-adapter';
80
+ import { type Scene2DFile, Scene2DSchema } from '@engine/world2d/schema/entity2d';
81
+ import type { ProjectUIRegistry } from '@vgai/scene-ui/registry';
82
+ import { type ComponentType, createElement } from 'react';
83
+ import { flushSync } from 'react-dom';
84
+ import { createRoot } from 'react-dom/client';
85
+ import { reactWorldProviderImportSpecifier } from '../vite-plugin-react-world-provider';
86
+ import { REACT_WORLD_RUNTIME_PATH } from '../vite-plugin-react-world-runtime';
87
+ import {
88
+ computeAchievedTier,
89
+ computeTierShortfall,
90
+ formatTierShortfallMessage,
91
+ } from './achieved-tier';
92
+ import { resolveExtraDeps } from './ingest/host-namespace-registry';
93
+ import type { IngestGame } from './ingest/types';
94
+ import { isPackagedRuntime } from './packaged-runtime';
95
+ import { loadProjectScripts } from './script-loader';
96
+
97
+ /**
98
+ * Build a `/@fs/<absolute path>` dev-server import URL WITHOUT a doubled
99
+ * leading slash (T6.2 slice 3 finding — see the "Report back" of that
100
+ * slice's dispatch). `projectRoot` is always an absolute path (it starts
101
+ * with `/`), so the naive `` `/@fs/${projectRoot}/...` `` template produces
102
+ * `/@fs//abs/olute/path/...` — cosmetically harmless on its own (Vite's
103
+ * `/@fs/` middleware tolerates it), UNTIL the SAME file is also reached via
104
+ * a DIFFERENT route: a react world's entry module's own STATIC relative
105
+ * import (e.g. `import { useGameState } from './src/ui/game-
106
+ * state'`) is rewritten by Vite's normal resolver into a SINGLE-slash,
107
+ * symlink-canonicalized `/@fs/` URL — a textually different string than
108
+ * this file's hand-built double-slash one. Browser ES module identity is
109
+ * per-URL, so the two builds of `/@fs/` string for the SAME file produced
110
+ * TWO SEPARATE module instances (two `createContext()` calls for
111
+ * `game-state.tsx`), and `useGameState`'s `useGame()` threw "no Game in
112
+ * context" even though `<GameProvider>` genuinely wrapped the entry — this
113
+ * is what `resolveDefaultReactAdapter`'s AC e2e (T6.2 slice 3) caught. The
114
+ * server-side half of this fix is `canonical-path.ts` (canonicalizes
115
+ * `projectRoot` itself so it agrees with Vite's own symlink-resolved form);
116
+ * this half removes the remaining single/double-slash divergence so the
117
+ * two strings are BYTE-IDENTICAL once `projectRoot` is canonical.
118
+ */
119
+ export function fsImportPath(projectRoot: string, relativePath: string): string {
120
+ const root = projectRoot.endsWith('/') ? projectRoot.slice(0, -1) : projectRoot;
121
+ const rel = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
122
+ // Windows canonical roots ('C:/…') carry no leading slash of their own —
123
+ // without inserting one the URL degenerates to '/@fsC:/…', which Vite
124
+ // serves as a 404. POSIX roots keep the concat form (root already starts
125
+ // with '/'), preserving the byte-identical contract described above.
126
+ return root.startsWith('/') ? `/@fs${root}/${rel}` : `/@fs/${root}/${rel}`;
127
+ }
128
+
129
+ /** Optional editor-session context for {@link resolveWorldAdapter}. */
130
+ export interface ResolveWorldAdapterOptions {
131
+ /**
132
+ * The editor's play-launch context (live in-memory scene document +
133
+ * viewport-camera spawn) — supplied by `play-mode.ts` when the resolver is
134
+ * invoked from the editor's play path, absent for non-editor callers (the
135
+ * CLI-on-folder flow). Threaded into BOTH `default-three` branches:
136
+ * the `entry` branch hands it to the project's `setup(ctx, editorPreview)`
137
+ * exactly like today's legacy play path (`createGameRuntime({ setup,
138
+ * editorPreview })` is the same `VgaiSceneGameAdapter` wrap), and the
139
+ * scene-only branch mounts `editorPreview.sceneData` (the LIVE document,
140
+ * unsaved edits included) instead of fetching `world.scene` from disk —
141
+ * matching the documented play-mode contract ("hands the in-memory scene
142
+ * document to the real engine runtime") the way `template/src/scripts/
143
+ * main.ts`'s generic fallback does (`editor ? loadSceneFromData(editor.
144
+ * sceneData) : loadScene(...)`).
145
+ */
146
+ editorPreview?: EditorPreview | undefined;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Part B — componentRegistry via the project's script registry
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /**
154
+ * Load the project's component registry (`src/scripts/registry.ts`'s
155
+ * `componentRegistry` export) via the same `/@fs/` dev-server route
156
+ * `loadProjectScripts` uses for `src/scripts/main.ts` (script-loader.ts).
157
+ *
158
+ * A project with no scripts registry — the file doesn't exist, doesn't export
159
+ * `componentRegistry`, or fails to import for any other reason — resolves to
160
+ * an EMPTY registry rather than throwing: most manifest-backed projects
161
+ * (especially ones with no named `GameComponent`s at all) have nothing here,
162
+ * and that must not block `default-three` resolution. This intentionally
163
+ * folds "file absent" and "file present but broken" into the same empty
164
+ * result; a project whose registry.ts has a real bug still resolves and
165
+ * mounts, just without any named components reachable from scene JSON
166
+ * (the same as today's behavior before this slice existed at all).
167
+ */
168
+ export async function loadComponentRegistryForProject(
169
+ projectRoot: string,
170
+ ): Promise<ComponentRegistry> {
171
+ const modulePath = fsImportPath(projectRoot, 'src/scripts/registry.ts');
172
+ try {
173
+ const mod = (await import(/* @vite-ignore */ modulePath)) as {
174
+ componentRegistry?: ComponentRegistry;
175
+ };
176
+ return mod.componentRegistry ?? {};
177
+ } catch {
178
+ return {};
179
+ }
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Part B.2 — the project's scene-UI registry (design/24-scene-ui.md D9/D11)
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /**
187
+ * Load the project's scene-UI registry (`src/ui/registry.ts`'s `uiRegistry`
188
+ * export) via the same `/@fs/` dev-server route as {@link
189
+ * loadComponentRegistryForProject} above — but with ONE deliberate departure
190
+ * from that loader's behavior: an import FAILURE (the file exists but throws
191
+ * — a syntax error, a bad import, a thrown top-level expression) logs a loud
192
+ * `editorConsole.warn` naming the real error before falling back to `null`.
193
+ * `loadComponentRegistryForProject` folds "absent" and "broken" into the same
194
+ * silent `{}` because most projects simply have no `src/scripts/registry.ts`
195
+ * at all; a scene-UI registry is different — the whole point of this PR is
196
+ * project-authored UI, so a registry with a typo silently demoting the
197
+ * editor back to demo fixtures (D10) is exactly the confusing failure mode
198
+ * this loader exists to make loud instead.
199
+ *
200
+ * The common "no registry file" case (every scaffolded project, by design —
201
+ * D10: the template does NOT ship one) stays silent: the editor server's
202
+ * `/__editor/project` response carries `hasUiRegistry` (a server-side stat of
203
+ * `src/ui/registry.ts`), which distinguishes "the file doesn't exist" (no
204
+ * warning, resolves `null`) from "the file exists but the import threw"
205
+ * (warns). Probing the `/@fs/` URL itself cannot make that call: a dev server
206
+ * answers 200 for missing files (SPA fallback), and the doomed dynamic import
207
+ * that follows logs an unsuppressible browser network error on every
208
+ * registry-less project — which strict harnesses
209
+ * (scripts/check-vgai-generated-project-p2p.ts) rightly fail on.
210
+ * Returns `null` for both "absent" and "present but empty" (`uiRegistry`
211
+ * undefined) — D10 treats both the same (demo mode).
212
+ */
213
+ export async function loadProjectUIRegistry(
214
+ projectRoot: string,
215
+ ): Promise<ProjectUIRegistry | null> {
216
+ const modulePath = fsImportPath(projectRoot, 'src/ui/registry.ts');
217
+ // Inline fetch rather than editor-api's getCurrentServerProject(): this
218
+ // module must stay importable by headless Node/vitest suites, and
219
+ // editor-api's module top level pulls in ./storage.
220
+ let exists: boolean;
221
+ try {
222
+ const res = await fetch('/__editor/project');
223
+ const data = (await res.json()) as { project?: { hasUiRegistry?: boolean } | null };
224
+ exists = Boolean(data.project?.hasUiRegistry);
225
+ } catch {
226
+ exists = false;
227
+ }
228
+ if (!exists) return null;
229
+ try {
230
+ const mod = (await import(/* @vite-ignore */ modulePath)) as {
231
+ uiRegistry?: ProjectUIRegistry;
232
+ };
233
+ return mod.uiRegistry ?? null;
234
+ } catch (err) {
235
+ // Lazy dynamic import — see withAchievedTierCheck's doc comment above:
236
+ // this resolver is imported by headless Node/vitest suites with no
237
+ // `window`, and editor-console.ts's module top level touches `window`
238
+ // unconditionally.
239
+ const { editorConsole } = await import('./editor-console');
240
+ editorConsole.warn(
241
+ `src/ui/registry.ts failed to import — UI mode falls back to demo fixtures: ${String(err)}`,
242
+ 'ui-mode',
243
+ );
244
+ return null;
245
+ }
246
+ }
247
+
248
+ let _cachedProjectUIRegistry: ProjectUIRegistry | null = null;
249
+
250
+ /**
251
+ * Cache slot for the project's scene-UI registry (D9/D11) — populated by
252
+ * `authoring/scene-ui-layer.ts`'s design-time layer mount (once per project
253
+ * open, when the scene has a `ui` root to disclose — C3) and read by
254
+ * `resolveDefaultThreeAdapter` below so the editor-play path doesn't
255
+ * reload/re-parse the same module a second time per play. A headless/CLI-
256
+ * on-folder resolve (no editor session, no scene-UI layer ever mounts)
257
+ * simply reads the initial `null` — zero behavior change there.
258
+ */
259
+ export function setCachedProjectUIRegistry(registry: ProjectUIRegistry | null): void {
260
+ _cachedProjectUIRegistry = registry;
261
+ }
262
+
263
+ /** @see setCachedProjectUIRegistry */
264
+ export function getCachedProjectUIRegistry(): ProjectUIRegistry | null {
265
+ return _cachedProjectUIRegistry;
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Named errors for identities not yet resolvable (report the task, not just fail)
270
+ // ---------------------------------------------------------------------------
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Part A — default-three
274
+ // ---------------------------------------------------------------------------
275
+
276
+ async function resolveDefaultThreeAdapter(
277
+ world: ResolvedWorldEntry,
278
+ projectRoot: string,
279
+ opts?: ResolveWorldAdapterOptions,
280
+ ): Promise<GameAdapter> {
281
+ const editorPreview = opts?.editorPreview;
282
+
283
+ // `entry` is preferred when a world declares BOTH `entry` and `scene`: the
284
+ // code entry owns MOUNTING (the same contract `src/scripts/main.ts`
285
+ // satisfies today — it decides what to load, delegates to a matching
286
+ // example's setup for gameplay systems, etc.); `scene` remains the world's
287
+ // data identity (what the compat view's `defaultScene` is synthesized from,
288
+ // project-view.ts §1C). The manifest loader allows both
289
+ // (`checkAdapterSceneEntryRules` only rejects NEITHER).
290
+ if (world.entry !== undefined) {
291
+ // entry-based default-three: W1 (docs/R3F-FIRST-PARTY-DESIGN.md §1.A/§2)
292
+ // accepts EITHER a pre-built `adapter` export OR a `setup` function.
293
+ // Before W1, `loadProjectScripts` hard-threw on any entry lacking
294
+ // `setup`, so an `adapter`-export entry that mounted fine standalone
295
+ // failed here — asymmetry, not doctrine (§0's "ground truth").
296
+ //
297
+ // Precedence when BOTH are exported is `setup` FIRST — the OPPOSITE of
298
+ // the standalone path (`mount-game.ts:176-177` prefers `adapter`), and
299
+ // deliberately so. The template (and every project scaffolded from it)
300
+ // exports both, with `adapter = fromSetup('default', setup)` as the
301
+ // standalone convenience, so standalone's adapter-first is
302
+ // behavior-identical there. In the EDITOR the two paths are NOT
303
+ // equivalent: only the `setup` branch threads `editorPreview` (the live
304
+ // in-memory scene + viewport camera — a bare `GameAdapter.mount(host)`
305
+ // has no channel for it) and mounts through the firstParty-branded
306
+ // `VgaiSceneGameAdapter` that script-HMR's prototype swap requires.
307
+ // Preferring `adapter` here made every scaffolded project silently load
308
+ // its ON-DISK default scene instead of the scene being edited, and
309
+ // killed component HMR (caught by e2e 07-script-hmr +
310
+ // 10-render-fidelity). An adapter-ONLY entry (e.g. a react-three-fiber
311
+ // bridge, §1.C) still returns the project's adapter directly — it owns
312
+ // its full mount lifecycle, is not wrapped in `VgaiSceneGameAdapter`,
313
+ // and has no `setup` to prefer.
314
+ const { adapter, setup } = await loadProjectScripts(projectRoot, world.entry);
315
+ if (setup) {
316
+ // Byte-equivalent to the legacy play path: `createGameRuntime({ setup,
317
+ // editorPreview })` wraps the exact same `VgaiSceneConfig`.
318
+ return new VgaiSceneGameAdapter({ id: world.id, setup, editorPreview });
319
+ }
320
+ // `loadProjectScripts` only returns with neither `adapter` nor `setup`
321
+ // by throwing first, so `adapter` is guaranteed defined here.
322
+ return adapter as GameAdapter;
323
+ }
324
+ if (world.scene !== undefined) {
325
+ // Scene-only world (§1D): no custom setup(). Thread the project's
326
+ // component registry so scene-authored `components:` entries resolve —
327
+ // the T7.2 finding this slice closes. From the editor's play path
328
+ // (editorPreview present), mount the LIVE in-memory document —
329
+ // `VgaiSceneGameAdapter.mount()`'s `scenePath` branch reads
330
+ // `editorPreview` only for the viewport camera and would fetch the
331
+ // on-disk file, silently dropping unsaved edits — so hand it
332
+ // `sceneData` instead (whose branch also honors the viewport camera).
333
+ const componentRegistry = await loadComponentRegistryForProject(projectRoot);
334
+ // The project's scene-UI registry (D9/D11) — read from the CACHE
335
+ // `authoring/scene-ui-layer.ts`'s design-time layer mount populated, not
336
+ // reloaded/re-parsed here. `undefined` (not `null`) when absent/never
337
+ // populated (e.g. a headless/CLI-on-folder resolve, or a scene with no
338
+ // `ui` root) so the spread below omits the key entirely rather than
339
+ // passing an explicit `uiRegistry: null`.
340
+ const uiRegistry = getCachedProjectUIRegistry() ?? undefined;
341
+ if (editorPreview) {
342
+ return new VgaiSceneGameAdapter({
343
+ id: world.id,
344
+ sceneData: editorPreview.sceneData,
345
+ editorPreview,
346
+ componentRegistry,
347
+ uiRegistry,
348
+ });
349
+ }
350
+ return new VgaiSceneGameAdapter({
351
+ id: world.id,
352
+ scenePath: world.scene,
353
+ componentRegistry,
354
+ uiRegistry,
355
+ });
356
+ }
357
+ // Unreachable in practice: `load.ts`'s `checkAdapterSceneEntryRules` requires
358
+ // `scene` or `entry` for every 'default' adapter world at manifest-load time.
359
+ throw new Error(
360
+ `resolveWorldAdapter: world "${world.id}" is default-three with neither \`scene\` nor ` +
361
+ '`entry` — this should have been rejected by manifest validation.',
362
+ );
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Part C (T6.1 slice 2) — default-pixi
367
+ // ---------------------------------------------------------------------------
368
+
369
+ /**
370
+ * Fetch + validate a `.scn2d.json` file from `path` (relative to the served
371
+ * project root, exactly like `default-three`'s `scenePath` branch fetches a
372
+ * `.vscn.json` inside `@engine/scene/scene-loader`'s `loadScene` — this file
373
+ * does the analogous fetch itself because `PixiSceneConfig` has no `scenePath`
374
+ * field of its own, only `sceneData` (see `resolveDefaultPixiAdapter` below).
375
+ * `resolveUrl` applies the SAME asset-prefix rewriting raw `fetch()` calls
376
+ * elsewhere in the engine use.
377
+ *
378
+ * A malformed payload throws a `SceneParseError` naming `path`, not a raw
379
+ * `ZodError` — the same T4.6 convention every fetched 3D scene format
380
+ * (`.vscn.json`/`.prefab.json`/`.mat.json`) already follows via
381
+ * `parseSceneFile`/`parsePrefabFile`/`parseMaterialFile`; `.scn2d.json` was
382
+ * the one fetched format still raw-parsing (§7.1-14).
383
+ *
384
+ * Exported (B1, docs/unified-world-editor/26-unified-editor-spec.md §3 B1):
385
+ * `design-time-layers.ts` reuses this exact fetch+validate step for a pixi
386
+ * world's design-time display-only mount, rather than duplicating it.
387
+ */
388
+ export async function fetchScene2DFile(path: string): Promise<Scene2DFile> {
389
+ const res = await fetch(resolveUrl(path));
390
+ if (!res.ok) {
391
+ throw new Error(
392
+ `resolveWorldAdapter: failed to load world2d scene "${path}" (${res.status} ${res.statusText}).`,
393
+ );
394
+ }
395
+ const result = Scene2DSchema.safeParse(await res.json());
396
+ if (!result.success) throw new SceneParseError(result.error.issues, path);
397
+ return result.data;
398
+ }
399
+
400
+ /**
401
+ * Resolve a `kind: 'pixijs'` + `adapter: 'default'` world into a
402
+ * `PixiSceneGameAdapter` (docs/MULTI-WORLD-DESIGN.md §1.D) — the pixi sibling
403
+ * of `resolveDefaultThreeAdapter` above, minus the `editorPreview`/legacy
404
+ * branch: v1 has NO live-document mount for pixi (§1.D — "No editorPreview
405
+ * equivalent in v1: the 2D editor edits flow through the overlay/authoring
406
+ * path, not a live-document preview"), so a pixi world always mounts from its
407
+ * on-disk `scene`/`entry`, editor session or not.
408
+ *
409
+ * `entry`-for-pixi ADJUDICATION (per this slice's study directive): the design
410
+ * doc already answers this — `World2DSetupFn` (`@engine/world2d/
411
+ * pixi-game-adapter.ts`) is an established, exported config shape (`PixiSceneConfig.setup`,
412
+ * re-exported from `@engine/world2d`'s barrel) structurally parallel to
413
+ * `default-three`'s `GameSetupFn`; it simply has no production caller YET
414
+ * (only hand-built test wiring, e.g. `create-runtime-worlds.test.ts`'s pixi
415
+ * world). So `entry` resolves via the SAME `/@fs/` dynamic-import route
416
+ * `loadProjectScripts` uses for `default-three`'s entry branch — no new
417
+ * mechanism invented, just a different exported shape (`setup(ctx:
418
+ * World2DContext)` instead of `setup(ctx: GameContext, editorPreview?)`).
419
+ * `loadProjectScripts`'s return type is pinned to the three-shaped
420
+ * `GameSetupFn`; the cast back to `World2DSetupFn` below is a type-level
421
+ * relabeling only — the runtime check (`typeof mod.setup === 'function'`)
422
+ * is shape-agnostic.
423
+ */
424
+ async function resolveDefaultPixiAdapter(
425
+ world: ResolvedWorldEntry,
426
+ projectRoot: string,
427
+ ): Promise<GameAdapter> {
428
+ if (world.entry !== undefined) {
429
+ const { adapter, setup } = await loadProjectScripts(projectRoot, world.entry);
430
+ // W1 (docs/R3F-FIRST-PARTY-DESIGN.md §0.1's kind-parity matrix / §2 "W1b,
431
+ // out of scope") is a `default-three`-only parity fix — pixi has no
432
+ // standalone `adapter`-export factory to match, so an `adapter`-only
433
+ // entry is not a shape this branch can honor. `loadProjectScripts` no
434
+ // longer hard-throws on a `setup`-less module (it now also accepts
435
+ // `adapter`), so that guarantee has to be re-asserted HERE, loudly,
436
+ // instead of letting `undefined` silently reach `PixiSceneGameAdapter`.
437
+ if (!setup) {
438
+ throw new Error(
439
+ `resolveWorldAdapter: world "${world.id}"'s entry "${world.entry}" exports \`adapter\` ` +
440
+ `but no \`setup\` — default-pixi worlds only accept a \`setup(ctx: World2DContext)\` ` +
441
+ `export${adapter ? ' (an `adapter` export is default-three/R3F-only, W1b)' : ''}.`,
442
+ );
443
+ }
444
+ return new PixiSceneGameAdapter({
445
+ id: world.id,
446
+ setup: setup as unknown as World2DSetupFn,
447
+ }) as unknown as GameAdapter;
448
+ }
449
+ if (world.scene !== undefined) {
450
+ const sceneData = await fetchScene2DFile(world.scene);
451
+ return new PixiSceneGameAdapter({ id: world.id, sceneData }) as unknown as GameAdapter;
452
+ }
453
+ // Unreachable in practice: `load.ts`'s `checkAdapterSceneEntryRules` requires
454
+ // `scene` or `entry` for every 'default' adapter world at manifest-load time.
455
+ throw new Error(
456
+ `resolveWorldAdapter: world "${world.id}" is default-pixi with neither \`scene\` nor ` +
457
+ '`entry` — this should have been rejected by manifest validation.',
458
+ );
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Part F (T6.2 slice — docs/REACT-WORLD-DESIGN.md §1.B/§3 slice 1) — default-react
463
+ // ---------------------------------------------------------------------------
464
+
465
+ /**
466
+ * The shape a react world's `entry` module must satisfy: default-export a
467
+ * React component (docs/REACT-WORLD-DESIGN.md §1.B — "The world's `entry`
468
+ * module default-exports a React component"). Validated loudly at import
469
+ * time below — a project whose entry exports the wrong shape gets a named
470
+ * error naming the world and the export it actually found, not a cryptic
471
+ * "X is not a function" deep inside `createRoot`/`render`. Shared by BOTH
472
+ * react identities (`default-react` and, since Track N, `ingest-react`) —
473
+ * `identityLabel` only changes the error message's parenthetical so a
474
+ * failure is attributed to the right one; the shape requirement itself is
475
+ * identical (an ingest world's `entry` is a host-shim module, D-N2, but a
476
+ * shim is still just a module default-exporting a component).
477
+ */
478
+ function loadReactEntryComponent(
479
+ world: ResolvedWorldEntry,
480
+ mod: { default?: unknown },
481
+ identityLabel: 'default-react' | 'ingest-react' = 'default-react',
482
+ ): ComponentType {
483
+ if (typeof mod.default !== 'function') {
484
+ throw new Error(
485
+ `resolveWorldAdapter: world "${world.id}" (${identityLabel}) entry module does not ` +
486
+ `default-export a component — got ${typeof mod.default}. A react world's \`entry\` must ` +
487
+ 'default-export a React component (docs/REACT-WORLD-DESIGN.md §1.B).',
488
+ );
489
+ }
490
+ return mod.default as ComponentType;
491
+ }
492
+
493
+ /**
494
+ * `ReactWorldHost.game` is a typed, guaranteed field (the T6.2 slice-1 gap
495
+ * this function originally worked around — `mountOneReactWorld` not
496
+ * threading `game` onto the host — was closed the same day, `5948678`,
497
+ * symmetric with `HostContext.game`). The `undefined` in the return type and
498
+ * the loud check at the call site stay: a foreign/hand-rolled host object
499
+ * that skipped the engine's mount path must still fail loudly, never lie an
500
+ * `undefined` Game into `<GameProvider>`.
501
+ */
502
+ function hostGame(host: ReactWorldHost): Game | undefined {
503
+ return host.game;
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // Part F.1 (C1, phase-b/c1-react-mount) — packaged-mode react-world mount
508
+ // runtime (the dual-React-instance fix)
509
+ // ---------------------------------------------------------------------------
510
+
511
+ /**
512
+ * The three pieces of "react itself" a react-world mount needs:
513
+ * `createElement`/`createRoot` (to build + render the `<GameProvider><Entry/>
514
+ * </GameProvider>` tree) and the engine-published `GameProvider` FALLBACK
515
+ * (`resolveGameProviderForProject`'s "absent" branch, D-Z5). Under dev/
516
+ * Electron these are always the module top level's own static imports
517
+ * (`createElement`/`createRoot`/`EngineGameProvider` above) — byte-identical
518
+ * to before this type existed. See {@link resolveReactWorldMountRuntime}'s
519
+ * doc comment for the packaged-mode branch this type also serves.
520
+ */
521
+ export interface ReactWorldMountRuntime {
522
+ createElement: typeof createElement;
523
+ createRoot: typeof createRoot;
524
+ /** `react-dom`'s synchronous-flush escape hatch — `design-time-layers.ts`'s
525
+ * react-world layer mount needs it from the SAME react-dom peer as
526
+ * `createRoot` above (both are entry points of the same installed
527
+ * package). Unused by `adapter-resolver.ts`'s own two mount()s. */
528
+ flushSync: typeof flushSync;
529
+ engineGameProvider: ComponentType<{ game: Game; children?: unknown }>;
530
+ }
531
+
532
+ /** The dev/Electron default: this module's own static imports, unchanged. */
533
+ function staticReactWorldMountRuntime(): ReactWorldMountRuntime {
534
+ return {
535
+ createElement,
536
+ createRoot,
537
+ flushSync,
538
+ engineGameProvider: EngineGameProvider as unknown as ComponentType<{
539
+ game: Game;
540
+ children?: unknown;
541
+ }>,
542
+ };
543
+ }
544
+
545
+ /**
546
+ * Import + shape-check the packaged runtime's synthetic
547
+ * `/__vgai-react-world-runtime` module (`vite-plugin-react-world-runtime.ts`,
548
+ * registered only by `packaged.ts`'s project-rooted Vite instance and by
549
+ * `vitest.config.ts` for test coverage — never by `dev.ts`/the root
550
+ * `vite.config.ts`). A loud, named failure if the module resolves but is
551
+ * malformed — never a silent fall-through that would re-introduce the dual-
552
+ * instance bug it exists to prevent.
553
+ */
554
+ async function loadPackagedReactWorldMountRuntime(): Promise<ReactWorldMountRuntime> {
555
+ const mod = (await import(/* @vite-ignore */ REACT_WORLD_RUNTIME_PATH)) as {
556
+ createElement?: unknown;
557
+ createRoot?: unknown;
558
+ flushSync?: unknown;
559
+ EngineGameProvider?: unknown;
560
+ };
561
+ if (
562
+ typeof mod.createElement !== 'function' ||
563
+ typeof mod.createRoot !== 'function' ||
564
+ typeof mod.flushSync !== 'function' ||
565
+ typeof mod.EngineGameProvider !== 'function'
566
+ ) {
567
+ throw new Error(
568
+ "resolveWorldAdapter: the packaged runtime's synthetic react-world-runtime module " +
569
+ `("${REACT_WORLD_RUNTIME_PATH}") did not export createElement/createRoot/flushSync/` +
570
+ 'EngineGameProvider as functions — see vite-plugin-react-world-runtime.ts.',
571
+ );
572
+ }
573
+ return {
574
+ createElement: mod.createElement as typeof createElement,
575
+ createRoot: mod.createRoot as typeof createRoot,
576
+ flushSync: mod.flushSync as typeof flushSync,
577
+ engineGameProvider: mod.EngineGameProvider as ComponentType<{
578
+ game: Game;
579
+ children?: unknown;
580
+ }>,
581
+ };
582
+ }
583
+
584
+ let cachedPackagedReactWorldMountRuntime: Promise<ReactWorldMountRuntime> | null = null;
585
+
586
+ /**
587
+ * Resolve the react-world mount's OWN `createElement`/`createRoot`/engine-
588
+ * `GameProvider`-fallback — the C1 fix for the packaged runtime's dual-React-
589
+ * instance bug (`packaged.ts`'s header doc comment, "React-world OID
590
+ * authoring parity" section): a react world is an ISOLATED `createRoot` (its
591
+ * own DOM subtree / react tree) that does NOT need to share react with the
592
+ * editor's own UI — it only needs to be internally consistent (the wrapper +
593
+ * `GameProvider` + the project's entry component all sharing ONE react
594
+ * instance). Under the packaged runtime, the editor's own UI is a separately-
595
+ * built static bundle with its OWN baked-in `react`/`react-dom` copy,
596
+ * disconnected from the SEPARATE project-rooted Vite instance the entry
597
+ * component's hooks resolve `react` through — so mounting with the module top
598
+ * level's static imports (the editor's copy) produces two react instances and
599
+ * `useContext` throws on a `null` dispatcher (confirmed live, see
600
+ * `packaged.ts`'s header doc comment history).
601
+ *
602
+ * The fix: under the packaged runtime ONLY (gated on `isPackagedRuntime()`,
603
+ * `packaged-runtime.ts` — reads a `packaged` flag `/__editor/project` now
604
+ * reports, itself computed server-side via `isMonorepoScaffoldRoot`), resolve
605
+ * `createElement`/`createRoot`/the engine `GameProvider` fallback from the
606
+ * PROJECT's own module graph instead — via `/__vgai-react-world-runtime`, a
607
+ * synthetic virtual module registered only on that project-rooted Vite
608
+ * instance (`vite-plugin-react-world-runtime.ts`), so both the mount call and
609
+ * the entry component's own hooks converge on the SAME project-resolved react
610
+ * module instance, the same "static synthetic re-export" trick
611
+ * `reactWorldProviderPlugin`/`loadProjectGameProvider` already use one layer
612
+ * up for `GameProvider` itself.
613
+ *
614
+ * Under dev/Electron, `isPackagedRuntime()` resolves `false` (no such server
615
+ * flag; one shared Vite instance + root `vite.config.ts`'s `resolve.dedupe`
616
+ * already collapses the editor's and the project's react onto one instance)
617
+ * — this function then returns the module top level's own static imports,
618
+ * BYTE-IDENTICAL to the pre-C1 behavior. Memoized: `isPackagedRuntime()` is
619
+ * itself session-memoized, and the packaged module load below is cached here
620
+ * too, so this only ever performs at most one `/__editor/project` fetch and
621
+ * one dynamic import per editor session, however many react worlds mount.
622
+ *
623
+ * Exported: `design-time-layers.ts`'s OWN, separate react-world layer mount
624
+ * (`mountReactLayer` — the edit-mode composite's live preview of a non-
625
+ * focused react/pixi world, B1) needs this SAME resolution — it is a second
626
+ * mount call site with the identical dual-instance exposure, not routed
627
+ * through this file's `ReactWorldAdapter.mount()` at all.
628
+ */
629
+ export async function resolveReactWorldMountRuntime(): Promise<ReactWorldMountRuntime> {
630
+ const packaged = await isPackagedRuntime();
631
+ if (!packaged) return staticReactWorldMountRuntime();
632
+ if (!cachedPackagedReactWorldMountRuntime) {
633
+ cachedPackagedReactWorldMountRuntime = loadPackagedReactWorldMountRuntime();
634
+ }
635
+ return cachedPackagedReactWorldMountRuntime;
636
+ }
637
+
638
+ /** Test-only: reset the packaged react-world mount runtime cache between
639
+ * tests that toggle `isPackagedRuntime()`'s own (separately memoized, see
640
+ * `packaged-runtime.ts`'s `resetPackagedRuntimeCacheForTest`) result. */
641
+ export function resetReactWorldMountRuntimeCacheForTest(): void {
642
+ cachedPackagedReactWorldMountRuntime = null;
643
+ }
644
+
645
+ /**
646
+ * SECOND FINDING (report prominently): `GameProvider` is not an engine or
647
+ * editor export — by design (`docs/REACT-STATE-BRIDGE.md` §2: "colocated
648
+ * with `mountUI`… rather than under `packages/engine/src/runtime/` — that
649
+ * directory stays react-free"), it lives PER-PROJECT at
650
+ * `src/ui/game-state.tsx` (`packages/editor/template/src/ui/
651
+ * game-state.tsx`, copied verbatim into every scaffolded project by
652
+ * `create-vgai-project`'s `scaffoldProject` — see that package's README:
653
+ * "Copies the template into the target directory"). There is no engine-side
654
+ * canonical `GameProvider` this resolver could import once and reuse for
655
+ * every project — it has to load the SAME project's own copy the entry
656
+ * module's OWN `../ui/game-state` import resolves to (a resolver-local
657
+ * `createContext()` would be a SECOND, non-identical Context: `useGame()`
658
+ * would throw "no Game in context" against it even though `<GameProvider>`
659
+ * genuinely wrapped the tree).
660
+ *
661
+ * THIRD FINDING, and the actual engine-adjacent-tooling gap this slice
662
+ * closes (T6.2 slice 3 — verified empirically, not assumed): a hand-built
663
+ * runtime `import(/* @vite-ignore *\/ '/@fs/' + projectRoot + '/src/
664
+ * ui/game-state.tsx')` does NOT reliably land on the SAME module
665
+ * instance a react-world entry's own STATIC relative import of that file
666
+ * resolves to. Vite's dev server treats the two differently at the HTTP
667
+ * level (a query-string divergence was observed in practice — confirmed by
668
+ * logging `import.meta.url` inside two otherwise-identical fetches) even
669
+ * though both target the identical file on disk; since browser ES module
670
+ * identity is keyed by the exact request URL, that is enough to produce TWO
671
+ * separate `createContext()` calls — exactly the multi-instance problem
672
+ * this doc comment already warns about, just triggered by the RESOLVER's
673
+ * own fetch mechanics rather than a hand-authored duplicate. The fix:
674
+ * `vite-plugin-react-world-provider.ts`'s tiny synthetic module, whose body
675
+ * is a LITERAL, STATIC `export { GameProvider } from '/@fs/…/game-state.tsx'`
676
+ * — Vite's import-analysis rewrites that specifier through the SAME
677
+ * transform-time resolver a react-world entry's own static import goes
678
+ * through, converging both on the identical final module.
679
+ *
680
+ * D-Z5 (`docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md` §D-Z5) revises what happens
681
+ * when `src/ui/game-state.tsx` is simply ABSENT: this function is now only
682
+ * ever called after a caller has confirmed (via `probeGameStateFileExists`)
683
+ * that the file genuinely exists, so the hard throw below is reserved for
684
+ * the file EXISTING but not exporting `GameProvider` correctly — a real bug,
685
+ * never swallowed. Absence itself degrades to the engine-published
686
+ * `GameProvider` (`@engine/react/game-state`) instead, at both call sites
687
+ * (`resolveDefaultReactAdapter` and `resolveIngestReactAdapter` below) — see
688
+ * `resolveGameProviderForProject`, the shared probe-then-load-or-fallback
689
+ * helper this revision introduces.
690
+ */
691
+ async function loadProjectGameProvider(
692
+ projectRoot: string,
693
+ ): Promise<ComponentType<{ game: Game; children?: unknown }>> {
694
+ const modulePath = reactWorldProviderImportSpecifier(projectRoot);
695
+ const mod = (await import(/* @vite-ignore */ modulePath)) as {
696
+ GameProvider?: ComponentType<{ game: Game; children?: unknown }>;
697
+ };
698
+ if (typeof mod.GameProvider !== 'function') {
699
+ throw new Error(
700
+ 'resolveWorldAdapter: could not find `GameProvider` at ' +
701
+ `"src/ui/game-state.tsx" under project root "${projectRoot}" — a react world's ` +
702
+ "mount needs the project's own GameProvider (docs/REACT-STATE-BRIDGE.md §2) so " +
703
+ "useGameState/useGame resolve against the SAME React context the entry's own code uses.",
704
+ );
705
+ }
706
+ return mod.GameProvider;
707
+ }
708
+
709
+ /**
710
+ * Resolve a `kind: 'react'` + `adapter: 'default'` world into a
711
+ * `ReactWorldAdapter` (docs/REACT-WORLD-DESIGN.md §1.B, T6.2 slice 1 task
712
+ * list's "Mount" item) — the react sibling of `resolveDefaultThreeAdapter`/
713
+ * `resolveDefaultPixiAdapter`. `entry` resolves via the SAME `/@fs/`
714
+ * dynamic-import route `loadProjectScripts` uses (`resolveDefaultPixiAdapter`'s
715
+ * own entry-adjudication precedent) — but a react entry module default-
716
+ * exports a component, not a `setup` function, so this reads the module
717
+ * directly rather than forcing it through `loadProjectScripts`'s
718
+ * `GameSetupFn`-shaped return.
719
+ *
720
+ * `mount(host)` follows the engine team's recorded recipe exactly:
721
+ * `createRoot(host.container).render(<GameProvider game={game}><Entry/></GameProvider>)`,
722
+ * returning `{ drivesOwnLoop: false, dispose: () => root.unmount() }`. See
723
+ * `hostGame`'s and `loadProjectGameProvider`'s doc comments above for the two
724
+ * honest gaps this depends on (the engine-merged `ReactWorldHost` not yet
725
+ * carrying `game`; `GameProvider` being a per-project file, not an export).
726
+ *
727
+ * D-Z5: `GameProvider` here comes from `resolveGameProviderForProject`, which
728
+ * now falls back to the engine-PUBLISHED `GameProvider`
729
+ * (`@engine/react/game-state`) plus a loud once-per-world report when the
730
+ * project ships no `src/ui/game-state.tsx` at all — this retires the former
731
+ * hard throw for that case (a project whose file EXISTS but is broken still
732
+ * throws for real; only genuine absence degrades). Safe by construction: the
733
+ * template's own `src/ui/game-state.tsx` is now itself a thin re-export of
734
+ * this same engine module (D-Z5 step 3), so a freshly scaffolded project's
735
+ * "present" and "absent" paths converge on the identical `GameProvider`
736
+ * either way.
737
+ *
738
+ * `scene` on a react world is a NAMED error (JSX-as-truth, D4 —
739
+ * docs/REACT-WORLD-DESIGN.md §1.A: "A react WORLD's truth is JSX source —
740
+ * … It is code; it never serializes to `ui` JSON" — there is no scene file
741
+ * format for a react world at all). The manifest loader's own
742
+ * `checkAdapterSceneEntryRules` only requires ONE of `scene`/`entry` for a
743
+ * `default`-adapter world (it does not forbid `scene` on a react world
744
+ * specifically — see `load.ts`), so this resolver is where the D4 rule is
745
+ * actually enforced for react worlds.
746
+ */
747
+
748
+ /**
749
+ * The `default-react` shared entry-loading step, extracted (D-V3,
750
+ * docs/WAVE5-MULTIWORLD-INGEST-DESIGN.md) so a composite's `default-react`
751
+ * SIBLING mount (`packages/editor/src/ingest-siblings.ts`) can reuse the
752
+ * EXACT SAME D4 `scene`-forbidden / `entry`-required checks and
753
+ * `loadReactEntryComponent` shape validation `resolveDefaultReactAdapter`
754
+ * below uses, WITHOUT duplicating any of it — the sibling then renders the
755
+ * returned component BARE (no `<GameProvider>` wrap: there is no native
756
+ * `Game` beside an ingest world), while `resolveDefaultReactAdapter` wraps
757
+ * it in `<GameProvider>` as it always has. Exported for that one caller.
758
+ */
759
+ export async function resolveReactWorldEntryComponent(
760
+ world: ResolvedWorldEntry,
761
+ projectRoot: string,
762
+ ): Promise<ComponentType> {
763
+ if (world.scene !== undefined) {
764
+ throw new Error(
765
+ `resolveWorldAdapter: world "${world.id}" (default-react) declares \`scene\` — react ` +
766
+ 'worlds have no scene-file format: JSX source is the truth for a react world (D4, ' +
767
+ 'docs/REACT-WORLD-DESIGN.md §1.A). Use `entry` (a .tsx module default-exporting a ' +
768
+ 'component) instead.',
769
+ );
770
+ }
771
+ if (world.entry === undefined) {
772
+ // Unreachable in practice: `load.ts`'s `checkAdapterSceneEntryRules` requires
773
+ // `scene` or `entry` for every 'default' adapter world at manifest-load time,
774
+ // and the check above already rejects `scene` for a react world — so a real
775
+ // manifest can never reach this branch. Still fails loudly, not silently.
776
+ throw new Error(
777
+ `resolveWorldAdapter: world "${world.id}" is default-react with no \`entry\` — this should ` +
778
+ 'have been rejected by manifest validation.',
779
+ );
780
+ }
781
+
782
+ const modulePath = fsImportPath(projectRoot, world.entry);
783
+ const mod = (await import(/* @vite-ignore */ modulePath)) as { default?: unknown };
784
+ return loadReactEntryComponent(world, mod);
785
+ }
786
+
787
+ async function resolveDefaultReactAdapter(
788
+ world: ResolvedWorldEntry,
789
+ projectRoot: string,
790
+ ): Promise<GameAdapter> {
791
+ const Entry = await resolveReactWorldEntryComponent(world, projectRoot);
792
+ const GameProvider = await resolveGameProviderForProject(projectRoot, world.id, 'default-react');
793
+
794
+ const adapter: ReactWorldAdapter = {
795
+ id: world.id,
796
+ async mount(host: ReactWorldHost): Promise<MountedReactGame> {
797
+ const game = hostGame(host);
798
+ if (!game) {
799
+ throw new Error(
800
+ `resolveWorldAdapter: world "${world.id}" (default-react) mount() received a ` +
801
+ 'ReactWorldHost with no `game` — GameProvider needs a live Game at mount time and ' +
802
+ "this host did not carry one (see adapter-resolver.ts's `hostGame` doc comment for " +
803
+ 'the engine-side gap this indicates).',
804
+ );
805
+ }
806
+ // C1: createElement/createRoot come from resolveReactWorldMountRuntime
807
+ // (the PROJECT's own react under the packaged runtime, this module's
808
+ // static imports otherwise) - never the module top-level statics
809
+ // directly, so this mount always shares ONE react instance with the
810
+ // entry component's own hooks. See that function's doc comment.
811
+ const runtime = await resolveReactWorldMountRuntime();
812
+ const root = runtime.createRoot(host.container);
813
+ root.render(
814
+ runtime.createElement(GameProvider, { game, children: runtime.createElement(Entry) }),
815
+ );
816
+ return {
817
+ kind: 'react',
818
+ container: host.container,
819
+ drivesOwnLoop: false,
820
+ dispose(): void {
821
+ root.unmount();
822
+ },
823
+ };
824
+ },
825
+ };
826
+ return adapter as unknown as GameAdapter;
827
+ }
828
+
829
+ // ---------------------------------------------------------------------------
830
+ // Part G (Track N, N1 — docs/REACT-INGEST-LANDING-DESIGN.md D-N2/D-N3/D-N4) —
831
+ // ingest-react
832
+ // ---------------------------------------------------------------------------
833
+
834
+ /**
835
+ * Resolve a `kind: 'react'` + `{ ingest: { strategy: 'deduped' } }` world into
836
+ * a `ReactWorldAdapter` — the ingest sibling of `resolveDefaultReactAdapter`
837
+ * immediately above. Mounts through the EXACT SAME T6.2 react host stack:
838
+ * the same `/@fs/` `entry`-module dynamic import, the same default-export-
839
+ * component validation (`loadReactEntryComponent`), the same per-project
840
+ * `GameProvider` wrap (`loadProjectGameProvider`), the same
841
+ * `createRoot(...).render(<GameProvider><Entry/></GameProvider>)` mount body.
842
+ * Only the adapter identity differs — D-N2's framing is explicit that this
843
+ * is "one stacking model, no special case": "the automatic `<GameProvider>`
844
+ * wrap is context-only and harmless to a foreign tree."
845
+ *
846
+ * `entry` here is NOT a first-party project's own react-world component —
847
+ * it is a HOST-SHIM module (D-N2) living beside the ingested game's per-game
848
+ * manifest (`packages/editor/src/ingest/games-react/<id>/`, N2), importing
849
+ * the vendored game's own `App`/store/`persistor` and default-exporting the
850
+ * composed component. This resolver has no knowledge of the vendored game's
851
+ * OWN standalone entry point (its raw `ReactDOM.render` call) either way —
852
+ * it only ever imports the manifest's declared `entry`, exactly like
853
+ * `default-react` — so that entry point is never reached through this path,
854
+ * matching D-N2's "never imported; it must never run."
855
+ *
856
+ * Two rules `default-react` does not need, both loud named errors — never a
857
+ * silent floor, matching every other ingest resolver in this file
858
+ * (`resolveIngestDescriptor`/`resolveIngest2DDescriptor` throw the same way
859
+ * for a strategy they can't build):
860
+ *
861
+ * - `strategy` MUST be `'deduped'` — react ingestion v1 ships ONLY the
862
+ * deduped rung (D-N3: "the vendored source is served through the
863
+ * editor's own Vite pipeline… with react/react-dom deduped to the host's
864
+ * React 19… an iframe/opaque-embed floor is recorded as out of scope for
865
+ * v1"). Any other strategy is structurally legal at the manifest-load
866
+ * layer (`load.ts` assigns `'ingest-react'` regardless of strategy,
867
+ * exactly like `'ingest-three'`/`'ingest-pixi'` stay strategy-agnostic
868
+ * there) but throws HERE, naming the strategy it actually got.
869
+ * - `entry` is REQUIRED — there is no scene-or-entry choice the way
870
+ * `default-three` has; an ingest-react world IS its host shim (D-N2).
871
+ *
872
+ * `scene` on an ingest-react world is already rejected before this function
873
+ * ever runs against a REAL manifest: `load.ts`'s `checkAdapterSceneEntryRules`
874
+ * forbids `scene` on any `{ ingest }`-adapter world, kind-agnostic. This
875
+ * function still guards it defensively (never trust a hand-built
876
+ * `ResolvedWorldEntry` a unit test — or a future caller — might construct
877
+ * past that check), with the same message shape `resolveDefaultReactAdapter`'s
878
+ * own `scene` check uses.
879
+ *
880
+ * D-N8 ("no authoring in v1") and D-N4 item 1 (no `writeBackend` for an
881
+ * ingest-react world): this function's return value is `{ id, mount }` only
882
+ * — structurally identical in shape to `resolveDefaultReactAdapter`'s return
883
+ * — there is no authoring/write-back hook anywhere on a `ReactWorldAdapter`
884
+ * for EITHER react identity. `ReactWorldAuthoringAdapter`/`writeBackend`
885
+ * wiring lives entirely outside this file, in `play-mode.ts`'s
886
+ * `installMultiWorldAuthoring`, keyed on `world.kind === 'react'` — not on
887
+ * adapter identity. That function is structurally unreachable for
888
+ * `ingest-react` today: `resolveAllWorlds`'s `MULTI_WORLD_ALLOWED_IDENTITIES`
889
+ * (below) deliberately excludes `'ingest-react'`, so an ingest-react world
890
+ * can only ever be the LONE world of a single-world manifest — and
891
+ * `play-mode.ts`'s single-world play branch calls `installPlayAuthoring`
892
+ * (wraps `getActiveAuthoring(store)`, the first-party
893
+ * `VgaiSceneAuthoringAdapter`) instead, for every world kind, never
894
+ * `installMultiWorldAuthoring`. So "no write-capable authoring surface" holds
895
+ * for `ingest-react` today by construction, not by an extra guard this
896
+ * function adds — recorded honestly here rather than claimed as this
897
+ * function's own achievement; see the N1 report for the residual this
898
+ * implies for N2 if react ingestion ever needs to join a multi-world
899
+ * manifest.
900
+ *
901
+ * D-Y3 (wave 2, docs/EXTERNAL-REACT-AND-MODULE-DESIGN.md §1) introduced ONE
902
+ * piece of the above: `loadProjectGameProvider`'s hard throw when
903
+ * `src/ui/game-state.tsx` is absent was right for `default-react` (a
904
+ * vgai-native world legitimately depends on the bridge) but wrong for
905
+ * `ingest-react` — a foreign tree ingested as-is has no reason to ship that
906
+ * file at all (it calls no vgai hooks unless its own shim explicitly wants
907
+ * them). `probeGameStateFileExists` checks (via a plain `fetch()` of the
908
+ * exact `/@fs/` path `loadProjectGameProvider` would import, mirroring
909
+ * `fetchScene2DFile`'s existing fetch-based existence check elsewhere in
910
+ * this file) whether the file is even THERE before ever calling the real
911
+ * loader.
912
+ *
913
+ * D-Z5 (`docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md` §D-Z5) revises what D-Y3's
914
+ * "absent" branch DOES, for both react identities: absence now wraps with
915
+ * the engine-PUBLISHED `GameProvider` (`@engine/react/game-state`) instead
916
+ * of mounting unwrapped — safe because an unmodified foreign entry (or a
917
+ * project with no bridge file of its own) imports no vgai hooks bound to a
918
+ * DIFFERENT context, so there is no context-identity split to cause (see
919
+ * `packages/engine/src/react/game-state.tsx`'s module doc comment, and
920
+ * `game-state-context-split.test.tsx`'s red proof of what WOULD happen if a
921
+ * project's own copy were wrapped by the wrong provider). Presence still
922
+ * runs the real loader (so a game-state.tsx that EXISTS but is broken still
923
+ * throws its own real error loudly — the probe never swallows that, it only
924
+ * distinguishes "not there" from "there"). See `resolveGameProviderForProject`
925
+ * below, the shared probe-then-load-or-fallback helper both `default-react`
926
+ * and `ingest-react` now call.
927
+ */
928
+ async function probeGameStateFileExists(projectRoot: string): Promise<boolean> {
929
+ const path = fsImportPath(projectRoot, 'src/ui/game-state.tsx');
930
+ const relative = resolveUrl(path);
931
+ // `fetch()` needs an absolute URL when there is no implicit browser
932
+ // document base to resolve a leading-slash path against (a real dev-server
933
+ // session always has one; a headless Node/vitest caller does not) — build
934
+ // one explicitly rather than relying on an environment-specific implicit
935
+ // base, so this probe behaves identically (and is mockable) in both.
936
+ const origin =
937
+ typeof window !== 'undefined' && window.location ? window.location.origin : 'http://localhost';
938
+ try {
939
+ const res = await fetch(new URL(relative, origin).toString());
940
+ // `res.ok` alone is NOT "the file exists": Vite's SPA fallback answers
941
+ // any unknown GET — including a missing `/@fs/…` path — with index.html
942
+ // and a 200, so on a real dev server the absence branch below was
943
+ // unreachable and a project genuinely shipping no `src/ui/game-state.tsx`
944
+ // crashed later inside `loadProjectGameProvider`'s synthetic re-export
945
+ // instead of degrading to the engine provider (found by the first such
946
+ // project: alien-stories' proto). A real hit comes back as transformed
947
+ // JS; only the fallback is `text/html`. (`headers` is optional-chained
948
+ // for partial `Response` stubs in tests.)
949
+ const contentType = res.headers?.get?.('content-type') ?? '';
950
+ return res.ok && !contentType.includes('text/html');
951
+ } catch {
952
+ // Fetch itself failing (network hiccup, no dev server route, …) is
953
+ // treated the same as "absent" — the honest fallback here is the
954
+ // engine-provider-plus-loud-report degrade (`resolveGameProviderForProject`),
955
+ // never a silent crash on an ambiguous probe failure.
956
+ return false;
957
+ }
958
+ }
959
+
960
+ /** Dedup key set for {@link reportEngineProviderFallbackOnce} — one
961
+ * structured warning per (identity, world id) per session, mirroring
962
+ * `runtime/game.ts`'s `reportGateShortfallOnce` "once per (kind, id)" idiom
963
+ * (`docs/CAPABILITY-TIERS.md` §d's reporting precedent) rather than
964
+ * re-logging on every mount of a session that re-enters the same world. */
965
+ const REPORTED_ENGINE_PROVIDER_FALLBACK = new Set<string>();
966
+
967
+ /**
968
+ * Greppable prefix for the D-Z5 "wrapped with the engine-published
969
+ * GameProvider" report — the renamed successor to wave-2's
970
+ * `INGEST_REACT_NO_PROVIDER_PREFIX` ("ingest-react-no-provider"), retired
971
+ * because that name described the OLD "mounted unwrapped" behavior; this
972
+ * fallback always wraps now, so the honest name says what actually happens.
973
+ * Shared by both `default-react` and `ingest-react` (D-Z5 extends the D-Y3
974
+ * ingest-only degrade to `default-react` too, since the template re-export
975
+ * — D-Z5 step 3 — landed in the same slice).
976
+ */
977
+ export const ENGINE_GAME_PROVIDER_FALLBACK_PREFIX = 'engine-game-provider-fallback';
978
+
979
+ /** Plain `console.warn` (not `editorConsole`) — same reasoning + idiom as
980
+ * `runtime/game.ts`'s `reportGateShortfallOnce`: this resolver is imported
981
+ * by headless Node/vitest suites with no `window`, and `editor-console.ts`
982
+ * touches `window` unconditionally at its module top level, so a
983
+ * first-class report here stays environment-neutral rather than forcing
984
+ * every caller (test or real) to stub `window` just to see it. */
985
+ function reportEngineProviderFallbackOnce(
986
+ identity: 'default-react' | 'ingest-react',
987
+ worldId: string,
988
+ ): void {
989
+ const key = `${identity}:${worldId}`;
990
+ if (REPORTED_ENGINE_PROVIDER_FALLBACK.has(key)) return;
991
+ REPORTED_ENGINE_PROVIDER_FALLBACK.add(key);
992
+ const locationText = identity === 'ingest-react' ? 'beside the manifest' : 'in this project';
993
+ // biome-ignore lint/suspicious/noConsole: structured, greppable — mirrors runtime/game.ts's reportGateShortfallOnce
994
+ console.warn(
995
+ `${ENGINE_GAME_PROVIDER_FALLBACK_PREFIX} world "${worldId}" (${identity}): no ` +
996
+ `src/ui/game-state.tsx ${locationText} — wrapping the entry with the engine-published ` +
997
+ 'GameProvider (@engine/react/game-state) instead. useGameState/useGame work against the ' +
998
+ 'engine bridge; ship your own src/ui/game-state.tsx to override with a custom bridge ' +
999
+ '(docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md D-Z5, docs/REACT-STATE-BRIDGE.md §2/addendum). ' +
1000
+ `${ENGINE_GAME_PROVIDER_FALLBACK_PREFIX} ${JSON.stringify({ worldId, identity })}`,
1001
+ );
1002
+ }
1003
+
1004
+ /**
1005
+ * Shared probe-then-load-or-fallback helper (D-Z5) for both react
1006
+ * identities: if the project ships `src/ui/game-state.tsx`, load it for real
1007
+ * (`loadProjectGameProvider` — a file that exists but is broken still throws
1008
+ * its own real error, never swallowed); if it's genuinely absent, fall back
1009
+ * to the engine-published `GameProvider` and report once per (identity,
1010
+ * world id). See the doc comment on `probeGameStateFileExists` above for the
1011
+ * full D-Y3 -> D-Z5 history of this behavior.
1012
+ *
1013
+ * Exported (B1): `design-time-layers.ts` needs this SAME per-project
1014
+ * resolution (a project's own `src/ui/game-state.tsx`, when present, has its
1015
+ * own `createContext()` — wrapping it with the wrong `GameProvider` splits
1016
+ * context and `useGame()` throws even though a provider IS in the tree, see
1017
+ * `@engine/react/game-state`'s module doc comment) for its design-time react
1018
+ * layer mount, rather than re-deriving project-vs-engine provider resolution.
1019
+ */
1020
+ export async function resolveGameProviderForProject(
1021
+ projectRoot: string,
1022
+ worldId: string,
1023
+ identity: 'default-react' | 'ingest-react',
1024
+ ): Promise<ComponentType<{ game: Game; children?: unknown }>> {
1025
+ const hasGameProvider = await probeGameStateFileExists(projectRoot);
1026
+ if (hasGameProvider) {
1027
+ return loadProjectGameProvider(projectRoot);
1028
+ }
1029
+ reportEngineProviderFallbackOnce(identity, worldId);
1030
+ // C1: the engine-published `GameProvider` fallback itself must come from
1031
+ // the SAME react instance the mount's `createRoot`/the entry component's
1032
+ // hooks use — under the packaged runtime that is the PROJECT's react, not
1033
+ // this module's own static `EngineGameProvider` import (the editor's
1034
+ // separately-bundled copy). `resolveReactWorldMountRuntime` already
1035
+ // encapsulates the dev/Electron-vs-packaged gate (byte-identical to the
1036
+ // old `EngineGameProvider as unknown as …` cast when not packaged); reuse
1037
+ // it here instead of re-deriving the gate.
1038
+ const runtime = await resolveReactWorldMountRuntime();
1039
+ return runtime.engineGameProvider;
1040
+ }
1041
+
1042
+ async function resolveIngestReactAdapter(
1043
+ world: ResolvedWorldEntry,
1044
+ projectRoot: string,
1045
+ ): Promise<GameAdapter> {
1046
+ const { adapter } = world;
1047
+ if (adapter.type !== 'ingest') {
1048
+ throw new Error(`resolveWorldAdapter: world "${world.id}" is not an { ingest } world.`);
1049
+ }
1050
+ if (adapter.strategy !== 'deduped') {
1051
+ throw new Error(
1052
+ `resolveWorldAdapter: world "${world.id}" (ingest-react) declares strategy ` +
1053
+ `"${adapter.strategy}" — react ingestion v1 supports ONLY the 'deduped' rung (loud ` +
1054
+ 'degrade, no silent floor: docs/REACT-INGEST-LANDING-DESIGN.md D-N3). An iframe-' +
1055
+ 'reachable/opaque-embed floor is explicitly out of scope for v1.',
1056
+ );
1057
+ }
1058
+ if (world.scene !== undefined) {
1059
+ throw new Error(
1060
+ `resolveWorldAdapter: world "${world.id}" (ingest-react) declares \`scene\` — react ` +
1061
+ 'worlds have no scene-file format: JSX source is the truth for a react world (D4, ' +
1062
+ 'docs/REACT-WORLD-DESIGN.md §1.A), and an ingested react world doubly so — the vendored ' +
1063
+ 'game is never modified. Use `entry` (the host-shim module, D-N2) instead.',
1064
+ );
1065
+ }
1066
+ if (world.entry === undefined) {
1067
+ throw new Error(
1068
+ `resolveWorldAdapter: world "${world.id}" (ingest-react) has no \`entry\` — an ingested ` +
1069
+ "react world's `entry` MUST point at a host-shim module that imports the vendored " +
1070
+ "game's own component and default-exports it (D-N2, docs/REACT-INGEST-LANDING-DESIGN.md).",
1071
+ );
1072
+ }
1073
+
1074
+ const modulePath = fsImportPath(projectRoot, world.entry);
1075
+ const mod = (await import(/* @vite-ignore */ modulePath)) as { default?: unknown };
1076
+ const Entry = loadReactEntryComponent(world, mod, 'ingest-react');
1077
+ // D-Z5: always resolves to SOME GameProvider now — the project's own
1078
+ // (present) or the engine-published fallback + a loud report (absent). See
1079
+ // `resolveGameProviderForProject`'s doc comment for the full history.
1080
+ const GameProvider = await resolveGameProviderForProject(projectRoot, world.id, 'ingest-react');
1081
+
1082
+ const reactAdapter: ReactWorldAdapter = {
1083
+ id: world.id,
1084
+ async mount(host: ReactWorldHost): Promise<MountedReactGame> {
1085
+ const game = hostGame(host);
1086
+ if (!game) {
1087
+ throw new Error(
1088
+ `resolveWorldAdapter: world "${world.id}" (ingest-react) mount() received a ` +
1089
+ 'ReactWorldHost with no `game` — GameProvider needs a live Game at mount time and ' +
1090
+ "this host did not carry one (see adapter-resolver.ts's `hostGame` doc comment for " +
1091
+ 'the engine-side gap this indicates).',
1092
+ );
1093
+ }
1094
+ // C1: createElement/createRoot come from resolveReactWorldMountRuntime
1095
+ // (the PROJECT's own react under the packaged runtime, this module's
1096
+ // static imports otherwise) - never the module top-level statics
1097
+ // directly, so this mount always shares ONE react instance with the
1098
+ // entry component's own hooks. See that function's doc comment.
1099
+ const runtime = await resolveReactWorldMountRuntime();
1100
+ const root = runtime.createRoot(host.container);
1101
+ root.render(
1102
+ runtime.createElement(GameProvider, { game, children: runtime.createElement(Entry) }),
1103
+ );
1104
+ return {
1105
+ kind: 'react',
1106
+ container: host.container,
1107
+ drivesOwnLoop: false,
1108
+ dispose(): void {
1109
+ root.unmount();
1110
+ },
1111
+ };
1112
+ },
1113
+ };
1114
+ return reactAdapter as unknown as GameAdapter;
1115
+ }
1116
+
1117
+ // ---------------------------------------------------------------------------
1118
+ // Part A/D — ingest-three
1119
+ // ---------------------------------------------------------------------------
1120
+
1121
+ /**
1122
+ * Fetch a project-relative file's TEXT bytes through the dev-server-only
1123
+ * `/project-game-static/<path>` route (D-W2, docs/WAVE4-FTUE-HARDENING-
1124
+ * DESIGN.md) — the opaque-embed floor's file read, shared by BOTH kinds
1125
+ * (`resolveIngestDescriptor`/`resolveIngest2DDescriptor` below): the route
1126
+ * serves the CURRENTLY OPEN project's folder verbatim
1127
+ * (`vite-plugin-project-game-static.ts`), so a manifest's `entryHtml` (a
1128
+ * project-relative path, exactly like the in-tree fixture glob's `entryHtml`
1129
+ * convention) maps directly to `/project-game-static/<entryHtml>`. Absolute
1130
+ * URL construction mirrors `probeGameStateFileExists`'s origin handling
1131
+ * above (a real dev-server session has an implicit browser document base; a
1132
+ * headless Node/vitest caller does not — build one explicitly so this stays
1133
+ * mockable/testable in both).
1134
+ */
1135
+ async function fetchProjectStaticText(entryHtmlPath: string): Promise<string> {
1136
+ const rel = entryHtmlPath.startsWith('/') ? entryHtmlPath.slice(1) : entryHtmlPath;
1137
+ const url = `/project-game-static/${rel}`;
1138
+ const origin =
1139
+ typeof window !== 'undefined' && window.location ? window.location.origin : 'http://localhost';
1140
+ const res = await fetch(new URL(url, origin).toString());
1141
+ if (!res.ok) {
1142
+ throw new Error(
1143
+ `resolveIngestDescriptor: failed to load opaque-embed entryHtml "${entryHtmlPath}" via ` +
1144
+ `${url} (${res.status} ${res.statusText}).`,
1145
+ );
1146
+ }
1147
+ return res.text();
1148
+ }
1149
+
1150
+ /**
1151
+ * The `iframe-reachable` branch of {@link resolveIngestDescriptor} — the
1152
+ * three-side twin of `resolveIngest2DDescriptor`'s
1153
+ * `buildIframeReachableDescriptor2D`, split out for the same cognitive-
1154
+ * complexity reason (biome's `noExcessiveCognitiveComplexity`). `bundleUrl`
1155
+ * is required (an `entryHtml`-only iframe-reachable world stays an unwired
1156
+ * named error, same text pattern pixi's sibling uses); a `pixiModuleUrl`
1157
+ * declared on a threejs world is a DIFFERENT named error — three has no
1158
+ * version-skew mount this wave (D-W7 explicitly parks a hypothetical
1159
+ * `threeModuleUrl` field: "no consumer exists").
1160
+ */
1161
+ function buildIframeReachableIngestDescriptor(
1162
+ world: ResolvedWorldEntry,
1163
+ adapter: Extract<ResolvedWorldEntry['adapter'], { type: 'ingest' }>,
1164
+ base: Pick<
1165
+ IngestGame,
1166
+ 'id' | 'name' | 'description' | 'assets' | 'domStubs' | 'captureTimeoutMs'
1167
+ >,
1168
+ ): IngestGame {
1169
+ if (adapter.pixiModuleUrl !== undefined) {
1170
+ throw new Error(
1171
+ `resolveIngestDescriptor: world "${world.id}" (kind threejs, strategy ` +
1172
+ '"iframe-reachable") declares `pixiModuleUrl` — that field is pixi-specific version-' +
1173
+ 'skew wiring (a standalone matching-major pixi build to trap instead of the host pixi) ' +
1174
+ 'with no threejs equivalent implemented this wave: D-W7 explicitly parks a hypothetical ' +
1175
+ '`threeModuleUrl` field (docs/WAVE4-FTUE-HARDENING-DESIGN.md) — "no consumer exists".',
1176
+ );
1177
+ }
1178
+ if (!adapter.bundleUrl) {
1179
+ throw new Error(
1180
+ `resolveIngestDescriptor: world "${world.id}" (strategy "iframe-reachable") has no ` +
1181
+ '`bundleUrl` — only the externalized-bundle route is buildable here (an ' +
1182
+ "`entryHtml`-only iframe-reachable world isn't wired; the manifest schema already " +
1183
+ 'requires exactly one of bundleUrl/entryHtml for this strategy).',
1184
+ );
1185
+ }
1186
+ const { bundleUrl, baseHref, assetBaseUrl, extraDeps, bodyHtml } = adapter;
1187
+ return {
1188
+ ...base,
1189
+ bundleUrl,
1190
+ ...(baseHref !== undefined ? { baseHref } : {}),
1191
+ ...(assetBaseUrl !== undefined ? { assetBaseUrl } : {}),
1192
+ ...(extraDeps !== undefined ? { extraDeps: () => resolveExtraDeps(extraDeps) } : {}),
1193
+ ...(bodyHtml !== undefined ? { bodyHtml } : {}),
1194
+ };
1195
+ }
1196
+
1197
+ /**
1198
+ * The `opaque-embed` branch of {@link resolveIngestDescriptor} (D-W2): reads
1199
+ * the declared `entryHtml` file's bytes through {@link fetchProjectStaticText}
1200
+ * as a LAZY thunk on the descriptor — never eagerly, only when the game is
1201
+ * actually mounted (`enterIngestModeWithGame` awaits `game.embedHtml()`).
1202
+ */
1203
+ function buildOpaqueEmbedIngestDescriptor(
1204
+ world: ResolvedWorldEntry,
1205
+ adapter: Extract<ResolvedWorldEntry['adapter'], { type: 'ingest' }>,
1206
+ base: Pick<
1207
+ IngestGame,
1208
+ 'id' | 'name' | 'description' | 'assets' | 'domStubs' | 'captureTimeoutMs'
1209
+ >,
1210
+ ): IngestGame {
1211
+ if (!adapter.entryHtml) {
1212
+ throw new Error(
1213
+ `resolveIngestDescriptor: world "${world.id}" (strategy "opaque-embed") has no ` +
1214
+ '`entryHtml` — the opaque-embed floor needs the entry HTML file path to read (D-W2, ' +
1215
+ 'docs/WAVE4-FTUE-HARDENING-DESIGN.md).',
1216
+ );
1217
+ }
1218
+ const entryHtml = adapter.entryHtml;
1219
+ return { ...base, embedHtml: () => fetchProjectStaticText(entryHtml) };
1220
+ }
1221
+
1222
+ /**
1223
+ * Build the {@link IngestGame} descriptor an `ingest-three` world resolves
1224
+ * to, straight from its manifest fields — deliberately WITHOUT looking up any
1225
+ * registry. This is the part of §1D that "parameterizes by the manifest's
1226
+ * ingest fields rather than a registry id" — and, as of T3.3 slice 3
1227
+ * (§1E), there IS no registry left to look up: `ingest/games/registry.ts`
1228
+ * (`INGEST_GAMES`) is deleted; the in-tree fixture games it used to list are
1229
+ * discovered separately by `ingest/discovery.ts` (`import.meta.glob` over
1230
+ * `./games/*\/vgai.game.json`, NOT through this function — see that file's
1231
+ * doc comment for why). This function stays the builder for the OTHER half
1232
+ * of §1E: an arbitrary manifest-backed project folder never registered in
1233
+ * source (the CLI-on-folder route), whose `ingest-three` world this function
1234
+ * turns into a mountable descriptor via `/@fs/${projectRoot}/...`.
1235
+ *
1236
+ * `shared`/`deduped` resolve to `IngestGame.load` via the same `/@fs/`
1237
+ * dynamic-import route `loadProjectScripts` uses, keyed off the world's
1238
+ * `entry` field. D-W1 (docs/WAVE4-FTUE-HARDENING-DESIGN.md) adds
1239
+ * `iframe-reachable`: the manifest's `bundleUrl`/`baseHref`/`assetBaseUrl`/
1240
+ * `bodyHtml` are carried straight through, `extraDeps` becomes a LAZY thunk
1241
+ * (`IngestGame.extraDeps`) wired to `./ingest/host-namespace-registry.ts` —
1242
+ * resolved only when the ingest-mode mount bridge actually invokes it, never
1243
+ * eagerly here (this function stays synchronous). D-W2 adds `opaque-embed`:
1244
+ * the declared `entryHtml`'s bytes, read through the project-static route as
1245
+ * a lazy `embedHtml` thunk. `iframe-reachable`+`entryHtml`-only (no
1246
+ * `bundleUrl`) stays an unwired named error on this kind too (mirrors the
1247
+ * pixi sibling): reading a raw inline-module-source file's contents into a
1248
+ * SHARED-instance-mounted string has no wired mechanism this wave.
1249
+ */
1250
+ export function resolveIngestDescriptor(
1251
+ world: ResolvedWorldEntry,
1252
+ projectRoot: string,
1253
+ ): IngestGame {
1254
+ const { adapter } = world;
1255
+ if (adapter.type !== 'ingest') {
1256
+ throw new Error(`resolveIngestDescriptor: world "${world.id}" is not an { ingest } world.`);
1257
+ }
1258
+
1259
+ const base: Pick<
1260
+ IngestGame,
1261
+ 'id' | 'name' | 'description' | 'assets' | 'domStubs' | 'captureTimeoutMs'
1262
+ > = {
1263
+ id: world.id,
1264
+ name: world.id,
1265
+ // T3.3 slice 3: `description` is the manifest's new optional per-world
1266
+ // field (schema.ts) — carried through `load.ts`'s `resolveWorld`, threaded
1267
+ // here into `IngestGame.description` (the field's designated reader).
1268
+ description: world.description ?? '',
1269
+ ...(adapter.assets !== undefined ? { assets: adapter.assets } : {}),
1270
+ ...(adapter.domStubs !== undefined ? { domStubs: adapter.domStubs } : {}),
1271
+ ...(adapter.captureTimeoutMs !== undefined
1272
+ ? { captureTimeoutMs: adapter.captureTimeoutMs }
1273
+ : {}),
1274
+ };
1275
+
1276
+ if (adapter.strategy === 'shared' || adapter.strategy === 'deduped') {
1277
+ if (!world.entry) {
1278
+ throw new Error(
1279
+ `resolveIngestDescriptor: world "${world.id}" (strategy "${adapter.strategy}") has no ` +
1280
+ "`entry` — shared/deduped ingest worlds need the game module's path there.",
1281
+ );
1282
+ }
1283
+ const modulePath = fsImportPath(projectRoot, world.entry);
1284
+ return { ...base, load: () => import(/* @vite-ignore */ modulePath) };
1285
+ }
1286
+
1287
+ if (adapter.strategy === 'iframe-reachable') {
1288
+ return buildIframeReachableIngestDescriptor(world, adapter, base);
1289
+ }
1290
+
1291
+ if (adapter.strategy === 'opaque-embed') {
1292
+ return buildOpaqueEmbedIngestDescriptor(world, adapter, base);
1293
+ }
1294
+
1295
+ // Exhaustive per IngestStrategySchema's 4 members.
1296
+ const exhaustive: never = adapter.strategy;
1297
+ throw new Error(
1298
+ `resolveIngestDescriptor: world "${world.id}" has an unhandled ingest strategy ` +
1299
+ `${JSON.stringify(exhaustive)}.`,
1300
+ );
1301
+ }
1302
+
1303
+ // ---------------------------------------------------------------------------
1304
+ // Part E (T6.1 slice 4) — ingest-pixi
1305
+ // ---------------------------------------------------------------------------
1306
+
1307
+ /**
1308
+ * Build the {@link IngestGame2D} descriptor an `ingest-pixi` world resolves to,
1309
+ * straight from its manifest fields — the pixi sibling of
1310
+ * {@link resolveIngestDescriptor} (docs/MULTI-WORLD-DESIGN.md §1.E: "the same
1311
+ * two-layer shape as ingest-three"). `shared`/`deduped` resolve to a `load()`
1312
+ * thunk via the `/@fs/` dynamic-import route `loadProjectScripts` uses, keyed
1313
+ * off the world's `entry` field. Track P (docs/PIXI-INGEST-LANDING-DESIGN.md
1314
+ * §2/P2) adds `iframe-reachable`: the manifest's `bundleUrl`/`baseHref`/
1315
+ * `assetBaseUrl`/`bodyHtml` are carried straight through, and `extraDeps`/
1316
+ * `pixiModuleUrl` become LAZY thunks (`IngestGame2D.extraDeps`/
1317
+ * `.pixiNamespace`) wired to `./ingest/host-namespace-registry.ts` / a
1318
+ * dynamic `import()` — resolved only when the ingest-mode mount bridge
1319
+ * actually invokes them, never eagerly here (this function stays
1320
+ * synchronous, matching `resolveIngestDescriptor`'s shape exactly). D-W2
1321
+ * (docs/WAVE4-FTUE-HARDENING-DESIGN.md) adds `opaque-embed`: the declared
1322
+ * `entryHtml`'s bytes, read through the project-static route
1323
+ * (`fetchProjectStaticText`, shared with the three side above) as a lazy
1324
+ * `embedHtml` thunk.
1325
+ *
1326
+ * `tier` is set to `'shared'` for BOTH the `shared` and `deduped` manifest
1327
+ * strategies: `IngestGame2D.tier` is descriptive-only (`mountIngestGame2D`
1328
+ * never reads it — grep-verified) and has no `'deduped'` member of its own;
1329
+ * both strategies are mechanically identical from `mountIngestGame2D`'s
1330
+ * perspective (a `load()` thunk + the shared-instance capture trap), matching
1331
+ * how `resolveIngestDescriptor` already treats them identically (both produce
1332
+ * a `load` thunk with no strategy-specific branching).
1333
+ */
1334
+ /**
1335
+ * The `iframe-reachable` branch of {@link resolveIngest2DDescriptor}, split
1336
+ * out to keep that function's cognitive complexity in check (biome's
1337
+ * `noExcessiveCognitiveComplexity`, max 15) — same "each strategy branch is
1338
+ * its own small builder" shape `discovery2d.ts`'s
1339
+ * `buildIframeReachableGame2D` already uses for its in-tree-fixture sibling.
1340
+ */
1341
+ function buildIframeReachableDescriptor2D(
1342
+ world: ResolvedWorldEntry,
1343
+ adapter: Extract<ResolvedWorldEntry['adapter'], { type: 'ingest' }>,
1344
+ base: Pick<IngestGame2D, 'id' | 'name' | 'description' | 'captureTimeoutMs'>,
1345
+ ): IngestGame2D {
1346
+ if (!adapter.bundleUrl) {
1347
+ throw new Error(
1348
+ `resolveIngest2DDescriptor: world "${world.id}" (strategy "iframe-reachable") has no ` +
1349
+ '`bundleUrl` — only the externalized-bundle route is buildable here (an ' +
1350
+ "`entryHtml`-only iframe-reachable world isn't wired; the manifest schema already " +
1351
+ 'requires exactly one of bundleUrl/entryHtml for this strategy).',
1352
+ );
1353
+ }
1354
+ const { bundleUrl, baseHref, assetBaseUrl, extraDeps, pixiModuleUrl, bodyHtml } = adapter;
1355
+ return {
1356
+ ...base,
1357
+ tier: 'iframe-reachable',
1358
+ bundleUrl,
1359
+ ...(baseHref !== undefined ? { baseHref } : {}),
1360
+ ...(assetBaseUrl !== undefined ? { assetBaseUrl } : {}),
1361
+ ...(extraDeps !== undefined ? { extraDeps: () => resolveExtraDeps(extraDeps) } : {}),
1362
+ ...(pixiModuleUrl !== undefined
1363
+ ? { pixiNamespace: () => import(/* @vite-ignore */ pixiModuleUrl) }
1364
+ : {}),
1365
+ ...(bodyHtml !== undefined ? { bodyHtml } : {}),
1366
+ };
1367
+ }
1368
+
1369
+ export function resolveIngest2DDescriptor(
1370
+ world: ResolvedWorldEntry,
1371
+ projectRoot: string,
1372
+ ): IngestGame2D {
1373
+ const { adapter } = world;
1374
+ if (adapter.type !== 'ingest') {
1375
+ throw new Error(`resolveIngest2DDescriptor: world "${world.id}" is not an { ingest } world.`);
1376
+ }
1377
+
1378
+ const base: Pick<IngestGame2D, 'id' | 'name' | 'description' | 'captureTimeoutMs'> = {
1379
+ id: world.id,
1380
+ name: world.id,
1381
+ description: world.description ?? '',
1382
+ ...(adapter.captureTimeoutMs !== undefined
1383
+ ? { captureTimeoutMs: adapter.captureTimeoutMs }
1384
+ : {}),
1385
+ };
1386
+
1387
+ if (adapter.strategy === 'shared' || adapter.strategy === 'deduped') {
1388
+ if (!world.entry) {
1389
+ throw new Error(
1390
+ `resolveIngest2DDescriptor: world "${world.id}" (strategy "${adapter.strategy}") has no ` +
1391
+ "`entry` — shared/deduped ingest-pixi worlds need the game module's path there.",
1392
+ );
1393
+ }
1394
+ const modulePath = fsImportPath(projectRoot, world.entry);
1395
+ return { ...base, tier: 'shared', load: () => import(/* @vite-ignore */ modulePath) };
1396
+ }
1397
+
1398
+ if (adapter.strategy === 'iframe-reachable') {
1399
+ return buildIframeReachableDescriptor2D(world, adapter, base);
1400
+ }
1401
+
1402
+ if (adapter.strategy === 'opaque-embed') {
1403
+ if (!adapter.entryHtml) {
1404
+ throw new Error(
1405
+ `resolveIngest2DDescriptor: world "${world.id}" (strategy "opaque-embed") has no ` +
1406
+ '`entryHtml` — the opaque-embed floor needs the entry HTML file path to read (D-W2, ' +
1407
+ 'docs/WAVE4-FTUE-HARDENING-DESIGN.md).',
1408
+ );
1409
+ }
1410
+ const entryHtml = adapter.entryHtml;
1411
+ return { ...base, tier: 'opaque-embed', embedHtml: () => fetchProjectStaticText(entryHtml) };
1412
+ }
1413
+
1414
+ // Exhaustive per IngestStrategySchema's 4 members.
1415
+ const exhaustive: never = adapter.strategy;
1416
+ throw new Error(
1417
+ `resolveIngest2DDescriptor: world "${world.id}" has an unhandled ingest strategy ` +
1418
+ `${JSON.stringify(exhaustive)}.`,
1419
+ );
1420
+ }
1421
+
1422
+ /**
1423
+ * Same shape as {@link resolveIngestThreeAdapter}: the pixi world2d ingest
1424
+ * machinery (`mountIngestGame2D`, `World2DAuthoringAdapter`) needs an
1425
+ * `EditorStore` and a dedicated game-container element that `HostContext`
1426
+ * does not provide — so this resolver does NOT attempt to make ingest-pixi
1427
+ * mountable through the generic `GameAdapter`/`HostContext` contract. It
1428
+ * returns a `GameAdapter` whose `id`/`descriptor` are built straight from the
1429
+ * manifest, and whose `mount()` throws a named error pointing at the real
1430
+ * mount path: `resolveIngest2DDescriptor` + `enterIngestModeFromManifestWorld2D`
1431
+ * (`ingest-mode.ts`), called from an ingest-mode-shaped flow that has a store
1432
+ * and a container — exactly `enterIngestModeFromManifestWorld`'s three-side
1433
+ * counterpart.
1434
+ */
1435
+ function resolveIngestPixiAdapter(world: ResolvedWorldEntry, projectRoot: string): GameAdapter {
1436
+ const descriptor = resolveIngest2DDescriptor(world, projectRoot);
1437
+ return {
1438
+ id: world.id,
1439
+ /** Exposed for slice-4 callers that want the descriptor without re-deriving it. */
1440
+ descriptor,
1441
+ async mount() {
1442
+ throw new Error(
1443
+ `resolveWorldAdapter: world "${world.id}" (ingest-pixi) cannot mount through the ` +
1444
+ 'generic GameAdapter/HostContext contract — the world2d ingest machinery ' +
1445
+ '(mountIngestGame2D + World2DAuthoringAdapter) requires an EditorStore and a ' +
1446
+ 'dedicated game-container element that HostContext does not provide (mirrors the ' +
1447
+ "ingest-three finding, see resolveIngestPixiAdapter's doc comment). Call " +
1448
+ 'resolveIngest2DDescriptor() + enterIngestModeFromManifestWorld2D() directly from an ' +
1449
+ 'ingest-mode-shaped flow instead (ingest-mode.ts).',
1450
+ );
1451
+ },
1452
+ } as GameAdapter & { descriptor: IngestGame2D };
1453
+ }
1454
+
1455
+ /**
1456
+ * FINDING (report this prominently, see the slice's "Report back"):
1457
+ * `mountIngestGame`/`mountIngestGameIframeReachable`
1458
+ * (`packages/editor/src/authoring/ingest-game-adapter.ts`,
1459
+ * `ingest-iframe-reachable-adapter.ts`) are NOT shaped like
1460
+ * `GameAdapter.mount(host: HostContext)` — they require an `EditorStore`
1461
+ * (threaded straight into `new IngestAuthoringAdapter(store, ...)` for
1462
+ * selection/undo wiring) and a dedicated game-container `HTMLElement`
1463
+ * (`ingest-mode.ts`'s `getGameContainer()`), neither of which `HostContext`
1464
+ * carries (`packages/engine/src/adapter/host-context.ts` — by design: it is
1465
+ * the NEUTRAL surface any game gets, and an `EditorStore` is an editor-only
1466
+ * concept). Building a second, `HostContext`-only capture path that skips the
1467
+ * authoring adapter would duplicate ~most of `mountIngestGame`'s body
1468
+ * (capture install, asset-URL modifier, DOM stubs, settle-wait, teardown) and
1469
+ * risk the two drifting — exactly the "half-refactor" the slice's brief
1470
+ * warns against. So this resolver does NOT attempt to make ingest-three
1471
+ * mountable through the generic contract: it returns a `GameAdapter` whose
1472
+ * `id` and descriptor are built straight from the manifest (no registry
1473
+ * lookup — the AC this slice's tests hold it to), and whose `mount()` throws
1474
+ * a named error pointing at the real mount path
1475
+ * (`resolveIngestDescriptor` + `mountIngestGame`/
1476
+ * `mountIngestGameIframeReachable`, called from an ingest-mode-shaped flow
1477
+ * that has a store and a container). T3.3 slice 3 wires exactly that flow:
1478
+ * `ingest-mode.ts`'s `enterIngestModeFromManifestWorld` calls
1479
+ * `resolveIngestDescriptor` + the existing `enterIngestModeWithGame` (which
1480
+ * already has both a store and `getGameContainer()`), satisfying slice 3's AC
1481
+ * ("point the CLI at an ingest game folder never registered in source ->
1482
+ * opens, plays, edits" — docs/CLI-ON-FOLDER-DESIGN.md §4) for the
1483
+ * `shared`/`deduped` strategies this function builds.
1484
+ */
1485
+ function resolveIngestThreeAdapter(world: ResolvedWorldEntry, projectRoot: string): GameAdapter {
1486
+ const descriptor = resolveIngestDescriptor(world, projectRoot);
1487
+ return {
1488
+ id: world.id,
1489
+ /** Exposed for slice-3 callers that want the descriptor without re-deriving it. */
1490
+ descriptor,
1491
+ async mount() {
1492
+ throw new Error(
1493
+ `resolveWorldAdapter: world "${world.id}" (ingest-three) cannot mount through the ` +
1494
+ 'generic GameAdapter/HostContext contract — the existing ingest machinery ' +
1495
+ '(mountIngestGame/mountIngestGameIframeReachable) requires an EditorStore and a ' +
1496
+ 'dedicated game-container element that HostContext does not provide (T3.3 slice 2 ' +
1497
+ "finding, see adapter-resolver.ts's resolveIngestThreeAdapter doc comment). Call " +
1498
+ 'resolveIngestDescriptor() + mountIngestGame() directly from an ingest-mode-shaped ' +
1499
+ 'flow instead; full CLI-on-folder ingest wiring is T3.3 slice 3.',
1500
+ );
1501
+ },
1502
+ } as GameAdapter & { descriptor: IngestGame };
1503
+ }
1504
+
1505
+ // ---------------------------------------------------------------------------
1506
+ // Part F (T3.4 slice 1) — module (docs/ADAPTER-AUTHORING-DESIGN.md §1.A/§1.C)
1507
+ // ---------------------------------------------------------------------------
1508
+
1509
+ /**
1510
+ * Validate `adapter.module` is a game-folder-RELATIVE path with no `..`
1511
+ * escape (§1.A: "validated game-folder-relative (normalized, no `..` escape —
1512
+ * the same rule the save-file endpoint enforces)"). This resolver runs
1513
+ * browser-side (no `node:path`), so the guard is hand-rolled by segment —
1514
+ * `packages/editor/server/editor-server.ts`'s `/__editor/save-file` handler is
1515
+ * the Node-side sibling this mirrors (reject empty, reject any `..` segment,
1516
+ * reject an absolute/rooted path); there is no reusable isomorphic helper in
1517
+ * the codebase today (`storage/paths.ts`'s `normalize()` collapses `.`
1518
+ * segments but deliberately does NOT reject `..` — a different job, key
1519
+ * canonicalization for the storage backends, not a trust-boundary check).
1520
+ * Returns the normalized (slash-joined, no leading/trailing slash) path.
1521
+ */
1522
+ export function guardModulePath(world: ResolvedWorldEntry, moduleId: string): string {
1523
+ const fail = (reason: string): never => {
1524
+ throw new Error(
1525
+ `resolveWorldAdapter: world "${world.id}" declares { module: "${moduleId}" } — ${reason} ` +
1526
+ '(docs/ADAPTER-AUTHORING-DESIGN.md §1.A: the module path must be game-folder-relative, ' +
1527
+ 'normalized, with no `..` escape — the same rule the save-file endpoint enforces).',
1528
+ );
1529
+ };
1530
+
1531
+ if (!moduleId || moduleId.trim() === '') fail('the module path is empty');
1532
+ if (moduleId.includes('\\')) fail('the module path contains a backslash (not POSIX-relative)');
1533
+ if (moduleId.startsWith('/') || /^[a-zA-Z]:/.test(moduleId)) {
1534
+ fail('the module path is absolute, not game-folder-relative');
1535
+ }
1536
+
1537
+ const segments = moduleId.split('/');
1538
+ if (segments.some((seg) => seg === '..')) {
1539
+ fail('the module path contains a `..` segment and would escape the game folder');
1540
+ }
1541
+ const normalized = segments.filter((seg) => seg.length > 0 && seg !== '.').join('/');
1542
+ if (normalized === '') fail('the module path resolves to nothing (empty after normalization)');
1543
+
1544
+ return normalized;
1545
+ }
1546
+
1547
+ /** Runtime shape every `GameAdapter` must satisfy — the module branch cannot
1548
+ * trust TypeScript alone (the imported value is `unknown` until checked). */
1549
+ function guardModuleShape(
1550
+ world: ResolvedWorldEntry,
1551
+ moduleId: string,
1552
+ candidate: unknown,
1553
+ ): asserts candidate is GameAdapter {
1554
+ const fail = (reason: string): never => {
1555
+ throw new Error(
1556
+ `resolveWorldAdapter: world "${world.id}"'s custom adapter module "${moduleId}" ${reason} ` +
1557
+ '— a { module } adapter must default-export an object shaped like `GameAdapter`: ' +
1558
+ '`{ id: string, mount(host): Promise<MountedGame> }` (docs/ADAPTER-AUTHORING-DESIGN.md §1.A).',
1559
+ );
1560
+ };
1561
+
1562
+ if (candidate === null || typeof candidate !== 'object') {
1563
+ fail('has no default export (or it is not an object)');
1564
+ }
1565
+ const obj = candidate as Record<string, unknown>;
1566
+ if (typeof obj['id'] !== 'string' || obj['id'].length === 0) {
1567
+ fail('default-exports an object with no non-empty string `id`');
1568
+ }
1569
+ if (typeof obj['mount'] !== 'function') {
1570
+ fail('default-exports an object with no `mount` function');
1571
+ }
1572
+ }
1573
+
1574
+ /**
1575
+ * Wrap a custom adapter's `mount` so that, once it resolves, the ACHIEVED
1576
+ * tier is computed from the real `MountedGame.authoring` surface and compared
1577
+ * against the world's declared `capabilities.local` (§1.C). `/@fs/` module
1578
+ * imports only run in the local CLI dev server (`getEditorMode() ===
1579
+ * 'server'` — see `editor-mode.ts`; the hosted/browser build has no such
1580
+ * import route, matching this design's non-goal "hosted-context module
1581
+ * adapters beyond honest tier declaration"), so `local` is the context this
1582
+ * resolver actually mounts under. A shortfall never blocks the mount — the
1583
+ * world still returns its (unmodified) `MountedGame`; the check only OBSERVES
1584
+ * and logs (§1.C: "the world still mounts at its achieved tier").
1585
+ *
1586
+ * The actual `editorConsole.warn(...)` call is a LAZY dynamic import of
1587
+ * `./editor-console`, not a static one: this resolver is imported by headless
1588
+ * Node/vitest suites with no `window` (`adapter-resolver.test.ts` et al.),
1589
+ * and `editor-console.ts`'s module top level touches `window`
1590
+ * unconditionally. A static import would break every existing Node suite
1591
+ * that imports this resolver; `import()` only evaluates that module the
1592
+ * first time a shortfall is actually reported (at which point a real
1593
+ * `window` is expected to exist — the module-adapter mount path only runs
1594
+ * inside the editor's dev-server browser session, per the note above).
1595
+ */
1596
+ function withAchievedTierCheck(world: ResolvedWorldEntry, adapter: GameAdapter): GameAdapter {
1597
+ return {
1598
+ id: adapter.id,
1599
+ async mount(host) {
1600
+ const mounted = await adapter.mount(host);
1601
+ const shortfall = computeTierShortfall(
1602
+ world.id,
1603
+ world.capabilities.local,
1604
+ computeAchievedTier(mounted),
1605
+ );
1606
+ if (shortfall) {
1607
+ const { editorConsole } = await import('./editor-console');
1608
+ editorConsole.warn(formatTierShortfallMessage(shortfall), 'adapter');
1609
+ }
1610
+ return mounted;
1611
+ },
1612
+ };
1613
+ }
1614
+
1615
+ /**
1616
+ * Resolve a `{ module }` adapter world (§1.A): `/@fs/`-import the game
1617
+ * folder's declared module — the exact route `loadProjectScripts` uses for
1618
+ * `default-three`'s `entry` branch (`./script-loader.ts`), so it inherits the
1619
+ * SAME `resolve.dedupe: ['three', 'pixi.js']` collapse the root
1620
+ * `vite.config.ts` already applies repo-wide (proven for THIS path by
1621
+ * `vite-dedupe-module-adapter.test.ts`'s own fixture — §1.A: "Dedupe is NOT
1622
+ * inherited silently"). Path-guarded (`guardModulePath`) before the import,
1623
+ * shape-checked (`guardModuleShape`) after — both loud, named failures, never
1624
+ * a silent `undefined`/crash deep in the mount call. The returned adapter's
1625
+ * `mount` is wrapped with the achieved-tier check (§1.C, `withAchievedTierCheck`).
1626
+ */
1627
+ export async function resolveModuleAdapter(
1628
+ world: ResolvedWorldEntry,
1629
+ projectRoot: string,
1630
+ ): Promise<GameAdapter> {
1631
+ const { adapter } = world;
1632
+ if (adapter.type !== 'module') {
1633
+ throw new Error(`resolveModuleAdapter: world "${world.id}" is not a { module } world.`);
1634
+ }
1635
+
1636
+ const normalizedPath = guardModulePath(world, adapter.module);
1637
+ const modulePath = fsImportPath(projectRoot, normalizedPath);
1638
+ const mod = (await import(/* @vite-ignore */ modulePath)) as { default?: unknown };
1639
+ const candidate = mod.default;
1640
+ guardModuleShape(world, adapter.module, candidate);
1641
+
1642
+ return withAchievedTierCheck(world, candidate);
1643
+ }
1644
+
1645
+ // ---------------------------------------------------------------------------
1646
+ // Entry point
1647
+ // ---------------------------------------------------------------------------
1648
+
1649
+ /**
1650
+ * Resolve a manifest world entry into a mountable {@link GameAdapter}
1651
+ * (docs/CLI-ON-FOLDER-DESIGN.md §1D). Instantiation only — the returned
1652
+ * adapter is not yet mounted; call `.mount(host)` to run it (see each
1653
+ * identity's doc comment above for what that does or doesn't support today).
1654
+ */
1655
+ export async function resolveWorldAdapter(
1656
+ world: ResolvedWorldEntry,
1657
+ projectRoot: string,
1658
+ opts?: ResolveWorldAdapterOptions,
1659
+ ): Promise<GameAdapter> {
1660
+ const { adapter } = world;
1661
+
1662
+ switch (adapter.identity) {
1663
+ case 'default-three':
1664
+ return resolveDefaultThreeAdapter(world, projectRoot, opts);
1665
+ case 'default-pixi':
1666
+ return resolveDefaultPixiAdapter(world, projectRoot);
1667
+ case 'default-react':
1668
+ return resolveDefaultReactAdapter(world, projectRoot);
1669
+ case 'module':
1670
+ return resolveModuleAdapter(world, projectRoot);
1671
+ case 'ingest-pixi':
1672
+ return resolveIngestPixiAdapter(world, projectRoot);
1673
+ case 'ingest-three':
1674
+ return resolveIngestThreeAdapter(world, projectRoot);
1675
+ case 'ingest-react':
1676
+ return resolveIngestReactAdapter(world, projectRoot);
1677
+ default: {
1678
+ const exhaustive: never = adapter;
1679
+ throw new Error(
1680
+ `resolveWorldAdapter: unhandled adapter identity ${JSON.stringify(exhaustive)}`,
1681
+ );
1682
+ }
1683
+ }
1684
+ }
1685
+
1686
+ // ---------------------------------------------------------------------------
1687
+ // Multi-world entry point (T6.1 slice 2 — docs/MULTI-WORLD-DESIGN.md §3 slice 2)
1688
+ // ---------------------------------------------------------------------------
1689
+
1690
+ /**
1691
+ * Adapter identities a manifest with MORE THAN ONE world may mix in this
1692
+ * slice (docs/MULTI-WORLD-DESIGN.md §3 slice 4's scope rule, extended by
1693
+ * docs/REACT-WORLD-DESIGN.md §3 slice 1/3 to admit `default-react`): only
1694
+ * `'default'`-adapter threejs/pixijs/react worlds. `ingest-three`/
1695
+ * `ingest-pixi` and `{ module }` still join a multi-world manifest in a
1696
+ * LATER slice (slice 4's ingest-pixi + retirement work, T3.4's module
1697
+ * adapters) — a worlds>1 manifest naming any of them today gets a named
1698
+ * scope-rule error instead of a silent/confusing failure deep inside
1699
+ * `createGameRuntime`'s worlds path. `default-react` is admitted here
1700
+ * because `resolveAllWorlds`/`createWorldsGameRuntime` is exactly the host
1701
+ * path a react world mounts through (docs/REACT-WORLD-DESIGN.md §1.B: a
1702
+ * react world's DOM-root layer is "one stacking model, no special case" —
1703
+ * `packages/engine/test/create-runtime-worlds.test.ts` and `-react-world
1704
+ * .test.ts` already prove N worlds, including react, mounting through this
1705
+ * exact host) — the AC fixture in that design (§2: 1 threejs + 2 react
1706
+ * worlds) is a worlds>1 manifest by construction, so react could not stay
1707
+ * scope-barred here without contradicting the design it is implementing.
1708
+ *
1709
+ * `ingest-react` (Track N, N1) stays OUT of this set on purpose — it is an
1710
+ * `{ ingest }`-adapter identity like `ingest-three`/`ingest-pixi`, both of
1711
+ * which are already excluded here, and docs/REACT-INGEST-LANDING-DESIGN.md
1712
+ * ships single-world manifests only ("Track N ships single-world manifests
1713
+ * and does not touch this", per that design's §2 seam table). This exclusion
1714
+ * also does load-bearing work for D-N4/D-N8 (no authoring surface for an
1715
+ * ingested react world): `installMultiWorldAuthoring` (`play-mode.ts`) is
1716
+ * the ONLY place a `ReactWorldAuthoringAdapter`/`writeBackend` gets
1717
+ * constructed, and it is only reachable via THIS multi-world path — keeping
1718
+ * `ingest-react` out of it keeps that constructor permanently unreachable
1719
+ * for a vendored game, not just unreachable today.
1720
+ */
1721
+ const MULTI_WORLD_ALLOWED_IDENTITIES: ReadonlySet<string> = new Set([
1722
+ 'default-three',
1723
+ 'default-pixi',
1724
+ 'default-react',
1725
+ ]);
1726
+
1727
+ function throwMultiWorldScopeViolation(world: ResolvedWorldEntry): never {
1728
+ throw new Error(
1729
+ `resolveAllWorlds: world "${world.id}" resolved to adapter identity ` +
1730
+ `"${world.adapter.identity}" — a manifest with more than one world may only mix ` +
1731
+ "'default'-adapter threejs/pixijs/react worlds in this slice (docs/MULTI-WORLD-DESIGN.md " +
1732
+ '§3 slice 4 reserves ingest/module worlds out of THIS path permanently, not just for a ' +
1733
+ 'later slice: a composite manifest (one { ingest } world + siblings) mounts via ' +
1734
+ "`ingest-mode.ts`'s own composite route instead (docs/WAVE5-MULTIWORLD-INGEST-DESIGN.md " +
1735
+ 'D-V2..D-V7), never resolveAllWorlds/installMultiWorldAuthoring — the play path stays out ' +
1736
+ 'of scope for ingest worlds by design (docs/REACT-WORLD-DESIGN.md §3 admits default-react ' +
1737
+ 'here).',
1738
+ );
1739
+ }
1740
+
1741
+ /**
1742
+ * Resolve EVERY world in a manifest into slice-1 `WorldMountSpec`s
1743
+ * (docs/MULTI-WORLD-DESIGN.md §1.A's host-facing shape), in the manifest's
1744
+ * already-z-sorted `worlds` order (`@engine/manifest/load`'s
1745
+ * `loadGameManifest` sorts by `zOrder` before returning — this function does
1746
+ * not re-sort). No `editorPreview` threading here (v1 scope decision, T6.1
1747
+ * slice 2's report): `default-pixi` has none by design (§1.D), and a
1748
+ * multi-world manifest's live editor document corresponds to no single one
1749
+ * of N worlds today (the editor is still a single-scene store) — EVERY
1750
+ * world in the worlds path mounts from its own on-disk `scene`/`entry`,
1751
+ * matching how `default-pixi` already behaves. Single-world manifests never
1752
+ * call this function (see `play-mode.ts`'s routing) — resolveWorldAdapter's
1753
+ * own editorPreview threading there is completely unaffected.
1754
+ *
1755
+ * Called only for manifests with more than one world (the play path's own
1756
+ * routing decision) but written to work for any `worlds.length >= 1` so a
1757
+ * caller (or a unit test) can exercise it directly.
1758
+ */
1759
+
1760
+ /**
1761
+ * Shared per-world resolution loop behind {@link resolveAllWorlds} and
1762
+ * {@link resolveAllWorldEntries} (E4.R2 reopen fix, split out so the two
1763
+ * never re-derive the multi-world scope check or the `resolveWorldAdapter`
1764
+ * fan-out independently): enforces the v1 scope rule (module/ingest worlds
1765
+ * banned from a >1-world manifest — `MULTI_WORLD_ALLOWED_IDENTITIES`), then
1766
+ * resolves every world's adapter, in manifest order.
1767
+ */
1768
+ async function resolveAllWorldAdapters(
1769
+ manifest: ResolvedGameManifest,
1770
+ projectRoot: string,
1771
+ ): Promise<{ world: ResolvedWorldEntry; adapter: GameAdapter }[]> {
1772
+ const { worlds } = manifest;
1773
+
1774
+ if (worlds.length > 1) {
1775
+ for (const world of worlds) {
1776
+ if (!MULTI_WORLD_ALLOWED_IDENTITIES.has(world.adapter.identity)) {
1777
+ throwMultiWorldScopeViolation(world);
1778
+ }
1779
+ }
1780
+ }
1781
+
1782
+ const resolved: { world: ResolvedWorldEntry; adapter: GameAdapter }[] = [];
1783
+ for (const world of worlds) {
1784
+ resolved.push({ world, adapter: await resolveWorldAdapter(world, projectRoot) });
1785
+ }
1786
+ return resolved;
1787
+ }
1788
+
1789
+ export async function resolveAllWorlds(
1790
+ manifest: ResolvedGameManifest,
1791
+ projectRoot: string,
1792
+ ): Promise<WorldMountSpec[]> {
1793
+ const resolved = await resolveAllWorldAdapters(manifest, projectRoot);
1794
+
1795
+ const specs: WorldMountSpec[] = [];
1796
+ for (const { world, adapter } of resolved) {
1797
+ const base = { id: world.id, zOrder: world.zOrder, pausable: world.pausable, loop: world.loop };
1798
+ if (world.kind === 'pixijs') {
1799
+ specs.push({ ...base, kind: 'pixijs', adapter: adapter as unknown as Pixi2DGameAdapter });
1800
+ } else if (world.kind === 'react') {
1801
+ specs.push({ ...base, kind: 'react', adapter: adapter as unknown as ReactWorldAdapter });
1802
+ } else if (world.kind === 'threejs') {
1803
+ specs.push({ ...base, kind: 'threejs', adapter });
1804
+ } else {
1805
+ // Exhaustiveness guard (§7.4-2): `world.kind` is `WorldEntry['kind']`,
1806
+ // the same closed `'threejs' | 'pixijs' | 'react'` union as `WorldKind`
1807
+ // (schema.ts's `WorldEntrySchema.kind` is `z.enum([...])`, so this is
1808
+ // NOT a plain-`string` field) — a hypothetical 4th kind must fail to
1809
+ // compile here rather than silently default to 'threejs' as the old
1810
+ // catch-all `else` did.
1811
+ assertNever(world.kind, 'resolveAllWorlds');
1812
+ }
1813
+ }
1814
+ return specs;
1815
+ }
1816
+
1817
+ /**
1818
+ * E4.R2 (reopen fix — design/26 S-D D2's "editor play mode calls it"):
1819
+ * `resolveAllWorlds`'s composer-path sibling. Resolves every world's adapter
1820
+ * through the SAME scope-checked loop ({@link resolveAllWorldAdapters}) but
1821
+ * returns `mountManifestWorlds`'s own `entries` shape (`{ kind, adapter }`
1822
+ * per world id, `@engine/runtime/mount-manifest`) instead of a hand-built
1823
+ * `WorldMountSpec[]` — so `zOrder`/`pausable`/`loop` and the per-kind
1824
+ * `WorldMountSpec` ASSEMBLY come from `mount-manifest.ts`'s
1825
+ * `buildWorldMountSpec`, the one authoritative place that translation lives,
1826
+ * rather than being hand-duplicated a second time here AND at the
1827
+ * `mountManifestWorlds` call site (the exact duplication a reopen review
1828
+ * found in `play-mode.ts`'s prior `resolveAllWorlds` + `createGameRuntime
1829
+ * ({ container, worlds })` call shape).
1830
+ *
1831
+ * `resolveAllWorlds` itself is UNCHANGED/byte-stable for its other callers
1832
+ * (`ingest-mode.ts`, `module-mode.ts`, its own unit tests) — this is purely
1833
+ * an additional entry point for `play-mode.ts`'s multi-world branch, which
1834
+ * now calls `mountManifestWorlds({ manifest, container, entries, ... })`
1835
+ * directly instead of `createGameRuntime({ container, worlds })`.
1836
+ */
1837
+ export async function resolveAllWorldEntries(
1838
+ manifest: ResolvedGameManifest,
1839
+ projectRoot: string,
1840
+ ): Promise<Record<string, MountEntry>> {
1841
+ const resolved = await resolveAllWorldAdapters(manifest, projectRoot);
1842
+
1843
+ const entries: Record<string, MountEntry> = {};
1844
+ for (const { world, adapter } of resolved) {
1845
+ if (world.kind === 'pixijs') {
1846
+ entries[world.id] = { kind: 'pixijs', adapter: adapter as unknown as Pixi2DGameAdapter };
1847
+ } else if (world.kind === 'react') {
1848
+ entries[world.id] = { kind: 'react', adapter: adapter as unknown as ReactWorldAdapter };
1849
+ } else if (world.kind === 'threejs') {
1850
+ entries[world.id] = { kind: 'threejs', adapter };
1851
+ } else {
1852
+ // Exhaustiveness guard (§7.4-2) — same reasoning as `resolveAllWorlds`'s
1853
+ // own identical guard above.
1854
+ assertNever(world.kind, 'resolveAllWorldEntries');
1855
+ }
1856
+ }
1857
+ return entries;
1858
+ }