@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,1537 @@
1
+ /**
2
+ * Express app factory for the editor backend.
3
+ *
4
+ * All /__editor/* API routes extracted from vite-plugin-asset-browser.ts.
5
+ * Works in both dev (alongside Vite middleware) and production (standalone).
6
+ */
7
+
8
+ import { type ChildProcess, execFile, spawn } from 'node:child_process';
9
+ import { createHash, randomUUID } from 'node:crypto';
10
+ import { existsSync } from 'node:fs';
11
+ import { appendFile, mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
12
+ import { homedir, platform } from 'node:os';
13
+ import { dirname, join, relative, resolve, sep } from 'node:path';
14
+ import chokidar, { type FSWatcher } from 'chokidar';
15
+ import { type ScaffoldTemplate, scaffoldProject } from 'create-vgai-project';
16
+ import type { Request, Response, Router } from 'express';
17
+ import express from 'express';
18
+ import { applyAssetRename } from '../../engine/src/scene/asset-paths';
19
+ import type { SceneFile } from '../../engine/src/scene/scene-types';
20
+ import { createAssetLibraryRouter } from './asset-library-routes';
21
+ import { canonicalProjectRoot } from './canonical-path';
22
+ import { addClient, broadcast, clientCount, removeClient } from './editor-sse';
23
+ import { readProjectView } from './project-view';
24
+ import type { CommandResult } from './server-utils';
25
+ import {
26
+ classifyProjectSrcPath,
27
+ commandResponseFor,
28
+ escapeAppleScriptString,
29
+ findUniqueMoveMatch,
30
+ isAllowedEditorOrigin,
31
+ isMonorepoScaffoldRoot,
32
+ isPathInside,
33
+ isServableFsExtension,
34
+ PACKAGED_NO_MONOREPO_TEMPLATE_ERROR,
35
+ resolveInstalledPackageSrcDir,
36
+ sanitizeContentDispositionFilename,
37
+ validateManifestWrite,
38
+ } from './server-utils';
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Types
42
+ // ---------------------------------------------------------------------------
43
+
44
+ interface AssetEntry {
45
+ name: string;
46
+ type: 'file' | 'directory';
47
+ size: number;
48
+ }
49
+
50
+ interface RecentProject {
51
+ name: string;
52
+ path: string;
53
+ lastOpened: string;
54
+ thumbnail?: string;
55
+ }
56
+
57
+ const RECENT_PROJECTS_PATH = join(homedir(), '.vgai', 'recent-projects.json');
58
+ const MAX_RECENT_PROJECTS = 20;
59
+
60
+ async function loadRecentProjects(): Promise<RecentProject[]> {
61
+ try {
62
+ const raw = await readFile(RECENT_PROJECTS_PATH, 'utf-8');
63
+ const projects = JSON.parse(raw) as RecentProject[];
64
+ // Filter out projects whose paths no longer exist, populate thumbnails
65
+ const valid: RecentProject[] = [];
66
+ for (const p of projects) {
67
+ // §1C: a manifest-only folder (no project.json) is still a valid
68
+ // recent project — readProjectView is the one place this rule lives.
69
+ const view = await readProjectView(p.path);
70
+ if (!view) continue; // Path no longer exists / neither file describes it — skip
71
+ // Check for thumbnail
72
+ try {
73
+ await stat(join(p.path, '.vgai', 'thumbnail.png'));
74
+ p.thumbnail = `/__editor/project-thumbnail?path=${encodeURIComponent(p.path)}`;
75
+ } catch {
76
+ delete p.thumbnail;
77
+ }
78
+ valid.push(p);
79
+ }
80
+ return valid;
81
+ } catch {
82
+ return [];
83
+ }
84
+ }
85
+
86
+ async function saveRecentProjects(projects: RecentProject[]): Promise<void> {
87
+ await mkdir(dirname(RECENT_PROJECTS_PATH), { recursive: true });
88
+ await writeFile(RECENT_PROJECTS_PATH, JSON.stringify(projects, null, 2), 'utf-8');
89
+ }
90
+
91
+ async function addToRecentProjects(name: string, projectPath: string): Promise<void> {
92
+ const projects = await loadRecentProjects();
93
+ // Remove existing entry for this path
94
+ const filtered = projects.filter((p) => p.path !== projectPath);
95
+ // Add at the front
96
+ filtered.unshift({ name, path: projectPath, lastOpened: new Date().toISOString() });
97
+ // Trim to max
98
+ await saveRecentProjects(filtered.slice(0, MAX_RECENT_PROJECTS));
99
+ }
100
+
101
+ function slugify(name: string): string {
102
+ return name
103
+ .toLowerCase()
104
+ .replace(/[^a-z0-9]+/g, '-')
105
+ .replace(/^-|-$/g, '');
106
+ }
107
+
108
+ export interface EditorServerOptions {
109
+ /** Absolute path to the editor/engine repo root (for build scripts + artifacts). */
110
+ engineRoot: string;
111
+ /** Initial project path (optional — can be set later via open-project endpoint). */
112
+ projectPath?: string | undefined;
113
+ /** Serve project scripts via /@fs/ with esbuild transform (production only — Vite handles this in dev). */
114
+ serveScripts?: boolean;
115
+ /** Called when a project is opened at runtime — allows the dev server to update Vite's fs.allow. */
116
+ onProjectOpened?: ((projectPath: string) => void) | undefined;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // File hashing — partial hash for large files (first 64KB + size)
121
+ // ---------------------------------------------------------------------------
122
+
123
+ const PARTIAL_HASH_THRESHOLD = 10 * 1024 * 1024; // 10MB
124
+
125
+ async function hashAndSize(absPath: string): Promise<{ hash: string; size: number }> {
126
+ const data = await readFile(absPath);
127
+ const hash = createHash('sha256');
128
+ if (data.length > PARTIAL_HASH_THRESHOLD) {
129
+ hash.update(data.subarray(0, 65536));
130
+ hash.update(`\0size:${data.length}`);
131
+ } else {
132
+ hash.update(data);
133
+ }
134
+ return { hash: hash.digest('hex').slice(0, 16), size: data.length };
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Recursive file scanning
139
+ // ---------------------------------------------------------------------------
140
+
141
+ async function findFiles(dir: string, ext: string): Promise<string[]> {
142
+ const results: string[] = [];
143
+ let entries;
144
+ try {
145
+ entries = await readdir(dir, { withFileTypes: true });
146
+ } catch {
147
+ return results;
148
+ }
149
+ for (const entry of entries) {
150
+ if (entry.name.startsWith('.')) continue;
151
+ const full = join(dir, entry.name);
152
+ if (entry.isDirectory()) {
153
+ results.push(...(await findFiles(full, ext)));
154
+ } else if (entry.isFile() && entry.name.endsWith(ext)) {
155
+ results.push(full);
156
+ }
157
+ }
158
+ return results;
159
+ }
160
+
161
+ async function scanDirForHashes(
162
+ dir: string,
163
+ hashCache: Map<string, string>,
164
+ sizeCache: Map<string, number>,
165
+ ): Promise<void> {
166
+ let entries;
167
+ try {
168
+ entries = await readdir(dir, { withFileTypes: true });
169
+ } catch {
170
+ return;
171
+ }
172
+ for (const entry of entries) {
173
+ if (entry.name.startsWith('.')) continue;
174
+ const full = join(dir, entry.name);
175
+ if (entry.isDirectory()) {
176
+ await scanDirForHashes(full, hashCache, sizeCache);
177
+ } else if (entry.isFile()) {
178
+ try {
179
+ const { hash, size } = await hashAndSize(full);
180
+ hashCache.set(full, hash);
181
+ sizeCache.set(full, size);
182
+ } catch {
183
+ /* skip unreadable files */
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // MIME type detection
191
+ // ---------------------------------------------------------------------------
192
+
193
+ const mimeTypes: Record<string, string> = {
194
+ json: 'application/json',
195
+ glb: 'model/gltf-binary',
196
+ gltf: 'model/gltf+json',
197
+ png: 'image/png',
198
+ jpg: 'image/jpeg',
199
+ jpeg: 'image/jpeg',
200
+ webp: 'image/webp',
201
+ svg: 'image/svg+xml',
202
+ wav: 'audio/wav',
203
+ mp3: 'audio/mpeg',
204
+ ogg: 'audio/ogg',
205
+ gif: 'image/gif',
206
+ };
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Factory
210
+ // ---------------------------------------------------------------------------
211
+
212
+ export function createEditorServer(options: EditorServerOptions): Router {
213
+ const { engineRoot } = options;
214
+
215
+ // Mutable project state — updated by open-project endpoint
216
+ let projectRoot = options.projectPath ?? engineRoot;
217
+ let publicRoot = resolve(projectRoot, 'public');
218
+
219
+ // ---- File hash cache + move detection ----
220
+ const fileHashCache = new Map<string, string>();
221
+ const fileSizeCache = new Map<string, number>();
222
+
223
+ interface PendingUnlink {
224
+ absPath: string;
225
+ relativePath: string;
226
+ hash: string;
227
+ size: number;
228
+ timestamp: number;
229
+ }
230
+
231
+ const MOVE_WINDOW_MS = 3000;
232
+ const pendingUnlinks: PendingUnlink[] = [];
233
+
234
+ // ---- Scene auto-rewrite on move ----
235
+ async function handleAssetMove(oldPath: string, newPath: string): Promise<void> {
236
+ const updatedFiles: string[] = [];
237
+
238
+ // Find and rewrite .vscn.json files
239
+ const sceneFiles = await findFiles(publicRoot, '.vscn.json');
240
+ for (const absFile of sceneFiles) {
241
+ try {
242
+ const raw = await readFile(absFile, 'utf-8');
243
+ if (!raw.includes(oldPath)) continue;
244
+ const doc = JSON.parse(raw) as SceneFile;
245
+ const renamed = applyAssetRename(doc, oldPath, newPath);
246
+ await writeFile(absFile, JSON.stringify(renamed, null, 2), 'utf-8');
247
+ updatedFiles.push('/' + relative(publicRoot, absFile));
248
+ } catch {
249
+ /* skip files that fail to parse/write */
250
+ }
251
+ }
252
+
253
+ // Find and rewrite .prefab.json files
254
+ const prefabFiles = await findFiles(publicRoot, '.prefab.json');
255
+ for (const absFile of prefabFiles) {
256
+ try {
257
+ const raw = await readFile(absFile, 'utf-8');
258
+ if (!raw.includes(oldPath)) continue;
259
+ const prefab = JSON.parse(raw) as { version: number; name: string; root: unknown };
260
+ const asSceneFile: SceneFile = {
261
+ version: prefab.version,
262
+ name: prefab.name,
263
+ entities: [prefab.root as SceneFile['entities'][number]],
264
+ };
265
+ const renamed = applyAssetRename(asSceneFile, oldPath, newPath);
266
+ prefab.root = renamed.entities[0];
267
+ await writeFile(absFile, JSON.stringify(prefab, null, 2), 'utf-8');
268
+ updatedFiles.push('/' + relative(publicRoot, absFile));
269
+ } catch {
270
+ /* skip */
271
+ }
272
+ }
273
+
274
+ broadcast('asset-moved', { oldPath, newPath, updatedFiles });
275
+ }
276
+
277
+ // ---- Chokidar watcher ----
278
+ let watcher: FSWatcher | null = null;
279
+ let srcWatcher: FSWatcher | null = null;
280
+
281
+ // Coalesce event bursts (e.g. a git checkout adding several files at once)
282
+ // into one broadcast per event name, trailing ~250ms after the last event.
283
+ const pendingBroadcasts = new Map<string, ReturnType<typeof setTimeout>>();
284
+ function broadcastDebounced(event: string, data: unknown, ms = 250): void {
285
+ const existing = pendingBroadcasts.get(event);
286
+ if (existing) clearTimeout(existing);
287
+ pendingBroadcasts.set(
288
+ event,
289
+ setTimeout(() => {
290
+ pendingBroadcasts.delete(event);
291
+ broadcast(event, data);
292
+ }, ms),
293
+ );
294
+ }
295
+
296
+ function startWatcher(): void {
297
+ watcher?.close();
298
+ srcWatcher?.close();
299
+ srcWatcher = null;
300
+ fileHashCache.clear();
301
+ fileSizeCache.clear();
302
+ pendingUnlinks.length = 0;
303
+
304
+ // Seed hash cache in background
305
+ scanDirForHashes(publicRoot, fileHashCache, fileSizeCache);
306
+
307
+ watcher = chokidar.watch(publicRoot, {
308
+ ignoreInitial: true,
309
+ ignored: /(^|[/\\])\../, // ignore dotfiles
310
+ });
311
+
312
+ watcher.on('change', async (filePath: string) => {
313
+ const abs = resolve(filePath);
314
+ if (!isPathInside(publicRoot, abs)) return;
315
+ try {
316
+ const { hash, size } = await hashAndSize(abs);
317
+ fileHashCache.set(abs, hash);
318
+ fileSizeCache.set(abs, size);
319
+ } catch {
320
+ /* ignore */
321
+ }
322
+
323
+ // Broadcast scene file changes so the editor can live-reload
324
+ if (abs.endsWith('.vscn.json')) {
325
+ try {
326
+ const content = await readFile(abs, 'utf-8');
327
+ const rel = '/' + relative(publicRoot, abs);
328
+ broadcast('scene-changed', { path: rel, content });
329
+ } catch {
330
+ /* file may have been deleted between events */
331
+ }
332
+ }
333
+ });
334
+
335
+ watcher.on('unlink', (filePath: string) => {
336
+ const abs = resolve(filePath);
337
+ if (!isPathInside(publicRoot, abs)) return;
338
+ const hash = fileHashCache.get(abs);
339
+ const size = fileSizeCache.get(abs);
340
+ if (hash !== undefined && size !== undefined) {
341
+ const rel = '/' + relative(publicRoot, abs);
342
+ pendingUnlinks.push({ absPath: abs, relativePath: rel, hash, size, timestamp: Date.now() });
343
+ fileHashCache.delete(abs);
344
+ fileSizeCache.delete(abs);
345
+ }
346
+ });
347
+
348
+ watcher.on('add', async (filePath: string) => {
349
+ const abs = resolve(filePath);
350
+ if (!isPathInside(publicRoot, abs)) return;
351
+
352
+ try {
353
+ const { hash, size } = await hashAndSize(abs);
354
+ fileHashCache.set(abs, hash);
355
+ fileSizeCache.set(abs, size);
356
+
357
+ // SC2: only treat a delete+create as a move when it is unambiguous —
358
+ // matching hash AND size, and a single such pending unlink. Otherwise
359
+ // an unrelated delete+create could silently rewrite scene refs.
360
+ const match = findUniqueMoveMatch(pendingUnlinks, hash, size, Date.now(), MOVE_WINDOW_MS);
361
+ if (match) {
362
+ const idx = pendingUnlinks.indexOf(match);
363
+ if (idx >= 0) pendingUnlinks.splice(idx, 1);
364
+ const newRelPath = '/' + relative(publicRoot, abs);
365
+ await handleAssetMove(match.relativePath, newRelPath);
366
+ }
367
+ } catch {
368
+ /* ignore read errors */
369
+ }
370
+ });
371
+
372
+ // ---- Second watcher: <project>/src (W6a, docs/DATA-TOOLS-DESIGN.md §7) ----
373
+ //
374
+ // The watcher above is scoped to `publicRoot` (`<project>/public`) only —
375
+ // it never sees `src/tools/*.tool.tsx` or `src/data/*.data.json`, so a
376
+ // NEW file there never showed up without a full editor reload (spec §7
377
+ // W4 field note c). Vite's own HMR (`vgai-script-hmr` in dev.ts, and
378
+ // `tool-loader.ts`'s listener on it) only fires on `change`, and the
379
+ // `VGAI_WATCH_POLL` poll-watcher (dev.ts) forwards its synthetic events
380
+ // into VITE's chokidar watcher only — never into this one. So this
381
+ // second watcher is self-contained here: it works in dev AND
382
+ // packaged/prod (this module is shared by both), and on drvfs
383
+ // (`/mnt/c`, no inotify) via the same `VGAI_WATCH_POLL` env var.
384
+ //
385
+ // add/unlink ONLY, never `change`: tool-content edits already ride the
386
+ // existing `vgai:script-update` HMR path, and a data-file `change` event
387
+ // would echo the DataPanel's own `/__editor/data-file` POST writes back
388
+ // into a refetch, clobbering in-flight drafts.
389
+ const srcDir = join(projectRoot, 'src');
390
+ if (projectRoot !== engineRoot && existsSync(srcDir)) {
391
+ // Same parse as dev.ts's `startProjectPollWatcher` (VGAI_WATCH_POLL=1,
392
+ // or =<ms> for the interval).
393
+ const pollRaw = Number(process.env['VGAI_WATCH_POLL']);
394
+ srcWatcher = chokidar.watch(srcDir, {
395
+ ignoreInitial: true,
396
+ ignored: /(^|[/\\])\../, // ignore dotfiles
397
+ ...(process.env['VGAI_WATCH_POLL']
398
+ ? { usePolling: true, interval: pollRaw > 1 ? pollRaw : 1000 }
399
+ : {}),
400
+ });
401
+ const onListChange = (filePath: string) => {
402
+ const rel = relative(projectRoot, resolve(filePath)).split(sep).join('/');
403
+ const kind = classifyProjectSrcPath(rel);
404
+ if (kind === 'tools') broadcastDebounced('tool-files-changed', { path: rel });
405
+ else if (kind === 'data') broadcastDebounced('data-files-changed', { path: rel });
406
+ };
407
+ srcWatcher.on('add', onListChange);
408
+ srcWatcher.on('unlink', onListChange);
409
+ }
410
+ }
411
+
412
+ // Start initial watcher
413
+ startWatcher();
414
+
415
+ // Periodic cleanup of stale pending unlinks
416
+ setInterval(() => {
417
+ const cutoff = Date.now() - MOVE_WINDOW_MS * 2;
418
+ while (pendingUnlinks.length > 0 && pendingUnlinks[0]!.timestamp < cutoff) {
419
+ pendingUnlinks.shift();
420
+ }
421
+ }, 5000);
422
+
423
+ // ---- Build state ----
424
+ const BUILD_SCRIPTS: Record<string, string> = {
425
+ web: 'build:web',
426
+ };
427
+ let activeBuild: ChildProcess | null = null;
428
+
429
+ // ---- Editor command relay + state ----
430
+ let editorState: Record<string, unknown> = {};
431
+
432
+ // Pending command results — keyed by requestId, resolved by browser POST
433
+ const pendingCommands = new Map<
434
+ string,
435
+ { resolve: (result: CommandResult) => void; timer: ReturnType<typeof setTimeout> }
436
+ >();
437
+ const COMMAND_TIMEOUT_MS = 5000;
438
+ // `play` is the one command whose ack is gated on the GAME, not the editor:
439
+ // the browser resolves it only after `enterPlayMode()` — which awaits the
440
+ // project's entire async `setup()` (asset loads, procedural worldgen). Under
441
+ // the generic 5s window any substantial game made `vgai play` report
442
+ // "editor connected but did not respond" while play in fact proceeded and
443
+ // succeeded (hit dogfooding a voxel game whose worldgen alone outlives 5s).
444
+ const PLAY_COMMAND_TIMEOUT_MS = 120_000;
445
+
446
+ // ---- Router ----
447
+ const router = express.Router();
448
+ router.use(express.json({ limit: '50mb' }));
449
+
450
+ // ---- CSRF / drive-by protection (S3) ----
451
+ // Mutating /__editor/* routes can write files, switch projects, and run npm
452
+ // builds (RCE). A malicious web page could POST to a locally-bound editor; an
453
+ // Origin allowlist (loopback only, by default) blocks that. Non-browser
454
+ // clients (CLI/SDK via Node fetch) send no Origin header and are allowed.
455
+ router.use((req: Request, res: Response, next) => {
456
+ const method = req.method.toUpperCase();
457
+ const isMutating =
458
+ method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE';
459
+ if (isMutating && req.path.startsWith('/__editor')) {
460
+ const host = process.env['VGAI_EDITOR_HOST'];
461
+ const extra = host ? [host] : [];
462
+ if (!isAllowedEditorOrigin(req.headers.origin, extra)) {
463
+ res.status(403).json({ error: 'Cross-origin request rejected.' });
464
+ return;
465
+ }
466
+ }
467
+ next();
468
+ });
469
+
470
+ // ---- Asset Library (online asset browsing + download) ----
471
+ router.use(
472
+ '/__editor/asset-library',
473
+ createAssetLibraryRouter(() => publicRoot),
474
+ );
475
+
476
+ // ---- Project file serving middleware ----
477
+ // Serves project.json from root and static files from project's public/
478
+ router.use((req: Request, res: Response, next) => {
479
+ // Only handle non-API requests when a project is open
480
+ if (projectRoot === engineRoot) return next();
481
+ const pathname = req.path;
482
+
483
+ // Skip editor API endpoints
484
+ if (pathname.startsWith('/__')) return next();
485
+ if (pathname.startsWith('/@')) return next();
486
+ if (pathname.startsWith('/node_modules')) return next();
487
+
488
+ // Serve project.json (or its manifest-synthesized compat view — §1C)
489
+ // from the project root, not public/.
490
+ if (pathname === '/project.json') {
491
+ readProjectView(projectRoot)
492
+ .then((view) => {
493
+ if (!view) return next();
494
+ res.json(view);
495
+ })
496
+ .catch(() => next());
497
+ return;
498
+ }
499
+
500
+ // Serve the RAW manifest (no view synthesis — the browser-side adapter
501
+ // resolver parses it itself via the pure `@engine/manifest/load` half;
502
+ // T3.3 slice 2 part C) from the project root, not public/. 404s (falls
503
+ // through to `next()`, same as any other missing project-root file) for
504
+ // legacy `project.json`-only projects — that is what makes the manifest
505
+ // branch a no-op for them.
506
+ if (pathname === '/vgai.game.json') {
507
+ readFile(join(projectRoot, 'vgai.game.json'), 'utf-8')
508
+ .then((raw) => {
509
+ res.setHeader('Content-Type', 'application/json');
510
+ res.send(raw);
511
+ })
512
+ .catch(() => next());
513
+ return;
514
+ }
515
+
516
+ const filePath = resolve(publicRoot, pathname.slice(1));
517
+ if (!filePath.startsWith(publicRoot)) return next();
518
+
519
+ stat(filePath)
520
+ .then(async (s) => {
521
+ if (!s.isFile()) return next();
522
+ const data = await readFile(filePath);
523
+ const ext = filePath.split('.').pop() ?? '';
524
+ res.writeHead(200, {
525
+ 'Content-Type': mimeTypes[ext] ?? 'application/octet-stream',
526
+ 'Content-Length': s.size,
527
+ });
528
+ res.end(data);
529
+ })
530
+ .catch(() => next());
531
+ });
532
+
533
+ // ---- Current project info ----
534
+ router.get('/__editor/project', async (_req: Request, res: Response) => {
535
+ if (projectRoot === engineRoot) {
536
+ // No project open
537
+ res.json({ project: null });
538
+ return;
539
+ }
540
+ const config = await readProjectView(projectRoot);
541
+ // hasUiRegistry: whether the project ships an OPTIONAL src/ui/registry.ts
542
+ // (scene-UI project mode, design/24-scene-ui.md D9). The client must not
543
+ // probe /@fs for it — a dev server answers 200 (SPA fallback) for missing
544
+ // files, and a dynamic import of a missing module logs an unsuppressible
545
+ // browser network error on every registry-less project.
546
+ const hasUiRegistry = await stat(join(projectRoot, 'src', 'ui', 'registry.ts')).then(
547
+ (s) => s.isFile(),
548
+ () => false,
549
+ );
550
+ // `packaged`: true iff this server is the no-monorepo-checkout runtime
551
+ // (`packages/editor/server/packaged.ts`) — same `isMonorepoScaffoldRoot`
552
+ // check the "New Project" packaged-mode guard already uses server-side
553
+ // (server-utils.ts). Client-side, `adapter-resolver.ts`'s react-world
554
+ // mount reads this to decide whether it must resolve its
555
+ // `react`/`react-dom/client`/`GameProvider` wrapper from the PROJECT's
556
+ // own module graph (packaged: the editor's prebuilt SPA bundles its OWN
557
+ // separate react copy, so a react world would dual-instance otherwise)
558
+ // or keep the existing static-import path (dev/electron: one shared Vite
559
+ // instance + root `vite.config.ts`'s `resolve.dedupe` already collapses
560
+ // them, so this flag is `false` there and that path stays unchanged).
561
+ const packaged = !isMonorepoScaffoldRoot(engineRoot);
562
+ res.json({ project: config ? { path: projectRoot, config, hasUiRegistry, packaged } : null });
563
+ });
564
+
565
+ // ---- SSE endpoint ----
566
+ router.get('/__editor/events', (req: Request, res: Response) => {
567
+ res.writeHead(200, {
568
+ 'Content-Type': 'text/event-stream',
569
+ 'Cache-Control': 'no-cache',
570
+ Connection: 'keep-alive',
571
+ });
572
+ res.write(':ok\n\n');
573
+ addClient(res);
574
+ req.on('close', () => removeClient(res));
575
+ });
576
+
577
+ // ---- Editor command relay ----
578
+ router.post('/__editor/command', (req: Request, res: Response) => {
579
+ const body = req.body as Record<string, unknown>;
580
+
581
+ // Fail fast when no editor browser tab holds a live SSE connection. Without
582
+ // this, the command silently waits the full COMMAND_TIMEOUT_MS before a
583
+ // generic timeout — and the caller can't tell "no editor open" from "editor
584
+ // open but the command hung". `clientCount()` is the live SSE-client count,
585
+ // so a 0 here is unambiguous: open the editor in a browser first.
586
+ if (clientCount() === 0) {
587
+ const { status, body: respBody } = commandResponseFor({
588
+ ok: false,
589
+ timedOut: true,
590
+ error: 'No editor connected — open the editor in a browser tab, then retry.',
591
+ });
592
+ res.status(status).json(respBody);
593
+ return;
594
+ }
595
+
596
+ // Assign a request ID so the browser can report the result back
597
+ const requestId = randomUUID();
598
+ const commandWithId = { ...body, _requestId: requestId };
599
+
600
+ // Create a promise that resolves when the browser reports the result.
601
+ // SC1: a timeout here means an editor IS connected but did not answer in
602
+ // time (the command hung or threw) — distinct from the no-editor case above.
603
+ const timeoutMs = body['type'] === 'play' ? PLAY_COMMAND_TIMEOUT_MS : COMMAND_TIMEOUT_MS;
604
+ const resultPromise = new Promise<CommandResult>((resolve) => {
605
+ const timer = setTimeout(() => {
606
+ pendingCommands.delete(requestId);
607
+ resolve({
608
+ ok: false,
609
+ timedOut: true,
610
+ error: 'Command timed out — editor connected but did not respond.',
611
+ });
612
+ }, timeoutMs);
613
+ pendingCommands.set(requestId, { resolve, timer });
614
+ });
615
+
616
+ broadcast('editor-command', commandWithId);
617
+
618
+ resultPromise.then((result) => {
619
+ const { status, body } = commandResponseFor(result);
620
+ res.status(status).json(body);
621
+ });
622
+ });
623
+
624
+ // ---- Editor command result (browser reports back) ----
625
+ router.post('/__editor/command-result', (req: Request, res: Response) => {
626
+ const body = req.body as {
627
+ _requestId: string;
628
+ ok: boolean;
629
+ error?: string;
630
+ data?: Record<string, unknown>;
631
+ };
632
+ const pending = pendingCommands.get(body._requestId);
633
+ if (pending) {
634
+ clearTimeout(pending.timer);
635
+ pendingCommands.delete(body._requestId);
636
+ pending.resolve(body);
637
+ }
638
+ res.json({ ok: true });
639
+ });
640
+
641
+ // ---- Editor state (browser reports / CLI reads) ----
642
+ router.get('/__editor/state', (_req: Request, res: Response) => {
643
+ // `editorState` is the last snapshot a browser POSTed; it persists even
644
+ // after every tab closes. Stamp the response with the LIVE SSE client count
645
+ // so callers can distinguish "an editor is attached now" from "this is stale
646
+ // cache from a tab that has since gone away".
647
+ const connected = clientCount();
648
+ res.json({ ...editorState, editorsConnected: connected, connected: connected > 0 });
649
+ });
650
+
651
+ router.post('/__editor/state', (req: Request, res: Response) => {
652
+ editorState = req.body as Record<string, unknown>;
653
+ res.json({ ok: true });
654
+ });
655
+
656
+ // ---- Asset listing ----
657
+ router.get('/__editor/assets', async (req: Request, res: Response) => {
658
+ const dir = (req.query['dir'] as string) ?? '';
659
+
660
+ if (dir.includes('..')) {
661
+ res.status(400).json({ error: 'Path traversal not allowed.' });
662
+ return;
663
+ }
664
+
665
+ const absRoot = resolve(projectRoot, 'public');
666
+ const absDir = join(absRoot, dir);
667
+
668
+ const rel = relative(absRoot, absDir);
669
+ if (rel.startsWith('..')) {
670
+ res.status(400).json({ error: 'Path outside root.' });
671
+ return;
672
+ }
673
+
674
+ try {
675
+ const dirents = await readdir(absDir, { withFileTypes: true });
676
+ const entries: AssetEntry[] = [];
677
+
678
+ for (const d of dirents) {
679
+ if (d.name.startsWith('.')) continue;
680
+ const entryPath = join(absDir, d.name);
681
+ if (d.isDirectory()) {
682
+ entries.push({ name: d.name, type: 'directory', size: 0 });
683
+ } else if (d.isFile()) {
684
+ const s = await stat(entryPath);
685
+ entries.push({ name: d.name, type: 'file', size: s.size });
686
+ }
687
+ }
688
+
689
+ entries.sort((a, b) => {
690
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
691
+ return a.name.localeCompare(b.name);
692
+ });
693
+
694
+ res.json({ entries });
695
+ } catch (err: unknown) {
696
+ const code = (err as NodeJS.ErrnoException).code;
697
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
698
+ res.status(404).json({ error: 'Directory not found.' });
699
+ } else {
700
+ res.status(500).json({ error: 'Internal server error.' });
701
+ }
702
+ }
703
+ });
704
+
705
+ // ---- Scene file listing (A4, D8) ----
706
+ // Recursive `*.vscn.json` listing under the project's `public/` — every
707
+ // scene file is a potential hierarchy root (D8 radio groups). NO user
708
+ // input is accepted (no query/body to derive a path from), so there is no
709
+ // traversal surface here at all — `findFiles` only ever walks DOWN from
710
+ // the fixed `publicRoot`.
711
+ router.get('/__editor/scenes', async (_req: Request, res: Response) => {
712
+ if (projectRoot === engineRoot) {
713
+ res.json({ scenes: [] });
714
+ return;
715
+ }
716
+ const files = await findFiles(publicRoot, '.vscn.json');
717
+ const scenes = files.map((f) => relative(publicRoot, f).split('\\').join('/')).sort();
718
+ res.json({ scenes });
719
+ });
720
+
721
+ // ---- Save file ----
722
+ router.post('/__editor/save-file', async (req: Request, res: Response) => {
723
+ const body = req.body as { path: string; content: string; encoding?: 'base64' };
724
+ const filePath = body.path ?? '';
725
+
726
+ if (!filePath || filePath.includes('..')) {
727
+ res.status(400).json({ error: 'Invalid path.' });
728
+ return;
729
+ }
730
+
731
+ const absPath = resolve(projectRoot, 'public', filePath);
732
+ if (!absPath.startsWith(publicRoot)) {
733
+ res.status(400).json({ error: 'Path outside public directory.' });
734
+ return;
735
+ }
736
+
737
+ try {
738
+ await mkdir(dirname(absPath), { recursive: true });
739
+ const data = body.encoding === 'base64' ? Buffer.from(body.content, 'base64') : body.content;
740
+ await writeFile(absPath, data, body.encoding === 'base64' ? undefined : 'utf-8');
741
+ res.json({ ok: true, path: filePath });
742
+ } catch {
743
+ res.status(500).json({ error: 'Failed to write file.' });
744
+ }
745
+ });
746
+
747
+ // ---- Download build artifact ----
748
+ router.get('/__editor/download', async (req: Request, res: Response) => {
749
+ const file = (req.query['file'] as string) ?? '';
750
+
751
+ if (!file || file.includes('..') || file.includes('/')) {
752
+ res.status(400).json({ error: 'Invalid file.' });
753
+ return;
754
+ }
755
+
756
+ // Build artifacts are produced in the engine repo, not the project dir
757
+ const absPath = resolve(engineRoot, file);
758
+ if (!absPath.startsWith(engineRoot)) {
759
+ res.status(400).json({ error: 'Path outside project.' });
760
+ return;
761
+ }
762
+
763
+ try {
764
+ const data = await readFile(absPath);
765
+ const s = await stat(absPath);
766
+ res.writeHead(200, {
767
+ 'Content-Type': 'application/octet-stream',
768
+ // S7: strip control/quote chars from the user-supplied filename.
769
+ 'Content-Disposition': `attachment; filename="${sanitizeContentDispositionFilename(file)}"`,
770
+ 'Content-Length': s.size,
771
+ });
772
+ res.end(data);
773
+ } catch {
774
+ res.status(404).json({ error: 'File not found.' });
775
+ }
776
+ });
777
+
778
+ // ---- Export/build ----
779
+ router.post('/__editor/export', async (req: Request, res: Response) => {
780
+ if (activeBuild) {
781
+ res.status(409).json({ error: 'A build is already running.' });
782
+ return;
783
+ }
784
+
785
+ const body = req.body as { target: string };
786
+ const script = BUILD_SCRIPTS[body.target];
787
+ if (!script) {
788
+ res.status(400).json({ error: `Unknown target: ${body.target}` });
789
+ return;
790
+ }
791
+
792
+ res.writeHead(200, {
793
+ 'Content-Type': 'text/event-stream',
794
+ 'Cache-Control': 'no-cache',
795
+ Connection: 'keep-alive',
796
+ });
797
+ res.write(':ok\n\n');
798
+
799
+ // Build scripts live in the engine repo's package.json
800
+ const child = spawn('npm', ['run', script], {
801
+ cwd: engineRoot,
802
+ shell: true,
803
+ stdio: ['ignore', 'pipe', 'pipe'],
804
+ });
805
+ activeBuild = child;
806
+
807
+ const sendLine = (line: string) => {
808
+ res.write(`event: log\ndata: ${JSON.stringify(line)}\n\n`);
809
+ };
810
+
811
+ let buffer = '';
812
+ const flushLines = (chunk: Buffer) => {
813
+ buffer += chunk.toString('utf-8');
814
+ const lines = buffer.split('\n');
815
+ buffer = lines.pop()!;
816
+ for (const line of lines) sendLine(line);
817
+ };
818
+
819
+ child.stdout!.on('data', flushLines);
820
+ child.stderr!.on('data', flushLines);
821
+
822
+ child.on('close', (code) => {
823
+ if (buffer) sendLine(buffer);
824
+ buffer = '';
825
+ activeBuild = null;
826
+ res.write(`event: done\ndata: ${JSON.stringify({ ok: code === 0, code })}\n\n`);
827
+ res.end();
828
+ });
829
+
830
+ req.on('close', () => {
831
+ if (activeBuild === child) {
832
+ child.kill();
833
+ activeBuild = null;
834
+ }
835
+ });
836
+ });
837
+
838
+ // ---- Recent projects ----
839
+ router.get('/__editor/recent-projects', async (_req: Request, res: Response) => {
840
+ const projects = await loadRecentProjects();
841
+ res.json({ projects });
842
+ });
843
+
844
+ // ---- Available `examples/<id>/` scaffold sources (Track E3, §1.F) ----
845
+ // Backs the "New Project" screen's optional "From example" picker — a
846
+ // best-effort convenience, not a critical path, so any failure (e.g. no
847
+ // examples/ dir at all in some deployment context) degrades to an empty
848
+ // list rather than a 500.
849
+ router.get('/__editor/examples', async (_req: Request, res: Response) => {
850
+ try {
851
+ const examplesDir = join(engineRoot, 'examples');
852
+ const dirents = await readdir(examplesDir, { withFileTypes: true });
853
+ const examples: Array<{ id: string; name: string }> = [];
854
+ for (const d of dirents) {
855
+ if (!d.isDirectory()) continue;
856
+ try {
857
+ const raw = await readFile(join(examplesDir, d.name, 'vgai.game.json'), 'utf-8');
858
+ const manifest = JSON.parse(raw) as { name?: string };
859
+ examples.push({ id: d.name, name: manifest.name ?? d.name });
860
+ } catch {}
861
+ }
862
+ res.json({ examples });
863
+ } catch {
864
+ res.json({ examples: [] });
865
+ }
866
+ });
867
+
868
+ // ---- Create project from template ----
869
+ router.post('/__editor/create-project', async (req: Request, res: Response) => {
870
+ // Phase B cleanup ITEM 2: packaged (no-monorepo) mode has no
871
+ // `<engineRoot>/packages/editor/template` to scaffold from — degrade
872
+ // loudly here, up front, instead of letting `scaffoldProject` crash
873
+ // confusingly partway through. See `isMonorepoScaffoldRoot`'s doc
874
+ // comment (server-utils.ts) for exactly what's missing and why.
875
+ if (!isMonorepoScaffoldRoot(engineRoot)) {
876
+ res.status(501).json({ error: PACKAGED_NO_MONOREPO_TEMPLATE_ERROR });
877
+ return;
878
+ }
879
+
880
+ const body = req.body as {
881
+ name: string;
882
+ location: string;
883
+ template: ScaffoldTemplate;
884
+ exampleId?: string;
885
+ };
886
+ const { name, location, template, exampleId } = body;
887
+
888
+ if (!name || !location) {
889
+ res.status(400).json({ error: 'Missing name or location.' });
890
+ return;
891
+ }
892
+
893
+ const slug = slugify(name);
894
+ if (!slug) {
895
+ res.status(400).json({ error: 'Invalid project name.' });
896
+ return;
897
+ }
898
+
899
+ // Expand ~ to home directory
900
+ const expandedLocation = location.startsWith('~/')
901
+ ? join(homedir(), location.slice(2))
902
+ : location;
903
+ const projectDir = resolve(expandedLocation, slug);
904
+
905
+ // Check if directory already exists
906
+ try {
907
+ await stat(projectDir);
908
+ res.status(409).json({ error: `Directory already exists: ${projectDir}` });
909
+ return;
910
+ } catch {
911
+ // Expected — directory doesn't exist yet
912
+ }
913
+
914
+ try {
915
+ // T3.6: shared scaffolder — packages/create-vgai-project/src/scaffold.ts.
916
+ // Same implementation `npx create-vgai-project` and `vgai create` use
917
+ // (copy template, rewrite @vgai/engine file: dep + tsconfig @engine/*
918
+ // paths/files array + vite alias + index.html title + project.json).
919
+ const result = scaffoldProject({
920
+ name,
921
+ targetDir: projectDir,
922
+ template,
923
+ monoRoot: engineRoot,
924
+ ...(exampleId !== undefined ? { exampleId } : {}),
925
+ });
926
+
927
+ // Ensure .vgai/ directory exists
928
+ await mkdir(join(projectDir, '.vgai'), { recursive: true });
929
+
930
+ // Add to recent projects
931
+ await addToRecentProjects(name, projectDir);
932
+
933
+ res.json({ ok: true, path: result.targetDir, config: result.projectConfig });
934
+ } catch (err) {
935
+ res.status(500).json({ error: `Failed to create project: ${err}` });
936
+ }
937
+ });
938
+
939
+ // ---- Native folder dialog ----
940
+ router.post('/__editor/browse-folder', async (req: Request, res: Response) => {
941
+ const body = req.body as { title?: string };
942
+ const title = body.title ?? 'Select Folder';
943
+ const os = platform();
944
+
945
+ try {
946
+ let selectedPath: string | null = null;
947
+
948
+ if (os === 'darwin') {
949
+ // macOS: use osascript to open a native folder dialog
950
+ const result = await new Promise<string>((ok, fail) => {
951
+ execFile(
952
+ 'osascript',
953
+ [
954
+ '-e',
955
+ // S4: escape backslashes BEFORE quotes (and strip control chars)
956
+ // so the title can't break out of the AppleScript string literal.
957
+ `set theFolder to choose folder with prompt "${escapeAppleScriptString(title)}"`,
958
+ '-e',
959
+ 'return POSIX path of theFolder',
960
+ ],
961
+ (err, stdout) => {
962
+ if (err) fail(err);
963
+ else ok(stdout.trim());
964
+ },
965
+ );
966
+ });
967
+ if (result) selectedPath = result.replace(/\/$/, ''); // strip trailing slash
968
+ } else if (os === 'linux') {
969
+ // Linux: try zenity, fall back to kdialog
970
+ const result = await new Promise<string>((ok, fail) => {
971
+ execFile(
972
+ 'zenity',
973
+ ['--file-selection', '--directory', `--title=${title}`],
974
+ (err, stdout) => {
975
+ if (err) {
976
+ // Try kdialog as fallback
977
+ execFile(
978
+ 'kdialog',
979
+ ['--getexistingdirectory', homedir(), '--title', title],
980
+ (err2, stdout2) => {
981
+ if (err2) fail(err2);
982
+ else ok(stdout2.trim());
983
+ },
984
+ );
985
+ } else {
986
+ ok(stdout.trim());
987
+ }
988
+ },
989
+ );
990
+ });
991
+ if (result) selectedPath = result;
992
+ }
993
+
994
+ res.json({ path: selectedPath });
995
+ } catch {
996
+ // User cancelled the dialog or no dialog tool available
997
+ res.json({ path: null });
998
+ }
999
+ });
1000
+
1001
+ // ---- Reveal in file manager ----
1002
+ router.post('/__editor/reveal', (req: Request, res: Response) => {
1003
+ const body = req.body as { path: string };
1004
+ const targetPath = body.path;
1005
+ if (!targetPath) {
1006
+ res.status(400).json({ error: 'Missing path.' });
1007
+ return;
1008
+ }
1009
+
1010
+ const os = platform();
1011
+ if (os === 'darwin') {
1012
+ spawn('open', ['-R', targetPath], { stdio: 'ignore', detached: true }).unref();
1013
+ } else if (os === 'linux') {
1014
+ spawn('xdg-open', [dirname(targetPath)], { stdio: 'ignore', detached: true }).unref();
1015
+ } else if (os === 'win32') {
1016
+ spawn('explorer', ['/select,', targetPath], { stdio: 'ignore', detached: true }).unref();
1017
+ }
1018
+ res.json({ ok: true });
1019
+ });
1020
+
1021
+ // ---- Save project thumbnail ----
1022
+ router.post('/__editor/save-thumbnail', async (req: Request, res: Response) => {
1023
+ const body = req.body as { dataUrl: string };
1024
+ if (!body.dataUrl || projectRoot === engineRoot) {
1025
+ res.status(400).json({ error: 'No project open or missing data.' });
1026
+ return;
1027
+ }
1028
+
1029
+ try {
1030
+ // Save thumbnail as .vgai/thumbnail.png in project root
1031
+ const base64Data = body.dataUrl.replace(/^data:image\/\w+;base64,/, '');
1032
+ const vgaiDir = join(projectRoot, '.vgai');
1033
+ await mkdir(vgaiDir, { recursive: true });
1034
+ const thumbnailPath = join(vgaiDir, 'thumbnail.png');
1035
+ await writeFile(thumbnailPath, Buffer.from(base64Data, 'base64'));
1036
+
1037
+ // Update recent projects entry with thumbnail path
1038
+ const projects = await loadRecentProjects();
1039
+ const entry = projects.find((p) => p.path === projectRoot);
1040
+ if (entry) {
1041
+ entry.thumbnail = `/__editor/project-thumbnail?path=${encodeURIComponent(projectRoot)}`;
1042
+ await saveRecentProjects(projects);
1043
+ }
1044
+
1045
+ res.json({ ok: true });
1046
+ } catch {
1047
+ res.status(500).json({ error: 'Failed to save thumbnail.' });
1048
+ }
1049
+ });
1050
+
1051
+ // ---- Serve project thumbnail ----
1052
+ router.get('/__editor/project-thumbnail', async (req: Request, res: Response) => {
1053
+ const targetPath = (req.query['path'] as string) ?? '';
1054
+ if (!targetPath) {
1055
+ res.status(400).json({ error: 'Missing path.' });
1056
+ return;
1057
+ }
1058
+
1059
+ const thumbnailPath = join(resolve(targetPath), '.vgai', 'thumbnail.png');
1060
+ try {
1061
+ const data = await readFile(thumbnailPath);
1062
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=60' });
1063
+ res.end(data);
1064
+ } catch {
1065
+ res.status(404).end();
1066
+ }
1067
+ });
1068
+
1069
+ // ---- Open project (runtime switching) ----
1070
+ router.post('/__editor/open-project', async (req: Request, res: Response) => {
1071
+ const body = req.body as { path: string };
1072
+ const newPath = body.path;
1073
+
1074
+ if (!newPath) {
1075
+ res.status(400).json({ error: 'Missing path.' });
1076
+ return;
1077
+ }
1078
+
1079
+ // Canonicalized (symlink-resolved), not a plain `resolve` — see
1080
+ // canonical-path.ts's doc comment (T6.2 slice 3 finding: a react world's
1081
+ // `GameProvider` colocation breaks if this path and Vite's own resolver
1082
+ // disagree on a symlinked segment).
1083
+ const absPath = canonicalProjectRoot(newPath);
1084
+
1085
+ // Read project.json (or synthesize from vgai.game.json — §1C); accepts a
1086
+ // manifest-only folder with no project.json.
1087
+ const config = await readProjectView(absPath);
1088
+ if (!config) {
1089
+ res.status(400).json({ error: `No vgai.game.json or project.json at ${absPath}` });
1090
+ return;
1091
+ }
1092
+
1093
+ // Update mutable state
1094
+ projectRoot = absPath;
1095
+ publicRoot = resolve(absPath, 'public');
1096
+
1097
+ // Clear stale editor state from previous project
1098
+ editorState = {};
1099
+
1100
+ // Ensure .vgai/ directory exists
1101
+ await mkdir(join(absPath, '.vgai'), { recursive: true });
1102
+
1103
+ // Restart file watcher on new project
1104
+ startWatcher();
1105
+
1106
+ // Allow Vite to serve /@fs/ paths from the project directory
1107
+ options.onProjectOpened?.(absPath);
1108
+
1109
+ // Track in recent projects
1110
+ await addToRecentProjects((config['name'] as string) ?? 'Untitled', absPath);
1111
+
1112
+ // Notify all connected editors
1113
+ broadcast('project-changed', { path: absPath, config });
1114
+
1115
+ res.json({ ok: true, config });
1116
+ });
1117
+
1118
+ // ---- /@fs/ script serving (production only) ----
1119
+ // In dev mode, Vite's middleware handles /@fs/ paths with full HMR support.
1120
+ // In production, this middleware provides the same capability: it reads files
1121
+ // from disk, transpiles TypeScript via esbuild, and rewrites @engine/ imports
1122
+ // so the browser's ES module loader can follow the dependency chain.
1123
+ if (options.serveScripts) {
1124
+ /** Resolve a module path, trying common extensions and index files. */
1125
+ async function resolveFile(fsPath: string): Promise<string | null> {
1126
+ const extensions = ['.ts', '.tsx', '.js', '.jsx'];
1127
+ // Exact path
1128
+ try {
1129
+ const s = await stat(fsPath);
1130
+ if (s.isFile()) return fsPath;
1131
+ if (s.isDirectory()) {
1132
+ for (const ext of extensions) {
1133
+ try {
1134
+ const idx = join(fsPath, `index${ext}`);
1135
+ if ((await stat(idx)).isFile()) return idx;
1136
+ } catch {
1137
+ /* continue */
1138
+ }
1139
+ }
1140
+ }
1141
+ } catch {
1142
+ /* continue */
1143
+ }
1144
+ // Try appending extensions
1145
+ for (const ext of extensions) {
1146
+ try {
1147
+ const withExt = fsPath + ext;
1148
+ if ((await stat(withExt)).isFile()) return withExt;
1149
+ } catch {
1150
+ /* continue */
1151
+ }
1152
+ }
1153
+ return null;
1154
+ }
1155
+
1156
+ router.use('/@fs', async (req: Request, res: Response, next) => {
1157
+ // req.path is the portion after /@fs, e.g. /Users/foo/project/src/main.ts
1158
+ const absPath = resolve(req.path);
1159
+
1160
+ // S5: containment with a trailing separator so a sibling directory whose
1161
+ // name merely shares a prefix (e.g. `<root>-secrets`) cannot pass.
1162
+ if (!isPathInside(projectRoot, absPath) && !isPathInside(engineRoot, absPath)) {
1163
+ res.status(403).json({ error: 'Path not allowed.' });
1164
+ return;
1165
+ }
1166
+
1167
+ const filePath = await resolveFile(absPath);
1168
+ if (!filePath) return next();
1169
+
1170
+ const ext = filePath.split('.').pop() ?? '';
1171
+
1172
+ // S5: only serve source/asset file types — never .env, keys, etc.
1173
+ if (!isServableFsExtension(ext)) {
1174
+ res.status(403).json({ error: 'File type not allowed.' });
1175
+ return;
1176
+ }
1177
+
1178
+ // B1b (packaging plan §3(b)1): rewrite `@engine/` to whatever the
1179
+ // CURRENTLY OPEN project has actually installed as `@vgai/engine`
1180
+ // (Node resolution rooted at `projectRoot`, re-read per request since
1181
+ // `projectRoot` can change live via /__editor/open-project) — never a
1182
+ // copy baked into wherever this server process's own code lives. Falls
1183
+ // back to the checkout-relative `packages/engine/src` when no project
1184
+ // is open or the project predates Phase B (no `@vgai/engine`
1185
+ // dependency installed yet) — this is what keeps in-repo dev/Electron
1186
+ // (prod.ts, no project-local install) working unchanged.
1187
+ const engineSrc =
1188
+ resolveInstalledPackageSrcDir(projectRoot, '@vgai/engine') ??
1189
+ resolve(engineRoot, 'packages/engine/src');
1190
+
1191
+ if (['ts', 'tsx', 'jsx'].includes(ext)) {
1192
+ try {
1193
+ const { transform } = await import('esbuild');
1194
+ const content = await readFile(filePath, 'utf-8');
1195
+ const result = await transform(content, {
1196
+ loader: ext as 'ts' | 'tsx' | 'jsx',
1197
+ format: 'esm',
1198
+ sourcemap: 'inline',
1199
+ target: 'es2022',
1200
+ });
1201
+ // Rewrite @engine/ bare imports to /@fs/ absolute paths
1202
+ const code = result.code.replace(
1203
+ /(from\s+['"]|import\s*\(\s*['"])@engine\//g,
1204
+ `$1/@fs/${engineSrc}/`,
1205
+ );
1206
+ res.setHeader('Content-Type', 'application/javascript');
1207
+ res.send(code);
1208
+ } catch (err) {
1209
+ res.status(500).json({ error: `Transform failed: ${err}` });
1210
+ }
1211
+ } else if (ext === 'js' || ext === 'mjs') {
1212
+ try {
1213
+ const content = await readFile(filePath, 'utf-8');
1214
+ // Rewrite @engine/ imports even in plain JS files
1215
+ const code = content.replace(
1216
+ /(from\s+['"]|import\s*\(\s*['"])@engine\//g,
1217
+ `$1/@fs/${engineSrc}/`,
1218
+ );
1219
+ res.setHeader('Content-Type', 'application/javascript');
1220
+ res.send(code);
1221
+ } catch {
1222
+ next();
1223
+ }
1224
+ } else {
1225
+ // Other files (JSON, WASM, etc.) — serve as-is
1226
+ try {
1227
+ const data = await readFile(filePath);
1228
+ res.setHeader('Content-Type', mimeTypes[ext] ?? 'application/octet-stream');
1229
+ res.send(data);
1230
+ } catch {
1231
+ next();
1232
+ }
1233
+ }
1234
+ });
1235
+ }
1236
+
1237
+ // ---- Persistent editor state (.vgai/editor-state.json) ----
1238
+ router.get('/__editor/editor-state', async (_req: Request, res: Response) => {
1239
+ if (projectRoot === engineRoot) {
1240
+ res.json({});
1241
+ return;
1242
+ }
1243
+ try {
1244
+ const raw = await readFile(join(projectRoot, '.vgai', 'editor-state.json'), 'utf-8');
1245
+ res.json(JSON.parse(raw));
1246
+ } catch {
1247
+ res.json({});
1248
+ }
1249
+ });
1250
+
1251
+ router.post('/__editor/editor-state', async (req: Request, res: Response) => {
1252
+ if (projectRoot === engineRoot) {
1253
+ res.status(400).json({ error: 'No project open.' });
1254
+ return;
1255
+ }
1256
+ try {
1257
+ const vgaiDir = join(projectRoot, '.vgai');
1258
+ await mkdir(vgaiDir, { recursive: true });
1259
+ await writeFile(
1260
+ join(vgaiDir, 'editor-state.json'),
1261
+ JSON.stringify(req.body, null, 2),
1262
+ 'utf-8',
1263
+ );
1264
+ res.json({ ok: true });
1265
+ } catch {
1266
+ res.status(500).json({ error: 'Failed to save editor state.' });
1267
+ }
1268
+ });
1269
+
1270
+ // ---- Project-root `.vgai/` file access (T6.1 slice 3 overlay unification) ----
1271
+ //
1272
+ // The editor-state/thumbnail routes above already read/write
1273
+ // `<projectRoot>/.vgai/...` directly via `node:fs` — but `StorageBackend`
1274
+ // (`HttpStorage`/`/__editor/save-file` above) is hard-rooted at
1275
+ // `<projectRoot>/public/`, so a `.vgai/overlays/<id>.json` written through
1276
+ // that seam actually lands at `<projectRoot>/public/.vgai/overlays/<id>.json`
1277
+ // (the two-`.vgai`-dirs finding recorded in BACKBONE-TASKS T3.3 close-out).
1278
+ // This route pair is the fix: project-root-relative, scoped to `.vgai/` only
1279
+ // (defense in depth — it is not a general project-file API), mirroring the
1280
+ // editor-state route's mkdir-on-write / read-or-empty shape. Manifest-era
1281
+ // world overlays (docs/MULTI-WORLD-DESIGN.md §1.G, `authoring/world-overlay.ts`)
1282
+ // unified on this seam first; `authoring/ingest-overlay.ts`'s `loadOverlay`/
1283
+ // `saveOverlay` (the T3.3 close-out remediation) now use it too, in server
1284
+ // mode — every overlay, ingest-game or manifest-world, lands under this ONE
1285
+ // project-root `.vgai/` namespace.
1286
+ router.get('/__editor/vgai-file', async (req: Request, res: Response) => {
1287
+ const relPath = (req.query['path'] as string) ?? '';
1288
+ if (!relPath || relPath.includes('..') || !relPath.startsWith('.vgai/')) {
1289
+ res.status(400).json({ error: 'Invalid path.' });
1290
+ return;
1291
+ }
1292
+ if (projectRoot === engineRoot) {
1293
+ res.status(404).json({ error: 'No project open.' });
1294
+ return;
1295
+ }
1296
+ try {
1297
+ const raw = await readFile(join(projectRoot, relPath), 'utf-8');
1298
+ res.type('text/plain').send(raw);
1299
+ } catch {
1300
+ res.status(404).json({ error: 'Not found.' });
1301
+ }
1302
+ });
1303
+
1304
+ router.post('/__editor/vgai-file', async (req: Request, res: Response) => {
1305
+ const body = req.body as { path?: string; content?: string };
1306
+ const relPath = body.path ?? '';
1307
+ if (!relPath || relPath.includes('..') || !relPath.startsWith('.vgai/')) {
1308
+ res.status(400).json({ error: 'Invalid path.' });
1309
+ return;
1310
+ }
1311
+ if (projectRoot === engineRoot) {
1312
+ res.status(400).json({ error: 'No project open.' });
1313
+ return;
1314
+ }
1315
+ try {
1316
+ const absPath = join(projectRoot, relPath);
1317
+ await mkdir(dirname(absPath), { recursive: true });
1318
+ await writeFile(absPath, body.content ?? '', 'utf-8');
1319
+ res.json({ ok: true });
1320
+ } catch {
1321
+ res.status(500).json({ error: 'Failed to write file.' });
1322
+ }
1323
+ });
1324
+
1325
+ // ---- Data assets (W2, docs/DATA-TOOLS-DESIGN.md §2.4/§2.5) ----
1326
+ //
1327
+ // The Data dock tab's server half. Discovery is the folder scan, per D3 (no
1328
+ // asset database): every `src/data/*.data.json` of the open project, plus
1329
+ // the sibling EMITTED `*.schema.json` when present — the editor consumes the
1330
+ // JSON Schema interchange artifact and never executes the project's
1331
+ // `.schema.ts` (§2.1). Raw file text is returned (not parsed JSON) so the
1332
+ // client owns parse errors and can report a malformed file instead of this
1333
+ // route 500ing on it.
1334
+ //
1335
+ // Why not `/__editor/save-file` + `/@fs/` reads: that seam is HARD-ROOTED at
1336
+ // `<projectRoot>/public/` (see `HttpStorage`'s doc comment and the
1337
+ // two-`.vgai`-dirs finding above) — data assets live under the project
1338
+ // root's `src/data/`, outside its reach. Same fix as the `vgai-file` pair:
1339
+ // a purpose-scoped route rooted at the project root, deliberately NOT a
1340
+ // general project-file API (defense in depth — the write half accepts only
1341
+ // `src/data/**/*.data.json`).
1342
+ router.get('/__editor/data-files', async (_req: Request, res: Response) => {
1343
+ if (projectRoot === engineRoot) {
1344
+ res.json({ files: [] });
1345
+ return;
1346
+ }
1347
+ const dataDir = join(projectRoot, 'src', 'data');
1348
+ const found = await findFiles(dataDir, '.data.json');
1349
+ const files: Array<{ path: string; content: string; schema: string | null }> = [];
1350
+ for (const abs of found.sort()) {
1351
+ let content: string;
1352
+ try {
1353
+ content = await readFile(abs, 'utf-8');
1354
+ } catch {
1355
+ continue; // deleted between scan and read — just omit it
1356
+ }
1357
+ // Sibling emitted schema (D4: generated + committed by default). Absent
1358
+ // (project hasn't run `npm run emit-schemas`) → null; the client shows
1359
+ // a teaching message rather than guessing widget types.
1360
+ let schema: string | null = null;
1361
+ try {
1362
+ schema = await readFile(abs.replace(/\.data\.json$/, '.schema.json'), 'utf-8');
1363
+ } catch {
1364
+ /* no emitted schema */
1365
+ }
1366
+ files.push({ path: relative(projectRoot, abs).split('\\').join('/'), content, schema });
1367
+ }
1368
+ res.json({ files });
1369
+ });
1370
+
1371
+ // D1 (spec §2.5): a Data-panel edit writes the `.data.json` IMMEDIATELY —
1372
+ // game-is-the-document; undo is git. D5 RESOLVED here as the spec
1373
+ // recommends: data writes are plain file writes and deliberately BYPASS the
1374
+ // play-mode overlay regime entirely (the overlay is scene-entity-only —
1375
+ // `.vgai/overlays/*` via `world-overlay.ts`/`ingest-overlay.ts`; nothing in
1376
+ // that path ever touches `src/data/`). A running game picks the write up
1377
+ // through the already-shipped HMR path (`dev.ts`'s `.data.json` exemption →
1378
+ // the owning module's `import.meta.hot.accept` → `DataHandle.hotSwap`).
1379
+ router.post('/__editor/data-file', async (req: Request, res: Response) => {
1380
+ const body = req.body as { path?: string; content?: string };
1381
+ const relPath = body.path ?? '';
1382
+ if (
1383
+ !relPath ||
1384
+ relPath.includes('..') ||
1385
+ !relPath.startsWith('src/data/') ||
1386
+ !relPath.endsWith('.data.json')
1387
+ ) {
1388
+ res.status(400).json({ error: 'Invalid path — expected src/data/**/*.data.json.' });
1389
+ return;
1390
+ }
1391
+ if (projectRoot === engineRoot) {
1392
+ res.status(400).json({ error: 'No project open.' });
1393
+ return;
1394
+ }
1395
+ if (typeof body.content !== 'string') {
1396
+ res.status(400).json({ error: 'Missing content.' });
1397
+ return;
1398
+ }
1399
+ // Fail loud on non-JSON before touching disk — a malformed write would
1400
+ // wedge every consumer of the file (parse-on-load is the runtime contract,
1401
+ // §2.3) and there is no buffering layer to catch it (D1).
1402
+ try {
1403
+ JSON.parse(body.content);
1404
+ } catch {
1405
+ res.status(400).json({ error: 'Content is not valid JSON.' });
1406
+ return;
1407
+ }
1408
+ try {
1409
+ const absPath = join(projectRoot, relPath);
1410
+ await writeFile(absPath, body.content, 'utf-8');
1411
+ res.json({ ok: true, path: relPath });
1412
+ } catch {
1413
+ res.status(500).json({ error: 'Failed to write file.' });
1414
+ }
1415
+ });
1416
+
1417
+ // ---- Project tools (W4, docs/DATA-TOOLS-DESIGN.md §3) ----
1418
+ //
1419
+ // Discovery for the dock's project-tool tabs: every
1420
+ // `src/tools/**/*.tool.tsx` of the open project (folder scan, same D3
1421
+ // physics as `/__editor/data-files` above — no registry, no cached
1422
+ // manifest; §3.5 rule 2). Paths only: unlike data files, tool MODULES are
1423
+ // loaded client-side through Vite's `/@fs/` dynamic-import path (the
1424
+ // `script-loader.ts` pattern), so the code arrives transpiled with HMR —
1425
+ // never as raw text through this route.
1426
+ router.get('/__editor/tool-files', async (_req: Request, res: Response) => {
1427
+ if (projectRoot === engineRoot) {
1428
+ res.json({ files: [] });
1429
+ return;
1430
+ }
1431
+ const toolsDir = join(projectRoot, 'src', 'tools');
1432
+ const found = await findFiles(toolsDir, '.tool.tsx');
1433
+ res.json({
1434
+ files: found.sort().map((abs) => ({
1435
+ path: relative(projectRoot, abs).split('\\').join('/'),
1436
+ })),
1437
+ });
1438
+ });
1439
+
1440
+ // ---- Manifest write (A4, D8 — the ONE manifest-write verb) ----
1441
+ // Unlike every other write route above, this one HARD-CODES its
1442
+ // destination: `join(projectRoot, 'vgai.game.json')`, NEVER derived from
1443
+ // `req.body`. `validateManifestWrite` (server-utils.ts, unit-tested there)
1444
+ // rejects anything else (a different filename, a traversal path, a
1445
+ // non-string/non-JSON body) before this handler ever touches the
1446
+ // filesystem — see `server-security.test.ts` for the rejection matrix.
1447
+ router.post('/__editor/manifest', async (req: Request, res: Response) => {
1448
+ const body = req.body as { path?: unknown; content?: unknown };
1449
+ const result = validateManifestWrite(projectRoot, engineRoot, body);
1450
+ if (!result.ok) {
1451
+ res.status(result.status).json({ error: result.error });
1452
+ return;
1453
+ }
1454
+ try {
1455
+ const absPath = join(projectRoot, 'vgai.game.json');
1456
+ await writeFile(absPath, result.content, 'utf-8');
1457
+ res.json({ ok: true });
1458
+ } catch {
1459
+ res.status(500).json({ error: 'Failed to write manifest.' });
1460
+ }
1461
+ });
1462
+
1463
+ // ---- Play-mode console log persistence ----
1464
+ const MAX_LOG_FILES = 20;
1465
+ let activeLogFile: string | null = null;
1466
+
1467
+ router.post('/__editor/log-session', async (req: Request, res: Response) => {
1468
+ const body = req.body as { action: 'start' | 'end' };
1469
+
1470
+ if (body.action === 'start') {
1471
+ const logsDir = join(projectRoot, 'logs');
1472
+ await mkdir(logsDir, { recursive: true });
1473
+
1474
+ // Prune old log files beyond MAX_LOG_FILES
1475
+ try {
1476
+ const files = (await readdir(logsDir))
1477
+ .filter((f) => f.startsWith('play-') && f.endsWith('.jsonl'))
1478
+ .sort();
1479
+ const toRemove = files.slice(0, Math.max(0, files.length - MAX_LOG_FILES + 1));
1480
+ for (const f of toRemove) {
1481
+ await unlink(join(logsDir, f)).catch(() => {});
1482
+ }
1483
+ } catch {
1484
+ /* logs dir may not exist yet */
1485
+ }
1486
+
1487
+ const now = new Date();
1488
+ const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1489
+ const filename = `play-${ts}.jsonl`;
1490
+ activeLogFile = join(logsDir, filename);
1491
+ res.json({ ok: true, file: filename });
1492
+ } else {
1493
+ activeLogFile = null;
1494
+ res.json({ ok: true });
1495
+ }
1496
+ });
1497
+
1498
+ router.get('/__editor/log-entries', async (_req: Request, res: Response) => {
1499
+ if (!activeLogFile) {
1500
+ res.json({ entries: [] });
1501
+ return;
1502
+ }
1503
+ try {
1504
+ const raw = await readFile(activeLogFile, 'utf-8');
1505
+ const entries = raw
1506
+ .trim()
1507
+ .split('\n')
1508
+ .filter(Boolean)
1509
+ .map((line) => JSON.parse(line));
1510
+ res.json({ entries });
1511
+ } catch {
1512
+ res.json({ entries: [] });
1513
+ }
1514
+ });
1515
+
1516
+ router.post('/__editor/log-entries', async (req: Request, res: Response) => {
1517
+ if (!activeLogFile) {
1518
+ res.status(409).json({ error: 'No active log session.' });
1519
+ return;
1520
+ }
1521
+ const body = req.body as {
1522
+ entries: Array<{
1523
+ t: number;
1524
+ level: string;
1525
+ source?: string;
1526
+ sub?: string;
1527
+ msg: string;
1528
+ meta?: Record<string, unknown>;
1529
+ }>;
1530
+ };
1531
+ const lines = body.entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
1532
+ await appendFile(activeLogFile, lines, 'utf-8');
1533
+ res.json({ ok: true });
1534
+ });
1535
+
1536
+ return router;
1537
+ }