@vgai/editor 0.2.0 → 0.4.0-canary.20260715.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.
- package/README.md +24 -0
- package/dist/assets/axe-C7YBtOuM.js +30 -0
- package/dist/assets/browserAll-DDtGGE48.js +1 -0
- package/dist/assets/create-runtime-DbTlBlKG.js +1 -0
- package/dist/assets/{game-BI111o5f.js → game-BE0r7DMI.js} +3 -3
- package/dist/assets/game-BdTHR2_X.js +1 -0
- package/dist/assets/{game-pt_1lQLv.js → game-C3iQGSqo.js} +1 -1
- package/dist/assets/game-CcHfYxjP.js +1 -0
- package/dist/assets/game-Cqzj_lpD.js +1 -0
- package/dist/assets/game-DSZdeCHu.js +1 -0
- package/dist/assets/game-Dch9sAPc.js +1 -0
- package/dist/assets/{game-xsOho_wg.js → game-SAFn90pA.js} +3 -3
- package/dist/assets/game-loop-DMIgSMV-.js +1 -0
- package/dist/assets/{game-source-CvidN7AB.js → game-source-Dk83w26t.js} +1 -1
- package/dist/assets/{index-Bp5cecD6.js → index-4idIsHHJ.js} +2 -2
- package/dist/assets/{index-DL7RcQ0n.js → index-C-LhOTBN.js} +1 -1
- package/dist/assets/{index-Cs8xwv3e.js → index-CDkxceqE.js} +1 -1
- package/dist/assets/index-CQEfi217.js +15412 -0
- package/dist/assets/index-Dvi-V0NE.css +1 -0
- package/dist/assets/ingest-siblings-s0P5I1Ub.js +1 -0
- package/dist/assets/mount-manifest-DXMOlF79.js +1 -0
- package/dist/assets/react-18-C9eHn8x1.js +1 -0
- package/dist/assets/webworkerAll-Fn0e-ouF.js +1 -0
- package/dist/assets/world-documents-GM0a9Kv7.js +1 -0
- package/dist/index.html +15 -13
- package/dist/ingest/racing-game/vgai.game.json +5 -4
- package/dist/ingest/simcity/vgai.game.json +5 -4
- package/dist/ingest/tanks/vgai.game.json +5 -4
- package/dist/project-scripts.bundle.js +3917 -0
- package/dist/scenes/untitled.vscn.json +2 -0
- package/dist-server/packaged.mjs +7697 -3435
- package/package.json +31 -4
- package/scripts/probe-reporter.ts +349 -0
- package/scripts/run-probe.ts +2260 -0
- package/server/asset-catalog-audit.ts +268 -0
- package/server/asset-catalog-v2.ts +146 -0
- package/server/asset-library-routes.ts +276 -58
- package/server/canonical-path.ts +1 -1
- package/server/dev.ts +148 -44
- package/server/editor-server.ts +888 -61
- package/server/editor-sse.ts +115 -4
- package/server/harness-chat-service.ts +796 -0
- package/server/local-asset-catalog.ts +157 -22
- package/server/packaged.ts +51 -21
- package/server/process-shutdown.ts +136 -0
- package/server/prod.ts +9 -6
- package/server/project-hmr-files.ts +41 -0
- package/server/project-operations.ts +343 -0
- package/server/project-optimize-deps-entries.ts +4 -4
- package/server/project-output-writer.ts +156 -0
- package/server/project-validation.ts +140 -0
- package/server/project-view.ts +8 -50
- package/server/server-utils.ts +75 -10
- package/server/session-registry.ts +12 -0
- package/src/EditorContext.tsx +107 -3
- package/src/action-registry.ts +115 -17
- package/src/adapter-resolver.ts +213 -478
- package/src/asset-events.ts +64 -1
- package/src/asset-preview.ts +906 -0
- package/src/asset-selection.ts +121 -0
- package/src/asset-workflow/asset-capabilities.ts +259 -0
- package/src/asset-workflow/asset-editor-errors.ts +46 -0
- package/src/asset-workflow/asset-import-pipeline.ts +176 -0
- package/src/asset-workflow/asset-library-query.ts +107 -0
- package/src/asset-workflow/asset-types.ts +192 -0
- package/src/asset-workflow/asset-workflow-quality.ts +65 -0
- package/src/asset-workflow/import-contract.ts +113 -0
- package/src/asset-workflow/model-conversion.ts +126 -0
- package/src/asset-workflow/model-inspection.ts +185 -0
- package/src/asset-workflow/project-asset-commands.ts +23 -0
- package/src/asset-workflow/project-asset-health.ts +153 -0
- package/src/asset-workflow/project-asset-operations.ts +119 -0
- package/src/asset-workflow/thumbnail-system.ts +172 -0
- package/src/authoring/active-systems.ts +15 -0
- package/src/authoring/boundary-authoring-adapter.ts +16 -5
- package/src/authoring/composite-authoring-adapter.ts +305 -124
- package/src/authoring/design-time-layers.ts +300 -117
- package/src/authoring/edit-mode-authoring.ts +278 -293
- package/src/authoring/embed-only-adapter.ts +4 -2
- package/src/authoring/first-party-hierarchy-menu.ts +7 -2
- package/src/authoring/first-party-inspector/AddSectionMenu.tsx +5 -12
- package/src/authoring/first-party-inspector/EnvironmentSection.tsx +53 -126
- package/src/authoring/first-party-inspector/HeaderSection.tsx +54 -65
- package/src/authoring/first-party-inspector/is-first-party-selection.ts +30 -11
- package/src/authoring/first-party-inspector/register.tsx +13 -2
- package/src/authoring/ingest-authoring-adapter.ts +142 -45
- package/src/authoring/mount-failure-report.ts +5 -5
- package/src/authoring/provenance.ts +41 -0
- package/src/authoring/react-dom-authoring-adapter.ts +92 -92
- package/src/authoring/react-inspector-section.tsx +489 -51
- package/src/authoring/react-world-authoring-adapter.ts +541 -306
- package/src/authoring/story-overlay.ts +1 -1
- package/src/authoring/story-types.ts +11 -0
- package/src/authoring/vgai-scene-authoring-adapter.ts +54 -25
- package/src/authoring/viewport-tool-context.ts +78 -0
- package/src/authoring/world-canvas-viewport-state.ts +35 -0
- package/src/authoring/world-hidden-viewport.ts +186 -0
- package/src/authoring/world-pan-state.ts +33 -17
- package/src/authoring/world-session-state.ts +1 -1
- package/src/authoring/world2d-authoring-adapter.ts +109 -52
- package/src/availability-tick.ts +56 -0
- package/src/browser-play-entry.ts +5 -5
- package/src/browser-project-scripts.ts +1 -0
- package/src/build-identity.ts +15 -0
- package/src/command-listener.ts +893 -19
- package/src/components/AlignToolbar.tsx +74 -63
- package/src/components/AppRoot.tsx +193 -26
- package/src/components/ApplicationMenus.tsx +272 -0
- package/src/components/AssetBrowser.tsx +1450 -293
- package/src/components/AssetEditorShell.tsx +141 -0
- package/src/components/AssetImportDetails.tsx +218 -0
- package/src/components/AssetLibraryPanel.tsx +14 -0
- package/src/components/CameraInfo.tsx +11 -20
- package/src/components/CenterDocuments.tsx +585 -0
- package/src/components/CommandPalette.tsx +75 -30
- package/src/components/ConsolePanel.css +55 -0
- package/src/components/ConsolePanel.tsx +57 -112
- package/src/components/DebugConsolePanel.tsx +461 -0
- package/src/components/DebuggerPanel.tsx +165 -0
- package/src/components/DefaultEditorLayout.tsx +167 -231
- package/src/components/GameHierarchy.tsx +873 -290
- package/src/components/GamePerformanceOverlay.tsx +46 -0
- package/src/components/HarnessChatPanel.tsx +2352 -0
- package/src/components/HistoryPanel.tsx +226 -0
- package/src/components/Inspector.tsx +153 -99
- package/src/components/InspectorToolSection.tsx +6 -4
- package/src/components/MountFailureBanner.tsx +31 -48
- package/src/components/NetworkSection.tsx +25 -14
- package/src/components/OnlineAssetBrowser.tsx +794 -343
- package/src/components/OverlayReportBanner.tsx +38 -64
- package/src/components/PerformancePanel.tsx +206 -0
- package/src/components/PlayBar.tsx +93 -263
- package/src/components/PrefabSaveDialog.tsx +55 -127
- package/src/components/ProjectHeader.css +60 -0
- package/src/components/ProjectHeader.tsx +170 -105
- package/src/components/ProjectScreen.tsx +246 -184
- package/src/components/ReactCanvasControls.tsx +185 -0
- package/src/components/ResolutionPicker.tsx +85 -0
- package/src/components/RootActivateDialog.tsx +39 -68
- package/src/components/SaveDialog.tsx +55 -127
- package/src/components/SaveStatus.tsx +11 -29
- package/src/components/StartupLoadingScreen.tsx +96 -0
- package/src/components/StateWatchPanel.tsx +302 -0
- package/src/components/StatsOverlay.tsx +12 -5
- package/src/components/StatusBar.tsx +64 -0
- package/src/components/ThemeManagerDialog.css +89 -0
- package/src/components/ThemeManagerDialog.tsx +371 -0
- package/src/components/ToolHost.tsx +26 -15
- package/src/components/Toolbar.tsx +117 -214
- package/src/components/VgaiVoxelLogo.tsx +89 -0
- package/src/components/ViewportOverlay.tsx +97 -172
- package/src/components/ViewportPanel.tsx +130 -31
- package/src/components/WorkspaceDock.tsx +891 -0
- package/src/components/WorldSelectionOverlay.tsx +51 -63
- package/src/components/WorldTextEditor.tsx +9 -5
- package/src/components/asset-documents.tsx +390 -0
- package/src/components/asset-editor-persistence.ts +44 -0
- package/src/components/asset-selection-section.tsx +338 -0
- package/src/components/asset-viewers/AudioViewer.tsx +73 -21
- package/src/components/asset-viewers/GenericJsonViewer.tsx +26 -11
- package/src/components/asset-viewers/ImageViewer.tsx +43 -11
- package/src/components/asset-viewers/InputMapViewer.tsx +512 -134
- package/src/components/asset-viewers/MaterialAssetEditor.tsx +540 -0
- package/src/components/asset-viewers/ModelViewer.tsx +805 -121
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +177 -60
- package/src/components/asset-viewers/SourceAssetViewer.tsx +121 -0
- package/src/components/asset-workflow.css +506 -0
- package/src/components/build-documents.tsx +696 -0
- package/src/components/core-utilities.tsx +75 -0
- package/src/components/data-documents.tsx +364 -0
- package/src/components/{DataPanel.tsx → data-editor.tsx} +138 -288
- package/src/components/debugger-panel-state.ts +57 -0
- package/src/components/inspector-widgets/AlignmentGrid.tsx +8 -14
- package/src/components/inspector-widgets/BorderEditor.tsx +41 -33
- package/src/components/inspector-widgets/ColorPicker.tsx +39 -39
- package/src/components/inspector-widgets/FontPicker.tsx +13 -21
- package/src/components/inspector-widgets/GradientEditor.tsx +11 -19
- package/src/components/inspector-widgets/ScrubbableInput.tsx +6 -3
- package/src/components/inspector-widgets/ShadowEditor.tsx +20 -33
- package/src/components/inspector-widgets/shared.tsx +90 -49
- package/src/components/inspectors/AnimationSection.tsx +39 -40
- package/src/components/inspectors/AudioSection.tsx +28 -39
- package/src/components/inspectors/BoneAttachmentSection.tsx +19 -24
- package/src/components/inspectors/CameraSection.tsx +14 -23
- package/src/components/inspectors/ComponentsSection.tsx +39 -43
- package/src/components/inspectors/JointsSection.tsx +34 -43
- package/src/components/inspectors/LightSection.tsx +90 -35
- package/src/components/inspectors/MaterialSection.tsx +137 -174
- package/src/components/inspectors/MeshSection.tsx +9 -17
- package/src/components/inspectors/NavigationSection.tsx +8 -15
- package/src/components/inspectors/ParticleSection.tsx +91 -76
- package/src/components/inspectors/PhysicsSection.tsx +42 -46
- package/src/components/inspectors/PostProcessingSection.tsx +28 -36
- package/src/components/inspectors/RenderingSection.tsx +12 -20
- package/src/components/inspectors/ShadowSection.tsx +7 -8
- package/src/components/inspectors/SplineSection.tsx +31 -42
- package/src/components/inspectors/TransformSection.tsx +14 -22
- package/src/components/patterns/Dialog.tsx +95 -0
- package/src/components/patterns/Fields.tsx +44 -0
- package/src/components/patterns/List.tsx +25 -0
- package/src/components/patterns/StateSurface.tsx +40 -0
- package/src/components/patterns/Surfaces.tsx +115 -0
- package/src/components/patterns/Tabs.tsx +80 -0
- package/src/components/patterns/Toolbar.tsx +51 -0
- package/src/components/patterns/Tree.tsx +72 -0
- package/src/components/primitives/Button.tsx +46 -0
- package/src/components/primitives/ColorInput.tsx +11 -47
- package/src/components/primitives/EditorIcon.tsx +99 -0
- package/src/components/primitives/FormControls.tsx +71 -0
- package/src/components/primitives/JsonInput.tsx +15 -2
- package/src/components/primitives/Layout.tsx +100 -0
- package/src/components/primitives/Menu.tsx +105 -0
- package/src/components/primitives/NumberInput.tsx +8 -35
- package/src/components/primitives/Panel.tsx +49 -44
- package/src/components/primitives/SectionHeader.tsx +10 -28
- package/src/components/primitives/Text.tsx +46 -0
- package/src/components/primitives/Tooltip.tsx +20 -12
- package/src/components/primitives/Vec3Input.tsx +3 -0
- package/src/components/primitives/banner-tones.ts +33 -0
- package/src/components/primitives/editor-icons.ts +102 -0
- package/src/components/primitives/panel-header-styles.ts +30 -0
- package/src/components/project-operation-documents.tsx +226 -0
- package/src/components/status-contributions.tsx +175 -0
- package/src/components/story-documents-register.ts +15 -0
- package/src/components/story-documents.tsx +1185 -0
- package/src/components/tool-documents.tsx +110 -0
- package/src/components/workspace-dock.css +293 -0
- package/src/components/workspace-history.ts +33 -0
- package/src/components/world-documents.tsx +145 -0
- package/src/components/xstate-documents.tsx +209 -0
- package/src/composite-screenshot.ts +248 -0
- package/src/design-system/index.ts +70 -0
- package/src/design-system-stories/Foundation.stories.tsx +183 -0
- package/src/design-system-stories/Patterns.stories.tsx +251 -0
- package/src/design-system-stories/Workspace.stories.tsx +538 -0
- package/src/doctor/report.ts +57 -13
- package/src/editor-api.ts +295 -31
- package/src/editor-compatibility.ts +200 -0
- package/src/editor-console.ts +35 -17
- package/src/editor-hotkeys.ts +85 -33
- package/src/editor-store.ts +538 -233
- package/src/editor-viewport.ts +160 -34
- package/src/entity-factory.ts +14 -3
- package/src/gizmo-registry.ts +21 -5
- package/src/harness-chat-editor-context.ts +37 -0
- package/src/harness-chat-indicator.ts +68 -0
- package/src/harness-chat-layout.ts +53 -0
- package/src/harness-chat-lifecycle.ts +170 -0
- package/src/harness-chat-reconnect.ts +85 -0
- package/src/harness-chat-types.ts +409 -0
- package/src/hierarchy-drop.ts +50 -0
- package/src/hierarchy-expansion-state.ts +100 -0
- package/src/hierarchy-menu-registry.ts +1 -1
- package/src/hierarchy-projection.ts +76 -0
- package/src/history/editor-session.ts +21 -0
- package/src/history/history-commands.ts +147 -0
- package/src/history/history-service.ts +904 -0
- package/src/history/json-history-resource.ts +165 -0
- package/src/history/persistence-coordinator.ts +35 -0
- package/src/history/project-file-history.ts +266 -0
- package/src/history/project-root-history-backends.ts +53 -0
- package/src/history/resource-registry.ts +209 -0
- package/src/history/snapshot-store.ts +108 -0
- package/src/history/source-history-backend.ts +374 -0
- package/src/history/types.ts +100 -0
- package/src/hmr-registration-group.ts +67 -0
- package/src/hmr-stable-react-context.ts +23 -0
- package/src/hosted-example.ts +12 -2
- package/src/hotkeys.ts +89 -18
- package/src/ingest/discovery-react.ts +9 -10
- package/src/ingest/discovery.ts +4 -4
- package/src/ingest/discovery2d.ts +5 -5
- package/src/ingest/games/bloom-composer/vgai.game.json +3 -3
- package/src/ingest/games/embed-bundle/vgai.game.json +3 -3
- package/src/ingest/games/games-fps/vgai.game.json +3 -3
- package/src/ingest/games/iframe-reachable/vgai.game.json +3 -3
- package/src/ingest/games/interactive-cubes/vgai.game.json +3 -3
- package/src/ingest/games/mismatched-three/vgai.game.json +3 -3
- package/src/ingest/games/own-three-bundled/vgai.game.json +3 -3
- package/src/ingest/games/pointerlock-maze/vgai.game.json +7 -4
- package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +3 -3
- package/src/ingest/games-2d/bunnymark/vgai.game.json +3 -3
- package/src/ingest/games-2d/flappy/vgai.game.json +3 -3
- package/src/ingest/games-2d/flappy-pixi/vgai.game.json +3 -3
- package/src/ingest/games-2d/puzzling-potions/vgai.game.json +3 -3
- package/src/ingest/games-2d/tilemap-camera/vgai.game.json +3 -3
- package/src/ingest/games-react/react-rpg/vgai.game.json +3 -3
- package/src/ingest/types.ts +1 -1
- package/src/ingest-mode.ts +47 -52
- package/src/ingest-siblings.ts +23 -33
- package/src/initial-scene.ts +1 -2
- package/src/main.tsx +16 -8
- package/src/manifest-project.ts +10 -12
- package/src/model-thumbnail.ts +261 -26
- package/src/module-mode.ts +44 -50
- package/src/play-log-events.ts +26 -0
- package/src/play-mode.ts +495 -401
- package/src/play-scene-adoption.ts +23 -0
- package/src/playground/main.ts +68 -24
- package/src/playwright-shim.ts +473 -0
- package/src/project-manager.ts +90 -10
- package/src/project-operations.ts +112 -0
- package/src/project-story-discovery.ts +25 -0
- package/src/project-tool-discovery.ts +43 -0
- package/src/scene-serializer.ts +0 -6
- package/src/scene-sync.ts +7 -2
- package/src/script-loader.ts +3 -3
- package/src/skeleton-helper.ts +57 -0
- package/src/storage/seed.ts +68 -8
- package/src/stories/StoryPreviewMount.tsx +265 -0
- package/src/stories/action-stories.fixture.tsx +44 -0
- package/src/stories/button-stories.fixture.tsx +50 -0
- package/src/stories/compose-project-stories.ts +182 -0
- package/src/stories/loader-stories.fixture.tsx +53 -0
- package/src/stories/story-discovery.ts +130 -0
- package/src/stories/story-registry.ts +146 -0
- package/src/theme-css.ts +1049 -0
- package/src/theme-library.ts +238 -0
- package/src/theme-preference.ts +130 -0
- package/src/theme.ts +527 -0
- package/src/tool-loader.ts +80 -24
- package/src/ui-source/inspect.ts +27 -4
- package/src/ui-source/source-write-backend.ts +83 -22
- package/src/widgets/index.ts +9 -18
- package/src/workspace-aux-commands.ts +11 -0
- package/src/workspace-dock-controller.ts +64 -0
- package/src/workspace-document-ids.ts +5 -0
- package/src/workspace-document-registry.ts +367 -0
- package/src/workspace-state-persistence.ts +455 -0
- package/src/workspace-status-registry.ts +110 -0
- package/src/workspace-utility-commands.ts +49 -0
- package/src/workspace-utility-registry.ts +161 -0
- package/src/world-document-routing.ts +16 -0
- package/src/xstate-inspect/XStateMachineInspector.tsx +544 -0
- package/src/xstate-inspect/character-animation-machine.fixture.ts +74 -0
- package/src/xstate-inspect/use-live-actor-state.ts +39 -0
- package/src/xstate-inspect/xstate-graph.ts +265 -0
- package/src/xstate-inspect/xstate-layout.ts +76 -0
- package/src/z-index.ts +26 -0
- package/template/.claude/skills/editor/SKILL.md +129 -90
- package/template/AGENTS.md +1110 -0
- package/template/CLAUDE.md +1 -512
- package/template/IDIOMS.md +50 -0
- package/template/check-idioms.ts +706 -0
- package/template/manifest-entry-modules-plugin.ts +53 -0
- package/template/package.json +13 -4
- package/template/public/scenes/default.scn2d.json +6 -0
- package/template/public/scenes/default.vscn.json +7 -1
- package/template/public/scenes/untitled.vscn.json +10 -0
- package/template/scripts/playtest.ts +40 -0
- package/template/server/loopback.ts +75 -0
- package/template/server/rooms/arena-room.ts +69 -1
- package/template/server/rooms/game-room.ts +68 -1
- package/template/src/data/README.md +9 -4
- package/template/src/data/data-ref.ts +35 -0
- package/template/src/data/tuning.schema.ts +1 -1
- package/template/src/main.ts +41 -62
- package/template/src/manifest-entry-modules.d.ts +3 -0
- package/template/src/operations/README.md +88 -0
- package/template/src/runtime/pixi-adapter.ts +24 -0
- package/template/src/scripts/components/data-spinner.ts +43 -2
- package/template/src/scripts/components/spin-lap-math.ts +27 -0
- package/template/src/tools/README.md +5 -3
- package/template/src/tools/example.tool.tsx +13 -7
- package/template/src/ui/Button.stories.tsx +66 -0
- package/template/src/ui/RebindPanel.tsx +2 -6
- package/template/src/ui/game.tsx +17 -0
- package/template/tests/acceptance/example.spec.ts +64 -0
- package/template/tests/logic/example.test.ts +31 -0
- package/template/tests/logic/room.test.ts +100 -0
- package/template/tests/tsconfig.json +30 -0
- package/template/validate-manifest.ts +5 -5
- package/template/validate-scenes.ts +50 -18
- package/template/vgai.game.json +20 -13
- package/template/vite.config.ts +77 -16
- package/vite-plugin-ui-oid.ts +261 -17
- package/dist/assets/CCDIKSolver-Dz-ysxZF.js +0 -1
- package/dist/assets/SelectionBox-BXsv_6aH.js +0 -1
- package/dist/assets/browser-project-scripts-DOru4L1x.js +0 -2
- package/dist/assets/browserAll-CEkWYlFk.js +0 -1
- package/dist/assets/create-runtime-DYIWr3va.js +0 -1
- package/dist/assets/data-asset-Bb9sIM7i.js +0 -5
- package/dist/assets/game-8SDZPPgH.js +0 -1
- package/dist/assets/game-CevorxyV.js +0 -1
- package/dist/assets/game-Di7XlywD.js +0 -1
- package/dist/assets/game-DjnCaw8R.js +0 -1
- package/dist/assets/game-Q9A0aXSV.js +0 -1
- package/dist/assets/index-CPVV6pjw.js +0 -1
- package/dist/assets/index-CpOnYyeD.js +0 -213
- package/dist/assets/index-Dr8H_6Rl.js +0 -1
- package/dist/assets/index-_bCaipUk.js +0 -13650
- package/dist/assets/ingest-siblings-Dz8eEqrV.js +0 -1
- package/dist/assets/mount-manifest-Bhk0ohsl.js +0 -1
- package/dist/assets/scene-query-BivAHhJu.js +0 -1
- package/dist/assets/webworkerAll-BLrH0F6F.js +0 -1
- package/dist/assets/xstate-animation-binding-1NGLmw6y.js +0 -1
- package/dist/assets/xstate.esm-BgUD33nl.js +0 -3
- package/src/authoring/scene-ui-layer.ts +0 -485
- package/src/authoring/ui-authoring-adapter.ts +0 -325
- package/src/authoring/ui-hierarchy-menu.ts +0 -40
- package/src/authoring/ui-inspector-sections.tsx +0 -156
- package/src/authoring/undo-timeline.ts +0 -145
- package/src/components/BottomPanel.tsx +0 -326
- package/src/components/BuildPanel.tsx +0 -455
- package/src/components/FolderTree.tsx +0 -179
- package/src/components/RightPanel.tsx +0 -166
- package/src/components/WorldContextMenu.tsx +0 -257
- package/src/components/asset-viewers/AssetViewer.tsx +0 -38
- package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +0 -129
- package/src/scene-ui-mode/active-scene-ui.ts +0 -58
- package/src/scene-ui-mode/scene-ui-palette-panel.tsx +0 -152
- package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +0 -29
- package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +0 -74
- package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +0 -49
- package/src/scene-ui-mode/ui-prefab-io.ts +0 -76
- package/src/ui-editor/inspector.tsx +0 -563
- package/src/ui-editor/node-ops.ts +0 -266
- package/src/ui-editor/overlay.tsx +0 -380
- package/src/ui-editor/palette.tsx +0 -114
- package/src/ui-editor/react-store.ts +0 -78
- package/src/ui-editor/timeline-panel.tsx +0 -443
- package/src/ui-editor/ui-edit-store.ts +0 -552
- package/src/ui-editor/ui-prefab.ts +0 -95
- package/template/project.json +0 -9
- package/template/src/scripts/main.ts +0 -94
- package/template/src/ui/DefaultHud.tsx +0 -40
- package/template/src/ui/game-state.tsx +0 -37
- package/template/src/ui/mount-default-hud.tsx +0 -19
- package/template/src/ui/mount-ui.ts +0 -15
- package/vite-plugin-react-world-provider.ts +0 -81
package/server/editor-server.ts
CHANGED
|
@@ -7,19 +7,43 @@
|
|
|
7
7
|
|
|
8
8
|
import { type ChildProcess, execFile, spawn } from 'node:child_process';
|
|
9
9
|
import { createHash, randomUUID } from 'node:crypto';
|
|
10
|
-
import { existsSync } from 'node:fs';
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
11
|
import { appendFile, mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
|
|
12
|
+
import type { ServerResponse } from 'node:http';
|
|
12
13
|
import { homedir, platform } from 'node:os';
|
|
13
|
-
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
14
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
14
15
|
import chokidar, { type FSWatcher } from 'chokidar';
|
|
15
16
|
import { type ScaffoldTemplate, scaffoldProject } from 'create-vgai-project';
|
|
16
17
|
import type { Request, Response, Router } from 'express';
|
|
17
18
|
import express from 'express';
|
|
19
|
+
import { GAME_MANIFEST_VERSION } from '../../engine/src/manifest/schema';
|
|
18
20
|
import { applyAssetRename } from '../../engine/src/scene/asset-paths';
|
|
19
21
|
import type { SceneFile } from '../../engine/src/scene/scene-types';
|
|
22
|
+
import {
|
|
23
|
+
assertEditorCompatibility,
|
|
24
|
+
assertProjectCompatibility,
|
|
25
|
+
type EditorServerCompatibility,
|
|
26
|
+
ProjectCompatibilityError,
|
|
27
|
+
} from '../src/editor-compatibility';
|
|
28
|
+
import { parseHarnessChatAction } from '../src/harness-chat-types';
|
|
20
29
|
import { createAssetLibraryRouter } from './asset-library-routes';
|
|
21
30
|
import { canonicalProjectRoot } from './canonical-path';
|
|
22
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
addClient,
|
|
33
|
+
broadcast,
|
|
34
|
+
clientCount,
|
|
35
|
+
controllerClientId,
|
|
36
|
+
removeClient,
|
|
37
|
+
sendToController,
|
|
38
|
+
updateClientControlHealth,
|
|
39
|
+
} from './editor-sse';
|
|
40
|
+
import { HarnessChatService } from './harness-chat-service';
|
|
41
|
+
import {
|
|
42
|
+
discoverProjectOperations,
|
|
43
|
+
executeProjectOperation,
|
|
44
|
+
type ProjectModuleLoader,
|
|
45
|
+
} from './project-operations';
|
|
46
|
+
import { classifyValidatableFile, validateProjectFile } from './project-validation';
|
|
23
47
|
import { readProjectView } from './project-view';
|
|
24
48
|
import type { CommandResult } from './server-utils';
|
|
25
49
|
import {
|
|
@@ -33,6 +57,7 @@ import {
|
|
|
33
57
|
isServableFsExtension,
|
|
34
58
|
PACKAGED_NO_MONOREPO_TEMPLATE_ERROR,
|
|
35
59
|
resolveInstalledPackageSrcDir,
|
|
60
|
+
resolveWatcherPollOptions,
|
|
36
61
|
sanitizeContentDispositionFilename,
|
|
37
62
|
validateManifestWrite,
|
|
38
63
|
} from './server-utils';
|
|
@@ -64,8 +89,8 @@ async function loadRecentProjects(): Promise<RecentProject[]> {
|
|
|
64
89
|
// Filter out projects whose paths no longer exist, populate thumbnails
|
|
65
90
|
const valid: RecentProject[] = [];
|
|
66
91
|
for (const p of projects) {
|
|
67
|
-
//
|
|
68
|
-
//
|
|
92
|
+
// A folder with a valid v2 manifest is a project; readProjectView is
|
|
93
|
+
// the one place this rule lives.
|
|
69
94
|
const view = await readProjectView(p.path);
|
|
70
95
|
if (!view) continue; // Path no longer exists / neither file describes it — skip
|
|
71
96
|
// Check for thumbnail
|
|
@@ -114,6 +139,28 @@ export interface EditorServerOptions {
|
|
|
114
139
|
serveScripts?: boolean;
|
|
115
140
|
/** Called when a project is opened at runtime — allows the dev server to update Vite's fs.allow. */
|
|
116
141
|
onProjectOpened?: ((projectPath: string) => void) | undefined;
|
|
142
|
+
/** Dev checkout only: mark this long-running process stale when server-loaded source changes. */
|
|
143
|
+
watchEngineSource?: boolean | undefined;
|
|
144
|
+
/** Node-side project-module loader. Vite's ssrLoadModule supplies this in
|
|
145
|
+
* dev and packaged editors so discoverable project operations can remain
|
|
146
|
+
* ordinary TypeScript using the project's own dependency graph. */
|
|
147
|
+
loadProjectModule?: ProjectModuleLoader | undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readRunningEngineVersion(engineRoot: string): string | null {
|
|
151
|
+
const candidates = [
|
|
152
|
+
join(engineRoot, 'packages', 'engine', 'package.json'),
|
|
153
|
+
join(engineRoot, 'node_modules', '@vgai', 'engine', 'package.json'),
|
|
154
|
+
];
|
|
155
|
+
for (const path of candidates) {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { version?: unknown };
|
|
158
|
+
if (typeof parsed.version === 'string') return parsed.version;
|
|
159
|
+
} catch {
|
|
160
|
+
// Try the next supported checkout/installed-package layout.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
117
164
|
}
|
|
118
165
|
|
|
119
166
|
// ---------------------------------------------------------------------------
|
|
@@ -158,6 +205,14 @@ async function findFiles(dir: string, ext: string): Promise<string[]> {
|
|
|
158
205
|
return results;
|
|
159
206
|
}
|
|
160
207
|
|
|
208
|
+
async function unlinkIfPresent(path: string): Promise<void> {
|
|
209
|
+
try {
|
|
210
|
+
await unlink(path);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
161
216
|
async function scanDirForHashes(
|
|
162
217
|
dir: string,
|
|
163
218
|
hashCache: Map<string, string>,
|
|
@@ -209,12 +264,74 @@ const mimeTypes: Record<string, string> = {
|
|
|
209
264
|
// Factory
|
|
210
265
|
// ---------------------------------------------------------------------------
|
|
211
266
|
|
|
212
|
-
export
|
|
267
|
+
export type EditorServerRouter = Router & { close(): Promise<void> };
|
|
268
|
+
|
|
269
|
+
export function createEditorServer(options: EditorServerOptions): EditorServerRouter {
|
|
213
270
|
const { engineRoot } = options;
|
|
271
|
+
const serverStartedAt = new Date().toISOString();
|
|
272
|
+
const runningEngineVersion = readRunningEngineVersion(engineRoot);
|
|
273
|
+
let staleSource: { changedPath: string; changedAt: string } | null = null;
|
|
274
|
+
let engineSourceWatcher: FSWatcher | null = null;
|
|
275
|
+
|
|
276
|
+
if (options.watchEngineSource) {
|
|
277
|
+
const watchedPaths = [
|
|
278
|
+
join(engineRoot, 'packages', 'editor', 'server'),
|
|
279
|
+
join(engineRoot, 'packages', 'engine', 'src'),
|
|
280
|
+
join(engineRoot, 'packages', 'create-vgai-project', 'src'),
|
|
281
|
+
join(engineRoot, 'vite.config.ts'),
|
|
282
|
+
join(engineRoot, 'package.json'),
|
|
283
|
+
join(engineRoot, 'package-lock.json'),
|
|
284
|
+
join(engineRoot, 'tsconfig.json'),
|
|
285
|
+
join(engineRoot, 'tsconfig.server.json'),
|
|
286
|
+
join(engineRoot, 'packages', 'engine', 'package.json'),
|
|
287
|
+
join(engineRoot, 'packages', 'editor', 'package.json'),
|
|
288
|
+
].filter(existsSync);
|
|
289
|
+
engineSourceWatcher = chokidar.watch(watchedPaths, {
|
|
290
|
+
ignoreInitial: true,
|
|
291
|
+
ignored: (watchedPath) => {
|
|
292
|
+
const name = basename(watchedPath);
|
|
293
|
+
return name.startsWith('.') || name.endsWith('~') || /\.(?:swp|swo|tmp|temp)$/i.test(name);
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
engineSourceWatcher.on('all', (_event, changedPath: string) => {
|
|
297
|
+
staleSource ??= {
|
|
298
|
+
changedPath: relative(engineRoot, resolve(changedPath)).split(sep).join('/'),
|
|
299
|
+
changedAt: new Date().toISOString(),
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
}
|
|
214
303
|
|
|
215
304
|
// Mutable project state — updated by open-project endpoint
|
|
216
305
|
let projectRoot = options.projectPath ?? engineRoot;
|
|
217
306
|
let publicRoot = resolve(projectRoot, 'public');
|
|
307
|
+
const harnessChat = new HarnessChatService({
|
|
308
|
+
engineRoot,
|
|
309
|
+
getProjectRoot: () => projectRoot,
|
|
310
|
+
onChange: (snapshot) => broadcast('harness-chat', snapshot),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const compatibilityIdentity = (): EditorServerCompatibility => ({
|
|
314
|
+
apiVersion: 1,
|
|
315
|
+
engineVersion: runningEngineVersion,
|
|
316
|
+
manifestVersion: GAME_MANIFEST_VERSION,
|
|
317
|
+
startedAt: serverStartedAt,
|
|
318
|
+
source: staleSource ? { state: 'restart-required', ...staleSource } : { state: 'current' },
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// #run2-cold-vite: last time this server served the editor's own INDEX
|
|
322
|
+
// page (as opposed to an API/SSE/asset request) to a browser. Detected by
|
|
323
|
+
// response Content-Type rather than by route/path, because the index page
|
|
324
|
+
// is served by three different mechanisms depending on mode (Vite's dev
|
|
325
|
+
// middleware at `/` in dev.ts, `express.static` + SPA fallback in
|
|
326
|
+
// prod.ts, the packaged server's own fallback) and none of those routes
|
|
327
|
+
// are owned by this file. Exposed on `/__editor/state` so the CLI's
|
|
328
|
+
// verified-open polling (`waitForVerifiedEditorOpen` in
|
|
329
|
+
// packages/vgai-cli/src/editor-sessions.ts) can tell "the browser tab
|
|
330
|
+
// arrived and is loading" apart from "the auto-open never reached a
|
|
331
|
+
// browser at all" — the distinction a cold Vite dep-optimize on drvfs
|
|
332
|
+
// needs, since that alone can blow past the old flat 15s timeout on a
|
|
333
|
+
// project's very first page load.
|
|
334
|
+
let lastIndexRequestAt: number | null = null;
|
|
218
335
|
|
|
219
336
|
// ---- File hash cache + move detection ----
|
|
220
337
|
const fileHashCache = new Map<string, string>();
|
|
@@ -277,6 +394,7 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
277
394
|
// ---- Chokidar watcher ----
|
|
278
395
|
let watcher: FSWatcher | null = null;
|
|
279
396
|
let srcWatcher: FSWatcher | null = null;
|
|
397
|
+
let manifestWatcher: FSWatcher | null = null;
|
|
280
398
|
|
|
281
399
|
// Coalesce event bursts (e.g. a git checkout adding several files at once)
|
|
282
400
|
// into one broadcast per event name, trailing ~250ms after the last event.
|
|
@@ -293,20 +411,165 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
293
411
|
);
|
|
294
412
|
}
|
|
295
413
|
|
|
414
|
+
// ---- Validate-on-change (owner decision, GitHub #103) ----
|
|
415
|
+
//
|
|
416
|
+
// Hand-editing project files is an accepted authoring path — the
|
|
417
|
+
// requirement is that invalid output surfaces IMMEDIATELY and OBVIOUSLY
|
|
418
|
+
// instead of only erroring at next load. `project-validation.ts` owns the
|
|
419
|
+
// content check (reusing the engine's own Zod parsers, same ones
|
|
420
|
+
// validate-scenes.ts/validate-manifest.ts run); this section owns
|
|
421
|
+
// debouncing + the three report surfaces: the terminal, connected editor
|
|
422
|
+
// tabs (the existing `server-log` SSE event `editor-console.ts` already
|
|
423
|
+
// listens for — previously unused, since nothing ever broadcast it), and
|
|
424
|
+
// `/__editor/state`'s `projectValidation` field.
|
|
425
|
+
//
|
|
426
|
+
// A file is present in `projectValidation` ONLY while it is currently
|
|
427
|
+
// failing — a valid write removes its entry entirely ("ok" is represented
|
|
428
|
+
// by absence, not a stored value), which is what keeps the happy path
|
|
429
|
+
// silent both on the wire and in the terminal (rule 4: editor-originated
|
|
430
|
+
// autosaves validate clean by construction and must produce zero noise).
|
|
431
|
+
interface FileValidationFailure {
|
|
432
|
+
errors: string[];
|
|
433
|
+
at: number;
|
|
434
|
+
}
|
|
435
|
+
const projectValidation = new Map<string, FileValidationFailure>();
|
|
436
|
+
const validationDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
437
|
+
const VALIDATION_DEBOUNCE_MS = 150;
|
|
438
|
+
const MAX_PRINTED_ERRORS = 5;
|
|
439
|
+
|
|
440
|
+
/** Project-relative, forward-slash path — the key `projectValidation` and
|
|
441
|
+
* the terminal/SSE messages use. */
|
|
442
|
+
function relKey(absPath: string): string {
|
|
443
|
+
return relative(projectRoot, absPath).split(sep).join('/');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function printValidationFailure(rel: string, errors: string[]): void {
|
|
447
|
+
const shown = errors.slice(0, MAX_PRINTED_ERRORS);
|
|
448
|
+
const rest = errors.length - shown.length;
|
|
449
|
+
console.error(
|
|
450
|
+
`\n \x1b[31m\x1b[1m✖ Invalid project file\x1b[0m \x1b[36m${rel}\x1b[0m\n` +
|
|
451
|
+
shown.map((e) => ` \x1b[31m•\x1b[0m ${e}`).join('\n') +
|
|
452
|
+
(rest > 0 ? `\n … and ${rest} more issue(s)` : '') +
|
|
453
|
+
'\n',
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function printValidationRecovery(rel: string): void {
|
|
458
|
+
console.log(` \x1b[32m✓ ${rel} is valid again\x1b[0m`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function validationMessage(rel: string, errors: string[]): string {
|
|
462
|
+
const shown = errors.slice(0, MAX_PRINTED_ERRORS);
|
|
463
|
+
const rest = errors.length - shown.length;
|
|
464
|
+
return (
|
|
465
|
+
`Invalid project file ${rel}:\n` +
|
|
466
|
+
shown.map((e) => ` - ${e}`).join('\n') +
|
|
467
|
+
(rest > 0 ? `\n … and ${rest} more issue(s)` : '')
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Run (already-debounced) validation for one absolute path and report the
|
|
472
|
+
* result on all three surfaces. Non-validatable paths are a silent no-op
|
|
473
|
+
* so callers can invoke this unconditionally from watcher events. */
|
|
474
|
+
async function runFileValidation(absPath: string): Promise<void> {
|
|
475
|
+
const kind = classifyValidatableFile(absPath);
|
|
476
|
+
if (!kind) return;
|
|
477
|
+
const rel = relKey(absPath);
|
|
478
|
+
const result = await validateProjectFile(absPath, kind);
|
|
479
|
+
const wasFailing = projectValidation.has(rel);
|
|
480
|
+
|
|
481
|
+
if (result.ok) {
|
|
482
|
+
if (wasFailing) {
|
|
483
|
+
projectValidation.delete(rel);
|
|
484
|
+
printValidationRecovery(rel);
|
|
485
|
+
broadcast('server-log', { level: 'info', message: `${rel} is valid again` });
|
|
486
|
+
}
|
|
487
|
+
// Clean write, never previously failing — the happy-path silence rule:
|
|
488
|
+
// no terminal line, no broadcast, no state entry.
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
projectValidation.set(rel, { errors: result.errors, at: Date.now() });
|
|
493
|
+
printValidationFailure(rel, result.errors);
|
|
494
|
+
broadcast('server-log', { level: 'error', message: validationMessage(rel, result.errors) });
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Debounce per absolute file path (~150ms) so a burst of writes to the
|
|
498
|
+
* same file (editors/OSes routinely write-then-touch, or write in two
|
|
499
|
+
* chunks) triggers exactly one validation pass. */
|
|
500
|
+
function scheduleFileValidation(absPath: string): void {
|
|
501
|
+
if (!classifyValidatableFile(absPath)) return;
|
|
502
|
+
const existing = validationDebounceTimers.get(absPath);
|
|
503
|
+
if (existing) clearTimeout(existing);
|
|
504
|
+
validationDebounceTimers.set(
|
|
505
|
+
absPath,
|
|
506
|
+
setTimeout(() => {
|
|
507
|
+
validationDebounceTimers.delete(absPath);
|
|
508
|
+
void runFileValidation(absPath);
|
|
509
|
+
}, VALIDATION_DEBOUNCE_MS),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** A deleted file can no longer be "invalid" — drop any stale entry/timer
|
|
514
|
+
* so `projectValidation` doesn't keep reporting a ghost error. */
|
|
515
|
+
function clearFileValidation(absPath: string): void {
|
|
516
|
+
const timer = validationDebounceTimers.get(absPath);
|
|
517
|
+
if (timer) {
|
|
518
|
+
clearTimeout(timer);
|
|
519
|
+
validationDebounceTimers.delete(absPath);
|
|
520
|
+
}
|
|
521
|
+
projectValidation.delete(relKey(absPath));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function validateSourceTree(dir: string): Promise<void> {
|
|
525
|
+
let entries;
|
|
526
|
+
try {
|
|
527
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
528
|
+
} catch {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
await Promise.all(
|
|
532
|
+
entries
|
|
533
|
+
.filter((entry) => !entry.name.startsWith('.'))
|
|
534
|
+
.map(async (entry) => {
|
|
535
|
+
const full = join(dir, entry.name);
|
|
536
|
+
if (entry.isDirectory()) return validateSourceTree(full);
|
|
537
|
+
if (entry.isFile()) return runFileValidation(full);
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
296
542
|
function startWatcher(): void {
|
|
297
543
|
watcher?.close();
|
|
298
544
|
srcWatcher?.close();
|
|
545
|
+
manifestWatcher?.close();
|
|
299
546
|
srcWatcher = null;
|
|
547
|
+
manifestWatcher = null;
|
|
300
548
|
fileHashCache.clear();
|
|
301
549
|
fileSizeCache.clear();
|
|
302
550
|
pendingUnlinks.length = 0;
|
|
551
|
+
// Validation state is project-scoped — a previous project's failures
|
|
552
|
+
// must not leak into (or block reporting for) the newly opened one.
|
|
553
|
+
for (const timer of validationDebounceTimers.values()) clearTimeout(timer);
|
|
554
|
+
validationDebounceTimers.clear();
|
|
555
|
+
projectValidation.clear();
|
|
303
556
|
|
|
304
557
|
// Seed hash cache in background
|
|
305
558
|
scanDirForHashes(publicRoot, fileHashCache, fileSizeCache);
|
|
306
559
|
|
|
560
|
+
// #131: on drvfs (WSL /mnt/<drive>) inotify never fires, so without
|
|
561
|
+
// polling every broadcast below is dead and the editor UI sits stale
|
|
562
|
+
// until a manual refresh. Auto-detection keys off the PROJECT root and is
|
|
563
|
+
// skipped in engine-repo mode (projectRoot === engineRoot), where
|
|
564
|
+
// `public/` includes the vendored-game trees — `VGAI_WATCH_POLL` still
|
|
565
|
+
// forces it there. Re-derived on every startWatcher() call so a project
|
|
566
|
+
// switch onto/off a drvfs mount re-decides correctly.
|
|
567
|
+
const pollOptions = resolveWatcherPollOptions(projectRoot === engineRoot ? null : projectRoot);
|
|
568
|
+
|
|
307
569
|
watcher = chokidar.watch(publicRoot, {
|
|
308
570
|
ignoreInitial: true,
|
|
309
571
|
ignored: /(^|[/\\])\../, // ignore dotfiles
|
|
572
|
+
...pollOptions,
|
|
310
573
|
});
|
|
311
574
|
|
|
312
575
|
watcher.on('change', async (filePath: string) => {
|
|
@@ -330,11 +593,17 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
330
593
|
/* file may have been deleted between events */
|
|
331
594
|
}
|
|
332
595
|
}
|
|
596
|
+
|
|
597
|
+
// Validate-on-change (#103): a hand-edited (or autosaved) .vscn.json/
|
|
598
|
+
// .prefab.json content change gets checked against the engine's own
|
|
599
|
+
// Zod parsers — see the "Validate-on-change" section above.
|
|
600
|
+
scheduleFileValidation(abs);
|
|
333
601
|
});
|
|
334
602
|
|
|
335
603
|
watcher.on('unlink', (filePath: string) => {
|
|
336
604
|
const abs = resolve(filePath);
|
|
337
605
|
if (!isPathInside(publicRoot, abs)) return;
|
|
606
|
+
clearFileValidation(abs);
|
|
338
607
|
const hash = fileHashCache.get(abs);
|
|
339
608
|
const size = fileSizeCache.get(abs);
|
|
340
609
|
if (hash !== undefined && size !== undefined) {
|
|
@@ -348,6 +617,9 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
348
617
|
watcher.on('add', async (filePath: string) => {
|
|
349
618
|
const abs = resolve(filePath);
|
|
350
619
|
if (!isPathInside(publicRoot, abs)) return;
|
|
620
|
+
// A brand-new scene/prefab file (not just an edit of an existing one)
|
|
621
|
+
// deserves the same immediate check.
|
|
622
|
+
scheduleFileValidation(abs);
|
|
351
623
|
|
|
352
624
|
try {
|
|
353
625
|
const { hash, size } = await hashAndSize(abs);
|
|
@@ -382,30 +654,64 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
382
654
|
// packaged/prod (this module is shared by both), and on drvfs
|
|
383
655
|
// (`/mnt/c`, no inotify) via the same `VGAI_WATCH_POLL` env var.
|
|
384
656
|
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
// into a refetch, clobbering in-flight drafts.
|
|
657
|
+
// Tool/data/story lists use add/unlink. Operation modules also use change:
|
|
658
|
+
// they are Node-loaded rather than browser-HMR-loaded, so metadata and
|
|
659
|
+
// implementation edits require a fresh catalog request.
|
|
389
660
|
const srcDir = join(projectRoot, 'src');
|
|
390
661
|
if (projectRoot !== engineRoot && existsSync(srcDir)) {
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
const pollRaw = Number(process.env['VGAI_WATCH_POLL']);
|
|
662
|
+
// #131: same polling decision as the publicRoot watcher above (env
|
|
663
|
+
// contract matches dev.ts's poller; auto-on for drvfs projects).
|
|
394
664
|
srcWatcher = chokidar.watch(srcDir, {
|
|
395
665
|
ignoreInitial: true,
|
|
396
666
|
ignored: /(^|[/\\])\../, // ignore dotfiles
|
|
397
|
-
...
|
|
398
|
-
? { usePolling: true, interval: pollRaw > 1 ? pollRaw : 1000 }
|
|
399
|
-
: {}),
|
|
667
|
+
...pollOptions,
|
|
400
668
|
});
|
|
401
669
|
const onListChange = (filePath: string) => {
|
|
402
670
|
const rel = relative(projectRoot, resolve(filePath)).split(sep).join('/');
|
|
403
671
|
const kind = classifyProjectSrcPath(rel);
|
|
404
|
-
|
|
405
|
-
|
|
672
|
+
const event = kind
|
|
673
|
+
? {
|
|
674
|
+
tools: 'tool-files-changed',
|
|
675
|
+
data: 'data-files-changed',
|
|
676
|
+
stories: 'story-files-changed',
|
|
677
|
+
operations: 'operation-files-changed',
|
|
678
|
+
}[kind]
|
|
679
|
+
: undefined;
|
|
680
|
+
if (event) broadcastDebounced(event, { path: rel });
|
|
406
681
|
};
|
|
407
682
|
srcWatcher.on('add', onListChange);
|
|
408
683
|
srcWatcher.on('unlink', onListChange);
|
|
684
|
+
// Tool edits ride Vite HMR and data edits have their own live-value
|
|
685
|
+
// path. Node-loaded project operations instead need a fresh catalog
|
|
686
|
+
// read when metadata or implementation changes in place.
|
|
687
|
+
srcWatcher.on('change', (filePath) => {
|
|
688
|
+
const rel = relative(projectRoot, resolve(filePath)).split(sep).join('/');
|
|
689
|
+
if (classifyProjectSrcPath(rel) === 'operations') {
|
|
690
|
+
broadcastDebounced('operation-files-changed', { path: rel });
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
srcWatcher.on('add', (filePath) => scheduleFileValidation(resolve(filePath)));
|
|
694
|
+
srcWatcher.on('change', (filePath) => scheduleFileValidation(resolve(filePath)));
|
|
695
|
+
srcWatcher.on('unlink', (filePath) => clearFileValidation(resolve(filePath)));
|
|
696
|
+
void validateSourceTree(srcDir);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ---- Third watcher: <project>/vgai.game.json (#103 validate-on-change) ----
|
|
700
|
+
//
|
|
701
|
+
// The manifest lives at the PROJECT ROOT, outside both `publicRoot` and
|
|
702
|
+
// `src/` — neither watcher above ever sees it. It's a single fixed-name
|
|
703
|
+
// file, so this watches that exact path rather than a directory (chokidar
|
|
704
|
+
// supports watching a not-yet-existing file and reports its later `add`).
|
|
705
|
+
if (projectRoot !== engineRoot) {
|
|
706
|
+
const manifestPath = join(projectRoot, 'vgai.game.json');
|
|
707
|
+
// #131: same polling decision as the watchers above.
|
|
708
|
+
manifestWatcher = chokidar.watch(manifestPath, {
|
|
709
|
+
ignoreInitial: true,
|
|
710
|
+
...pollOptions,
|
|
711
|
+
});
|
|
712
|
+
manifestWatcher.on('add', () => scheduleFileValidation(manifestPath));
|
|
713
|
+
manifestWatcher.on('change', () => scheduleFileValidation(manifestPath));
|
|
714
|
+
manifestWatcher.on('unlink', () => clearFileValidation(manifestPath));
|
|
409
715
|
}
|
|
410
716
|
}
|
|
411
717
|
|
|
@@ -428,11 +734,25 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
428
734
|
|
|
429
735
|
// ---- Editor command relay + state ----
|
|
430
736
|
let editorState: Record<string, unknown> = {};
|
|
737
|
+
const editorStatesByClient = new Map<
|
|
738
|
+
string,
|
|
739
|
+
{ state: Record<string, unknown>; updatedAt: number }
|
|
740
|
+
>();
|
|
741
|
+
// When `editorState` was last POSTed by a browser tab (epoch ms), so a
|
|
742
|
+
// caller reading a cached snapshot (no live SSE client, `connected: false`
|
|
743
|
+
// below) can say HOW STALE it is instead of just THAT it's stale. `null`
|
|
744
|
+
// until the first POST of this server's lifetime (or after a project
|
|
745
|
+
// switch clears the cache).
|
|
746
|
+
let editorStateUpdatedAt: number | null = null;
|
|
431
747
|
|
|
432
748
|
// Pending command results — keyed by requestId, resolved by browser POST
|
|
433
749
|
const pendingCommands = new Map<
|
|
434
750
|
string,
|
|
435
|
-
{
|
|
751
|
+
{
|
|
752
|
+
resolve: (result: CommandResult) => void;
|
|
753
|
+
timer: ReturnType<typeof setTimeout>;
|
|
754
|
+
controller?: ServerResponse;
|
|
755
|
+
}
|
|
436
756
|
>();
|
|
437
757
|
const COMMAND_TIMEOUT_MS = 5000;
|
|
438
758
|
// `play` is the one command whose ack is gated on the GAME, not the editor:
|
|
@@ -442,11 +762,70 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
442
762
|
// "editor connected but did not respond" while play in fact proceeded and
|
|
443
763
|
// succeeded (hit dogfooding a voxel game whose worldgen alone outlives 5s).
|
|
444
764
|
const PLAY_COMMAND_TIMEOUT_MS = 120_000;
|
|
765
|
+
// `stop`'s ack is gated on teardown the same way play's is gated on setup:
|
|
766
|
+
// `exitPlayMode()` disposes the whole game (component dispose chain, Rapier
|
|
767
|
+
// world free, renderer teardown). Under software rendering (SwiftShader on
|
|
768
|
+
// a headless/CI box) that routinely outlives the generic 5s window — the
|
|
769
|
+
// command reported "did not respond" while the stop in fact completed
|
|
770
|
+
// (dogfooded 2026-07-12 on a scaffolded project). 30s is generous for any
|
|
771
|
+
// teardown while still failing fast relative to play's 120s setup budget.
|
|
772
|
+
const STOP_COMMAND_TIMEOUT_MS = 30_000;
|
|
773
|
+
// Asset Lab capture loads a local model, renders four deterministic views,
|
|
774
|
+
// and encodes five PNGs before acknowledging. Give that bounded local job a
|
|
775
|
+
// real rendering budget instead of the generic interaction timeout.
|
|
776
|
+
const ASSET_PREVIEW_COMMAND_TIMEOUT_MS = 30_000;
|
|
777
|
+
|
|
778
|
+
/** Fail every in-flight command with one error — used when the last SSE
|
|
779
|
+
* client goes away: a reloaded page has no memory of the requestId it was
|
|
780
|
+
* supposed to answer, so waiting out the timer only delays the inevitable
|
|
781
|
+
* (and mislabels a reload as "editor did not respond"). */
|
|
782
|
+
function failPendingCommands(error: string): void {
|
|
783
|
+
for (const [id, pending] of pendingCommands) {
|
|
784
|
+
clearTimeout(pending.timer);
|
|
785
|
+
pendingCommands.delete(id);
|
|
786
|
+
pending.resolve({ ok: false, timedOut: true, error });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function failCommandsOwnedBy(controller: ServerResponse): void {
|
|
791
|
+
for (const [id, pending] of pendingCommands) {
|
|
792
|
+
if (pending.controller !== controller) continue;
|
|
793
|
+
clearTimeout(pending.timer);
|
|
794
|
+
pendingCommands.delete(id);
|
|
795
|
+
pending.resolve({
|
|
796
|
+
ok: false,
|
|
797
|
+
timedOut: true,
|
|
798
|
+
error: 'The editor tab controlling this command disconnected before reporting a result.',
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
445
802
|
|
|
446
803
|
// ---- Router ----
|
|
447
|
-
const router = express.Router();
|
|
804
|
+
const router = express.Router() as EditorServerRouter;
|
|
448
805
|
router.use(express.json({ limit: '50mb' }));
|
|
449
806
|
|
|
807
|
+
// ---- Cold-Vite verified-open signal ----
|
|
808
|
+
// Stamp `lastIndexRequestAt` for any GET response that turns out to be
|
|
809
|
+
// genuine HTML (status 2xx, `Content-Type: text/html…`) — that is, purely
|
|
810
|
+
// observational, via `res.on('finish')`, so it applies no matter which
|
|
811
|
+
// downstream middleware (Vite, express.static, the SPA fallback) actually
|
|
812
|
+
// produced the response. API responses are JSON, SSE is
|
|
813
|
+
// `text/event-stream`, assets carry their own MIME types — none of those
|
|
814
|
+
// match, so only real page loads stamp.
|
|
815
|
+
router.use((req: Request, res: Response, next) => {
|
|
816
|
+
if (req.method === 'GET') {
|
|
817
|
+
res.on('finish', () => {
|
|
818
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
819
|
+
const contentType = res.get('Content-Type');
|
|
820
|
+
if (contentType?.includes('text/html')) {
|
|
821
|
+
lastIndexRequestAt = Date.now();
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
next();
|
|
827
|
+
});
|
|
828
|
+
|
|
450
829
|
// ---- CSRF / drive-by protection (S3) ----
|
|
451
830
|
// Mutating /__editor/* routes can write files, switch projects, and run npm
|
|
452
831
|
// builds (RCE). A malicious web page could POST to a locally-bound editor; an
|
|
@@ -474,7 +853,7 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
474
853
|
);
|
|
475
854
|
|
|
476
855
|
// ---- Project file serving middleware ----
|
|
477
|
-
// Serves
|
|
856
|
+
// Serves the v2 manifest from root and static files from project's public/
|
|
478
857
|
router.use((req: Request, res: Response, next) => {
|
|
479
858
|
// Only handle non-API requests when a project is open
|
|
480
859
|
if (projectRoot === engineRoot) return next();
|
|
@@ -485,24 +864,11 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
485
864
|
if (pathname.startsWith('/@')) return next();
|
|
486
865
|
if (pathname.startsWith('/node_modules')) return next();
|
|
487
866
|
|
|
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
867
|
// Serve the RAW manifest (no view synthesis — the browser-side adapter
|
|
501
868
|
// resolver parses it itself via the pure `@engine/manifest/load` half;
|
|
502
869
|
// T3.3 slice 2 part C) from the project root, not public/. 404s (falls
|
|
503
870
|
// through to `next()`, same as any other missing project-root file) for
|
|
504
|
-
//
|
|
505
|
-
// branch a no-op for them.
|
|
871
|
+
// folders without a valid manifest.
|
|
506
872
|
if (pathname === '/vgai.game.json') {
|
|
507
873
|
readFile(join(projectRoot, 'vgai.game.json'), 'utf-8')
|
|
508
874
|
.then((raw) => {
|
|
@@ -530,6 +896,12 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
530
896
|
.catch(() => next());
|
|
531
897
|
});
|
|
532
898
|
|
|
899
|
+
// ---- Browser/server compatibility identity ----
|
|
900
|
+
router.get('/__editor/compatibility', (_req: Request, res: Response) => {
|
|
901
|
+
res.set('Cache-Control', 'no-store');
|
|
902
|
+
res.json(compatibilityIdentity());
|
|
903
|
+
});
|
|
904
|
+
|
|
533
905
|
// ---- Current project info ----
|
|
534
906
|
router.get('/__editor/project', async (_req: Request, res: Response) => {
|
|
535
907
|
if (projectRoot === engineRoot) {
|
|
@@ -570,37 +942,86 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
570
942
|
Connection: 'keep-alive',
|
|
571
943
|
});
|
|
572
944
|
res.write(':ok\n\n');
|
|
573
|
-
|
|
574
|
-
|
|
945
|
+
const clientId = typeof req.query['clientId'] === 'string' ? req.query['clientId'] : undefined;
|
|
946
|
+
addClient(res, clientId);
|
|
947
|
+
req.on('close', () => {
|
|
948
|
+
removeClient(res);
|
|
949
|
+
failCommandsOwnedBy(res);
|
|
950
|
+
// Last tab gone with commands still in flight: nobody left can answer
|
|
951
|
+
// them (a reload mints a fresh page with no memory of the requestId),
|
|
952
|
+
// so fail them NOW with the real reason instead of letting the timer
|
|
953
|
+
// expire into a misleading "editor connected but did not respond".
|
|
954
|
+
// The classic trigger is a mid-command full page reload — e.g. Vite
|
|
955
|
+
// discovering/re-optimizing deps on a first play (dogfooded 2026-07-12:
|
|
956
|
+
// every first `vgai play` on a cold cache burned the full 120s window).
|
|
957
|
+
if (clientCount() === 0 && pendingCommands.size > 0) {
|
|
958
|
+
failPendingCommands(
|
|
959
|
+
'Editor page disconnected while the command was pending — usually a mid-command page ' +
|
|
960
|
+
'reload (e.g. Vite dep re-optimization on a cold cache) or a closed tab. ' +
|
|
961
|
+
'Reload the editor tab and retry the command.',
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
});
|
|
575
965
|
});
|
|
576
966
|
|
|
577
|
-
// ----
|
|
578
|
-
|
|
579
|
-
|
|
967
|
+
// ---- Coding-harness Chat (Supercode) ----
|
|
968
|
+
// GET is a complete snapshot; mutations are one explicit action endpoint;
|
|
969
|
+
// live transcript/runtime changes ride the shared SSE connection above.
|
|
970
|
+
// Hiding/collapsing the browser panel calls neither route and therefore
|
|
971
|
+
// never changes ownership of a terminal-driven session.
|
|
972
|
+
router.get('/__editor/harness-chat', async (_req: Request, res: Response) => {
|
|
973
|
+
try {
|
|
974
|
+
res.json(await harnessChat.load(true));
|
|
975
|
+
} catch (error) {
|
|
976
|
+
res.status(503).json({ error: error instanceof Error ? error.message : String(error) });
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
router.post('/__editor/harness-chat/action', async (req: Request, res: Response) => {
|
|
981
|
+
try {
|
|
982
|
+
const action = parseHarnessChatAction(req.body);
|
|
983
|
+
res.json(await harnessChat.act(action));
|
|
984
|
+
} catch (error) {
|
|
985
|
+
res.status(400).json({
|
|
986
|
+
error: error instanceof Error ? error.message : String(error),
|
|
987
|
+
snapshot: harnessChat.snapshot(),
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
});
|
|
580
991
|
|
|
992
|
+
// ---- Editor command relay ----
|
|
993
|
+
/** The relay round trip, extracted (#146) so POST `/__editor/command` and
|
|
994
|
+
* the GET poke surface below share ONE implementation: fail fast with no
|
|
995
|
+
* connected tab, assign a request id, deliver to the controller tab over
|
|
996
|
+
* SSE, and resolve when `/__editor/command-result` reports back (or the
|
|
997
|
+
* per-type timeout fires — SC1: a timeout means an editor IS connected
|
|
998
|
+
* but did not answer, distinct from the no-editor case). */
|
|
999
|
+
function relayCommand(body: Record<string, unknown>): Promise<CommandResult> {
|
|
581
1000
|
// Fail fast when no editor browser tab holds a live SSE connection. Without
|
|
582
1001
|
// this, the command silently waits the full COMMAND_TIMEOUT_MS before a
|
|
583
1002
|
// generic timeout — and the caller can't tell "no editor open" from "editor
|
|
584
1003
|
// open but the command hung". `clientCount()` is the live SSE-client count,
|
|
585
1004
|
// so a 0 here is unambiguous: open the editor in a browser first.
|
|
586
1005
|
if (clientCount() === 0) {
|
|
587
|
-
|
|
1006
|
+
return Promise.resolve({
|
|
588
1007
|
ok: false,
|
|
589
1008
|
timedOut: true,
|
|
590
1009
|
error: 'No editor connected — open the editor in a browser tab, then retry.',
|
|
591
1010
|
});
|
|
592
|
-
res.status(status).json(respBody);
|
|
593
|
-
return;
|
|
594
1011
|
}
|
|
595
1012
|
|
|
596
1013
|
// Assign a request ID so the browser can report the result back
|
|
597
1014
|
const requestId = randomUUID();
|
|
598
1015
|
const commandWithId = { ...body, _requestId: requestId };
|
|
599
1016
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1017
|
+
const timeoutMs =
|
|
1018
|
+
body['type'] === 'play'
|
|
1019
|
+
? PLAY_COMMAND_TIMEOUT_MS
|
|
1020
|
+
: body['type'] === 'stop'
|
|
1021
|
+
? STOP_COMMAND_TIMEOUT_MS
|
|
1022
|
+
: body['type'] === 'capture-asset-preview'
|
|
1023
|
+
? ASSET_PREVIEW_COMMAND_TIMEOUT_MS
|
|
1024
|
+
: COMMAND_TIMEOUT_MS;
|
|
604
1025
|
const resultPromise = new Promise<CommandResult>((resolve) => {
|
|
605
1026
|
const timer = setTimeout(() => {
|
|
606
1027
|
pendingCommands.delete(requestId);
|
|
@@ -613,9 +1034,30 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
613
1034
|
pendingCommands.set(requestId, { resolve, timer });
|
|
614
1035
|
});
|
|
615
1036
|
|
|
616
|
-
|
|
1037
|
+
// Commands mutate editor/game state, so exactly one browser tab owns each
|
|
1038
|
+
// command. Ordinary notifications still broadcast to every tab.
|
|
1039
|
+
const controller = sendToController('editor-command', commandWithId);
|
|
1040
|
+
if (controller) {
|
|
1041
|
+
const pending = pendingCommands.get(requestId);
|
|
1042
|
+
if (pending) pending.controller = controller;
|
|
1043
|
+
} else {
|
|
1044
|
+
const pending = pendingCommands.get(requestId);
|
|
1045
|
+
if (pending) {
|
|
1046
|
+
clearTimeout(pending.timer);
|
|
1047
|
+
pendingCommands.delete(requestId);
|
|
1048
|
+
pending.resolve({
|
|
1049
|
+
ok: false,
|
|
1050
|
+
timedOut: true,
|
|
1051
|
+
error: 'Editor disconnected before the command could be delivered.',
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return resultPromise;
|
|
1057
|
+
}
|
|
617
1058
|
|
|
618
|
-
|
|
1059
|
+
router.post('/__editor/command', (req: Request, res: Response) => {
|
|
1060
|
+
relayCommand(req.body as Record<string, unknown>).then((result) => {
|
|
619
1061
|
const { status, body } = commandResponseFor(result);
|
|
620
1062
|
res.status(status).json(body);
|
|
621
1063
|
});
|
|
@@ -639,20 +1081,153 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
639
1081
|
});
|
|
640
1082
|
|
|
641
1083
|
// ---- Editor state (browser reports / CLI reads) ----
|
|
642
|
-
router.get('/__editor/state', (_req: Request, res: Response) => {
|
|
1084
|
+
router.get('/__editor/state', async (_req: Request, res: Response) => {
|
|
643
1085
|
// `editorState` is the last snapshot a browser POSTed; it persists even
|
|
644
1086
|
// after every tab closes. Stamp the response with the LIVE SSE client count
|
|
645
1087
|
// so callers can distinguish "an editor is attached now" from "this is stale
|
|
646
1088
|
// cache from a tab that has since gone away".
|
|
647
1089
|
const connected = clientCount();
|
|
648
|
-
|
|
1090
|
+
const controlledState = (() => {
|
|
1091
|
+
const clientId = controllerClientId();
|
|
1092
|
+
return clientId ? editorStatesByClient.get(clientId) : undefined;
|
|
1093
|
+
})();
|
|
1094
|
+
const state = controlledState?.state ?? editorState;
|
|
1095
|
+
const stateUpdatedAt = controlledState?.updatedAt ?? editorStateUpdatedAt;
|
|
1096
|
+
// #124: project identity, server-computed exactly like `projectValidation`
|
|
1097
|
+
// below (never part of the browser-POSTed snapshot) — lets a watcher/relay
|
|
1098
|
+
// holding only a port number tell which project that port serves. Mirrors
|
|
1099
|
+
// `/__editor/project`'s own `projectRoot === engineRoot` -> "no project"
|
|
1100
|
+
// convention rather than introducing a new one.
|
|
1101
|
+
const projectOpen = projectRoot !== engineRoot;
|
|
1102
|
+
const view = projectOpen ? await readProjectView(projectRoot) : null;
|
|
1103
|
+
const nameField = view?.['name'];
|
|
1104
|
+
res.json({
|
|
1105
|
+
...state,
|
|
1106
|
+
editorsConnected: connected,
|
|
1107
|
+
connected: connected > 0,
|
|
1108
|
+
// #run2-cold-vite: null until the FIRST genuine index-page GET this
|
|
1109
|
+
// server has served (see the response-observing middleware above).
|
|
1110
|
+
lastIndexRequestAt,
|
|
1111
|
+
...(stateUpdatedAt !== null ? { stateUpdatedAt } : {}),
|
|
1112
|
+
// #103: server-computed (never part of the browser-POSTed snapshot
|
|
1113
|
+
// above), so it's always current — a file present here is currently
|
|
1114
|
+
// failing validation; a clean project reports `{}`. `vgai status`
|
|
1115
|
+
// prints this whole object as-is, so no CLI changes were needed to
|
|
1116
|
+
// surface it.
|
|
1117
|
+
projectValidation: Object.fromEntries(projectValidation),
|
|
1118
|
+
projectRoot: projectOpen ? projectRoot : null,
|
|
1119
|
+
projectName: typeof nameField === 'string' ? nameField : null,
|
|
1120
|
+
});
|
|
649
1121
|
});
|
|
650
1122
|
|
|
651
1123
|
router.post('/__editor/state', (req: Request, res: Response) => {
|
|
652
|
-
|
|
1124
|
+
const reported = req.body as Record<string, unknown>;
|
|
1125
|
+
const clientId = typeof reported['_clientId'] === 'string' ? reported['_clientId'] : null;
|
|
1126
|
+
const { _clientId: _ignoredClientId, ...state } = reported;
|
|
1127
|
+
const updatedAt = Date.now();
|
|
1128
|
+
editorState = state;
|
|
1129
|
+
editorStateUpdatedAt = updatedAt;
|
|
1130
|
+
if (clientId) {
|
|
1131
|
+
editorStatesByClient.set(clientId, { state, updatedAt });
|
|
1132
|
+
const pageErrors = state['pageErrors'];
|
|
1133
|
+
const presence = state['presence'];
|
|
1134
|
+
const presenceRecord =
|
|
1135
|
+
presence && typeof presence === 'object' ? (presence as Record<string, unknown>) : null;
|
|
1136
|
+
updateClientControlHealth(clientId, {
|
|
1137
|
+
errorState: Array.isArray(pageErrors)
|
|
1138
|
+
? pageErrors.length === 0
|
|
1139
|
+
? 'healthy'
|
|
1140
|
+
: 'errored'
|
|
1141
|
+
: 'unknown',
|
|
1142
|
+
visible: presenceRecord?.['visibility'] === 'visible',
|
|
1143
|
+
focused: presenceRecord?.['focused'] === true,
|
|
1144
|
+
updatedAt,
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
653
1147
|
res.json({ ok: true });
|
|
654
1148
|
});
|
|
655
1149
|
|
|
1150
|
+
// ---- #146: the agent poke surface (docs/SHARED-SESSION-SPEC.md §3 rung 2)
|
|
1151
|
+
//
|
|
1152
|
+
// Plain-GET reads for the operations an agent repeats most — check state,
|
|
1153
|
+
// check validation, grab a frame. Deliberately curl-shaped: no JSON body to
|
|
1154
|
+
// quote, terse output, and bulky payloads returned as PATHS inside the
|
|
1155
|
+
// project (never blobs — an agent's context budget is metered, and a
|
|
1156
|
+
// sandboxed agent can read project-internal paths without permission
|
|
1157
|
+
// prompts). Mutations stay on POST `/__editor/command`. The index below is
|
|
1158
|
+
// the wire's self-discovery: arbitrary endpoint names are fine when
|
|
1159
|
+
// queryable, fatal when memorized.
|
|
1160
|
+
router.get('/__vgai', (_req: Request, res: Response) => {
|
|
1161
|
+
res.json({
|
|
1162
|
+
project: projectRoot !== engineRoot ? projectRoot : null,
|
|
1163
|
+
connected: clientCount() > 0,
|
|
1164
|
+
endpoints: {
|
|
1165
|
+
'GET /__vgai': 'this index',
|
|
1166
|
+
'GET /__vgai/state': 'every observable game-state provider (play mode must be running)',
|
|
1167
|
+
'GET /__vgai/state/:provider': 'one provider, e.g. /__vgai/state/gemGame',
|
|
1168
|
+
'GET /__vgai/validation':
|
|
1169
|
+
'validate-on-save results for this project (failing: {} means clean)',
|
|
1170
|
+
'GET /__vgai/screenshot':
|
|
1171
|
+
'capture the live game canvas to <project>/.vgai/captures/<ts>.png and return its path',
|
|
1172
|
+
'POST /__editor/command': 'the full command relay (bridge-call, play, stop, select, …)',
|
|
1173
|
+
},
|
|
1174
|
+
});
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
// The edit→verify loop's other half: after writing a scene/manifest/asset
|
|
1178
|
+
// file, one cheap GET answers "was my last save accepted?" instead of
|
|
1179
|
+
// tailing the (detached) dev-server log. Same server-computed map
|
|
1180
|
+
// `/__editor/state` exposes as `projectValidation`, purpose-shaped.
|
|
1181
|
+
router.get('/__vgai/validation', (_req: Request, res: Response) => {
|
|
1182
|
+
res.json({
|
|
1183
|
+
clean: projectValidation.size === 0,
|
|
1184
|
+
failing: Object.fromEntries(projectValidation),
|
|
1185
|
+
});
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
router.get('/__vgai/state', async (_req: Request, res: Response) => {
|
|
1189
|
+
const result = await relayCommand({ type: 'bridge-call', method: 'stateAll', callArgs: [] });
|
|
1190
|
+
const { status, body } = commandResponseFor(result);
|
|
1191
|
+
res.status(status).json(body);
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
router.get('/__vgai/state/:provider', async (req: Request, res: Response) => {
|
|
1195
|
+
const result = await relayCommand({
|
|
1196
|
+
type: 'bridge-call',
|
|
1197
|
+
method: 'state',
|
|
1198
|
+
callArgs: [req.params['provider']],
|
|
1199
|
+
});
|
|
1200
|
+
const { status, body } = commandResponseFor(result);
|
|
1201
|
+
res.status(status).json(body);
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
// Paths-not-blobs: the page hands back base64 over the relay
|
|
1205
|
+
// (`bridge-screenshot` — a pure "hand back the pixels" primitive), and THIS
|
|
1206
|
+
// side owns the filesystem, so the PNG lands under the project where the
|
|
1207
|
+
// agent's Read tool can open it without leaving the sandbox.
|
|
1208
|
+
router.get('/__vgai/screenshot', async (_req: Request, res: Response) => {
|
|
1209
|
+
if (projectRoot === engineRoot) {
|
|
1210
|
+
res.status(400).json({ ok: false, error: 'no project open' });
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const result = await relayCommand({ type: 'bridge-screenshot' });
|
|
1214
|
+
if (!result.ok) {
|
|
1215
|
+
const { status, body } = commandResponseFor(result);
|
|
1216
|
+
res.status(status).json(body);
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
const base64 = result.data?.['base64'];
|
|
1220
|
+
if (typeof base64 !== 'string') {
|
|
1221
|
+
res.status(500).json({ ok: false, error: 'bridge-screenshot returned no image data' });
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
const capturesDir = join(projectRoot, '.vgai', 'captures');
|
|
1225
|
+
await mkdir(capturesDir, { recursive: true });
|
|
1226
|
+
const file = join(capturesDir, `capture-${new Date().toISOString().replace(/[:.]/g, '-')}.png`);
|
|
1227
|
+
await writeFile(file, Buffer.from(base64, 'base64'));
|
|
1228
|
+
res.json({ ok: true, path: file, relativePath: relative(projectRoot, file) });
|
|
1229
|
+
});
|
|
1230
|
+
|
|
656
1231
|
// ---- Asset listing ----
|
|
657
1232
|
router.get('/__editor/assets', async (req: Request, res: Response) => {
|
|
658
1233
|
const dir = (req.query['dir'] as string) ?? '';
|
|
@@ -720,7 +1295,12 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
720
1295
|
|
|
721
1296
|
// ---- Save file ----
|
|
722
1297
|
router.post('/__editor/save-file', async (req: Request, res: Response) => {
|
|
723
|
-
const body = req.body as {
|
|
1298
|
+
const body = req.body as {
|
|
1299
|
+
path: string;
|
|
1300
|
+
content?: string;
|
|
1301
|
+
encoding?: 'base64';
|
|
1302
|
+
delete?: boolean;
|
|
1303
|
+
};
|
|
724
1304
|
const filePath = body.path ?? '';
|
|
725
1305
|
|
|
726
1306
|
if (!filePath || filePath.includes('..')) {
|
|
@@ -735,6 +1315,15 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
735
1315
|
}
|
|
736
1316
|
|
|
737
1317
|
try {
|
|
1318
|
+
if (body.delete) {
|
|
1319
|
+
await unlinkIfPresent(absPath);
|
|
1320
|
+
res.json({ ok: true, path: filePath });
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (typeof body.content !== 'string') {
|
|
1324
|
+
res.status(400).json({ error: 'Missing content.' });
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
738
1327
|
await mkdir(dirname(absPath), { recursive: true });
|
|
739
1328
|
const data = body.encoding === 'base64' ? Buffer.from(body.content, 'base64') : body.content;
|
|
740
1329
|
await writeFile(absPath, data, body.encoding === 'base64' ? undefined : 'utf-8');
|
|
@@ -915,7 +1504,7 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
915
1504
|
// T3.6: shared scaffolder — packages/create-vgai-project/src/scaffold.ts.
|
|
916
1505
|
// Same implementation `npx create-vgai-project` and `vgai create` use
|
|
917
1506
|
// (copy template, rewrite @vgai/engine file: dep + tsconfig @engine/*
|
|
918
|
-
// paths/files array + vite alias + index.html title +
|
|
1507
|
+
// paths/files array + vite alias + index.html title + v2 manifest).
|
|
919
1508
|
const result = scaffoldProject({
|
|
920
1509
|
name,
|
|
921
1510
|
targetDir: projectDir,
|
|
@@ -930,7 +1519,7 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
930
1519
|
// Add to recent projects
|
|
931
1520
|
await addToRecentProjects(name, projectDir);
|
|
932
1521
|
|
|
933
|
-
res.json({ ok: true, path: result.targetDir, config: result.
|
|
1522
|
+
res.json({ ok: true, path: result.targetDir, config: result.manifest });
|
|
934
1523
|
} catch (err) {
|
|
935
1524
|
res.status(500).json({ error: `Failed to create project: ${err}` });
|
|
936
1525
|
}
|
|
@@ -1082,20 +1671,37 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
1082
1671
|
// disagree on a symlinked segment).
|
|
1083
1672
|
const absPath = canonicalProjectRoot(newPath);
|
|
1084
1673
|
|
|
1085
|
-
// Read
|
|
1086
|
-
// manifest-only folder with no project.json.
|
|
1674
|
+
// Read the v2 game manifest and derive the editor project view.
|
|
1087
1675
|
const config = await readProjectView(absPath);
|
|
1088
1676
|
if (!config) {
|
|
1089
|
-
res.status(400).json({ error: `No vgai.game.json
|
|
1677
|
+
res.status(400).json({ error: `No valid vgai.game.json at ${absPath}` });
|
|
1090
1678
|
return;
|
|
1091
1679
|
}
|
|
1092
1680
|
|
|
1681
|
+
// Compatibility is enforced by the process that owns the mutable project
|
|
1682
|
+
// state, immediately before activation. The browser also preflights this
|
|
1683
|
+
// for fast feedback, but it cannot make a client-side check atomic and
|
|
1684
|
+
// non-browser API callers must not be able to bypass the contract.
|
|
1685
|
+
try {
|
|
1686
|
+
const compatibility = compatibilityIdentity();
|
|
1687
|
+
assertEditorCompatibility(compatibility);
|
|
1688
|
+
assertProjectCompatibility(config, compatibility);
|
|
1689
|
+
} catch (error) {
|
|
1690
|
+
if (error instanceof ProjectCompatibilityError) {
|
|
1691
|
+
res.status(409).json({ error: error.message, recovery: error.recovery });
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
throw error;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1093
1697
|
// Update mutable state
|
|
1094
1698
|
projectRoot = absPath;
|
|
1095
1699
|
publicRoot = resolve(absPath, 'public');
|
|
1096
1700
|
|
|
1097
1701
|
// Clear stale editor state from previous project
|
|
1098
1702
|
editorState = {};
|
|
1703
|
+
editorStateUpdatedAt = null;
|
|
1704
|
+
editorStatesByClient.clear();
|
|
1099
1705
|
|
|
1100
1706
|
// Ensure .vgai/ directory exists
|
|
1101
1707
|
await mkdir(join(absPath, '.vgai'), { recursive: true });
|
|
@@ -1111,6 +1717,15 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
1111
1717
|
|
|
1112
1718
|
// Notify all connected editors
|
|
1113
1719
|
broadcast('project-changed', { path: absPath, config });
|
|
1720
|
+
// Coding sessions are project-scoped. Tear down only editor-owned
|
|
1721
|
+
// Supercode runtime/follower state and rediscover the new folder; this
|
|
1722
|
+
// never touches an independently-running terminal harness process.
|
|
1723
|
+
void harnessChat.setWorkspace().catch((error) => {
|
|
1724
|
+
broadcast('server-log', {
|
|
1725
|
+
level: 'error',
|
|
1726
|
+
message: `Harness Chat could not switch projects: ${error instanceof Error ? error.message : String(error)}`,
|
|
1727
|
+
});
|
|
1728
|
+
});
|
|
1114
1729
|
|
|
1115
1730
|
res.json({ ok: true, config });
|
|
1116
1731
|
});
|
|
@@ -1368,9 +1983,9 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
1368
1983
|
res.json({ files });
|
|
1369
1984
|
});
|
|
1370
1985
|
|
|
1371
|
-
// D1 (spec §2.5): a Data-panel edit writes the `.data.json` IMMEDIATELY
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1986
|
+
// D1 (spec §2.5): a Data-panel edit writes the `.data.json` IMMEDIATELY.
|
|
1987
|
+
// Project History captures exact bytes around this transport. Data writes
|
|
1988
|
+
// deliberately BYPASS the
|
|
1374
1989
|
// play-mode overlay regime entirely (the overlay is scene-entity-only —
|
|
1375
1990
|
// `.vgai/overlays/*` via `world-overlay.ts`/`ingest-overlay.ts`; nothing in
|
|
1376
1991
|
// that path ever touches `src/data/`). A running game picks the write up
|
|
@@ -1407,6 +2022,7 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
1407
2022
|
}
|
|
1408
2023
|
try {
|
|
1409
2024
|
const absPath = join(projectRoot, relPath);
|
|
2025
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
1410
2026
|
await writeFile(absPath, body.content, 'utf-8');
|
|
1411
2027
|
res.json({ ok: true, path: relPath });
|
|
1412
2028
|
} catch {
|
|
@@ -1414,6 +2030,51 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
1414
2030
|
}
|
|
1415
2031
|
});
|
|
1416
2032
|
|
|
2033
|
+
router.get('/__editor/data-file', async (req: Request, res: Response) => {
|
|
2034
|
+
const relPath = (req.query['path'] as string) ?? '';
|
|
2035
|
+
if (
|
|
2036
|
+
!relPath ||
|
|
2037
|
+
relPath.includes('..') ||
|
|
2038
|
+
!relPath.startsWith('src/data/') ||
|
|
2039
|
+
!relPath.endsWith('.data.json')
|
|
2040
|
+
) {
|
|
2041
|
+
res.status(400).json({ error: 'Invalid path — expected src/data/**/*.data.json.' });
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
if (projectRoot === engineRoot) {
|
|
2045
|
+
res.status(404).json({ error: 'No project open.' });
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
try {
|
|
2049
|
+
res.type('application/json').send(await readFile(join(projectRoot, relPath)));
|
|
2050
|
+
} catch {
|
|
2051
|
+
res.status(404).json({ error: 'Not found.' });
|
|
2052
|
+
}
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
router.delete('/__editor/data-file', async (req: Request, res: Response) => {
|
|
2056
|
+
const relPath = (req.body as { path?: string }).path ?? '';
|
|
2057
|
+
if (
|
|
2058
|
+
!relPath ||
|
|
2059
|
+
relPath.includes('..') ||
|
|
2060
|
+
!relPath.startsWith('src/data/') ||
|
|
2061
|
+
!relPath.endsWith('.data.json')
|
|
2062
|
+
) {
|
|
2063
|
+
res.status(400).json({ error: 'Invalid path — expected src/data/**/*.data.json.' });
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
if (projectRoot === engineRoot) {
|
|
2067
|
+
res.status(400).json({ error: 'No project open.' });
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
try {
|
|
2071
|
+
await unlinkIfPresent(join(projectRoot, relPath));
|
|
2072
|
+
res.json({ ok: true });
|
|
2073
|
+
} catch {
|
|
2074
|
+
res.status(500).json({ error: 'Failed to delete data file.' });
|
|
2075
|
+
}
|
|
2076
|
+
});
|
|
2077
|
+
|
|
1417
2078
|
// ---- Project tools (W4, docs/DATA-TOOLS-DESIGN.md §3) ----
|
|
1418
2079
|
//
|
|
1419
2080
|
// Discovery for the dock's project-tool tabs: every
|
|
@@ -1437,6 +2098,108 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
1437
2098
|
});
|
|
1438
2099
|
});
|
|
1439
2100
|
|
|
2101
|
+
// ---- Project operations (docs/PROJECT-OPERATIONS-DESIGN.md) ----
|
|
2102
|
+
//
|
|
2103
|
+
// Unlike project tools, operation modules are loaded on the NODE side.
|
|
2104
|
+
// Merely listing a Node-native asset/map build script must never import it
|
|
2105
|
+
// into the editor browser or make it reachable from a standalone game
|
|
2106
|
+
// bundle. Vite's ssrLoadModule is injected by the owning server and gives
|
|
2107
|
+
// us normal TS transform + project dependency resolution without a second
|
|
2108
|
+
// script runtime.
|
|
2109
|
+
router.get('/__editor/project-operations', async (_req: Request, res: Response) => {
|
|
2110
|
+
if (projectRoot === engineRoot) {
|
|
2111
|
+
res.json({ operations: [], loadErrors: [] });
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
res.json(await discoverProjectOperations(projectRoot, options.loadProjectModule));
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
router.post('/__editor/project-operations/run', async (req: Request, res: Response) => {
|
|
2118
|
+
if (projectRoot === engineRoot) {
|
|
2119
|
+
res.status(400).json({
|
|
2120
|
+
ok: false,
|
|
2121
|
+
error: { code: 'NO_PROJECT_OPEN', message: 'Open a project before running an operation.' },
|
|
2122
|
+
});
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
const body = req.body as { name?: unknown; input?: unknown; confirm?: unknown };
|
|
2126
|
+
if (typeof body.name !== 'string' || body.name.trim() === '') {
|
|
2127
|
+
res.status(400).json({
|
|
2128
|
+
ok: false,
|
|
2129
|
+
error: { code: 'INVALID_OPERATION_NAME', message: 'Expected a non-empty operation name.' },
|
|
2130
|
+
});
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
const controller = new AbortController();
|
|
2134
|
+
req.once('aborted', () => controller.abort());
|
|
2135
|
+
const result = await executeProjectOperation({
|
|
2136
|
+
projectRoot,
|
|
2137
|
+
loadModule: options.loadProjectModule,
|
|
2138
|
+
name: body.name,
|
|
2139
|
+
input: Object.hasOwn(body, 'input') ? body.input : {},
|
|
2140
|
+
confirmed: body.confirm === true,
|
|
2141
|
+
signal: controller.signal,
|
|
2142
|
+
});
|
|
2143
|
+
// A project command may create an ordinary file anywhere below public/.
|
|
2144
|
+
// The public watcher validates files and detects moves, but it deliberately
|
|
2145
|
+
// does not broadcast every add/change. Tell every editor client to rescan
|
|
2146
|
+
// its Assets view after a successful declared mutation; no generator- or
|
|
2147
|
+
// asset-specific operation contract is needed.
|
|
2148
|
+
if (result.changedProject) broadcast('assets-changed', {});
|
|
2149
|
+
res.status(result.status).json(result.body);
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
// ---- Story discovery (C3, spec §9 — "Add CSF Discovery and Portable
|
|
2153
|
+
// Story Mounting") ----
|
|
2154
|
+
//
|
|
2155
|
+
// Conventional `*.stories.tsx` / `*.stories.ts` files, colocated anywhere
|
|
2156
|
+
// under the project's `src/` (Storybook's own convention — unlike
|
|
2157
|
+
// `src/tools/`/`src/data/`, stories are not confined to one folder). Same
|
|
2158
|
+
// D3 physics as every other editor list here: a live folder scan, never a
|
|
2159
|
+
// cached manifest — add/remove a `*.stories.tsx` and the next refresh sees
|
|
2160
|
+
// it. Paths only: the CSF MODULE itself is loaded client-side through
|
|
2161
|
+
// Vite's `/@fs/` dynamic-import path (`story-discovery.ts`, mirroring
|
|
2162
|
+
// `tool-loader.ts`), so it arrives transpiled with HMR, and the editor
|
|
2163
|
+
// never stores a duplicate story definition — the CSF module stays the one
|
|
2164
|
+
// source of truth (spec §9 C3 "Story source remains the CSF module").
|
|
2165
|
+
//
|
|
2166
|
+
// `previewModulePath` rides along in the same response: the project's
|
|
2167
|
+
// OPTIONAL `.storybook/preview.tsx`/`.ts` (checked with `existsSync`, same
|
|
2168
|
+
// "server tells the truth about disk, client never guesses" contract as
|
|
2169
|
+
// the rest of this route), or `null` when the project has no `.storybook/`
|
|
2170
|
+
// folder — most projects don't. `story-discovery.ts`'s
|
|
2171
|
+
// `loadProjectPreviewAnnotations` used to blind-probe both candidate paths
|
|
2172
|
+
// client-side via `/@fs/` and swallow the import failure; that still
|
|
2173
|
+
// logged a real failed network request for every project without a
|
|
2174
|
+
// preview config (the common case), which is indistinguishable from a
|
|
2175
|
+
// genuine broken-editor error to anything watching the page's network/
|
|
2176
|
+
// console (e.g. `check-vgai-generated-project-p2p.ts`'s browser-proof).
|
|
2177
|
+
// Reporting existence here lets the client only ever import a path that is
|
|
2178
|
+
// actually there.
|
|
2179
|
+
router.get('/__editor/story-files', async (_req: Request, res: Response) => {
|
|
2180
|
+
if (projectRoot === engineRoot) {
|
|
2181
|
+
res.json({ files: [], previewModulePath: null });
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
const srcDir = join(projectRoot, 'src');
|
|
2185
|
+
const [tsxFiles, tsFiles] = await Promise.all([
|
|
2186
|
+
findFiles(srcDir, '.stories.tsx'),
|
|
2187
|
+
findFiles(srcDir, '.stories.ts'),
|
|
2188
|
+
]);
|
|
2189
|
+
const found = [...tsxFiles, ...tsFiles].filter(
|
|
2190
|
+
(abs) => !abs.includes(`${sep}node_modules${sep}`),
|
|
2191
|
+
);
|
|
2192
|
+
const previewCandidates = ['.storybook/preview.tsx', '.storybook/preview.ts'];
|
|
2193
|
+
const previewModulePath =
|
|
2194
|
+
previewCandidates.find((candidate) => existsSync(join(projectRoot, candidate))) ?? null;
|
|
2195
|
+
res.json({
|
|
2196
|
+
files: found.sort().map((abs) => ({
|
|
2197
|
+
path: relative(projectRoot, abs).split('\\').join('/'),
|
|
2198
|
+
})),
|
|
2199
|
+
previewModulePath,
|
|
2200
|
+
});
|
|
2201
|
+
});
|
|
2202
|
+
|
|
1440
2203
|
// ---- Manifest write (A4, D8 — the ONE manifest-write verb) ----
|
|
1441
2204
|
// Unlike every other write route above, this one HARD-CODES its
|
|
1442
2205
|
// destination: `join(projectRoot, 'vgai.game.json')`, NEVER derived from
|
|
@@ -1460,6 +2223,31 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
1460
2223
|
}
|
|
1461
2224
|
});
|
|
1462
2225
|
|
|
2226
|
+
router.get('/__editor/manifest', async (_req: Request, res: Response) => {
|
|
2227
|
+
if (projectRoot === engineRoot) {
|
|
2228
|
+
res.status(404).json({ error: 'No project open.' });
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
try {
|
|
2232
|
+
res.type('application/json').send(await readFile(join(projectRoot, 'vgai.game.json')));
|
|
2233
|
+
} catch {
|
|
2234
|
+
res.status(404).json({ error: 'Not found.' });
|
|
2235
|
+
}
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
router.delete('/__editor/manifest', async (_req: Request, res: Response) => {
|
|
2239
|
+
if (projectRoot === engineRoot) {
|
|
2240
|
+
res.status(400).json({ error: 'No project open.' });
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
try {
|
|
2244
|
+
await unlinkIfPresent(join(projectRoot, 'vgai.game.json'));
|
|
2245
|
+
res.json({ ok: true });
|
|
2246
|
+
} catch {
|
|
2247
|
+
res.status(500).json({ error: 'Failed to delete manifest.' });
|
|
2248
|
+
}
|
|
2249
|
+
});
|
|
2250
|
+
|
|
1463
2251
|
// ---- Play-mode console log persistence ----
|
|
1464
2252
|
const MAX_LOG_FILES = 20;
|
|
1465
2253
|
let activeLogFile: string | null = null;
|
|
@@ -1533,5 +2321,44 @@ export function createEditorServer(options: EditorServerOptions): Router {
|
|
|
1533
2321
|
res.json({ ok: true });
|
|
1534
2322
|
});
|
|
1535
2323
|
|
|
2324
|
+
// ---- #125 item 2: probe lifecycle narration relay ----
|
|
2325
|
+
// `vgai probe` (packages/vgai-cli/src/index.ts's `runProbe`) fires
|
|
2326
|
+
// best-effort lifecycle events at this route so they land in the SAME live
|
|
2327
|
+
// editor console PR #120 wired the `server-log` SSE event into (see
|
|
2328
|
+
// `runFileValidation`'s `broadcast('server-log', …)` calls above) — a human
|
|
2329
|
+
// watching the editor sees "probe: run started (headed)" / "probe: 3/3
|
|
2330
|
+
// specs passed" without tailing a separate terminal. Deliberately dumb:
|
|
2331
|
+
// this is a narration RELAY, not an API — it validates shape, broadcasts,
|
|
2332
|
+
// and gets out of the way; no state is kept here, and a caller with
|
|
2333
|
+
// nothing to say just never POSTs (`vgai probe` skips this entirely when
|
|
2334
|
+
// no live session covers the project).
|
|
2335
|
+
router.post('/__editor/server-log', (req: Request, res: Response) => {
|
|
2336
|
+
const body = req.body as { level?: unknown; message?: unknown };
|
|
2337
|
+
const level = body.level;
|
|
2338
|
+
if (
|
|
2339
|
+
(level !== 'info' && level !== 'warn' && level !== 'error') ||
|
|
2340
|
+
typeof body.message !== 'string'
|
|
2341
|
+
) {
|
|
2342
|
+
res
|
|
2343
|
+
.status(400)
|
|
2344
|
+
.json({ error: "Expected { level: 'info'|'warn'|'error', message: string }." });
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
broadcast('server-log', { level, message: body.message });
|
|
2348
|
+
res.status(204).end();
|
|
2349
|
+
});
|
|
2350
|
+
|
|
2351
|
+
router.close = async () => {
|
|
2352
|
+
for (const timer of validationDebounceTimers.values()) clearTimeout(timer);
|
|
2353
|
+
validationDebounceTimers.clear();
|
|
2354
|
+
await Promise.all([
|
|
2355
|
+
watcher?.close(),
|
|
2356
|
+
srcWatcher?.close(),
|
|
2357
|
+
manifestWatcher?.close(),
|
|
2358
|
+
engineSourceWatcher?.close(),
|
|
2359
|
+
harnessChat.close(),
|
|
2360
|
+
]);
|
|
2361
|
+
};
|
|
2362
|
+
|
|
1536
2363
|
return router;
|
|
1537
2364
|
}
|