@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,2092 @@
1
+ /**
2
+ * Ingest-mode orchestrator — load an UNMODIFIED three.js OR PixiJS game into
3
+ * the real editor and make its live scene editable, with ZERO edits to the
4
+ * game's code.
5
+ *
6
+ * Phase B (the inversion done right): this is a thin HOST. It mounts the game
7
+ * through the {@link mountIngestGame} `GameAdapter` path — which captures the
8
+ * game's live scene and returns a `MountedGame` whose `authoring` is an
9
+ * {@link IngestAuthoringAdapter} implemented DIRECTLY over the live `Object3D`
10
+ * tree (NO fabricated `SceneEntity`). The host then:
11
+ * - installs that adapter as the editor's active authoring override, so the
12
+ * hierarchy + inspector render from the adapter's providers, and
13
+ * - swaps the editor viewport onto the live scene (`enterPlayScene`, which binds
14
+ * by the stable `entityId` the adapter tagged) so selection + gizmo work.
15
+ *
16
+ * The first-party `.vscn` editor is untouched; the SAME editor surface now drives
17
+ * a foreign game. See `docs/ADAPTER-ARCHITECTURE.md` §5 (migration, steps 3–4).
18
+ *
19
+ * T6.1 slice 4 (docs/MULTI-WORLD-DESIGN.md §1.E/§3 slice 4, world2d silo
20
+ * retirement) adds the PixiJS sibling of this same shape below
21
+ * (`enterIngestModeFromManifestWorld2D` + `tryManifestIngestRoute2D`):
22
+ * `resolveIngest2DDescriptor` (adapter-resolver.ts) builds an `IngestGame2D`
23
+ * straight from an `ingest-pixi` manifest world, `mountIngestGame2D` captures
24
+ * its live Pixi stage via the SAME shared-instance render-trap mechanics as
25
+ * the three-side `shared`/`deduped` strategies, and a `World2DAuthoringAdapter`
26
+ * (the pixi peer of `IngestAuthoringAdapter`) becomes the active authoring
27
+ * override — no `SceneEntity`, no separate editor data model, exactly like
28
+ * the three-side path. The silo's `world2d-mode.ts` (deleted this slice) used
29
+ * to own this kind of session for its 'vendored'/'realgame' hand-launched
30
+ * demos; this is its manifest-driven, discoverable-fixture replacement.
31
+ */
32
+
33
+ import { readGameContract } from '@engine/adapter/ingest/game-contract';
34
+ import { extractUpstreamPin } from '@engine/adapter/ingest/upstream-pin';
35
+ import type { ResolvedGameManifest, ResolvedWorldEntry } from '@engine/manifest/load';
36
+ import type { Tier } from '@engine/manifest/schema';
37
+ import type { Overlay2D } from '@engine/world2d/authoring-2d';
38
+ import {
39
+ type EmbedMount2D,
40
+ type IframeReachableMount2D,
41
+ mountIngestGame2DEmbed,
42
+ mountIngestGame2DIframeReachableMulti,
43
+ } from '@engine/world2d/ingest-iframe-2d';
44
+ import type { IngestGame2D, IngestMount2D } from '@engine/world2d/ingest2d';
45
+ import { mountIngestGame2D } from '@engine/world2d/ingest2d';
46
+ import { createPhysics2DRegistry } from '@engine/world2d/physics2d-registry';
47
+ import { createPhysicsAdapter2D } from '@engine/world2d/system-adapters-2d';
48
+ import type { Container } from 'pixi.js';
49
+ import * as PIXI from 'pixi.js';
50
+ import {
51
+ computeAchievedTier,
52
+ computeTierShortfall,
53
+ formatTierShortfallMessage,
54
+ } from './achieved-tier';
55
+ import {
56
+ resolveAllWorlds,
57
+ resolveIngest2DDescriptor,
58
+ resolveIngestDescriptor,
59
+ } from './adapter-resolver';
60
+ import { getAuthoringOverride, setActiveAuthoring } from './authoring/active-adapter';
61
+ import { getActiveSystems, setActiveSystems } from './authoring/active-systems';
62
+ import {
63
+ CompositeAuthoringAdapter,
64
+ type CompositeChild,
65
+ } from './authoring/composite-authoring-adapter';
66
+ import { makeEmbedOnlyAdapter } from './authoring/embed-only-adapter';
67
+ import {
68
+ type IngestMount,
69
+ type MountIngestOptions,
70
+ mountIngestGame,
71
+ } from './authoring/ingest-game-adapter';
72
+ import { type EmbedMount, mountIngestGameEmbed } from './authoring/ingest-iframe-adapter';
73
+ import {
74
+ mountIngestGameIframeReachable,
75
+ mountIngestGameIframeReachableBundle,
76
+ } from './authoring/ingest-iframe-reachable-adapter';
77
+ import { loadOverlay, overlayPath, saveOverlay } from './authoring/ingest-overlay';
78
+ import {
79
+ addMountFailureReport,
80
+ formatMountFailureMessage,
81
+ getMountFailureReports,
82
+ setMountFailureReport,
83
+ } from './authoring/mount-failure-report';
84
+ import {
85
+ buildOverlayApplyReport,
86
+ logOverlayApplyReport,
87
+ setActiveOverlayReport,
88
+ } from './authoring/overlay-report';
89
+ import {
90
+ ReactDomAuthoringAdapter,
91
+ type ReactDomOverlay,
92
+ } from './authoring/react-dom-authoring-adapter';
93
+ import { unregisterUndoAdapter } from './authoring/undo-timeline';
94
+ import { loadWorldOverlay, saveWorldOverlay, worldOverlayPath } from './authoring/world-overlay';
95
+ import { World2DAuthoringAdapter } from './authoring/world2d-authoring-adapter';
96
+ import { editorConsole } from './editor-console';
97
+ import { getEditorMode } from './editor-mode';
98
+ import type { EditorStore } from './editor-store';
99
+ import { setGameInputGate } from './gated-globals';
100
+ import { getIngestGame } from './ingest/discovery';
101
+ import { getIngestGameReact, ingestGameReactProjectRoot } from './ingest/discovery-react';
102
+ import { getIngestGame2D } from './ingest/discovery2d';
103
+ import type { IngestGame } from './ingest/types';
104
+ // TYPE-ONLY (D-V2): `ingest-siblings.ts` imports from `module-mode.ts`, which
105
+ // itself statically imports `waitForGameContainer` from THIS module — a
106
+ // value import here would close that cycle at the wrong end. The actual
107
+ // `mountIngestSiblings` call below is a dynamic `import()`, right where it's
108
+ // used (`mountCompositeSiblings`); only the TYPE is needed at the top level,
109
+ // and type-only imports are erased before the cycle could ever matter.
110
+ import type { SiblingMount } from './ingest-siblings';
111
+ import { fetchGameManifest } from './manifest-project';
112
+ import { getGameContainer } from './play-mode';
113
+ import { getCurrentProject } from './project-manager';
114
+
115
+ interface IngestSession {
116
+ store: EditorStore;
117
+ /** Shared-`three` capture path (a real authoring adapter over the live scene). */
118
+ mount?: IngestMount;
119
+ /** Opaque-bundle embed path (iframe-hosted, embed-only). */
120
+ embed?: EmbedMount;
121
+ /** Keeps the captured game renderer sized to its container (host lifecycle). */
122
+ resizeObserver?: ResizeObserver;
123
+ }
124
+
125
+ /**
126
+ * F26 slot collapse (D-V1, docs/WAVE5-MULTIWORLD-INGEST-DESIGN.md): at most
127
+ * one ingest session is EVER active, of any kind — this is the single
128
+ * module-level slot for it. Pre-collapse, this was three independent
129
+ * per-kind singletons (`_session`/`_session2D`/`_sessionReact`, one per
130
+ * `IngestSession*` shape below), each with its own entry guard that only
131
+ * tore down its OWN slot — so switching kinds leaked the prior kind's live
132
+ * session (F26 residual, docs/FTUE-EXTERNAL-R3F-GAME.md :222-231).
133
+ * `IngestSession2D`/`IngestSessionReact` are declared further down (their
134
+ * own sections), referenced here by forward type reference.
135
+ *
136
+ * `siblings` (D-V2, docs/WAVE5-MULTIWORLD-INGEST-DESIGN.md, F24 composite):
137
+ * a composite manifest's OTHER worlds, mounted beside this session's ingest
138
+ * world (`ingest-siblings.ts`'s `mountIngestSiblings`) — always `[]` for
139
+ * every non-composite entry path (the in-tree fixture glob route, the
140
+ * `?ingest=` URL route, a single-world manifest). `exitActiveIngest` below
141
+ * disposes these, reverse order, in EVERY kind's teardown path — a sibling
142
+ * never outlives the session it was mounted beside.
143
+ */
144
+ type ActiveIngest = (
145
+ | { kind: 'threejs'; session: IngestSession }
146
+ | { kind: 'pixi'; session: IngestSession2D }
147
+ | { kind: 'react'; session: IngestSessionReact }
148
+ ) & { siblings: SiblingMount[] };
149
+
150
+ let _active: ActiveIngest | null = null;
151
+ let _boundStore: EditorStore | null = null;
152
+
153
+ /**
154
+ * Shared shortfall-reporting step for both embed-only degradation points in
155
+ * {@link enterIngestModeWithGame} below (T3.4 slice 1 retrofit, §1.C): both
156
+ * degrade to the achieved tier 'opaque-embed'; report a structured shortfall
157
+ * only when a declared tier is actually known (anti-shim — never fabricate
158
+ * one). Returns the report (for the `__vgaiIngest` dev hook) or `null`.
159
+ */
160
+ function reportEmbedOnlyShortfall(
161
+ worldId: string,
162
+ declaredTier: Tier | undefined,
163
+ ): ReturnType<typeof computeTierShortfall> {
164
+ if (declaredTier === undefined) return null;
165
+ const shortfall = computeTierShortfall(worldId, declaredTier, 'opaque-embed');
166
+ if (shortfall) editorConsole.warn(formatTierShortfallMessage(shortfall), 'adapter');
167
+ return shortfall;
168
+ }
169
+
170
+ /**
171
+ * R5 (docs/MASTER-ARCHITECTURE-REVIEW.md §4(c)/§7.1 item 9): best-effort read
172
+ * of the CURRENTLY-OPEN project's `UPSTREAM.md` pin, for the overlay-identity
173
+ * fix — binding a saved overlay to the vendored game's actual upstream commit
174
+ * rather than only its (often static) manifest `version`.
175
+ *
176
+ * Fetches root-relative `/UPSTREAM.md` — the SAME "the dev server's document
177
+ * root IS the open project's root" convention `./manifest-project.ts`'s
178
+ * `fetchGameManifest` already relies on for `/vgai.game.json` (no `/@fs/`
179
+ * needed; that route is for ESM import specifiers, not plain-text reads).
180
+ * Every failure mode resolves to `null`, never throws: no dev server
181
+ * (`getEditorMode() === 'browser'`), a 404 (the common case — most projects/
182
+ * fixtures are NOT vendored upstream games and have no `UPSTREAM.md` at all,
183
+ * e.g. every in-tree fixture under `packages/editor/src/ingest/games/*`, or
184
+ * an externally-authored non-vendored project opened via the CLI-on-folder
185
+ * route), or a network error. READ-ONLY: this never writes `UPSTREAM.md`
186
+ * (anti-shim — the pin is read, never fabricated or persisted back).
187
+ *
188
+ * Only wired into the manifest / CLI-on-folder route below
189
+ * (`tryManifestIngestRoute`) — the in-tree fixture route (`enterIngestMode`)
190
+ * has no project root the dev server is serving as document root beyond the
191
+ * editor's OWN source tree, so there is no meaningful "current project" to
192
+ * fetch an `UPSTREAM.md` for there; it correctly stays `null`/never-called,
193
+ * same honest outcome as any other non-vendored folder.
194
+ */
195
+ export async function readVendoredUpstreamPin(): Promise<string | null> {
196
+ if (getEditorMode() === 'browser') return null; // no dev server to serve /UPSTREAM.md from
197
+ try {
198
+ const res = await fetch('/UPSTREAM.md');
199
+ if (!res.ok) return null;
200
+ return extractUpstreamPin(await res.text());
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Bind the editor store so the dev hook / UI can launch ingestion. Called
208
+ * once per `ViewportPanel` mount, right before `loadInitialScene` +
209
+ * `autoLaunchIngestFromURL` — i.e. once per project session (`AppRoot`
210
+ * unmounts/remounts `DefaultEditorLayout` under a fresh `EditorProvider` on
211
+ * "return to project screen" -> reopen).
212
+ *
213
+ * D-W3: there is no OTHER project-switch teardown hook in this codebase
214
+ * today — `switchProject`/`onReturnToProjectScreen` reset React state, not
215
+ * this module-level ingest singleton (`_active`, D-V1's slot collapse, and
216
+ * the `active-adapter.ts`/`overlay-report.ts` globals it sets), and nothing
217
+ * calls `exitIngestMode`/`exitModuleMode` on that path — a pre-existing gap,
218
+ * not introduced or fixed here. This bind call is
219
+ * the earliest point every new project session reaches, so it is the best
220
+ * available place to make sure a PREVIOUS project's stale mount-failure
221
+ * banner never bleeds into a freshly opened one.
222
+ */
223
+ export function bindIngestMode(store: EditorStore): void {
224
+ _boundStore = store;
225
+ setMountFailureReport(null);
226
+ }
227
+
228
+ /**
229
+ * The world kind of the currently-active ingest session, if any (F26,
230
+ * VGAI_FTUE_LOG.md): ingest is an ADAPTER TIER, not a mode of one kind — D-V1
231
+ * collapsed the three per-kind session slots this used to fixed-priority-scan
232
+ * (`_session` / `_session2D` / `_sessionReact`) into the single `_active` slot
233
+ * above, so at most one session is ever active, of any kind, and this is just
234
+ * that slot's tag. The play surface ({@link getIngestPlayControl}) and
235
+ * {@link isIngestActive} read this, never a slot directly.
236
+ */
237
+ export type IngestKind = 'threejs' | 'pixi' | 'react';
238
+
239
+ export function activeIngestKind(): IngestKind | null {
240
+ return _active?.kind ?? null;
241
+ }
242
+
243
+ /** True if an unmodified game is currently ingested — ANY kind (F26). */
244
+ export function isIngestActive(): boolean {
245
+ return activeIngestKind() !== null;
246
+ }
247
+
248
+ /**
249
+ * The play-state control surface for the CURRENT ingest session (F17/F21,
250
+ * docs/FTUE-EXTERNAL-R3F-GAME.md): mounts are COLD by doctrine — the adapter
251
+ * sets `window.__vgaiMountCold` before the game's entry executes, and a
252
+ * session-driven game may defer its side-effects (backend connection,
253
+ * narrative, audio) until the first `play()`. Everything is contract-first
254
+ * (game-contract.ts): `lifecycle.start` is called AT MOST ONCE per mount —
255
+ * later ▶ presses resume rather than re-start, so games never need an
256
+ * idempotent start(); `lifecycle.pause`/`resume` are preferred over the
257
+ * outside-in loop gate (`MountedGame.setPaused`), which cannot gate a raw
258
+ * requestAnimationFrame loop (R3F games) at all. Games that declare nothing
259
+ * get the pre-contract wire event + loop gate, exactly as before — nothing
260
+ * is demanded of them.
261
+ */
262
+ export interface IngestPlayControl {
263
+ readonly playing: boolean;
264
+ play(): void;
265
+ pause(): void;
266
+ resume(): void;
267
+ }
268
+
269
+ let _ingestPlaying = false;
270
+
271
+ /**
272
+ * F26: the play surface's state is session-global (one active session,
273
+ * whatever its kind), so EVERY per-kind exit path resets it — the playing
274
+ * flag plus the cold-mount/played window globals the entry paths set.
275
+ */
276
+ function resetIngestPlaySurface(): void {
277
+ _ingestPlaying = false;
278
+ const w = window as unknown as { __vgaiMountCold?: boolean; __vgaiIngestPlayed?: boolean };
279
+ delete w.__vgaiMountCold;
280
+ delete w.__vgaiIngestPlayed;
281
+ }
282
+
283
+ /**
284
+ * F26 helper: the active session's kind + its own honest pause capability
285
+ * (D-V1: dispatches on the single `_active` slot, never a per-kind global).
286
+ * `null` when nothing is ingested OR the session is opaque-embed (iframe
287
+ * realm: the game-contract global is unreachable and there is no loop to
288
+ * gate — no play surface is honest there).
289
+ */
290
+ function activeIngestSurface(): { kind: IngestKind; setPaused(paused: boolean): void } | null {
291
+ const active = _active;
292
+ if (!active) return null;
293
+ switch (active.kind) {
294
+ case 'threejs': {
295
+ const mount = active.session.mount;
296
+ if (!mount) return null; // opaque-embed
297
+ return { kind: 'threejs', setPaused: (p) => mount.game.setPaused?.(p) };
298
+ }
299
+ case 'pixi': {
300
+ const session = active.session;
301
+ if (session.embed) return null; // D-W2 opaque-embed — no honest play surface, same doctrine as three's embed branch
302
+ return { kind: 'pixi', setPaused: (p) => session.setPaused(p) };
303
+ }
304
+ case 'react':
305
+ return { kind: 'react', setPaused: (p) => active.session.setPaused(p) };
306
+ }
307
+ }
308
+
309
+ /**
310
+ * D-V1 slot collapse: tears down WHATEVER kind is currently active,
311
+ * dispatching to that kind's own teardown body (`disposeThreejsSession`/
312
+ * `disposePixiSession`/`disposeReactSession`, each below in its own
313
+ * section) — the exact same per-kind dispose semantics as before the
314
+ * collapse (resizeObserver disconnect, `exitPlayScene`, mount/embed
315
+ * dispose, undo-adapter unregister, authoring/systems/overlay-report/
316
+ * mount-failure clears, canvas removal, react `stop()`), just no longer
317
+ * gated behind "is THIS kind the one currently active" — every
318
+ * `enterIngestMode*` entry guard calls this unconditionally, closing the
319
+ * cross-kind leak (F26 residual): switching kinds always disposes the
320
+ * prior session, whichever kind it was. `exitIngestMode`/`exitIngestMode2D`/
321
+ * `exitIngestModeReact` (the frozen public API) become thin wrappers around
322
+ * this that only act when `_active` is their own kind — so a same-kind
323
+ * caller sees the exact same observable behavior as before the collapse.
324
+ *
325
+ * D-V2 (docs/WAVE5-MULTIWORLD-INGEST-DESIGN.md, F24 composite): a composite
326
+ * session's `siblings` (mounted by `ingest-siblings.ts`'s
327
+ * `mountIngestSiblings`, attached via `attachIngestSiblings` below) tear
328
+ * down FIRST, in REVERSE mount order, before the primary session's own
329
+ * per-kind dispose body runs — every kind shares this ONE teardown path, so
330
+ * a sibling never outlives the ingest session it was mounted beside. A
331
+ * throwing sibling `dispose()` is caught individually (never blocks the rest
332
+ * of teardown) and logged loudly, never silently swallowed.
333
+ */
334
+ function exitActiveIngest(): void {
335
+ const active = _active;
336
+ if (!active) return;
337
+ _active = null;
338
+ resetIngestPlaySurface();
339
+ for (let i = active.siblings.length - 1; i >= 0; i--) {
340
+ try {
341
+ active.siblings[i]!.dispose();
342
+ } catch (err) {
343
+ editorConsole.error(`Composite sibling dispose failed: ${err}`, 'ingest');
344
+ }
345
+ }
346
+ switch (active.kind) {
347
+ case 'threejs':
348
+ disposeThreejsSession(active.session);
349
+ return;
350
+ case 'pixi':
351
+ disposePixiSession(active.session);
352
+ return;
353
+ case 'react':
354
+ disposeReactSession(active.session);
355
+ return;
356
+ }
357
+ }
358
+
359
+ /**
360
+ * D-B2 (docs/WAVE6-DOCTOR-DESIGN.md Slice B, wave-5 residual closure): fan a
361
+ * pause/resume state out to every mounted composite sibling's OPTIONAL
362
+ * `SiblingMount.setPaused` (`ingest-siblings.ts`), AFTER the primary
363
+ * surface's own pause/resume already ran above — mirrors
364
+ * `exitActiveIngest`'s reverse-order dispose fan-out just above (the same
365
+ * "one bad sibling never breaks the rest" isolation: per-sibling try/catch,
366
+ * loud log on failure, never swallowed silently). A sibling with no
367
+ * `setPaused` at all (the bare `default-react` sibling, D-B1 — it has no
368
+ * loop to gate) is silently skipped via the optional call — never an error,
369
+ * never a fabricated pause. A no-op (empty `siblings`, or no `_active`
370
+ * session at all) for every non-composite entry path, unchanged.
371
+ */
372
+ function fanOutSiblingPause(paused: boolean): void {
373
+ const active = _active;
374
+ if (!active) return;
375
+ for (const sibling of active.siblings) {
376
+ try {
377
+ sibling.setPaused?.(paused);
378
+ } catch (err) {
379
+ editorConsole.error(`Composite sibling setPaused(${paused}) failed: ${err}`, 'ingest');
380
+ }
381
+ }
382
+ }
383
+
384
+ export function getIngestPlayControl(): IngestPlayControl | null {
385
+ const surface = activeIngestSurface();
386
+ if (!surface) return null;
387
+ const resume = () => {
388
+ const lifecycle = readGameContract()?.lifecycle;
389
+ if (lifecycle?.resume) lifecycle.resume();
390
+ else surface.setPaused(false);
391
+ fanOutSiblingPause(false);
392
+ _ingestPlaying = true;
393
+ };
394
+ return {
395
+ get playing() {
396
+ return _ingestPlaying;
397
+ },
398
+ play() {
399
+ // Late-constructed game clients must not re-defer: the flag records
400
+ // that play already happened this session — and makes ▶-after-⏸ a
401
+ // RESUME, so `lifecycle.start` fires at most once per mount.
402
+ const w = window as unknown as { __vgaiIngestPlayed?: boolean };
403
+ if (w.__vgaiIngestPlayed) return resume();
404
+ w.__vgaiIngestPlayed = true;
405
+ // Contract-first: a game declaring `vgaiGame.lifecycle.start` gets the
406
+ // typed call; otherwise the wire event covers pre-contract games.
407
+ const lifecycle = readGameContract()?.lifecycle;
408
+ if (lifecycle?.start) lifecycle.start();
409
+ else window.dispatchEvent(new CustomEvent('vgai:ingest-play'));
410
+ surface.setPaused(false);
411
+ fanOutSiblingPause(false);
412
+ _ingestPlaying = true;
413
+ },
414
+ pause() {
415
+ const lifecycle = readGameContract()?.lifecycle;
416
+ if (lifecycle?.pause) lifecycle.pause();
417
+ else surface.setPaused(true);
418
+ fanOutSiblingPause(true);
419
+ _ingestPlaying = false;
420
+ },
421
+ resume,
422
+ };
423
+ }
424
+
425
+ /**
426
+ * D-W1 (docs/WAVE4-FTUE-HARDENING-DESIGN.md) — mount an `IngestGame` whose
427
+ * descriptor carries a `bundleUrl` (an externalized-`three` prebuilt bundle)
428
+ * via `mountIngestGameIframeReachableBundle` — the three-side sibling of
429
+ * {@link mountIframeReachableGame2D} below. `extraDeps` is a LAZY thunk on
430
+ * the descriptor (`resolveIngestDescriptor`, adapter-resolver.ts) — invoked
431
+ * here, right before the actual mount, so the host-namespace registry's
432
+ * dynamic imports only ever run for a game actually being ingested (same
433
+ * "resolve lazily, at mount time" discipline as the pixi sibling).
434
+ * `assetBaseUrl` is root-relative-resolved against the editor page's own URL
435
+ * here too (a static manifest can't know the dev-server port) — the exact
436
+ * `mountIframeReachableGame2D` idiom this mirrors.
437
+ */
438
+ async function mountThreeIframeReachableBundle(
439
+ game: IngestGame,
440
+ container: HTMLElement,
441
+ store: EditorStore,
442
+ opts: MountIngestOptions,
443
+ ): Promise<IngestMount> {
444
+ if (!game.bundleUrl) {
445
+ throw new Error(
446
+ 'mountThreeIframeReachableBundle: called with no `bundleUrl` on the descriptor — ' +
447
+ 'unreachable given the caller already checked `game.bundleUrl` (bug if hit).',
448
+ );
449
+ }
450
+ const resolvedExtraDeps = game.extraDeps ? await game.extraDeps() : { namespaces: {}, urls: {} };
451
+ // The asset-adapter URL modifier needs an ABSOLUTE URL (see
452
+ // `makeAssetUrlModifier`'s doc comment), but a static manifest can't know
453
+ // the editor's origin/port. Resolve a root-relative manifest value against
454
+ // the editor page here; absolute URLs pass through `new URL()` unchanged.
455
+ const assetBaseUrl =
456
+ game.assetBaseUrl !== undefined
457
+ ? new URL(game.assetBaseUrl, window.location.href).href
458
+ : undefined;
459
+ return mountIngestGameIframeReachableBundle(store, container, {
460
+ ...opts,
461
+ bundleUrl: game.bundleUrl,
462
+ ...(game.baseHref !== undefined ? { baseHref: game.baseHref } : {}),
463
+ ...(assetBaseUrl !== undefined ? { assetBaseUrl } : {}),
464
+ ...(game.bodyHtml !== undefined ? { bodyHtml: game.bodyHtml } : {}),
465
+ extraDeps: resolvedExtraDeps.namespaces,
466
+ ...(Object.keys(resolvedExtraDeps.urls).length > 0
467
+ ? { extraDepUrls: resolvedExtraDeps.urls }
468
+ : {}),
469
+ });
470
+ }
471
+
472
+ /**
473
+ * Enter ingest mode for an already-resolved {@link IngestGame} descriptor,
474
+ * however it was built. Resolves once the game's live scene is captured, the
475
+ * authoring adapter is active, and it is shown in the editor for editing.
476
+ *
477
+ * T3.3 slice 3 (docs/CLI-ON-FOLDER-DESIGN.md §1E) split this out of
478
+ * `enterIngestMode` so the descriptor's ORIGIN is a separate concern from
479
+ * mounting it: `enterIngestMode(store, gameId)` below resolves `gameId`
480
+ * against the in-tree fixture glob (`ingest/discovery.ts`, the `?ingest=`
481
+ * URL route's discovery frontend); `enterIngestModeFromManifestWorld` builds
482
+ * the descriptor straight from an arbitrary project's manifest instead (the
483
+ * CLI-on-folder route, for a game folder never registered in source) — both
484
+ * end up here. `overlayId` namespaces the saved-edit overlay
485
+ * (`.vgai/overlays/<overlayId>.json`) and the `__vgaiIngest` dev-hook's
486
+ * reported `gameId`; it is always the world/game id (`game.id`), passed
487
+ * explicitly rather than re-read off `game` so the two callers' "id" concept
488
+ * — a fixture's registry-era id vs. a manifest's world id — stays one
489
+ * parameter, not two silently-assumed-equal fields.
490
+ *
491
+ * `declaredTier` (T3.4 slice 1, docs/ADAPTER-AUTHORING-DESIGN.md §1.C
492
+ * retrofit) is the world's declared `capabilities.local` tier, when known —
493
+ * only `enterIngestModeFromManifestWorld` has a `ResolvedWorldEntry` to read
494
+ * it from; the in-tree fixture route (`enterIngestMode`) has no manifest at
495
+ * all, so it stays `undefined` there (anti-shim: never fabricate a declared
496
+ * tier that was never actually declared). Both embed-only degradation points
497
+ * below report a shortfall through the SAME structured log
498
+ * (`reportTierShortfall`, `./achieved-tier.ts`) the module-adapter
499
+ * achieved-tier check uses, ONLY when a declared tier is actually known.
500
+ *
501
+ * `gameVersion` (T7.7, D9 — docs/DECISIONS-PENDING.md §D9) is the game's own
502
+ * manifest `version`, when known at this call site — it gets stamped onto the
503
+ * overlay envelope's `authoredAgainst.gameVersion` on save, and compared
504
+ * against a PREVIOUSLY-saved overlay's stamp to report a mismatch
505
+ * (`./authoring/overlay-report.ts`). Falls back to `game.gameVersion` (set by
506
+ * `ingest/discovery.ts` for the in-tree fixture route) when omitted, since
507
+ * `enterIngestModeFromManifestWorld` below is the only caller that has a
508
+ * SEPARATE source for it (the resolved manifest, which `resolveIngestDescriptor`
509
+ * has no access to — see `IngestGame.gameVersion`'s doc comment). Neither
510
+ * source existing is legal — it stamps `null` rather than fabricating a
511
+ * version (anti-shim).
512
+ *
513
+ * `upstreamPin` (R5, docs/MASTER-ARCHITECTURE-REVIEW.md §4(c)/§7.1 item 9) is
514
+ * this session's discovered `UPSTREAM.md` pin, when the caller found one
515
+ * (`tryManifestIngestRoute` below, via `readVendoredUpstreamPin`) — `null`
516
+ * when it looked and found nothing (no fallback source: unlike `gameVersion`,
517
+ * `IngestGame` carries no pin field, since no discovery route can populate one
518
+ * for the in-tree fixture glob — those fixtures are first-party, never
519
+ * vendored). It serves BOTH ends of the same check: at APPLY time it's
520
+ * compared against the loaded overlay's `authoredAgainst.upstreamPin`
521
+ * (`./authoring/overlay-report.ts`'s `upstreamPin.mismatch`); at SAVE time
522
+ * it's the value threaded into `saveOverlay`'s stamp, so a save made THIS
523
+ * session records what it was actually authored against.
524
+ */
525
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive mount sequence (embed-only vs. captured-scene dispatch, capture-failure degrade, tier-shortfall reporting, gameVersion/upstreamPin resolution) — R5's one extra `??` tipped this over the threshold; splitting it would fragment the documented ordering contract rather than reduce real complexity (same precedent as game.ts's runFrame/achieved-tier fan-outs).
526
+ export async function enterIngestModeWithGame(
527
+ store: EditorStore,
528
+ game: IngestGame,
529
+ overlayId: string,
530
+ declaredTier?: Tier,
531
+ gameVersion?: string,
532
+ upstreamPin?: string | null,
533
+ ): Promise<void> {
534
+ // D-V1: tear down WHATEVER kind is currently active, not just a prior
535
+ // threejs session — closes the cross-kind leak (F26 residual).
536
+ exitActiveIngest();
537
+ const effectiveGameVersion = gameVersion ?? game.gameVersion ?? null;
538
+ const effectiveUpstreamPin = upstreamPin ?? null;
539
+
540
+ const gameContainer = getGameContainer();
541
+ if (!gameContainer) {
542
+ editorConsole.error('Game container not mounted — cannot ingest', 'ingest');
543
+ return;
544
+ }
545
+
546
+ // Ingest edits are runtime-only. Autosave/save-guard no longer suppress on a
547
+ // flag — the active adapter installed below (setActiveAuthoring) carries its
548
+ // own persistence provider (overlay destination, or absent for embed-only),
549
+ // which is what EditorStore's autosave/saveNow now consult (design
550
+ // docs/PERSISTENCE-PROVIDER-DESIGN.md §2). Still switch into the guarded
551
+ // "playing" state so the viewport swaps onto the live scene.
552
+ store.setPlayState('playing');
553
+ editorConsole.log(`Ingesting unmodified game: ${game.name}`, 'ingest');
554
+
555
+ // R2c (docs/MASTER-ARCHITECTURE-REVIEW.md §7.1 item 10): drive the SAME
556
+ // input gate play-mode uses (play-mode.ts's `gameInputActive`, wired via
557
+ // `setGameInputGate` at ~691) so a same-realm ingested game's raw
558
+ // window/document input listeners (gated via gated-globals.ts's proxy —
559
+ // reachable here because `server/dev.ts`'s prelude now shadows the in-tree
560
+ // ingest fixture roots too, see game-globals-shadow.ts) only fire while the
561
+ // Game tab is the active viewport surface, exactly like a first-party play
562
+ // session. Harmless for the iframe-reachable/opaque-embed tiers below too
563
+ // (a separate browsing context never reads this gate at all) — set once
564
+ // here, unconditionally, right alongside every `_active` assignment below,
565
+ // rather than duplicating it per branch.
566
+ const ingestInputActive = () =>
567
+ store.playState === 'playing' && store.activeViewportTab === 'game';
568
+
569
+ // Opaque prebuilt bundle → iframe embed-only floor (no scene introspection).
570
+ // `embedHtml` is either an already-read string (the in-tree fixture glob
571
+ // route) or a D-W2 lazy thunk (a manifest-driven external folder reading
572
+ // its `entryHtml` file's bytes through the project-static route) —
573
+ // resolve either shape to a plain string before mounting.
574
+ if (game.embedHtml) {
575
+ let embedHtmlText: string;
576
+ try {
577
+ embedHtmlText = typeof game.embedHtml === 'string' ? game.embedHtml : await game.embedHtml();
578
+ } catch (err) {
579
+ editorConsole.error(
580
+ `Failed to load opaque-embed entryHtml for "${game.name}": ${err}`,
581
+ 'ingest',
582
+ );
583
+ return;
584
+ }
585
+ const embed = mountIngestGameEmbed(embedHtmlText, gameContainer);
586
+ _active = { kind: 'threejs', session: { store, embed }, siblings: [] };
587
+ setGameInputGate(ingestInputActive);
588
+ setActiveAuthoring(makeEmbedOnlyAdapter(store, game.name));
589
+ store.setActiveViewportTab('game'); // show the hosted iframe
590
+ store.notifyIngestEdit();
591
+ setMountFailureReport(null); // D-W3: a (degraded-but-hosted) mount succeeded
592
+ editorConsole.log(`Embedded opaque bundle "${game.name}" (embed-only)`, 'ingest');
593
+ const shortfall = reportEmbedOnlyShortfall(overlayId, declaredTier);
594
+ (window as unknown as Record<string, unknown>)['__vgaiIngest'] = {
595
+ gameId: overlayId,
596
+ embedOnly: true,
597
+ reflected: { count: 0, meshes: 0, lights: 0 },
598
+ ...(shortfall ? { tierShortfall: shortfall } : {}),
599
+ };
600
+ return;
601
+ }
602
+
603
+ try {
604
+ // Load any saved overlay envelope (previous-session edits + save-time
605
+ // provenance — D9) to reapply on mount. `overlay.overrides` is the flat
606
+ // `IngestOverlay` the authoring adapter operates on; `overlay.authoredAgainst`
607
+ // is what the OverlayApplyReport below compares against `effectiveGameVersion`.
608
+ const overlay = await loadOverlay(overlayId);
609
+ const mountOpts: MountIngestOptions = {
610
+ overlay: overlay.overrides,
611
+ persist: (o) => saveOverlay(overlayId, o, effectiveGameVersion, effectiveUpstreamPin),
612
+ destination: overlayPath(overlayId),
613
+ captureTimeoutMs: game.captureTimeoutMs,
614
+ };
615
+
616
+ // Mount the unmodified game through the GameAdapter path → captured live scene
617
+ // + an IngestAuthoringAdapter over its Object3D tree (no SceneEntity). The
618
+ // adapter reapplies the overlay after walking the scene. Reachable-three
619
+ // prebuilt bundles (inline source OR a D-W1 externalized `bundleUrl`) run
620
+ // in an iframe but share the host three → same contract.
621
+ const mount = game.iframeReachableSrc
622
+ ? await mountIngestGameIframeReachable(
623
+ store,
624
+ game.iframeReachableSrc,
625
+ gameContainer,
626
+ mountOpts,
627
+ )
628
+ : game.bundleUrl
629
+ ? await mountThreeIframeReachableBundle(game, gameContainer, store, mountOpts)
630
+ : await mountIngestGame(store, game, gameContainer, mountOpts);
631
+ const session: IngestSession = { store, mount };
632
+ _active = { kind: 'threejs', session, siblings: [] };
633
+ setGameInputGate(ingestInputActive);
634
+
635
+ // Capture failed → the game's three is unreachable (bundled / MISMATCHED version).
636
+ // Degrade gracefully to embed-only (host it, no scene introspection) — never silent.
637
+ if (mount.embedOnly || !mount.authoring || !mount.scene) {
638
+ setActiveAuthoring(
639
+ makeEmbedOnlyAdapter(store, `${game.name} — capture failed (three unreachable/mismatched)`),
640
+ );
641
+ store.setActiveViewportTab('game');
642
+ store.notifyIngestEdit();
643
+ setMountFailureReport(null); // D-W3: a (degraded-but-hosted) mount succeeded
644
+ editorConsole.log(
645
+ `Capture failed for "${game.name}": three unreachable/mismatched — hosting embed-only`,
646
+ 'ingest',
647
+ );
648
+ const shortfall = reportEmbedOnlyShortfall(overlayId, declaredTier);
649
+ (window as unknown as Record<string, unknown>)['__vgaiIngest'] = {
650
+ gameId: overlayId,
651
+ embedOnly: true,
652
+ captureFailed: true,
653
+ reflected: { count: 0, meshes: 0, lights: 0 },
654
+ ...(shortfall ? { tierShortfall: shortfall } : {}),
655
+ };
656
+ return;
657
+ }
658
+
659
+ // Host lifecycle: keep the game's renderer sized to its container (resize).
660
+ const ro = new ResizeObserver(() => {
661
+ const r = gameContainer.getBoundingClientRect();
662
+ if (r.width > 0 && r.height > 0) mount.game.resize?.(r.width, r.height);
663
+ });
664
+ ro.observe(gameContainer);
665
+ session.resizeObserver = ro;
666
+
667
+ const stats = mount.authoring.refresh();
668
+ const overlayCount = Object.keys(overlay.overrides).length;
669
+ editorConsole.log(
670
+ `Captured live scene (${mount.scene.children.length} top-level children, ` +
671
+ `${mount.capture.getDrawCount()} draws); ingested ${stats.count} objects ` +
672
+ `(${stats.meshes} meshes, ${stats.lights} lights)` +
673
+ (overlayCount ? `; reapplied ${overlayCount} saved edit(s)` : ''),
674
+ 'ingest',
675
+ );
676
+
677
+ // D9 (docs/DECISIONS-PENDING.md §D9): report a version mismatch and/or
678
+ // orphaned overlay ids — never blocks mounting, everything applicable was
679
+ // already applied by the adapter's constructor-time `applyOverlay`
680
+ // (`mount.authoring.lastApplyResult` is that call's result). R5 adds the
681
+ // upstream-pin check (§4(c)) — `overlay.authoredAgainst.upstreamPin` is
682
+ // `undefined` for a legacy overlay (never recorded — the report's
683
+ // `upstreamPin.recorded` reflects that honestly) and `effectiveUpstreamPin`
684
+ // is this session's discovered pin (`null` if undiscoverable here).
685
+ const overlayReport = buildOverlayApplyReport({
686
+ gameId: overlayId,
687
+ authoredAgainst: overlay.authoredAgainst.gameVersion,
688
+ currentVersion: effectiveGameVersion,
689
+ applied: mount.authoring.lastApplyResult.applied,
690
+ orphanedIds: mount.authoring.lastApplyResult.orphanedIds,
691
+ authoredUpstreamPin: overlay.authoredAgainst.upstreamPin,
692
+ currentUpstreamPin: effectiveUpstreamPin,
693
+ });
694
+ logOverlayApplyReport(overlayReport);
695
+ setActiveOverlayReport(overlayReport);
696
+
697
+ // D-A3 (docs/WAVE6-DOCTOR-DESIGN.md): the achieved tier for a healthy,
698
+ // fully-captured ingest mount — 'shared'/'deduped' rank identically
699
+ // (TIER_RANK, achieved-tier.ts), so this collapses to 'shared' for
700
+ // either load()-based route; the iframe-reachable-bundle route
701
+ // (`game.iframeReachableSrc`/`game.bundleUrl`) reports its own rung.
702
+ // Ingest tier was previously console-only (`reportEmbedOnlyShortfall`
703
+ // covers only the embed-only floor) — doctor's headless mount phase
704
+ // needs it on the healthy path too, since it has no console to read.
705
+ const achievedTier: Tier =
706
+ game.iframeReachableSrc || game.bundleUrl ? 'iframe-reachable' : 'shared';
707
+
708
+ // The editor's hierarchy + inspector now render from THIS adapter.
709
+ setActiveAuthoring(mount.authoring);
710
+ // An unmodified game exposes NO engine System adapters (it owns its own
711
+ // physics/input/animation/etc.) → those are correctly N/A while ingested. Only
712
+ // the asset-path remap (done at mount) is a first-party asset-adapter behavior.
713
+ setActiveSystems(mount.game.systems ?? {});
714
+ // Bind the viewport/objectMap/gizmo to the live scene (by the tagged entityId).
715
+ store.enterPlayScene(mount.scene);
716
+ store.setActiveViewportTab('scene');
717
+ store.notifyIngestEdit(); // ensure panels re-render against the override
718
+ setMountFailureReport(null); // D-W3: mount succeeded
719
+
720
+ // Dev/proof hook: expose ingest state for automation + screenshots.
721
+ (window as unknown as Record<string, unknown>)['__vgaiIngest'] = {
722
+ gameId: overlayId,
723
+ drawCount: () => mount.capture.getDrawCount(),
724
+ reflected: stats,
725
+ sceneChildren: mount.scene.children.length,
726
+ // D-A3: achieved tier for a headless-verifiable healthy mount (see comment above).
727
+ achievedTier,
728
+ // Host-lifecycle resize: drive the game renderer's size (proof/automation).
729
+ resize: (w: number, h: number) => mount.game.resize?.(w, h),
730
+ // Loop-host: freeze/resume the game's own rAF (proof/automation).
731
+ setPaused: (p: boolean) => mount.game.setPaused?.(p),
732
+ // Recorded N/A: which engine System adapters are active for this game (none
733
+ // for an unmodified game — it owns its own subsystems).
734
+ engineSystems: () => Object.keys(getActiveSystems()),
735
+ // D9 (T7.7): the version-mismatch/orphan findings for this mount, for
736
+ // automation/proof — mirrors the embed-only branches' `tierShortfall`.
737
+ overlayReport,
738
+ // DEV-only: the live authoring adapter + store, so e2e/proof can drive the
739
+ // SAME providers the hierarchy/inspector/gizmo use.
740
+ ...(import.meta.env.DEV ? { adapter: mount.authoring, store } : {}),
741
+ };
742
+ } catch (err) {
743
+ editorConsole.error(`Ingest failed: ${err}`, 'ingest');
744
+ exitIngestMode();
745
+ throw err;
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Enter ingest mode for an in-tree fixture game by its discovered id (the
751
+ * `?ingest=<id>` URL route's entry point — `ingest/discovery.ts`'s glob
752
+ * discovery replaces the deleted `INGEST_GAMES` registry lookup here).
753
+ */
754
+ export async function enterIngestMode(store: EditorStore, gameId: string): Promise<void> {
755
+ const game = getIngestGame(gameId);
756
+ if (!game) {
757
+ editorConsole.error(`Unknown ingest game: ${gameId}`, 'ingest');
758
+ return;
759
+ }
760
+ return enterIngestModeWithGame(store, game, gameId);
761
+ }
762
+
763
+ /**
764
+ * Enter ingest mode straight from a manifest's `ingest-three` world — the
765
+ * CLI-on-folder route (T3.3 slice 3, docs/CLI-ON-FOLDER-DESIGN.md §1E): a
766
+ * game folder never registered in source, whose `vgai.game.json` declares an
767
+ * ingest-three world. Builds the descriptor via `resolveIngestDescriptor`
768
+ * (adapter-resolver.ts — parameterized by the manifest's ingest fields, no
769
+ * registry lookup) and mounts it exactly like any other {@link IngestGame}.
770
+ * The overlay namespaces off the world id, consistent with today's
771
+ * `.vgai/overlays/<id>.json` convention (the `?ingest=<id>` route's `id` IS a
772
+ * world id too, for the in-tree fixtures — same namespace, different origin).
773
+ *
774
+ * `gameVersion` (T7.7/D9): `resolveIngestDescriptor` only has a
775
+ * `ResolvedWorldEntry`, not the manifest's top-level `version` — so the caller
776
+ * (`tryManifestIngestRoute` below, which fetched the full `ResolvedGameManifest`)
777
+ * passes it here explicitly, threaded straight through to `enterIngestModeWithGame`.
778
+ *
779
+ * `upstreamPin` (R5, docs/MASTER-ARCHITECTURE-REVIEW.md §4(c)): same shape —
780
+ * `resolveIngestDescriptor` has no way to discover an `UPSTREAM.md` pin
781
+ * either, so `tryManifestIngestRoute` (which has a dev server + project root
782
+ * to fetch one from, via `readVendoredUpstreamPin`) passes it here explicitly.
783
+ */
784
+ export async function enterIngestModeFromManifestWorld(
785
+ store: EditorStore,
786
+ world: ResolvedWorldEntry,
787
+ projectRoot: string,
788
+ gameVersion?: string,
789
+ upstreamPin?: string | null,
790
+ ): Promise<void> {
791
+ const game = resolveIngestDescriptor(world, projectRoot);
792
+ return enterIngestModeWithGame(
793
+ store,
794
+ game,
795
+ world.id,
796
+ world.capabilities.local,
797
+ gameVersion,
798
+ upstreamPin,
799
+ );
800
+ }
801
+
802
+ // ---------------------------------------------------------------------------
803
+ // PixiJS ingest sessions (T6.1 slice 4 — docs/MULTI-WORLD-DESIGN.md §1.E)
804
+ // ---------------------------------------------------------------------------
805
+
806
+ interface IngestSession2D {
807
+ store: EditorStore;
808
+ /** Absent for a D-W2 opaque-embed session — see `embed` below. */
809
+ adapter?: World2DAuthoringAdapter;
810
+ /** Opaque prebuilt-bundle embed path (D-W2, iframe-hosted, embed-only — mirrors the three side's `IngestSession.embed`). */
811
+ embed?: EmbedMount2D;
812
+ /** The mount's own honest pause (ticker stop/start) — the F26 play surface. No-op for an opaque-embed session (no reachable pixi instance). */
813
+ setPaused: (paused: boolean) => void;
814
+ dispose: () => void;
815
+ }
816
+
817
+ /** True if an unmodified PixiJS game is currently ingested. */
818
+ export function isIngestActive2D(): boolean {
819
+ return _active?.kind === 'pixi';
820
+ }
821
+
822
+ /**
823
+ * D-P4 (docs/PIXI-INGEST-LANDING-DESIGN.md §3) — mount-shape adapter:
824
+ * `IframeReachableMount2D` (`ingest-iframe-2d.ts`) has `{ iframe, capture,
825
+ * captured, dispose }`, not the `IngestMount2D` shape (`{ stage, capture,
826
+ * embedOnly, setPaused, dispose }`) the rest of ingest-mode.ts's PixiJS path
827
+ * expects. This wraps the former into the latter:
828
+ * - `stage`: the captured runtime's stage, or `null` if capture never
829
+ * yielded one (timed out / pixi unreachable) — the SAME "null stage"
830
+ * signal `mountIngestGame2D`'s degrade path already produces, so the
831
+ * caller's existing `!mount.stage` check covers both mounts uniformly.
832
+ * - `embedOnly`: always `false` — this route has no embed-only floor
833
+ * (capture either succeeded, or the mount call itself already failed
834
+ * loudly / `stage` is null); never fabricate a degraded-but-hosted state.
835
+ * - `setPaused`: a REAL capability, not a shim — the trap yields the live
836
+ * `Application` (`captured.app`), so pausing drives its OWN
837
+ * `ticker.stop()`/`start()`. If capture hasn't yielded an app yet (or
838
+ * failed), this THROWS loudly rather than silently no-oping — the honest-
839
+ * pause design call.
840
+ * - `dispose`: delegates straight to the iframe mount's own `dispose`.
841
+ */
842
+ function wrapIframeReachableMount2D(mount: IframeReachableMount2D): IngestMount2D {
843
+ return {
844
+ stage: mount.captured?.stage ?? null,
845
+ capture: mount.capture,
846
+ embedOnly: false,
847
+ setPaused(paused: boolean): void {
848
+ const app = mount.captured?.app as
849
+ | { ticker?: { stop: () => void; start: () => void } }
850
+ | undefined;
851
+ if (!app?.ticker) {
852
+ throw new Error(
853
+ 'ingest-mode: setPaused() called on an iframe-reachable-multi mount before capture ' +
854
+ 'yielded a live Application (or capture failed) — this never silently no-ops (D-P4, ' +
855
+ 'docs/PIXI-INGEST-LANDING-DESIGN.md §3).',
856
+ );
857
+ }
858
+ if (paused) app.ticker.stop();
859
+ else app.ticker.start();
860
+ },
861
+ dispose: () => mount.dispose(),
862
+ };
863
+ }
864
+
865
+ /**
866
+ * Mount an `iframe-reachable` `IngestGame2D` descriptor (Track P,
867
+ * docs/PIXI-INGEST-LANDING-DESIGN.md §2/P2) via
868
+ * `mountIngestGame2DIframeReachableMulti` — the externalized-built-bundle
869
+ * sibling of {@link mountIngestGame2D}'s shared-instance path. `extraDeps`/
870
+ * `pixiNamespace` are LAZY thunks on the descriptor (`resolveIngest2DDescriptor`
871
+ * / `discovery2d.ts`) — invoked here, right before the actual mount, so the
872
+ * host-namespace registry's dynamic imports (`@pixi/sound`/`gsap`/spine/a
873
+ * standalone pixi build) only ever run for a game actually being ingested.
874
+ */
875
+ async function mountIframeReachableGame2D(
876
+ game: IngestGame2D,
877
+ container: HTMLElement,
878
+ ): Promise<IngestMount2D> {
879
+ if (!game.bundleUrl) {
880
+ throw new Error(
881
+ `enterIngestModeWithGame2D: world2d ingest game "${game.id}" has neither \`load\` nor ` +
882
+ '`bundleUrl` — resolveIngest2DDescriptor only builds shared/deduped (load) or ' +
883
+ 'iframe-reachable (bundleUrl) descriptors this slice.',
884
+ );
885
+ }
886
+ const pixiNamespace = game.pixiNamespace ? await game.pixiNamespace() : PIXI;
887
+ // D-Z3: two kinds — registry-known specifiers resolve to host namespaces
888
+ // (`.namespaces`, unchanged shape/behavior); everything else resolves via
889
+ // the project-node_modules fallback into verbatim importmap URLs (`.urls`).
890
+ const resolvedExtraDeps = game.extraDeps ? await game.extraDeps() : { namespaces: {}, urls: {} };
891
+ // The Assets.init basePath remap needs an ABSOLUTE URL (Pixi's Assets resolver
892
+ // builds URLs from window.location — see IframeReachableMultiOpts.assetBaseUrl),
893
+ // but a static manifest can't know the editor's origin/port. Resolve a
894
+ // root-relative manifest value against the editor page here; absolute URLs
895
+ // pass through new URL() unchanged.
896
+ const assetBaseUrl =
897
+ game.assetBaseUrl !== undefined
898
+ ? new URL(game.assetBaseUrl, window.location.href).href
899
+ : undefined;
900
+ const mount = await mountIngestGame2DIframeReachableMulti(pixiNamespace, container, {
901
+ extraDeps: resolvedExtraDeps.namespaces,
902
+ ...(Object.keys(resolvedExtraDeps.urls).length > 0
903
+ ? { extraDepUrls: resolvedExtraDeps.urls }
904
+ : {}),
905
+ bundleUrl: game.bundleUrl,
906
+ ...(game.baseHref !== undefined ? { baseHref: game.baseHref } : {}),
907
+ ...(assetBaseUrl !== undefined ? { assetBaseUrl } : {}),
908
+ ...(game.bodyHtml !== undefined ? { bodyHtml: game.bodyHtml } : {}),
909
+ ...(game.captureTimeoutMs !== undefined ? { captureTimeoutMs: game.captureTimeoutMs } : {}),
910
+ });
911
+ return wrapIframeReachableMount2D(mount);
912
+ }
913
+
914
+ /**
915
+ * Enter ingest mode for an already-resolved {@link IngestGame2D} descriptor —
916
+ * the pixi sibling of {@link enterIngestModeWithGame}. `overlayId` is always
917
+ * the world id, namespacing the saved overlay at the §1.G unified path
918
+ * (`.vgai/overlays/<worldId>.json`, `authoring/world-overlay.ts` — the SAME
919
+ * module `play-mode.ts`'s multi-world path already uses, NOT the retired
920
+ * `2d-`-prefixed `world2d-overlay.ts`).
921
+ */
922
+ /**
923
+ * D-W2 (docs/WAVE4-FTUE-HARDENING-DESIGN.md): opaque prebuilt bundle → iframe
924
+ * embed-only floor (no scene introspection) — the pixi sibling of
925
+ * `enterIngestModeWithGame`'s identical `game.embedHtml` branch. Extracted
926
+ * out of {@link enterIngestModeWithGame2D} to keep that function's cognitive
927
+ * complexity under biome's `noExcessiveCognitiveComplexity` ceiling (this
928
+ * branch's own try/catch + session/hook wiring pushed it over on its own).
929
+ * Always a lazy thunk here (see `IngestGame2D.embedHtml`'s doc comment).
930
+ */
931
+ async function enterIngestModeWithGame2DEmbed(
932
+ store: EditorStore,
933
+ game: IngestGame2D,
934
+ overlayId: string,
935
+ gameContainer: HTMLElement,
936
+ embedHtml: () => Promise<string>,
937
+ ): Promise<void> {
938
+ let embedHtmlText: string;
939
+ try {
940
+ embedHtmlText = await embedHtml();
941
+ } catch (err) {
942
+ editorConsole.error(
943
+ `Failed to load opaque-embed entryHtml for "${game.name}" (world2d): ${err}`,
944
+ 'ingest',
945
+ );
946
+ store.setPlayState('stopped');
947
+ return;
948
+ }
949
+ const embed: EmbedMount2D = mountIngestGame2DEmbed(embedHtmlText, gameContainer);
950
+ _active = {
951
+ kind: 'pixi',
952
+ session: {
953
+ store,
954
+ embed,
955
+ setPaused: () => {},
956
+ dispose: () => embed.dispose(),
957
+ },
958
+ siblings: [],
959
+ };
960
+ setGameInputGate(() => store.playState === 'playing' && store.activeViewportTab === 'game');
961
+ setActiveAuthoring(makeEmbedOnlyAdapter(store, game.name));
962
+ setActiveSystems({});
963
+ store.setActiveViewportTab('game');
964
+ store.notifyIngestEdit();
965
+ editorConsole.log(`Embedded opaque pixi bundle "${game.name}" (embed-only, world2d)`, 'ingest');
966
+ (window as unknown as Record<string, unknown>)['__vgaiIngest2D'] = {
967
+ gameId: overlayId,
968
+ embedOnly: true,
969
+ reflected: { count: 0, sprites: 0 },
970
+ };
971
+ }
972
+
973
+ export async function enterIngestModeWithGame2D(
974
+ store: EditorStore,
975
+ game: IngestGame2D,
976
+ overlayId: string,
977
+ ): Promise<void> {
978
+ // D-V1: tear down WHATEVER kind is currently active, not just a prior
979
+ // pixi session — closes the cross-kind leak (F26 residual).
980
+ exitActiveIngest();
981
+
982
+ const gameContainer = getGameContainer();
983
+ if (!gameContainer) {
984
+ editorConsole.error('Game container not mounted — cannot ingest (world2d)', 'ingest');
985
+ return;
986
+ }
987
+
988
+ store.setPlayState('playing');
989
+ editorConsole.log(`Ingesting unmodified PixiJS game: ${game.name}`, 'ingest');
990
+
991
+ if (game.embedHtml) {
992
+ return enterIngestModeWithGame2DEmbed(store, game, overlayId, gameContainer, game.embedHtml);
993
+ }
994
+
995
+ if (!game.load && !game.bundleUrl) {
996
+ throw new Error(
997
+ `enterIngestModeWithGame2D: world2d ingest game "${game.id}" has neither \`load\` nor ` +
998
+ '`bundleUrl` — only shared/deduped (load) or iframe-reachable (bundleUrl) ingest-pixi ' +
999
+ 'worlds are mountable this slice (resolveIngest2DDescriptor).',
1000
+ );
1001
+ }
1002
+
1003
+ // Track P (docs/PIXI-INGEST-LANDING-DESIGN.md §2/D-P4): the iframe-
1004
+ // reachable-multi route mounts its OWN iframe straight into `gameContainer`
1005
+ // (`mountIngestGame2DIframeReachableMulti`) — it needs no placeholder
1006
+ // `<canvas>`, unlike the shared-instance path below (whose game creates its
1007
+ // own PixiJS `Application` and needs a container element reserved for it).
1008
+ const canvas = game.load ? document.createElement('canvas') : undefined;
1009
+ if (canvas) gameContainer.appendChild(canvas);
1010
+
1011
+ // F26: same cold-mount announcement the threejs adapter makes
1012
+ // (ingest-game-adapter.ts) — a shared-realm pixi game may declare the
1013
+ // game contract and defer its session until ▶; games that ignore the flag
1014
+ // (including iframe-realm bundles, which can't see it) behave as before.
1015
+ (window as unknown as { __vgaiMountCold?: boolean }).__vgaiMountCold = true;
1016
+ const overlay = await loadWorldOverlay<Overlay2D>(overlayId);
1017
+ const mount = game.load
1018
+ ? await mountIngestGame2D(
1019
+ PIXI,
1020
+ game,
1021
+ game.captureTimeoutMs !== undefined ? { captureTimeoutMs: game.captureTimeoutMs } : {},
1022
+ )
1023
+ : await mountIframeReachableGame2D(game, gameContainer);
1024
+
1025
+ if (mount.embedOnly || !mount.stage) {
1026
+ canvas?.remove();
1027
+ mount.dispose();
1028
+ editorConsole.error(
1029
+ `world2d ingest capture failed for "${game.name}" (pixi unreachable) — no embed-only ` +
1030
+ 'floor exists for ingest-pixi this slice (resolveIngest2DDescriptor builds no ' +
1031
+ 'embedHtml games; iframe-reachable-multi capture just timed out/failed).',
1032
+ 'ingest',
1033
+ );
1034
+ return;
1035
+ }
1036
+
1037
+ const stage = mount.stage as Container;
1038
+ // The game's async setup may add display objects after its first captured frame —
1039
+ // wait for the stage to settle (mirrors world2d-mode.ts's prior behavior).
1040
+ for (let i = 0; i < 90 && (stage.children?.length ?? 0) === 0; i++) {
1041
+ await new Promise((r) => requestAnimationFrame(r));
1042
+ }
1043
+
1044
+ const physicsAdapter = createPhysicsAdapter2D(createPhysics2DRegistry());
1045
+ const adapter = new World2DAuthoringAdapter(stage, physicsAdapter, store, {
1046
+ overlay,
1047
+ persist: (o) => saveWorldOverlay(overlayId, o),
1048
+ destination: worldOverlayPath(overlayId),
1049
+ });
1050
+ const stats = adapter.refresh();
1051
+
1052
+ // D-A3 (docs/WAVE6-DOCTOR-DESIGN.md): pixi sibling of the three-side
1053
+ // achieved-tier computation above — `game.load` is the shared/deduped
1054
+ // load() route, otherwise this reached here via the iframe-reachable-multi
1055
+ // bundleUrl route (`mountIframeReachableGame2D`).
1056
+ const achievedTier: Tier = game.load ? 'shared' : 'iframe-reachable';
1057
+
1058
+ _active = {
1059
+ kind: 'pixi',
1060
+ session: {
1061
+ store,
1062
+ adapter,
1063
+ setPaused: (p) => mount.setPaused(p),
1064
+ dispose: () => {
1065
+ mount.dispose();
1066
+ canvas?.remove();
1067
+ },
1068
+ },
1069
+ siblings: [],
1070
+ };
1071
+
1072
+ // R2c: same-realm PixiJS ingest fixtures (`ingest/games-2d/**`) are just as
1073
+ // unshadowed-by-default as the three.js ones — drive the same gate as
1074
+ // {@link enterIngestModeWithGame} (see that function's doc comment).
1075
+ setGameInputGate(() => store.playState === 'playing' && store.activeViewportTab === 'game');
1076
+
1077
+ setActiveAuthoring(adapter);
1078
+ setActiveSystems({});
1079
+ store.setActiveViewportTab('game');
1080
+ store.notifyIngestEdit();
1081
+ setMountFailureReport(null); // D-W3: mount succeeded
1082
+
1083
+ editorConsole.log(
1084
+ `world2d ingest: captured "${game.name}" (${stats.count} nodes, ${stats.sprites} sprites)`,
1085
+ 'ingest',
1086
+ );
1087
+
1088
+ (window as unknown as Record<string, unknown>)['__vgaiIngest2D'] = {
1089
+ gameId: overlayId,
1090
+ drawCount: () => mount.capture.getDrawCount(),
1091
+ reflected: stats,
1092
+ setPaused: (p: boolean) => mount.setPaused(p),
1093
+ // D-A3: achieved tier for a headless-verifiable healthy mount (see comment above).
1094
+ achievedTier,
1095
+ adapter,
1096
+ store,
1097
+ };
1098
+ }
1099
+
1100
+ /**
1101
+ * Enter ingest mode straight from a manifest's `ingest-pixi` world — the pixi
1102
+ * sibling of {@link enterIngestModeFromManifestWorld}. Builds the descriptor
1103
+ * via `resolveIngest2DDescriptor` (adapter-resolver.ts) and mounts it exactly
1104
+ * like any other {@link IngestGame2D}.
1105
+ */
1106
+ export async function enterIngestModeFromManifestWorld2D(
1107
+ store: EditorStore,
1108
+ world: ResolvedWorldEntry,
1109
+ projectRoot: string,
1110
+ ): Promise<void> {
1111
+ const game = resolveIngest2DDescriptor(world, projectRoot);
1112
+ return enterIngestModeWithGame2D(store, game, world.id);
1113
+ }
1114
+
1115
+ /**
1116
+ * Enter ingest mode for an in-tree world2d fixture game by its discovered id
1117
+ * (the `?ingest=<id>` URL route's pixi fallback — `ingest/discovery2d.ts`'s
1118
+ * glob discovery replaces the deleted `WORLD2D_INGEST_GAMES` registry lookup
1119
+ * here). Pixi sibling of {@link enterIngestMode}.
1120
+ */
1121
+ export async function enterIngestMode2D(store: EditorStore, gameId: string): Promise<void> {
1122
+ const game = getIngestGame2D(gameId);
1123
+ if (!game) {
1124
+ editorConsole.error(`Unknown world2d ingest game: ${gameId}`, 'ingest');
1125
+ return;
1126
+ }
1127
+ return enterIngestModeWithGame2D(store, game, gameId);
1128
+ }
1129
+
1130
+ /**
1131
+ * D-V1: the pixi teardown body — byte-for-byte the pre-collapse
1132
+ * `exitIngestMode2D` logic below, extracted so `exitActiveIngest` can
1133
+ * dispatch to it for ANY kind switch. `_active`/`resetIngestPlaySurface` are
1134
+ * handled by the caller (`exitActiveIngest`) — this only does the
1135
+ * pixi-specific teardown.
1136
+ */
1137
+ function disposePixiSession(s: IngestSession2D): void {
1138
+ // R2c: reset the input gate — see disposeThreejsSession's identical comment.
1139
+ setGameInputGate(() => true);
1140
+ // v4 §7.1-13: `World2DAuthoringAdapter` now self-registers with the undo
1141
+ // timeline (mirrors `IngestAuthoringAdapter` — see disposeThreejsSession's
1142
+ // identical call above), so this session's entries must be dropped the same
1143
+ // way on exit. Absent for a D-W2 opaque-embed session (no adapter, no undo entries).
1144
+ if (s.adapter) unregisterUndoAdapter(s.adapter);
1145
+ setActiveAuthoring(null);
1146
+ setActiveSystems(null);
1147
+ setMountFailureReport(null); // D-W3: no session -> no banner to show (mirrors setActiveOverlayReport(null) above)
1148
+ s.store.setPlayState('stopped');
1149
+ try {
1150
+ s.dispose();
1151
+ } catch {
1152
+ /* ignore */
1153
+ }
1154
+ delete (window as unknown as Record<string, unknown>)['__vgaiIngest2D'];
1155
+ editorConsole.log('world2d ingest mode stopped', 'ingest');
1156
+ }
1157
+
1158
+ /**
1159
+ * Exit PixiJS ingest mode: tear down the captured game + adapter. D-V1: a
1160
+ * thin wrapper that only acts when a pixi session is the one currently
1161
+ * active in `_active` — same observable behavior as before the collapse;
1162
+ * the actual teardown is `disposePixiSession` via `exitActiveIngest`.
1163
+ */
1164
+ export function exitIngestMode2D(): void {
1165
+ if (_active?.kind !== 'pixi') return;
1166
+ exitActiveIngest();
1167
+ }
1168
+
1169
+ // ---------------------------------------------------------------------------
1170
+ // Native-React ingest sessions (Track N, N2 — docs/REACT-INGEST-LANDING-
1171
+ // DESIGN.md D-N2/D-N8)
1172
+ // ---------------------------------------------------------------------------
1173
+
1174
+ interface IngestSessionReact {
1175
+ store: EditorStore;
1176
+ /** The runtime Game's real play-state control — the F26 play surface. */
1177
+ setPaused: (paused: boolean) => void;
1178
+ stop: () => void;
1179
+ /** D-K4 (wave 10 Slice A): the live DOM-authoring adapter both routes now
1180
+ * install — self-registered with the global undo timeline at construction,
1181
+ * so teardown must unregister it (mirrors `IngestSession2D.adapter`). */
1182
+ adapter: ReactDomAuthoringAdapter;
1183
+ }
1184
+
1185
+ /** True if an ingested native-React game is currently mounted. */
1186
+ export function isIngestActiveReact(): boolean {
1187
+ return _active?.kind === 'react';
1188
+ }
1189
+
1190
+ /**
1191
+ * D-L1 (wave 10 Slice B, docs/WAVE10-REACT-INGEST-DOM-AUTHORING-DESIGN.md
1192
+ * §2) — the LIVE value behind `__vgaiIngestReact.domEvidence()`, shared by
1193
+ * both react ingest routes below. Evaluated at scrape time (a function
1194
+ * result, never memoized), same "read the live state fresh on every call"
1195
+ * discipline as `__vgaiSiblingMounts` (D-H1).
1196
+ *
1197
+ * `elementCount` prefers the DOM's real `querySelectorAll('*').length` — a
1198
+ * real browser `HTMLElement` (what `reactRoot()` actually returns in
1199
+ * production) always has it — and falls back to the adapter's own
1200
+ * structural-walk size (`nodeCount()`) only when the mounted root doesn't
1201
+ * expose `querySelectorAll` at all (this repo's own vitest-node adapter
1202
+ * fixtures have no real DOM). Never a fabricated number either way: both
1203
+ * paths count real, currently-present elements.
1204
+ *
1205
+ * `reactRootPresent` checks for a React 19 `createRoot` own-property mount
1206
+ * stamp (`__reactContainer$<hash>`) on `root` itself — the SAME element
1207
+ * `reactRoot()` returned, which is exactly the element `createRoot(...)`
1208
+ * rendered into (`create-runtime.ts`'s `ReactWorldHost.container` doc
1209
+ * comment: "the adapter's `mount` renders its react tree INTO this exact
1210
+ * element via `createRoot`").
1211
+ */
1212
+ function reactDomEvidence(
1213
+ root: HTMLElement,
1214
+ adapter: ReactDomAuthoringAdapter,
1215
+ ): { elementCount: number; hierarchyNodeCount: number; reactRootPresent: boolean } {
1216
+ const withQuery = root as unknown as { querySelectorAll?: (sel: string) => ArrayLike<unknown> };
1217
+ const elementCount =
1218
+ typeof withQuery.querySelectorAll === 'function'
1219
+ ? withQuery.querySelectorAll('*').length
1220
+ : adapter.nodeCount();
1221
+ const reactRootPresent = Object.keys(root as object).some((k) =>
1222
+ k.startsWith('__reactContainer'),
1223
+ );
1224
+ return { elementCount, hierarchyNodeCount: adapter.nodeCount(), reactRootPresent };
1225
+ }
1226
+
1227
+ /**
1228
+ * Enter ingest mode for a discovered native-React fixture game (the
1229
+ * `?ingest=<id>` URL route's react fallback, tried after the three/pixi
1230
+ * fixture globs — see `autoLaunchIngestFromURL` below). Mounts through the
1231
+ * EXACT SAME T6.2 react-world host stack `default-react`/multi-world react
1232
+ * worlds use — NOT the capture-based three/pixi bridge (there is no scene to
1233
+ * capture; the mounted tree IS React, D-N1).
1234
+ *
1235
+ * Reuses `resolveAllWorlds` + the WORLDS-PATH `createGameRuntime` (not the
1236
+ * legacy single-`{canvas,adapter}` path, which is three-only and has no
1237
+ * `ReactWorldHost`/`container` concept — see `create-runtime.ts`'s
1238
+ * `HostContext`) rather than hand-building a `ReactWorldHost`/`Game`: a
1239
+ * single-element `worlds` array gets EVERY host concern for free (the
1240
+ * absolutely-positioned DOM-root layer, a real `Game`, dispose via
1241
+ * `session.stop()`) with zero reinvention — `resolveAllWorlds`'s
1242
+ * `MULTI_WORLD_ALLOWED_IDENTITIES` gate only fires for `worlds.length > 1`
1243
+ * (see that function's doc comment), so a ONE-element manifest containing an
1244
+ * `ingest-react` world resolves cleanly through it despite that identity
1245
+ * being excluded from real multi-world manifests.
1246
+ *
1247
+ * D-N8 v2 / D-K4 (wave 10 Slice A, docs/WAVE10-REACT-INGEST-DOM-AUTHORING-
1248
+ * DESIGN.md §1): the active authoring override is a real
1249
+ * `ReactDomAuthoringAdapter` — a structural-path DOM walker over the mounted
1250
+ * react tree, persisting edits to an overlay (never to source, see that
1251
+ * file's doc comment). The published `achievedTier` is COMPUTED from this
1252
+ * real adapter via `computeAchievedTier` (the same honest authority the
1253
+ * embed-only floor used to derive `'opaque-embed'` from), not hard-coded.
1254
+ */
1255
+ export async function enterIngestModeReact(store: EditorStore, folderId: string): Promise<void> {
1256
+ if (getEditorMode() === 'browser') {
1257
+ editorConsole.error(
1258
+ 'Native-React ingest needs the dev server (/@fs/ dynamic import) — unavailable in browser mode',
1259
+ 'ingest',
1260
+ );
1261
+ return;
1262
+ }
1263
+ const game = getIngestGameReact(folderId);
1264
+ if (!game) {
1265
+ editorConsole.error(`Unknown react ingest game: ${folderId}`, 'ingest');
1266
+ return;
1267
+ }
1268
+ const projectRoot = ingestGameReactProjectRoot(folderId);
1269
+ if (!projectRoot) {
1270
+ editorConsole.error(
1271
+ `React ingest "${folderId}": no __VGAI_ENGINE_ROOT__ available to resolve its entry`,
1272
+ 'ingest',
1273
+ );
1274
+ return;
1275
+ }
1276
+ const gameContainer = getGameContainer();
1277
+ if (!gameContainer) {
1278
+ editorConsole.error('Game container not mounted — cannot ingest (react)', 'ingest');
1279
+ return;
1280
+ }
1281
+ // D-V1: tear down WHATEVER kind is currently active, not just a prior
1282
+ // react session — closes the cross-kind leak (F26 residual).
1283
+ exitActiveIngest();
1284
+
1285
+ store.setPlayState('playing');
1286
+ editorConsole.log(`Ingesting unmodified native-React game: ${game.manifest.name}`, 'ingest');
1287
+
1288
+ // F26: same cold-mount announcement as the other ingest tiers — the react
1289
+ // world's entry executes in the editor realm and may declare the game
1290
+ // contract to defer its session until ▶. Opt-in, never demanded.
1291
+ (window as unknown as { __vgaiMountCold?: boolean }).__vgaiMountCold = true;
1292
+ const { createGameRuntime } = await import('@engine/runtime/create-runtime');
1293
+ const specs = await resolveAllWorlds(game.manifest, projectRoot);
1294
+ const w = gameContainer.clientWidth;
1295
+ const h = gameContainer.clientHeight;
1296
+ const session = await createGameRuntime({
1297
+ container: gameContainer,
1298
+ worlds: specs,
1299
+ width: w,
1300
+ height: h,
1301
+ });
1302
+
1303
+ // D-K4: load any previously-saved DOM style overlay, then construct the
1304
+ // real authoring adapter over the mounted world's live DOM root. The
1305
+ // adapter has its own `selection` provider (store-backed, same shape as
1306
+ // `ReactWorldAuthoringAdapter.selection`), so — unlike a `{ module }`
1307
+ // adapter with none — it is installed directly, never `withStoreSelection`-wrapped.
1308
+ const worldId = game.worldId;
1309
+ const overlay = await loadWorldOverlay<ReactDomOverlay>(worldId);
1310
+ const root = session.game.worlds[0]!.reactRoot();
1311
+ const adapter = new ReactDomAuthoringAdapter(root, store, {
1312
+ overlay,
1313
+ persist: (o) => saveWorldOverlay(worldId, o),
1314
+ destination: worldOverlayPath(worldId),
1315
+ });
1316
+ // Unlike the capture tiers, a react ingest mounts through the NATIVE
1317
+ // runtime — its Game.play pause/resume IS the honest control (D10). Named
1318
+ // (rather than inlined below) so `setPausedPresent`'s D-L1 `typeof`
1319
+ // check reads the SAME real control the session actually installs, never
1320
+ // an independent literal.
1321
+ const setPaused = (p: boolean) => (p ? session.game.play.pause() : session.game.play.resume());
1322
+
1323
+ _active = {
1324
+ kind: 'react',
1325
+ session: {
1326
+ store,
1327
+ setPaused,
1328
+ stop: () => session.stop(),
1329
+ adapter,
1330
+ },
1331
+ siblings: [],
1332
+ };
1333
+ // Same play-mode input gate every other same-realm ingest tier drives
1334
+ // (R2c) — harmless here too even though a native-React game's own
1335
+ // listeners are NOT same-realm-shadowed the way `ingest/games`/`games-2d`
1336
+ // are (see game-globals-shadow.ts's roots — `games-react` is not among
1337
+ // them; R-N6 residual, recorded in the N2 report).
1338
+ setGameInputGate(() => store.playState === 'playing' && store.activeViewportTab === 'game');
1339
+
1340
+ setActiveAuthoring(adapter);
1341
+ setActiveSystems({});
1342
+ store.setActiveViewportTab('game');
1343
+ store.notifyIngestEdit();
1344
+ setMountFailureReport(null); // D-W3: mount succeeded
1345
+
1346
+ (window as unknown as Record<string, unknown>)['__vgaiIngestReact'] = {
1347
+ gameId: folderId,
1348
+ worldId: game.worldId,
1349
+ noAuthoring: false,
1350
+ // D-K4: COMPUTED from the real adapter (the same honest authority
1351
+ // `achieved-tier.ts`'s `computeAchievedTier` already was for the prior
1352
+ // embed-only floor) — a hierarchy+inspector+persistence adapter with NO
1353
+ // `transforms` honestly bands as 'shared', never hard-coded.
1354
+ achievedTier: computeAchievedTier({ authoring: adapter }),
1355
+ // D-L1 (wave 10 Slice B): the doctor MOUNTED-bar evidence accessor —
1356
+ // see `reactDomEvidence`'s own doc comment above.
1357
+ domEvidence: () => reactDomEvidence(root, adapter),
1358
+ // D-L1: honest `typeof === 'function'` check on the REAL session
1359
+ // control this route just installed above — never an independent
1360
+ // literal `true` (mirrors `__vgaiSiblingMounts`'s identical discipline).
1361
+ setPausedPresent: typeof setPaused === 'function',
1362
+ };
1363
+ }
1364
+
1365
+ /**
1366
+ * D-Y2 (docs/EXTERNAL-REACT-AND-MODULE-DESIGN.md §1/§4 slice S2) — the
1367
+ * manifest-route sibling of {@link enterIngestModeReact} above: an
1368
+ * EXTERNAL-FOLDER `ingest-react` world (the opened project's OWN
1369
+ * `vgai.game.json`, not the vendored `games-react/<id>` discovery glob)
1370
+ * enters the same react ingest session. Exactly the same worlds-path recipe
1371
+ * (one-element `resolveAllWorlds` + worlds-path `createGameRuntime`) and the
1372
+ * same `ReactDomAuthoringAdapter` D-K4 wiring (see that function's doc
1373
+ * comment) — only the SOURCE of `world`/`projectRoot` differs (the opened project's manifest +
1374
+ * `project.rootPath`, via `tryManifestIngestRouteReact` below, instead of
1375
+ * `getIngestGameReact`/`ingestGameReactProjectRoot`'s vendored-fixture
1376
+ * lookup). `resolveIngestReactAdapter`'s own guards (D-N3 deduped-only,
1377
+ * `entry` required, no `scene`) run unchanged inside `resolveAllWorlds` — this
1378
+ * function adds no new validation of its own, mirroring
1379
+ * `enterIngestModeFromManifestWorld2D`'s relationship to its vendored
1380
+ * `?ingest=<id>` sibling.
1381
+ */
1382
+ export async function enterIngestModeFromManifestWorldReact(
1383
+ store: EditorStore,
1384
+ manifest: ResolvedGameManifest,
1385
+ world: ResolvedWorldEntry,
1386
+ projectRoot: string,
1387
+ ): Promise<void> {
1388
+ if (getEditorMode() === 'browser') {
1389
+ editorConsole.error(
1390
+ 'Native-React ingest needs the dev server (/@fs/ dynamic import) — unavailable in browser mode',
1391
+ 'ingest',
1392
+ );
1393
+ return;
1394
+ }
1395
+ const gameContainer = getGameContainer();
1396
+ if (!gameContainer) {
1397
+ editorConsole.error('Game container not mounted — cannot ingest (react)', 'ingest');
1398
+ return;
1399
+ }
1400
+ // D-V1: tear down WHATEVER kind is currently active, not just a prior
1401
+ // react session — closes the cross-kind leak (F26 residual).
1402
+ exitActiveIngest();
1403
+
1404
+ store.setPlayState('playing');
1405
+ editorConsole.log(
1406
+ `Ingesting unmodified native-React game (external folder): ${world.id}`,
1407
+ 'ingest',
1408
+ );
1409
+
1410
+ // F26: same cold-mount announcement the vendored route (`enterIngestModeReact`
1411
+ // above) makes — an external-folder game may declare the game contract too.
1412
+ (window as unknown as { __vgaiMountCold?: boolean }).__vgaiMountCold = true;
1413
+ const { createGameRuntime } = await import('@engine/runtime/create-runtime');
1414
+ const specs = await resolveAllWorlds({ ...manifest, worlds: [world] }, projectRoot);
1415
+ const w = gameContainer.clientWidth;
1416
+ const h = gameContainer.clientHeight;
1417
+ const session = await createGameRuntime({
1418
+ container: gameContainer,
1419
+ worlds: specs,
1420
+ width: w,
1421
+ height: h,
1422
+ });
1423
+
1424
+ // D-K4: same overlay-load + adapter construction as the vendored route
1425
+ // above — `worldId` here is `world.id` (the manifest world's own id),
1426
+ // not `game.worldId` (the vendored-fixture wrapper's field).
1427
+ const worldId = world.id;
1428
+ const overlay = await loadWorldOverlay<ReactDomOverlay>(worldId);
1429
+ const root = session.game.worlds[0]!.reactRoot();
1430
+ const adapter = new ReactDomAuthoringAdapter(root, store, {
1431
+ overlay,
1432
+ persist: (o) => saveWorldOverlay(worldId, o),
1433
+ destination: worldOverlayPath(worldId),
1434
+ });
1435
+ // Same honest control as the vendored route (D10): a manifest-routed react
1436
+ // ingest mounts through the NATIVE runtime, so Game.play pause/resume IS
1437
+ // real. Named (D-L1) so `setPausedPresent` checks the SAME real control.
1438
+ const setPaused = (p: boolean) => (p ? session.game.play.pause() : session.game.play.resume());
1439
+
1440
+ _active = {
1441
+ kind: 'react',
1442
+ session: {
1443
+ store,
1444
+ setPaused,
1445
+ stop: () => session.stop(),
1446
+ adapter,
1447
+ },
1448
+ siblings: [],
1449
+ };
1450
+ setGameInputGate(() => store.playState === 'playing' && store.activeViewportTab === 'game');
1451
+
1452
+ setActiveAuthoring(adapter);
1453
+ setActiveSystems({});
1454
+ store.setActiveViewportTab('game');
1455
+ store.notifyIngestEdit();
1456
+ setMountFailureReport(null); // D-W3: mount succeeded
1457
+
1458
+ (window as unknown as Record<string, unknown>)['__vgaiIngestReact'] = {
1459
+ worldId: world.id,
1460
+ noAuthoring: false,
1461
+ // D-K4: see enterIngestModeReact's identical comment above — COMPUTED,
1462
+ // never hard-coded.
1463
+ achievedTier: computeAchievedTier({ authoring: adapter }),
1464
+ // D-L1: see enterIngestModeReact's identical comment above.
1465
+ domEvidence: () => reactDomEvidence(root, adapter),
1466
+ setPausedPresent: typeof setPaused === 'function',
1467
+ };
1468
+ }
1469
+
1470
+ /**
1471
+ * D-V1: the react teardown body — byte-for-byte the pre-collapse
1472
+ * `exitIngestModeReact` logic below, extracted so `exitActiveIngest` can
1473
+ * dispatch to it for ANY kind switch. `_active`/`resetIngestPlaySurface` are
1474
+ * handled by the caller (`exitActiveIngest`) — this only does the
1475
+ * react-specific teardown.
1476
+ */
1477
+ function disposeReactSession(s: IngestSessionReact): void {
1478
+ setGameInputGate(() => true);
1479
+ // D-K4: this session's ReactDomAuthoringAdapter self-registered with the
1480
+ // global undo timeline at construction (mirrors `IngestAuthoringAdapter`/
1481
+ // `World2DAuthoringAdapter`/`ReactWorldAuthoringAdapter`) — drop it so any
1482
+ // of its entries still on the global stack are dropped (with a warn), not
1483
+ // undone against a torn-down DOM.
1484
+ unregisterUndoAdapter(s.adapter);
1485
+ setActiveAuthoring(null);
1486
+ setActiveSystems(null);
1487
+ setMountFailureReport(null); // D-W3: no session -> no banner to show (mirrors disposeThreejsSession's identical call)
1488
+ s.store.setPlayState('stopped');
1489
+ try {
1490
+ s.stop();
1491
+ } catch {
1492
+ /* ignore */
1493
+ }
1494
+ delete (window as unknown as Record<string, unknown>)['__vgaiIngestReact'];
1495
+ editorConsole.log('react ingest mode stopped', 'ingest');
1496
+ }
1497
+
1498
+ /**
1499
+ * Exit react ingest mode: unmount the react world + tear down its Game/loop.
1500
+ * D-V1: a thin wrapper that only acts when a react session is the one
1501
+ * currently active in `_active` — same observable behavior as before the
1502
+ * collapse; the actual teardown is `disposeReactSession` via
1503
+ * `exitActiveIngest`.
1504
+ */
1505
+ export function exitIngestModeReact(): void {
1506
+ if (_active?.kind !== 'react') return;
1507
+ exitActiveIngest();
1508
+ }
1509
+
1510
+ /**
1511
+ * D-V1: the threejs teardown body — byte-for-byte the pre-collapse
1512
+ * `exitIngestMode` logic below, extracted so `exitActiveIngest` can dispatch
1513
+ * to it for ANY kind switch, not just a same-kind `exitIngestMode()` call.
1514
+ * `_active`/`resetIngestPlaySurface` are handled by the caller
1515
+ * (`exitActiveIngest`) — this only does the threejs-specific teardown.
1516
+ */
1517
+ function disposeThreejsSession(s: IngestSession): void {
1518
+ // R2c: no ingest session running — the input gate `enterIngestModeWithGame`
1519
+ // set (alongside every `_active` assignment above) must never stay
1520
+ // suppressed. Mirrors play-mode.ts's exitPlayMode, which resets the same
1521
+ // way on exit.
1522
+ setGameInputGate(() => true);
1523
+
1524
+ // T3.2 slice 2: this ingest session's authoring adapter is done recording —
1525
+ // drop it from the undo timeline's live set so any of its entries still on the
1526
+ // global stack are dropped (with a warn), not undone against a torn-down scene.
1527
+ if (s.mount?.authoring) unregisterUndoAdapter(s.mount.authoring);
1528
+ setActiveAuthoring(null);
1529
+ setActiveSystems(null);
1530
+ setActiveOverlayReport(null); // D9: no session -> no banner to show
1531
+ setMountFailureReport(null); // D-W3: no session -> no mount-failure banner to show either
1532
+ s.resizeObserver?.disconnect();
1533
+ // Only the shared-`three` capture path swaps the editor scene; embed-only never did.
1534
+ if (s.mount) {
1535
+ try {
1536
+ s.store.exitPlayScene();
1537
+ } catch {
1538
+ /* scene may not have been swapped if capture failed */
1539
+ }
1540
+ }
1541
+ s.store.setPlayState('stopped');
1542
+
1543
+ // Tear down the mounted game (stops its loop, frees renderer, uninstalls trap)
1544
+ // or the embedded iframe.
1545
+ try {
1546
+ s.mount?.game.dispose();
1547
+ s.embed?.dispose();
1548
+ } catch {
1549
+ /* ignore */
1550
+ }
1551
+
1552
+ delete (window as unknown as Record<string, unknown>)['__vgaiIngest'];
1553
+ editorConsole.log('Ingest mode stopped', 'ingest');
1554
+ }
1555
+
1556
+ /**
1557
+ * Exit ingest mode: restore the editor scene and tear down the game. D-V1: a
1558
+ * thin wrapper that only acts when a threejs session is the one currently
1559
+ * active in `_active` — same observable behavior as before the collapse
1560
+ * (a no-op if nothing, or a DIFFERENT kind, is active); the actual teardown
1561
+ * is `disposeThreejsSession` via `exitActiveIngest`.
1562
+ */
1563
+ export function exitIngestMode(): void {
1564
+ if (_active?.kind !== 'threejs') return;
1565
+ exitActiveIngest();
1566
+ }
1567
+
1568
+ // Dev hook so the editor UI / e2e can drive ingestion against the bound store.
1569
+ if (import.meta.env.DEV) {
1570
+ (window as unknown as Record<string, unknown>)['__vgaiIngestApi'] = {
1571
+ enter: (gameId: string) => {
1572
+ if (!_boundStore) throw new Error('ingest-mode not bound');
1573
+ return enterIngestMode(_boundStore, gameId);
1574
+ },
1575
+ exit: exitIngestMode,
1576
+ // This hook is the per-kind (threejs) API — keep it slot-specific;
1577
+ // `isIngestActive` itself is kind-agnostic as of F26.
1578
+ isActive: () => _active?.kind === 'threejs',
1579
+ // Test/dev: reset a game's persisted overlay to empty (clean-slate ingests).
1580
+ clearOverlay: (gameId: string) => saveOverlay(gameId, {}),
1581
+ };
1582
+ // Pixi sibling (T6.1 slice 4) — same shape, over the world2d ingest session.
1583
+ (window as unknown as Record<string, unknown>)['__vgaiIngestApi2D'] = {
1584
+ enter: (gameId: string) => {
1585
+ if (!_boundStore) throw new Error('ingest-mode not bound');
1586
+ return enterIngestMode2D(_boundStore, gameId);
1587
+ },
1588
+ exit: exitIngestMode2D,
1589
+ isActive: isIngestActive2D,
1590
+ clearOverlay: (worldId: string) => saveWorldOverlay(worldId, {}),
1591
+ };
1592
+ // React sibling (Track N, N2) — same shape, over the native-React ingest
1593
+ // session. No `clearOverlay` (D-N8: no authoring/overlay surface in v1).
1594
+ (window as unknown as Record<string, unknown>)['__vgaiIngestApiReact'] = {
1595
+ enter: (folderId: string) => {
1596
+ if (!_boundStore) throw new Error('ingest-mode not bound');
1597
+ return enterIngestModeReact(_boundStore, folderId);
1598
+ },
1599
+ exit: exitIngestModeReact,
1600
+ isActive: isIngestActiveReact,
1601
+ };
1602
+ // D-A3 (docs/WAVE6-DOCTOR-DESIGN.md): headless-readable mount outcomes —
1603
+ // `packages/editor/src/authoring/mount-failure-report.ts`'s multi-entry
1604
+ // list is otherwise only ever read by `MountFailureBanner.tsx` (a React
1605
+ // component), invisible to `page.evaluate()`. This is a plain function
1606
+ // returning the LIVE list (never a snapshot) — `vgai doctor`'s browser
1607
+ // phase is this surface's first consumer, but any other dev/e2e caller can
1608
+ // poll it the same way `__vgaiIngestApi*` are already polled.
1609
+ (window as unknown as Record<string, unknown>)['__vgaiMountFailureReports'] = () =>
1610
+ getMountFailureReports();
1611
+ // D-H1 (docs/WAVE9-SIBLING-PERSISTENCE-DOCTOR-DESIGN.md Slice B):
1612
+ // headless-readable per-sibling signal — `_active.siblings` (D-V2's
1613
+ // composite-sibling handles, `ingest-siblings.ts`'s `SiblingMount[]`) is
1614
+ // otherwise invisible to `page.evaluate()`, same blind spot
1615
+ // `__vgaiMountFailureReports` closed for mount failures. A plain function
1616
+ // returning a LIVE view (never a snapshot, re-reads `_active` on every
1617
+ // call) of honest per-sibling flags only — no capture/draw proof, see
1618
+ // `DoctorSiblingEvidence`'s doc comment (`packages/editor/src/doctor/report.ts`).
1619
+ (window as unknown as Record<string, unknown>)['__vgaiSiblingMounts'] = () =>
1620
+ (_active?.siblings ?? []).map((s) => ({
1621
+ worldId: s.worldId,
1622
+ setPausedPresent: typeof s.setPaused === 'function',
1623
+ authoringPresent: !!s.authoring,
1624
+ authoringPersist: s.authoring?.capabilities.persist === true,
1625
+ }));
1626
+ }
1627
+
1628
+ /**
1629
+ * Wait for the Game-tab container (registered via a callback ref at layout
1630
+ * mount) — poll a bounded number of rAF ticks so we don't race the first
1631
+ * paint. Exported (T3.4 slice 2) so `module-mode.ts`'s manifest-route auto-
1632
+ * mount for `{ module }` worlds — the sibling of `tryManifestIngestRoute`
1633
+ * below — can reuse the SAME wait rather than duplicating it.
1634
+ */
1635
+ export async function waitForGameContainer(): Promise<boolean> {
1636
+ return new Promise<boolean>((resolve) => {
1637
+ let tries = 0;
1638
+ const tick = () => {
1639
+ if (getGameContainer()) return resolve(true);
1640
+ if (++tries > 120) return resolve(false); // ~2s at 60fps
1641
+ requestAnimationFrame(tick);
1642
+ };
1643
+ tick();
1644
+ });
1645
+ }
1646
+
1647
+ /**
1648
+ * D-V2 (docs/WAVE5-MULTIWORLD-INGEST-DESIGN.md, F24 composite): every world
1649
+ * in `manifest.worlds` whose adapter is `{ ingest }` — a composite manifest
1650
+ * caps this at exactly one (see each `tryManifestIngestRoute*` below); ≥2 is
1651
+ * a loud named error via {@link reportTooManyIngestWorlds}, never a silent
1652
+ * first-wins pick (mirrors `run-bundle.ts`'s fail-on-ambiguity pattern).
1653
+ */
1654
+ function findIngestWorlds(manifest: ResolvedGameManifest): ResolvedWorldEntry[] {
1655
+ return manifest.worlds.filter((w) => w.adapter.type === 'ingest');
1656
+ }
1657
+
1658
+ /**
1659
+ * D-V2: the "composite declares ≥2 ingest worlds" violation — named, listing
1660
+ * every offending world id, reported once (into BOTH `editorConsole` and the
1661
+ * D-V5 multi-entry mount-failure report, one entry per offending world so
1662
+ * the banner names all of them) by whichever `tryManifestIngestRoute*`
1663
+ * happens to run first (module route order: three, pixi, react — see
1664
+ * `autoLaunchIngestFromURL`) — the caller returns `true` right after this
1665
+ * (claims the route), so the other two ingest routes never also report it.
1666
+ */
1667
+ function reportTooManyIngestWorlds(ingestWorlds: ResolvedWorldEntry[]): void {
1668
+ const ids = ingestWorlds.map((w) => `"${w.id}"`).join(', ');
1669
+ const message =
1670
+ 'a composite manifest declares exactly ONE ingest world in this slice — found ' +
1671
+ `${ingestWorlds.length}: ${ids} (docs/WAVE5-MULTIWORLD-INGEST-DESIGN.md D-V2).`;
1672
+ editorConsole.error(`Manifest composite ingest route: ${message}`, 'ingest');
1673
+ for (const world of ingestWorlds) {
1674
+ addMountFailureReport({
1675
+ worldId: world.id,
1676
+ kind: world.kind,
1677
+ identity: world.adapter.identity,
1678
+ message,
1679
+ });
1680
+ }
1681
+ }
1682
+
1683
+ /**
1684
+ * D-V2: attach a composite's sibling handles onto the JUST-entered `_active`
1685
+ * session (set by the primary ingest world's own `enterIngestMode*` call,
1686
+ * immediately before this runs) — a no-op if `_active` is somehow no longer
1687
+ * set (defensive only; nothing in this synchronous call chain clears it
1688
+ * between the primary mount and this attach).
1689
+ */
1690
+ function attachIngestSiblings(siblings: readonly SiblingMount[]): void {
1691
+ if (_active) _active.siblings = [...siblings];
1692
+ }
1693
+
1694
+ /**
1695
+ * D-V2: mount every OTHER world in a composite manifest (every world besides
1696
+ * `ingestWorldId`, the one just entered through its own `enterIngestMode*`
1697
+ * path) via `ingest-siblings.ts`'s `mountIngestSiblings`, and attach the
1698
+ * resulting handles onto `_active` so `exitActiveIngest` tears them down.
1699
+ * Dynamically imported — `ingest-siblings.ts` imports from `module-mode.ts`,
1700
+ * which itself statically imports `waitForGameContainer` from THIS module;
1701
+ * a static import here would close that cycle at the wrong end (mirrors
1702
+ * `autoLaunchIngestFromURL`'s own dynamic import of `module-mode.ts` below,
1703
+ * for the identical reason). Never throws: `mountIngestSiblings` already
1704
+ * catches every individual sibling's failure into the mount-failure report.
1705
+ * Returns the mounted handles so the caller can promote authoring (D-E2,
1706
+ * {@link promoteToCompositeAuthoring}) — `store` is threaded through so each
1707
+ * sibling gets an honest `SiblingMount.authoring` (D-E1).
1708
+ */
1709
+ async function mountCompositeSiblings(
1710
+ manifest: ResolvedGameManifest,
1711
+ ingestWorldId: string,
1712
+ gameContainer: HTMLElement,
1713
+ projectRoot: string,
1714
+ store: EditorStore,
1715
+ ): Promise<SiblingMount[]> {
1716
+ const { mountIngestSiblings } = await import('./ingest-siblings');
1717
+ const siblings = await mountIngestSiblings(
1718
+ manifest,
1719
+ ingestWorldId,
1720
+ gameContainer,
1721
+ projectRoot,
1722
+ store,
1723
+ );
1724
+ attachIngestSiblings(siblings);
1725
+ return siblings;
1726
+ }
1727
+
1728
+ /**
1729
+ * D-E2 (docs/WAVE8-SIBLING-AUTHORING-DESIGN.md, §1F promotion on the ingest
1730
+ * composite): when a composite's siblings actually mounted (`siblings.length
1731
+ * > 0`), promote the active authoring override — currently the PRIMARY
1732
+ * ingest world's own plain adapter, installed by the route's own enter path
1733
+ * that just ran immediately before this call — to a `CompositeAuthoringAdapter`
1734
+ * whose ordered children are the primary world FIRST (it is the route's
1735
+ * anchor) followed by every mounted sibling in manifest order, each routed by
1736
+ * its own manifest `kind`. A single-world mount (`siblings.length === 0`) is
1737
+ * untouched — byte-stable, no group node, no wrapper: §1F's single-world
1738
+ * clause.
1739
+ *
1740
+ * The primary's own adapter is read back via `getAuthoringOverride()` rather
1741
+ * than threaded as a parameter — every composite route's own `enterIngestMode*`
1742
+ * call already installed it via `setActiveAuthoring` synchronously before
1743
+ * this runs (mirrors `attachIngestSiblings`'s identical "just installed"
1744
+ * assumption above); `null` (defensive only — nothing in this synchronous
1745
+ * call chain clears it in between) is a no-op, same guard shape.
1746
+ */
1747
+ function promoteToCompositeAuthoring(
1748
+ manifest: ResolvedGameManifest,
1749
+ ingestWorld: ResolvedWorldEntry,
1750
+ siblings: readonly SiblingMount[],
1751
+ ): void {
1752
+ if (siblings.length === 0) return;
1753
+ const primaryAdapter = getAuthoringOverride();
1754
+ if (!primaryAdapter) return;
1755
+ const children: CompositeChild[] = [
1756
+ { worldId: ingestWorld.id, kind: ingestWorld.kind, adapter: primaryAdapter },
1757
+ ...siblings.map((s) => ({
1758
+ worldId: s.worldId,
1759
+ kind: manifest.worlds.find((w) => w.id === s.worldId)?.kind ?? 'unknown',
1760
+ // D-E1: every successfully-mounted SiblingMount gets an honest
1761
+ // `authoring` whenever a store was supplied — every composite route
1762
+ // below supplies one, so this is never actually undefined in
1763
+ // production; the non-null assertion mirrors D-E2's own spec.
1764
+ adapter: s.authoring!,
1765
+ })),
1766
+ ];
1767
+ setActiveAuthoring(new CompositeAuthoringAdapter(children));
1768
+ }
1769
+
1770
+ /**
1771
+ * Route 1 of `autoLaunchIngestFromURL` (T3.3 slice 3, §1E): a manifest-backed
1772
+ * project declaring an `ingest-three` world enters ingest mode straight from
1773
+ * that world. Returns `true` if this route applied (whether it succeeded or
1774
+ * logged an error) — the caller must not also try route 2 in that case
1775
+ * (§1B's "manifest wins" discovery order); `false` means "no manifest, no
1776
+ * ingest world at all, or the one ingest world isn't ingest-three", so the
1777
+ * next route (pixi, react, then the URL route) still gets a chance.
1778
+ *
1779
+ * D-V2 (F24 composite): scans ALL worlds for `{ ingest }` adapters, not just
1780
+ * `worlds[0]` — 0 ingest worlds declines (unchanged non-ingest-manifest
1781
+ * behavior); ≥2 claims the route and fails loudly
1782
+ * ({@link reportTooManyIngestWorlds}), mounting nothing; exactly 1 whose
1783
+ * identity is `ingest-three` mounts THAT world (wherever it sits in the
1784
+ * array) through the existing enter path, then mounts every other world as a
1785
+ * sibling ({@link mountCompositeSiblings}) — a sibling failure never
1786
+ * unmounts the ingest world (it is caught internally, per-world, by
1787
+ * `ingest-siblings.ts`).
1788
+ */
1789
+ async function tryManifestIngestRoute(store: EditorStore): Promise<boolean> {
1790
+ if (getEditorMode() === 'browser') return false; // no dev server to serve /vgai.game.json from
1791
+ const manifest = await fetchGameManifest().catch(() => null);
1792
+ if (!manifest) return false;
1793
+ const ingestWorlds = findIngestWorlds(manifest);
1794
+ if (ingestWorlds.length === 0) return false;
1795
+ if (ingestWorlds.length > 1) {
1796
+ reportTooManyIngestWorlds(ingestWorlds);
1797
+ return true;
1798
+ }
1799
+ const ingestWorld = ingestWorlds[0]!;
1800
+ if (ingestWorld.adapter.identity !== 'ingest-three') return false;
1801
+
1802
+ const project = getCurrentProject();
1803
+ if (!project) {
1804
+ editorConsole.error(
1805
+ 'Manifest declares an ingest-three world but no project is open — cannot auto-ingest',
1806
+ 'ingest',
1807
+ );
1808
+ return true;
1809
+ }
1810
+ const ready = await waitForGameContainer();
1811
+ if (!ready) {
1812
+ editorConsole.error('Game container never mounted — cannot auto-ingest', 'ingest');
1813
+ return true;
1814
+ }
1815
+ try {
1816
+ // D9/T7.7: thread the manifest's OWN version through — resolveIngestDescriptor
1817
+ // (called inside enterIngestModeFromManifestWorld) only sees the per-world
1818
+ // ResolvedWorldEntry, not this top-level field.
1819
+ // R5 (§4(c)): best-effort read of this project's UPSTREAM.md pin, same
1820
+ // spirit — resolveIngestDescriptor has no way to discover one either.
1821
+ const upstreamPin = await readVendoredUpstreamPin();
1822
+ await enterIngestModeFromManifestWorld(
1823
+ store,
1824
+ ingestWorld,
1825
+ project.rootPath,
1826
+ manifest.version,
1827
+ upstreamPin,
1828
+ );
1829
+ // D-V2: mount every OTHER world as a composite sibling — a no-op loop
1830
+ // when this is a single-world manifest (nothing else to iterate).
1831
+ const gameContainer = getGameContainer();
1832
+ if (gameContainer) {
1833
+ const siblings = await mountCompositeSiblings(
1834
+ manifest,
1835
+ ingestWorld.id,
1836
+ gameContainer,
1837
+ project.rootPath,
1838
+ store,
1839
+ );
1840
+ // D-E2: promote to a composite once any sibling actually mounted —
1841
+ // byte-stable (no-op) for a single-world manifest.
1842
+ promoteToCompositeAuthoring(manifest, ingestWorld, siblings);
1843
+ }
1844
+ } catch (err) {
1845
+ editorConsole.error(`Manifest ingest-three auto-launch failed: ${err}`, 'ingest');
1846
+ // D-W3 (F9): this console line was, until now, the ONLY trace this
1847
+ // failure left anywhere in the UI — the default Untitled scene has
1848
+ // already loaded by this point (§0.4), so nothing visibly changes.
1849
+ // Populate the loud banner slot too.
1850
+ setMountFailureReport({
1851
+ worldId: ingestWorld.id,
1852
+ kind: ingestWorld.kind,
1853
+ identity: ingestWorld.adapter.identity,
1854
+ message: formatMountFailureMessage(err),
1855
+ });
1856
+ }
1857
+ return true;
1858
+ }
1859
+
1860
+ /**
1861
+ * PixiJS sibling of {@link tryManifestIngestRoute} (T6.1 slice 4): a
1862
+ * manifest-backed project declaring an `ingest-pixi` world enters world2d
1863
+ * ingest mode straight from that world. Same "applied or not" contract, same
1864
+ * D-V2 composite scan/route-selection/sibling-mount shape as its three
1865
+ * sibling above.
1866
+ */
1867
+ async function tryManifestIngestRoute2D(store: EditorStore): Promise<boolean> {
1868
+ if (getEditorMode() === 'browser') return false; // no dev server to serve /vgai.game.json from
1869
+ const manifest = await fetchGameManifest().catch(() => null);
1870
+ if (!manifest) return false;
1871
+ const ingestWorlds = findIngestWorlds(manifest);
1872
+ if (ingestWorlds.length === 0) return false;
1873
+ if (ingestWorlds.length > 1) {
1874
+ reportTooManyIngestWorlds(ingestWorlds);
1875
+ return true;
1876
+ }
1877
+ const ingestWorld = ingestWorlds[0]!;
1878
+ if (ingestWorld.adapter.identity !== 'ingest-pixi') return false;
1879
+
1880
+ const project = getCurrentProject();
1881
+ if (!project) {
1882
+ editorConsole.error(
1883
+ 'Manifest declares an ingest-pixi world but no project is open — cannot auto-ingest',
1884
+ 'ingest',
1885
+ );
1886
+ return true;
1887
+ }
1888
+ const ready = await waitForGameContainer();
1889
+ if (!ready) {
1890
+ editorConsole.error('Game container never mounted — cannot auto-ingest', 'ingest');
1891
+ return true;
1892
+ }
1893
+ try {
1894
+ await enterIngestModeFromManifestWorld2D(store, ingestWorld, project.rootPath);
1895
+ const gameContainer = getGameContainer();
1896
+ if (gameContainer) {
1897
+ const siblings = await mountCompositeSiblings(
1898
+ manifest,
1899
+ ingestWorld.id,
1900
+ gameContainer,
1901
+ project.rootPath,
1902
+ store,
1903
+ );
1904
+ // D-E2: promote to a composite once any sibling actually mounted —
1905
+ // byte-stable (no-op) for a single-world manifest.
1906
+ promoteToCompositeAuthoring(manifest, ingestWorld, siblings);
1907
+ }
1908
+ } catch (err) {
1909
+ editorConsole.error(`Manifest ingest-pixi auto-launch failed: ${err}`, 'ingest');
1910
+ // D-W3 (F9) — see tryManifestIngestRoute's identical comment above.
1911
+ setMountFailureReport({
1912
+ worldId: ingestWorld.id,
1913
+ kind: ingestWorld.kind,
1914
+ identity: ingestWorld.adapter.identity,
1915
+ message: formatMountFailureMessage(err),
1916
+ });
1917
+ }
1918
+ return true;
1919
+ }
1920
+
1921
+ /**
1922
+ * React sibling of {@link tryManifestIngestRoute}/{@link tryManifestIngestRoute2D}
1923
+ * (D-Y2, docs/EXTERNAL-REACT-AND-MODULE-DESIGN.md §1/§4 slice S2): a
1924
+ * manifest-backed project declaring an `ingest-react` world enters the
1925
+ * native-React ingest session straight from that world (external folder —
1926
+ * `vgai edit <folder>` — not the vendored `games-react/<id>` discovery glob).
1927
+ * Same "applied or not" contract, same D-V2 composite scan/route-selection/
1928
+ * sibling-mount shape as its three/pixi siblings above.
1929
+ */
1930
+ async function tryManifestIngestRouteReact(store: EditorStore): Promise<boolean> {
1931
+ if (getEditorMode() === 'browser') return false; // no dev server to serve /vgai.game.json from
1932
+ const manifest = await fetchGameManifest().catch(() => null);
1933
+ if (!manifest) return false;
1934
+ const ingestWorlds = findIngestWorlds(manifest);
1935
+ if (ingestWorlds.length === 0) return false;
1936
+ if (ingestWorlds.length > 1) {
1937
+ reportTooManyIngestWorlds(ingestWorlds);
1938
+ return true;
1939
+ }
1940
+ const ingestWorld = ingestWorlds[0]!;
1941
+ if (ingestWorld.adapter.identity !== 'ingest-react') return false;
1942
+
1943
+ const project = getCurrentProject();
1944
+ if (!project) {
1945
+ editorConsole.error(
1946
+ 'Manifest declares an ingest-react world but no project is open — cannot auto-ingest',
1947
+ 'ingest',
1948
+ );
1949
+ return true;
1950
+ }
1951
+ const ready = await waitForGameContainer();
1952
+ if (!ready) {
1953
+ editorConsole.error('Game container never mounted — cannot auto-ingest', 'ingest');
1954
+ return true;
1955
+ }
1956
+ try {
1957
+ await enterIngestModeFromManifestWorldReact(store, manifest, ingestWorld, project.rootPath);
1958
+ const gameContainer = getGameContainer();
1959
+ if (gameContainer) {
1960
+ const siblings = await mountCompositeSiblings(
1961
+ manifest,
1962
+ ingestWorld.id,
1963
+ gameContainer,
1964
+ project.rootPath,
1965
+ store,
1966
+ );
1967
+ // D-E2: promote to a composite once any sibling actually mounted —
1968
+ // byte-stable (no-op) for a single-world manifest.
1969
+ promoteToCompositeAuthoring(manifest, ingestWorld, siblings);
1970
+ }
1971
+ } catch (err) {
1972
+ editorConsole.error(`Manifest ingest-react auto-launch failed: ${err}`, 'ingest');
1973
+ // D-W3 (F9) — see tryManifestIngestRoute's identical comment above.
1974
+ setMountFailureReport({
1975
+ worldId: ingestWorld.id,
1976
+ kind: ingestWorld.kind,
1977
+ identity: ingestWorld.adapter.identity,
1978
+ message: formatMountFailureMessage(err),
1979
+ });
1980
+ }
1981
+ return true;
1982
+ }
1983
+
1984
+ /**
1985
+ * Hosted entry point, called once after `loadInitialScene`: auto-launch ingest
1986
+ * mode from whichever discovery route applies, so ingestion is drivable
1987
+ * without the dev hook. Checked in order (§1B's "manifest wins" discovery
1988
+ * order):
1989
+ *
1990
+ * 1. Manifest route, `{ module }` (T3.4 slice 2, docs/ADAPTER-AUTHORING-
1991
+ * DESIGN.md §2/§3 slice 2, `./module-mode.ts`'s `tryManifestModuleRoute`)
1992
+ * — a manifest's first world declaring a custom adapter module
1993
+ * auto-mounts through the generic `GameAdapter`/`HostContext` contract
1994
+ * (or, since wave 2's D-Y1, the WORLDS PATH for a non-three module world
1995
+ * — see that file) and installs ITS OWN `MountedGame.authoring` as the
1996
+ * active override, so the SAME adapter-driven hierarchy/inspector panels
1997
+ * below drive it. Mutually exclusive with routes 2–4 by construction (a
1998
+ * world resolves to exactly one adapter identity).
1999
+ * 2. Manifest route, three (T3.3 slice 3, docs/CLI-ON-FOLDER-DESIGN.md §1E,
2000
+ * `tryManifestIngestRoute` above) — the CLI-on-folder AC ("point the CLI
2001
+ * at an ingest game folder never registered in source -> opens, plays,
2002
+ * edits"). Browser-mode builds and legacy `project.json`-only projects
2003
+ * both fall straight through to route 5, unchanged.
2004
+ * 3. Manifest route, pixi (T6.1 slice 4, `tryManifestIngestRoute2D` above) —
2005
+ * the world2d silo retirement's replacement discovery path for an
2006
+ * `ingest-pixi` world.
2007
+ * 4. Manifest route, react (D-Y2, wave 2, `tryManifestIngestRouteReact`
2008
+ * above) — the external-folder replacement discovery path for an
2009
+ * `ingest-react` world (the vendored `?ingest=<id>` glob below stays the
2010
+ * in-tree-fixture route). A manifest's first world resolves to exactly
2011
+ * one adapter identity, so routes 1–4 are mutually exclusive in
2012
+ * practice; all are tried since neither can tell that in advance without
2013
+ * fetching.
2014
+ * 5. `?ingest=<gameId>` URL route: resolves `gameId` against the in-tree
2015
+ * three.js fixture glob first (`ingest/discovery.ts`, unchanged), then —
2016
+ * T6.1 slice 4 — the world2d fixture glob (`ingest/discovery2d.ts`,
2017
+ * bunnymark/flappy/tilemap-camera's replacement launch route for the
2018
+ * retired `__vgaiWorld2dApi.enterVendored`-style dev hook), then —
2019
+ * Track N, N2 — the native-React fixture glob (`ingest/discovery-react
2020
+ * .ts`, e.g. `?ingest=react-rpg`). Unknown ids are ignored (a console
2021
+ * note); the editor stays a normal session.
2022
+ */
2023
+ export async function autoLaunchIngestFromURL(store: EditorStore): Promise<void> {
2024
+ const { tryManifestModuleRoute } = await import('./module-mode');
2025
+ if (await tryManifestModuleRoute(store)) return;
2026
+ if (await tryManifestIngestRoute(store)) return;
2027
+ if (await tryManifestIngestRoute2D(store)) return;
2028
+ if (await tryManifestIngestRouteReact(store)) return;
2029
+
2030
+ const gameId = new URLSearchParams(window.location.search).get('ingest');
2031
+ if (!gameId) return;
2032
+ const ready = await waitForGameContainer();
2033
+ if (getIngestGame(gameId)) {
2034
+ if (!ready) {
2035
+ editorConsole.error('Game container never mounted — cannot auto-ingest', 'ingest');
2036
+ return;
2037
+ }
2038
+ try {
2039
+ await enterIngestMode(store, gameId);
2040
+ } catch (err) {
2041
+ editorConsole.error(`Auto-ingest failed: ${err}`, 'ingest');
2042
+ // D-W3 (F9): the `?ingest=<id>` URL-fallback route has no
2043
+ // `ResolvedWorldEntry` to read a world id/kind from (it resolves
2044
+ // straight off the in-tree fixture globs) — `gameId` and the fixed
2045
+ // 'threejs'/'ingest-three' vocabulary are the closest honest analog.
2046
+ setMountFailureReport({
2047
+ worldId: gameId,
2048
+ kind: 'threejs',
2049
+ identity: 'ingest-three',
2050
+ message: formatMountFailureMessage(err),
2051
+ });
2052
+ }
2053
+ return;
2054
+ }
2055
+ if (getIngestGame2D(gameId)) {
2056
+ if (!ready) {
2057
+ editorConsole.error('Game container never mounted — cannot auto-ingest', 'ingest');
2058
+ return;
2059
+ }
2060
+ try {
2061
+ await enterIngestMode2D(store, gameId);
2062
+ } catch (err) {
2063
+ editorConsole.error(`Auto-ingest failed (world2d): ${err}`, 'ingest');
2064
+ setMountFailureReport({
2065
+ worldId: gameId,
2066
+ kind: 'pixijs',
2067
+ identity: 'ingest-pixi',
2068
+ message: formatMountFailureMessage(err),
2069
+ });
2070
+ }
2071
+ return;
2072
+ }
2073
+ if (getIngestGameReact(gameId)) {
2074
+ if (!ready) {
2075
+ editorConsole.error('Game container never mounted — cannot auto-ingest', 'ingest');
2076
+ return;
2077
+ }
2078
+ try {
2079
+ await enterIngestModeReact(store, gameId);
2080
+ } catch (err) {
2081
+ editorConsole.error(`Auto-ingest failed (react): ${err}`, 'ingest');
2082
+ setMountFailureReport({
2083
+ worldId: gameId,
2084
+ kind: 'react',
2085
+ identity: 'ingest-react',
2086
+ message: formatMountFailureMessage(err),
2087
+ });
2088
+ }
2089
+ return;
2090
+ }
2091
+ editorConsole.error(`Unknown ingest game in ?ingest= : "${gameId}"`, 'ingest');
2092
+ }