From 328e37e8ec6eb3a36e28a14c4fbcd89ae0c5fffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E9=A3=8E=E9=80=8D=E9=81=A5=E6=B8=B8?= Date: Mon, 24 Aug 2026 22:46:54 +0800 Subject: [PATCH] feat(editor): complete P0 and P1 authoring services --- cmake/module_manifest.cmake | 1 + docs/dev/AI与MCP支持.md | 46 +- docs/dev/游戏内容编辑器组件设计分析.md | 788 ++++++ docs/dev/编辑器具体API与组件设计.md | 2219 +++++++++++++++++ docs/usr/modules/editor.md | 13 +- docs/usr/modules/ui.md | 13 + examples/ai-editor/README.md | 10 + src/engine/cmdline/Mcp.cpp | 62 +- src/engine/common/EditorAutomation.h | 29 + src/engine/common/EditorHost.h | 6 + src/engine/common/UIAutomation.h | 43 + src/engine/devtools/McpServer.cpp | 560 +++-- src/modules/editor/Editor.cpp | 154 ++ src/modules/editor/Editor.h | 9 +- src/modules/editor/EditorAssetDatabase.cpp | 21 +- src/modules/editor/EditorAssetDatabase.h | 1 + src/modules/editor/EditorDiagnostics.cpp | 48 + src/modules/editor/EditorDiagnostics.h | 55 + src/modules/editor/EditorDiskAssetCatalog.cpp | 156 ++ src/modules/editor/EditorDiskAssetCatalog.h | 50 + .../editor/EditorDiskDocumentStore.cpp | 226 ++ src/modules/editor/EditorDiskDocumentStore.h | 80 + src/modules/editor/EditorDocumentService.cpp | 70 +- src/modules/editor/EditorDocumentService.h | 4 +- src/modules/editor/EditorGraph.cpp | 83 +- src/modules/editor/EditorGraph.h | 11 +- src/modules/editor/EditorPersistence.cpp | 110 + src/modules/editor/EditorPersistence.h | 51 + src/modules/editor/EditorRuntimeManifest.cpp | 86 + src/modules/editor/EditorRuntimeManifest.h | 46 + src/modules/editor/EditorSelection.cpp | 70 + src/modules/editor/EditorSelection.h | 47 + src/modules/editor/EditorSession.cpp | 10 + src/modules/editor/EditorSession.h | 2 + src/modules/editor/EditorTaskService.cpp | 147 ++ src/modules/editor/EditorTaskService.h | 95 + src/modules/editor/EditorValueJson.cpp | 123 + src/modules/editor/EditorValueJson.h | 23 + src/modules/ui/EditorHost.cpp | 61 +- src/modules/ui/EditorHost.h | 9 + src/modules/ui/EditorHostCapabilities.cpp | 9 + src/modules/ui/EditorHostHotReload.cpp | 224 ++ src/modules/ui/UI.cpp | 6 +- src/modules/ui/UIAutomationCapabilities.cpp | 199 ++ src/modules/ui/UIAutomationCapabilities.h | 5 + test/CMakeLists.txt | 1 + test/UISystem.cpp | 33 + test/editor_automation_v2.cpp | 63 + test/editor_disk_asset_catalog_v2.cpp | 41 + test/editor_disk_document_v2.cpp | 104 + test/editor_document_asset_v2.cpp | 28 +- test/editor_persistence_v2.cpp | 36 + test/editor_runtime_manifest_v2.cpp | 76 + test/editor_selection_v2.cpp | 27 + test/editor_task_diagnostic_v2.cpp | 86 + test/mcp.cpp | 193 +- test/mcp_hot_reload.cpp | 103 + 57 files changed, 6478 insertions(+), 394 deletions(-) create mode 100644 docs/dev/游戏内容编辑器组件设计分析.md create mode 100644 docs/dev/编辑器具体API与组件设计.md create mode 100644 src/engine/common/EditorAutomation.h create mode 100644 src/engine/common/UIAutomation.h create mode 100644 src/modules/editor/EditorDiagnostics.cpp create mode 100644 src/modules/editor/EditorDiagnostics.h create mode 100644 src/modules/editor/EditorDiskAssetCatalog.cpp create mode 100644 src/modules/editor/EditorDiskAssetCatalog.h create mode 100644 src/modules/editor/EditorDiskDocumentStore.cpp create mode 100644 src/modules/editor/EditorDiskDocumentStore.h create mode 100644 src/modules/editor/EditorPersistence.cpp create mode 100644 src/modules/editor/EditorPersistence.h create mode 100644 src/modules/editor/EditorRuntimeManifest.cpp create mode 100644 src/modules/editor/EditorRuntimeManifest.h create mode 100644 src/modules/editor/EditorSelection.cpp create mode 100644 src/modules/editor/EditorTaskService.cpp create mode 100644 src/modules/editor/EditorTaskService.h create mode 100644 src/modules/editor/EditorValueJson.cpp create mode 100644 src/modules/editor/EditorValueJson.h create mode 100644 src/modules/ui/EditorHostHotReload.cpp create mode 100644 src/modules/ui/UIAutomationCapabilities.cpp create mode 100644 src/modules/ui/UIAutomationCapabilities.h create mode 100644 test/editor_automation_v2.cpp create mode 100644 test/editor_disk_asset_catalog_v2.cpp create mode 100644 test/editor_disk_document_v2.cpp create mode 100644 test/editor_persistence_v2.cpp create mode 100644 test/editor_runtime_manifest_v2.cpp create mode 100644 test/editor_selection_v2.cpp create mode 100644 test/editor_task_diagnostic_v2.cpp create mode 100644 test/mcp_hot_reload.cpp diff --git a/cmake/module_manifest.cmake b/cmake/module_manifest.cmake index 4a01b031..faf8e2c4 100644 --- a/cmake/module_manifest.cmake +++ b/cmake/module_manifest.cmake @@ -85,6 +85,7 @@ eve_declare_module(NAME crowd LAYER 0 SCRIPT Crowd SLOT crowd eve_declare_module(NAME ik LIB EVIK LAYER 0 SCRIPT IK GROUP 2d 3d web) eve_declare_module(NAME editor LAYER 6 SCRIPT Editor SLOT editor + THIRDPARTY poco GROUP 3d web OPTIONAL_DEPS procgen) eve_declare_module(NAME plugins LAYER 0 SCRIPT Plugins diff --git a/docs/dev/AI与MCP支持.md b/docs/dev/AI与MCP支持.md index fc73e160..8979fec3 100644 --- a/docs/dev/AI与MCP支持.md +++ b/docs/dev/AI与MCP支持.md @@ -62,6 +62,7 @@ MCP listening on 127.0.0.1:7529 (newline JSON-RPC; use tools/eve-mcp for Cursor | 名称 | 用途 | |------|------| | `eve_status` | 附着 / 暂停 / 端口 / callgraph 摘要 | +| `eve_ui_tree` / `eve_ui_get` / `eve_ui_click` | 普通游戏 retained UI 的语义树查询、控件状态读取和按 ID 点击;与 `eve_host_*` 编辑器宿主互补 | | `eve_eval` | 求值表达式 | | `eve_pause` / `eve_continue` / `eve_step_*` | 运行控制 | | `eve_stack` / `eve_locals` | 栈与局部变量 | @@ -88,6 +89,22 @@ MCP listening on 127.0.0.1:7529 (newline JSON-RPC; use tools/eve-mcp for Cursor - `eve://ai-session` - `eve://callgraph` +### 普通游戏 UI 自动化 + +`eve_ui_*` 面向游戏内通过 `UIHost` / `ui.mountBuildAs()` 创建的 retained UI; +`eve_host_*` 则面向 `eve mcp` 的 JSON EditorHost。两套 UI 都通过 MCP 暴露,但不会互相绕过 +各自的事件和绑定模型。 + +```text +eve_ui_tree { "host": "editor-v2" } +eve_ui_get { "host": "editor-v2", "widget": "asset-tree" } +eve_ui_click { "host": "editor-v2", "widget": "asset-tree" } +``` + +`eve_ui_click` 把 click 排入普通 UI 的 pending event 队列。事件仍由 +`ui.dispatchEvents()` 分发,再由下一帧游戏逻辑通过 `ui.consumeClick()` 或注册回调消费; +它不会直接调用业务函数。省略 `host` 时,widget id 必须在全部 host 中唯一,否则返回歧义错误。 + ### Prompts - `debug_failure` — 用切片排查失败 @@ -246,16 +263,38 @@ AI 通过 `eve_host_vm_register` 把 Squirrel 源编译进主机 VM 并注册为 `eve_host_editor_apply/remove/list/state/set_value/save/unload`、 `eve_host_vm_register/unregister`、`eve_host_events`(读取并清空 `{editor,widget,type:click|change,value}`)、`eve_host_widget_rect`、 -`eve_host_capture`、`eve_host_script`、`eve_host_shutdown`。 +`eve_host_capture`、`eve_host_script`、`eve_host_resource_reload`、 +`eve_host_hot_reload_status`、`eve_host_shutdown`。 首次 `editor_apply` 自动开默认窗口(1280×800);`editor_save` 把 View + VM 源持久化到 项目 `editors/.editor.json` 与 `editors/.vm.nut`,`eve mcp` 启动时自动恢复。 +### MCP 模式资源与脚本热更新 + +`eve mcp` 会监听项目目录,并在主循环内对文件事件做 100 ms 防抖。以下文件保存后无需 +重新编译或重启主机;当前 MCP 连接、窗口和编辑器会话保持不变: + +- `mcp.nut`:项目级 MCP 主机扩展脚本,启动时自动执行,后续保存自动重载。 +- `mcp/**/*.nut`:可拆分的主机脚本。 +- `editors/.vm.nut`:对应编辑器的 Squirrel ViewModel。 +- `editors/.editor.json`:对应编辑器的 JSON View;JSON 内 `id` 必须与文件名一致。 +- 其他资源交给已有 `IAssetReloader`(材质、着色器等)按扩展名认领。 + +View 或 ViewModel 成功替换后,主机会恢复该编辑器现有的控件值,避免滑杆、输入框等 +交互状态因改代码而重置。脚本编译失败或 JSON 无效时,旧版本继续运行;失败详情只进入 +诊断状态,不会终止 MCP 进程。运行期错误仍应避免在脚本顶层产生不可逆副作用。 + +Agent 可用 `eve_host_resource_reload {"path":"mcp.nut"}` 主动触发一次确定性重载, +并用 `eve_host_hot_reload_status` 查询 `enabled / watchCount / reloadCount / +failureCount / lastPath / lastResult`。Squirrel 内可调用同构接口 +`eve.host.reloadResource(path)` 与 `eve.host.hotReloadStatus()`。 + ### 脚本 API `eve.host`:`status / openWindow / closeWindow / windowState / applyEditor / removeEditor / setValue / events / registerVM / unregisterVM / widgetRect / -capture / save / runScript`。脚本可定义 `eve_host_update(dt)` 与 `eve_host_render()` +capture / save / runScript / reloadResource / hotReloadStatus`。脚本可定义 +`eve_host_update(dt)` 与 `eve_host_render()` 钩子参与每帧更新与绘制(用 `eve.host.widgetRect` 在 viewport 内自绘预览)。 ### 测试 @@ -266,7 +305,8 @@ capture / save / runScript`。脚本可定义 `eve_host_update(dt)` 与 `eve_hos 新增用例:`devtools.mcp.stdioTransport`(stdio 握手 + tools/list)、 `devtools.mcp.hostEditorBinding`(VM 注册、双向绑定、onChange、事件、 -save→unload→reload 持久化往返)。 +save→unload→reload 持久化往返)、`devtools.mcp.hostResourceHotReload` +(View / ViewModel / mcp.nut 重载、状态保留、错误回退、路径隔离与诊断)。 ## 测试 diff --git a/docs/dev/游戏内容编辑器组件设计分析.md b/docs/dev/游戏内容编辑器组件设计分析.md new file mode 100644 index 00000000..1d2f52fc --- /dev/null +++ b/docs/dev/游戏内容编辑器组件设计分析.md @@ -0,0 +1,788 @@ +# EVEngine 游戏内容编辑器组件设计分析 + +日期:2026-08-24 +状态:设计提案 +范围:场景、资产、材质、完整内容生产编辑器及玩家创造/建造玩法体系 +设计依据:EVEngine 当前模块、工具链、内容生产需求与玩家创造玩法需求 + +配套落地设计:[`编辑器具体 API 与组件设计`](编辑器具体API与组件设计.md) + +## 1. 结论先行 + +EVEngine 不应继续停留在“由每个游戏自行拼装若干 Toolbar/Inspector 状态”的阶段。推荐建设一套 **可选安装、可裁剪、数据驱动、编辑器/游戏/Agent 三端同构** 的官方内容编辑器: + +- `editor-core` 提供文档、命令、选择、焦点、事务、撤销、属性描述和扩展注册,不依赖渲染、场景、材质等业务模块; +- `editor-shell` 提供窗口、Dock、布局、菜单、命令面板、状态栏和工作区,不拥有业务数据; +- 场景、资产、材质、动画、粒子、UI、地图等模块各自通过 capability 和 descriptor 提供编辑能力,禁止编辑器核心向上包含模块头文件; +- 资产使用稳定 GUID、可异步查询的元数据索引、依赖图和导入产物;源文件、可编辑文档、平台产物三者分离; +- 所有写操作统一走 `Command -> Transaction -> Validation -> Document/Asset mutation -> Journal`,菜单、快捷键、拖放、MCP 和脚本不得各自实现修改逻辑; +- 官方编辑器只是一种宿主。相同工具、命令、事务与描述符还可供玩家建造模式、游戏内编辑器、CLI、测试和 `eve-mcp` 使用; +- “开发编辑器”和“玩家建造玩法”不分叉业务实现:二者只替换 Shell、权限策略、数据目标和持久化后端;过山车、公园、城市、房屋、载具等搭建玩法应直接复用场景编辑能力; +- 游戏玩法模块可以反向注册专用工具、Inspector、Overlay、验证规则与模拟模式,使官方编辑器自动成为该游戏的专用编辑器; +- 第一阶段优先实现编辑器内核、资产数据库、反射属性和场景文档,而不是先堆 UI 面板。 + +这套方案建立在 EVEngine 的模块裁剪、capability、Squirrel 生产接口和 AI 工具优势之上,以稳定身份、文档事务、完整内容工作流和运行时可裁剪性补齐当前能力,同时避免引入全局对象模型和重型二进制包体系。 + +## 2. 设计边界与需求归纳 + +### 2.1 内容编辑内核需求 + +内容编辑能力需要形成以下统一机制: + +1. `DocumentKey` 以资产 GUID、资源路径或会话 ID 表示稳定文档身份;文档记录 revision、saved revision、外部 revision、脏状态、冲突和保存票据。 +2. 一个文档允许多个 View,脏状态属于文档而不是面板;View 关闭后,历史记录仍能通过稳定 locator 找回文档。 +3. `EditorCommandRegistry` 将菜单、快捷键、工具栏、上下文菜单、拖放、自动化和 API 统一为带焦点与选择快照的命令上下文。 +4. 命令执行自动进入用户动作事务;Undo 不只恢复属性,还恢复选择、焦点、层级、Prefab、组件顺序和文档 revision。 +5. Panel 类型先声明交互契约再注册到窗口系统,防止 UI 类成为业务权威。 +6. 资产 GUID、依赖图、导入设置、导入产物、删除预检、外部文件冲突和原子保存形成完整闭环。 +7. 通用 Node Graph 被材质、粒子、动画状态机等复用,而非各写一套节点画布。 +8. MCP 使用同一公开命令/资产/场景接口,Agent 并不模拟鼠标点击 UI。 + +实现应避免过多细颗粒代理、全局 singleton 服务和 UI 驱动的运行时约定。EVEngine 更适合用 C++ 小接口、值类型描述符和 Squirrel 扩展保持边界清晰。 + +### 2.2 完整内容生产工作流需求 + +官方内容编辑器不能只提供局部控件,还应覆盖完整工作流:主工作区组合 Viewport、Outliner、Details、Content Browser 和编辑模式;不同资产按类型进入专用编辑器;资产索引在不加载资产的情况下异步提供元数据;Cook 将编辑源格式转换为平台运行格式;数据验证、源代码管理和协作状态在内容工作流中可见。 + +相关能力按当前项目阶段分三类处理: + +- **首期必需**:主工作区、资产浏览/搜索、统一 Details、专用资产编辑器、预览场景、导入/Cook、验证、引用审计、Play In Editor; +- **适度简化**:世界分区、地形植被、建模、Sequencer、视觉脚本、行为树、动画蓝图;先提供扩展框架,再随运行时能力落地; +- **暂不建设**:全局对象反射体系、重型二进制 Package、专用多人事务服务器和完整视觉脚本编译器;它们的工程规模不符合当前 EVEngine。 + +### 2.3 EVEngine 当前基础 + +EVEngine 已有可复用基础: + +- `editor` 模块:`IEditorTool`、`IEditableTarget`、笔刷、约束、Overlay、Inspector、Gizmo、History; +- `scene` / `entity` / `sceneloader`:场景树、实体及场景加载能力; +- `filesystem`:资源访问、HotReload、压缩与虚拟路径; +- `graphics` / `model3d` / `animation` / `particles` / `ui`:内容运行时; +- `Capability.h`:可选模块跨层能力注册; +- `module_manifest.cmake`:单一模块声明与裁剪; +- DevTools、MCP、`creative-brain`、`scene-qc-agent`、`story-scene-agent`:自动化、场景构建和视觉质检; +- JSON View + Squirrel ViewModel 的 editor host:适合快速生成项目专用工具。 + +当前缺口是“构件之上的内容生产协议”:缺少统一资产身份与索引、通用属性元数据、正式文档模型、跨面板事务、专用资产编辑器注册、导入产物状态以及官方完整工作区。现有 `EditorHistory` 适合局部工具,不能单独承担整个编辑器的一致性。 + +## 3. 设计原则 + +### 3.1 开发编辑器是可选产品,编辑能力是运行时可复用内核 + +发布游戏默认不携带开发者 Dock Shell、源文件导入器、缩略图生成、项目设置、Cook、源控和无限制资产浏览等生产工具;但 Tool、EditableTarget、Command、Transaction、Selection、Property、Overlay 等 **运行时安全的编辑协议** 必须可独立裁剪进游戏。模拟经营、建造和沙盒游戏可以把它们作为核心玩法,而不是复制一套“游戏版编辑器”。 + +因此应拆成两种构建能力: + +- `editing-runtime`:可发布,提供工具、目标、命令、事务、约束、选择和运行时属性; +- `authoring`:仅开发环境,增加项目文档、源资产导入、Cook、源控、开发诊断和官方桌面 Shell。 + +`3d`/`2d` 运行组不自动包含二者,游戏在 manifest 中按需选择 `editing-runtime`。Cook 必须移除未被游戏授权的开发者命令和 EditorOnly 元数据,而不能简单假设所有编辑功能都不进入发布包。 + +### 3.2 数据模型先于 UI + +面板只投影文档和服务状态。按钮不得直接写 SceneNode 或材质;它触发命令,命令在事务里操作明确的文档或编辑目标。这样同一动作才能被快捷键、脚本、MCP、测试和未来协作系统复用。 + +### 3.3 跨模块只走接口 + +遵循仓库 layering 规则:低层编辑器核心不包含 `scene`、`graphics`、`animation` 等模块头。消费者在 `common/` 或自己模块声明接口,提供者通过 `eve::cap::provide/query` 注册。模块是否存在必须可查询,缺失时相关工具和资产类型自然消失。 + +### 3.4 文本友好与稳定引用 + +场景、Prefab、材质图和导入设置优先采用有 schema/version 的确定性文本格式;大二进制源文件与 Cook 产物单独保存。引用使用 GUID,不把可变路径作为身份。保存输出字段顺序稳定,便于 Git diff 和合并。 + +### 3.5 人类与 Agent 同构 + +MCP 工具调用命令服务和查询服务,不控制 ImGui。每个编辑能力都应同时定义:人类入口、命令 ID、参数 schema、查询快照、校验结果和可撤销语义。 + +### 3.6 渐进复杂度 + +默认界面只暴露常用属性;高级、调试和平台特定项可折叠。专用编辑器复用统一 Viewport、Property、Graph、Timeline 等组件,避免形成多个风格不一致的孤岛。 + +### 3.7 编辑器与游戏同构 + +编辑行为不得以 Dock、菜单、ImGui 控件或桌面文件为前提。一项能力只有同时满足下列条件,才算正式进入编辑器体系: + +1. 业务操作由 presentation-neutral 的 Tool/Command 表达; +2. 操作目标通过 capability 暴露,而不是绑定开发编辑器对象类型; +3. UI 由描述符投影,桌面 Editor UI 和游戏 UI 可以采用不同布局与视觉; +4. 持久化由上下文注入,可写项目文档、关卡草稿、SaveGame、服务器世界状态或用户蓝图; +5. 权限、成本、解锁条件、地图边界等由 Policy/Constraint 决定,不散落在按钮回调中; +6. 同一操作在两个宿主中具有一致的预览、提交、取消和撤销语义。 + +这里的“同构”不是要求游戏显示开发者界面,而是要求 **模型与行为同源、呈现与权限可换**。例如开发编辑器的“放置过山车轨道”可以显示坐标、曲率和碰撞调试;玩家界面则显示造价、满意度影响和解锁状态,两者仍调用同一个轨道放置工具和轨道网络命令。 + +## 4. 总体架构 + +```text +Developer Shell Game Shell +(Dock/Menu/Project/Source UI) (HUD/Radial/Palette/Touch UI) + \ / + Presentation Descriptors + | +Editing Runtime (Tool / Command / Selection / Transaction / Constraint) + | +Target & Gameplay Capabilities ---------------------------------+ + | | | | | | + Scene target Asset target Material Animation UI Game systems + | | | | | | + Runtime modules: scene, filesystem, graphics, model3d, animation, ui, ... + | + Persistence / Authority Adapter + Project Document | SaveGame | Server World | User Creation + +Developer-only Authoring Services: +Document Registry / Asset Import / Cook / Source Control / Project Validation +``` + +推荐模块拆分: + +| 模块 | 层级/依赖 | 职责 | +|---|---|---| +| `editor`(扩展现有,发布安全) | 纯协议和值类型 | Tool、Target、Command、Transaction、Selection、Constraint、Property descriptor | +| `editing_runtime`(可与 editor 合并) | 可选运行时组 | 编辑会话、Undo、运行时 palette/schema、SaveGame/服务器提交适配 | +| `assetdb` | L0,依赖 filesystem/data | GUID、元数据索引、依赖图、导入状态、查询;运行时可裁剪为只读 manifest | +| `authoring` | authoring 组 | DocumentRegistry、UndoJournal、Validation、ImportCoordinator、Cook orchestration | +| `editorui` | authoring,依赖 ui/window/input | Dock shell、通用面板、PropertyGrid、GraphView、TimelineView | +| `editor_scene` | authoring adapter | 场景文档、Outliner、Viewport、Prefab、Play session | +| `editor_render` | authoring adapter | 材质、纹理、网格、RenderGraph/后处理预览 | +| 各业务编辑适配 | 可选 | animation/particles/map/ui/audio 等专用编辑器与描述符 | + +不建议建立一个依赖所有模块的巨型 `EVEditor`。模块清单派生编辑器功能,编辑器启动时从 registry 构造可用菜单与资产类型。 + +同一注册表允许多个 host profile:`developer`、`runtime-builder`、`runtime-admin`、`automation`。Profile 只决定某个 descriptor 是否可见、用哪种 presenter、允许哪些命令以及连接哪个持久化后端,不改变工具实现。 + +## 5. 编辑器基础组件 + +### 5.1 Workspace 与 Dock Shell + +职责:多窗口、多显示器、Dock/浮动、文档标签、工具面板、布局保存和工作区切换。 + +预置工作区: + +- Level:场景视口 + Outliner + Inspector + Content Browser; +- Material/VFX:图画布 + Preview + Details + Palette + Compile Results; +- Animation:骨骼树 + Preview + Timeline/Curve + Details; +- UI:Canvas + Hierarchy + Inspector + Anchors/Layout; +- Debug:Game View + Console + Profiler + Render/Physics inspectors。 + +布局保存为用户级配置,不能污染项目内容。项目可提供推荐布局,但用户布局优先。面板缺失时保留占位记录,插件恢复后可重新装载。 + +核心接口:`IPanelFactory`、`PanelDescriptor`、`WorkspaceLayout`、`IWorkspaceService`。Descriptor 至少包含稳定 type ID、显示名、菜单路径、单例策略、所需 capability、默认停靠区和交互上下文。 + +### 5.2 Command、Shortcut、Focus 与 Selection + +所有用户意图注册为稳定命令 ID,例如: + +```text +scene.object.create +scene.selection.frame +asset.import +asset.rename +material.graph.connect +edit.undo +play.start +build.cook +``` + +`CommandContext` 固化触发时的 source、active document、focused panel、selection snapshot、input context、payload。延迟菜单必须保存原上下文,不能执行时重新读取选择,否则会修改错误对象。 + +Selection 使用带类型 item:`SceneObjectSelection`、`AssetSelection`、`GraphNodeSelection`、`TimelineKeySelection`。主选择和集合分离;支持 selection channel,使 Scene View、Outliner、Inspector 联动,同时不覆盖 Material Graph 的内部选择。 + +快捷键按 input context 分层解析:模态框 > 文本输入 > 当前工具 > 当前文档 > 全局。支持用户配置、冲突检查和可搜索命令面板。 + +### 5.3 Document Registry + +文档是可保存内容的唯一编辑权威。建议模型: + +```cpp +struct DocumentKey { AssetTypeId type; Guid asset; }; +struct DocumentRevision { uint64_t edit, saved, disk, preview; }; +enum class DocumentState { Loading, Ready, Saving, Conflict, Failed, Closed }; +struct DocumentLocator { Guid stableViewlessId; DocumentKey key; }; +``` + +一个 Scene 文档可以同时被 Scene View、Outliner、Inspector 投影;材质文档可以被图、预览和参数面板投影。面板关闭不等于文档关闭。文档管理: + +- 打开去重和最近文档; +- revision 与 dirty; +- Save、Save As、Save All、Discard; +- 异步保存 ticket,保存期间继续编辑不会错误清除 dirty; +- 外部文件变化检测与 Reload/Keep/Merge; +- 关闭项目/编辑器的统一未保存协调; +- 会话恢复与崩溃恢复草稿; +- 文档 schema migration。 + +保存采用 compare-and-swap 文件状态与临时文件原子替换,避免外部修改或并发保存被静默覆盖。 + +### 5.4 Transaction 与 Undo Journal + +事务是所有变更的边界。`IEditCommand` 提供 `apply/revert/merge/describe/affectedDocuments`。连续拖动在开始时捕获 before,更新 preview,结束时提交一个合并命令;取消则恢复 before。禁止每个鼠标像素生成一条历史。 + +历史需要覆盖: + +- 属性修改与多选批改; +- 创建、删除、复制、重父级和重排; +- 组件增删与顺序; +- Prefab override/apply/revert/unpack; +- 资产创建、移动、重命名、删除、导入设置; +- 图节点与连线; +- 时间轴 key/curve; +- 选择和焦点的合理恢复; +- 文档 revision 同步。 + +每个动作记录 origin(User、Script、Automation、Importer)、命令 ID、时间、影响文档和简短摘要。History 面板可查看、跳转和复制诊断信息。导入器后台刷新不应混入用户 Undo,但必须可通过 reimport 恢复确定状态。 + +### 5.5 Property/Reflection 系统 + +这是 Inspector、序列化、资产元数据和自动化共同依赖的关键设施。避免引入宏驱动的全局对象模型,采用显式注册和值访问器: + +```cpp +PropertyDescriptor { + id, displayName, category, type, flags, + getter, setter, defaultValue, + range, step, units, enumItems, + assetTypeFilter, visibilityPredicate, + validator, customEditorFactory +} +``` + +支持 bool、整数、浮点、字符串、枚举、颜色、向量、矩阵/TRS、数组、结构、Variant、对象引用、资产引用和只读统计。元数据支持 tooltip、单位、范围、滑块、角度、颜色空间、文件过滤、可见条件、ReadOnly、Advanced、EditorOnly、Transient。 + +Inspector 必须支持:搜索、分类折叠、混合值、多选交集、恢复默认、复制/粘贴属性、锁定选择、资产拖放、错误/警告内联显示和属性级 Undo。属性 descriptor 同时导出为 MCP/JSON schema。 + +### 5.6 Validation 与 Diagnostics + +验证是独立服务,不属于 Inspector。规则可针对 Asset、Document、Selection、Scene、Project、BuildTarget;结果包括 severity、稳定 rule ID、对象定位、说明和可选 quick fix 命令。 + +基础规则:命名、丢失引用、循环依赖、非法层级、Prefab override 失效、纹理/网格预算、材质编译、碰撞缺失、场景无主相机、脚本错误、Cook 不支持格式。支持保存前轻量验证、手动全项目扫描和 CI headless 扫描。 + +## 6. 资产系统与 Content Browser + +### 6.1 三层资产模型 + +```text +Source 原始 PSD/PNG/FBX/WAV/GLTF 等,可在项目内或外部源目录 +Authoring .eveasset/.evscene/.evmat 等可编辑文档 + .meta GUID/导入设置 +Artifact 缓存中的平台/配置产物,内容寻址,可删除重建 +``` + +不要让运行时直接依赖原始源文件,也不要把派生产物提交为业务权威。`AssetRecord` 至少包含 GUID、逻辑路径、类型、schema、source URI、content hash、importer ID/version、settings hash、artifact keys、tags、dependencies、status 和 diagnostics。 + +### 6.2 Asset Database + +AssetDB 启动时异步扫描元数据;Content Browser 查询未加载资产,不为显示一张列表而加载模型或材质。功能包括: + +- GUID <-> path 与移动/重命名保持引用; +- 类型、标签、路径、修改状态和全文字段查询; +- hard/soft/editor-only/build 依赖边; +- 正向 References 与反向 Referencers; +- 文件监听、增量索引、重复 GUID 检测; +- 缩略图 key 与导入状态; +- 资产删除预检、重定向/修复引用; +- 缓存可重建,索引损坏不丢业务数据。 + +建议项目布局: + +```text +Content/ 可编辑资产 +SourceAssets/ 可选原始文件 +.eve/meta/ 若不使用同目录 sidecar,可集中存 GUID 元数据 +.eve/cache// 导入/Cook 产物(忽略提交) +.eve/editor/ 用户无关的项目编辑设置 +Saved/ 日志、草稿、自动保存、崩溃恢复 +``` + +### 6.3 Content Browser 组件 + +组成:Sources Tree、Breadcrumb、Search、Filters、Collections、Asset View、Preview/Details、Import Queue。提供网格、列表、列三种视图,支持多实例和保存查询。 + +操作:创建、导入、打开、拖入场景、重命名、移动、复制、复制引用、重新导入、Reveal Source、验证、引用查看、迁移、删除预检。集合是对 GUID 的软组织,不改变目录;支持本地和项目共享集合。 + +搜索语法建议: + +```text +type:material tag:environment path:/Content/World has:error +ref:/Content/Characters/Hero size>10MB modified:true +``` + +缩略图由 `IAssetPreviewProvider` 异步生成,使用固定预览场景与内容哈希缓存。列表滚动不得同步创建 GPU 资源。 + +### 6.4 Import、Reimport 与 Cook + +`IAssetImporter` 声明扩展名、目标类型、设置 schema、版本和 headless 能力。导入流程:候选识别 -> 设置草稿 -> 后台解析 -> 临时产物 -> 验证 -> 原子发布 -> 更新依赖/缩略图 -> 通知热重载。失败保留上一份可用产物。 + +Reimport 使用保存的 source URI 与 settings;Importer 版本或设置 hash 变化使资产变为 stale。批量导入显示队列、阶段、耗时、取消和诊断。 + +Cook 从显式入口场景/Primary Asset 出发遍历依赖,剔除 EditorOnly,按 target 生成 manifest 和内容包。支持 dry-run、缺失引用报告、体积统计、chunk/标签规则和增量 Cook。编辑器 Build 面板只是 CLI pipeline 的前端,CI 使用完全相同命令。 + +## 7. 场景与 Prefab 编辑器 + +### 7.1 场景文档 + +场景源格式应包含稳定 object/component ID、父子关系、local TRS、组件类型与属性、Prefab 来源与 override、外部子场景引用、编辑器标签/层。序列化顺序确定,运行时句柄不写入文档。 + +场景修改先落在 `SceneDocument`,再由 adapter 同步到预览运行时;不要把正在运行的 SceneNode 指针当作持久文档。文档可在无 GPU/headless 环境加载、验证和变换。 + +### 7.2 Level Workspace 组件 + +默认布局:中央 Scene View;左侧 Place/Tools;右侧 Outliner + Inspector;底部 Content Browser;状态栏含 Console、任务、源控和 Cook 状态。 + +Scene View: + +- 透视、顶/前/侧正交、多视口; +- 编辑相机书签、聚焦选择、飞行/轨道导航; +- 选择轮廓、框选、多选、遮挡选择策略; +- Translate/Rotate/Scale/Bounds gizmo,local/world/pivot,网格/角度/表面/顶点吸附; +- Lit、Unlit、Wireframe、Normals、UV density、Overdraw、Lighting-only、Collision、Navigation 等可注册视图模式; +- Grid、坐标轴、相机、灯光、音频范围、碰撞、路径、物理约束等 overlay; +- 资产拖放预览,确认时形成单个事务; +- 工具模式:选择、放置、地形、植被、瓦片、样条/道路、体素、测量;模式来自 registry。 + +Outliner: + +- 层级、搜索、类型/标签/层过滤; +- 多选、重命名、拖拽重父级和顺序调整; +- 可见、锁定、EditorOnly、Prefab 状态列; +- Folder/Collection 只组织编辑器视图,不必改变运行时层级; +- 多实例、保存过滤器和列配置; +- 大场景虚拟化列表和增量 diff,避免每帧重建整棵树。 + +Place Palette:按类别显示可创建对象、组件模板、Prefab 和常用资产;拖放或点击进入 placement tool。项目可注册自己的 archetype。 + +### 7.3 Prefab + +Prefab 是独立资产文档,实例保存 Prefab GUID、内部对象稳定 ID 映射和最小 override 集。功能: + +- 从选择创建、嵌套 Prefab、隔离编辑; +- override 可视化、Apply、Revert、Unpack; +- 丢失/重命名字段迁移; +- 实例结构冲突诊断; +- 变体通过父 Prefab + override 实现,不复制整份数据。 + +Prefab 修改向所有预览实例传播,但必须保留实例 override。场景历史引用稳定 ID,传播后仍可恢复。 + +### 7.4 Play In Editor + +提供 Edit、Simulate、Play 三态: + +- Play 从场景文档快照创建隔离运行世界; +- Stop 丢弃运行态变化并恢复编辑选择/相机; +- 可选 Keep Changes 只生成可审查的属性差异事务,不能直接把整个运行世界覆盖回文档; +- Pause、Step Frame、Time Scale、输入焦点切换; +- Game View 与 Scene View 分开,相机和 overlay 状态互不污染; +- 脚本热重载失败保留上一可运行版本,并显示诊断。 + +未来大型世界可通过 `IScenePartitionProvider` 增加 cell/streaming、Data Layer、HLOD 和世界原点工具;核心场景编辑器只理解可加载区域接口,不内置 UE World Partition 模型。 + +## 8. 材质、Shader 与渲染编辑器 + +### 8.1 材质资产模型 + +分为: + +- Material Graph:可编译图,定义 shading model、render state、参数和输出; +- Material Function:可复用子图; +- Material Instance:父材质 + 参数 override,不复制图; +- Render Effect/Render Pipeline:后处理或 RenderGraph 资产,使用同一通用图基础设施但不同节点域。 + +图文档使用稳定 node/pin ID、类型化 pin、默认值、位置/注释、schema version;编译结果和生成 shader 不写回业务文档。 + +### 8.2 Material Editor 布局 + +Graph Canvas、Preview Viewport、Details、Node Palette、Parameters、Compile Results、Stats。支持: + +- 搜索创建节点、拖线创建兼容节点、reroute、comment、group、对齐; +- 类型检查、隐式转换规则、循环检测、死节点提示; +- 实时预览开关和 debounce,编辑期间取消过时编译; +- 球/平面/立方体/自定义网格、HDRI/灯光环境、背景和曝光; +- 编译错误定位到节点/pin; +- shader instruction、sampler、uniform、variant 和 pass 统计; +- 参数分组、默认值、instance hierarchy 和查找使用者; +- Apply/Save 语义明确:预览可用草稿,运行场景只接收成功编译版本。 + +### 8.3 通用 Graph Framework + +Material、Particle、Animation FSM、Behavior Tree、RenderGraph 不共享业务节点,却共享画布和文档操作:pan/zoom、选择、节点布局、连线、复制粘贴、搜索、注释、minimap、Undo、diff。定义 `GraphDomainDescriptor` 提供节点目录、pin 类型系统、连接规则、编译器、Details provider 和调试 overlay。 + +这避免不同资产编辑器重复实现 graph core,也为 EVEngine 未来可视化玩法逻辑保留入口,而不承诺立即实现完整视觉脚本系统。 + +## 9. 其他专用编辑器清单 + +以下是根据 EVEngine 内容生产与玩家创造需求补全后的组件版图。优先级见第 13 节。 + +| 编辑器/工具 | 核心组件 | 与运行时模块关系 | +|---|---|---| +| Static Mesh | Preview、LOD、材质槽、Bounds、UV、Normals/Tangents、Collision、导入统计 | model3d/graphics/physics | +| Skeletal Mesh/Skeleton | 骨骼树、Socket、Skin/权重预览、材质、LOD | animation/model3d | +| Animation Clip | 播放、Timeline、Notify、Root Motion、曲线、压缩统计 | animation | +| Animation FSM/Blend | 通用图、状态/过渡、参数、实时状态调试 | animation | +| Physics Asset | Body/Shape、Constraint、碰撞层、模拟预览 | physics | +| Particle/VFX | Emitter/Init/Update/Render 图、预览、参数、GPU 统计 | particles/gpgpu | +| Audio | Waveform、Trim/Loop、Cue/Graph、衰减范围、混音预览 | audio/sound | +| UI | Canvas、Hierarchy、Inspector、Anchor、Layout、Style、交互事件、分辨率预览 | ui | +| Tile/Map | Tile palette、layer、brush/fill/stamp、collision/nav overlay | map/grid/editor | +| Terrain | Sculpt/Smooth/Flatten、paint layer、erosion、foliage、LOD/chunk | procgen/scene | +| Voxel | block/material palette、brush、chunk/LOD、mesh preview | voxel | +| Scene Lighting/Environment | 天空、雾、日夜、天气、探针和调试视图 | daynight/weather/graphics | +| Sequencer/Timeline | 多轨道、对象绑定、Transform/Animation/Audio/Event、曲线 | animation/audio/scene | +| Behavior/Decision | Behavior Tree/utility/decision graph、blackboard、运行调试 | decision/AI modules | +| Navigation | Navmesh bake、区域、link、路径查询和热力图 | spatial/pathfinding | +| Localization | 文本表、语言、缺失键、伪本地化、字体覆盖 | i18n/font | +| Dialogue/Quest | 节点图、条件、文本/配音引用、流程验证 | dialogue/rpg | +| Database/Data Table | schema、表格、过滤、引用、CSV import/export | data/database | +| Render Pipeline | Pass/资源图、效果槽、帧捕获与资源生命周期诊断 | graphics/gpgpu | +| Build/Profile | target、模块裁剪、Cook、包体分析、性能捕获 | system/module manifest/devtools | + +专用编辑器应按资产类型注册 `IAssetEditorFactory`。双击资产由工厂选择编辑器;没有专用编辑器时回退到通用 Inspector/文本/二进制信息视图。 + +## 10. 编辑器与游戏双向复用 + +### 10.1 一份能力,四种可替换部件 + +任意可复用编辑功能必须拆成以下四部分: + +| 部件 | 例:铺设过山车轨道 | 开发编辑器 | 玩家游戏 | +|---|---|---|---| +| Tool/Command | 放置控制点、连接轨道、调整坡度 | 工具栏、快捷键、数值输入 | 建造菜单、手柄/触摸、引导提示 | +| EditableTarget | 轨道网络与地形能力 | SceneDocument 预览目标 | 权威游戏世界/服务器目标 | +| Policy/Constraint | 曲率、碰撞、边界、连接规则 | 可警告或调试绕过 | 强制规则、造价、科技、领地权限 | +| Presenter | palette、properties、overlay 意图 | Dock/Inspector/debug overlay | HUD、径向菜单、世界空间 UI | + +Tool 不查询“当前是不是编辑器”,而是查询上下文提供的 capability。例如 `ICurrencyBudget` 缺失时不显示造价;存在时提交前执行预算预留。这样开发工具可以完整调试几何,玩家工具可以叠加玩法规则,核心轨道生成逻辑仍只有一份。 + +### 10.2 Host、Session、Target 与 Authority + +建议明确四个不同概念: + +- **Host**:输入和 UI 容器,例如桌面编辑器、游戏 HUD、触屏建造界面、VR 手柄或 MCP; +- **EditorSession**:当前工具、选择、指针捕获、事务和局部历史; +- **EditableTarget**:实际被改的能力集合,例如 SceneDocument、运行世界、地图存档、Prefab 草稿; +- **Authority**:谁能接受提交,例如本地项目、单机 SaveGame、多人服务器或创意工坊草稿。 + +`EditorSession` 不拥有目标,也不假设文件存在。一次事务先在目标上 preview,经 Constraint/Authority 预检后 commit。多人游戏中客户端可预测预览,服务器复验命令并返回 authoritative transaction ID;拒绝时恢复预览。开发编辑器则由 Document Authority 校验 revision 并写入文档历史。 + +### 10.3 持久化策略 + +同一命令产生领域差异或操作描述,不直接打开文件: + +```text +SceneDocumentPersistence -> .evscene/.evprefab,支持 Save/Save As/Git +SaveGamePersistence -> 玩家存档,记录世界状态或玩家蓝图 +ServerWorldPersistence -> 服务端事件/快照,带权限和并发版本 +UserCreationPersistence -> 沙盒作品/关卡分享包,经过白名单 Cook +TransientPersistence -> 教程、预览、规划模式,不落盘 +``` + +开发文档可以保存源资产 GUID、EditorOnly 注释和精确浮点;玩家作品只能引用运行时白名单资产和参数,不能夹带任意路径、脚本、shader 或开发命令。用户创作内容导出前运行独立验证和安全 Cook。 + +### 10.4 UI 描述符而非 UI 复用 + +不要直接把开发编辑器 Inspector 嵌进游戏。Tool/Property/Action descriptor 提供语义:类别、图标语义、字段类型、单位、范围、危险级别、适用条件、快捷 action 和帮助文本。不同 presenter 决定具体控件、信息密度和美术风格。 + +例如同一个 `TrackSlope` 属性: + +- 开发 Inspector:精确角度输入、曲率图、碰撞与切线诊断; +- PC 游戏:滑杆、造价变化、有效/无效颜色; +- 主机游戏:左右肩键分档调整; +- 移动端:世界空间拖柄和大尺寸确认按钮; +- Agent:JSON schema 中的 number、范围、单位与 validation rule。 + +共享的是属性路径、读写命令、约束和 Undo,不是像素布局。 + +### 10.5 游戏玩法注入开发编辑器 + +游戏模块应提供 `IGameEditorExtension` 或等价注册入口,在项目加载后注册: + +- 自定义 Tool 与 placement archetype; +- 领域 Property provider 和 Inspector section; +- 场景 Overlay、Gizmo 与 debug visualization; +- 资产类型、预览器和创建菜单; +- Validation/Quick Fix; +- 自定义 Workspace/Panel; +- Play/Simulate 模式和领域 profiler; +- 命令、MCP schema 和自动化 fixture。 + +示例:城市建造游戏注入道路、分区、公共交通、人口热力图和经济模拟时间控制;塔防游戏注入路径波次、射程 Overlay 和关卡合法性检查;过山车经营游戏注入轨道编辑、列车测试、G-force 曲线、游客可达性和造价预算。它们进入官方编辑器后仍调用游戏正式运行系统,不能维护一套“仅编辑器近似模拟”。 + +扩展注册必须由模块 manifest/插件派生,不能手改中央菜单和 boot list。游戏模块卸载时,对应面板和工具优雅消失,未知组件/数据仍可保真保存。 + +### 10.6 玩家建造模式的产品级要求 + +运行时编辑不是开发工具的阉割开关,还需要正式玩法能力: + +- 规划/预览、确认/取消和批量连续建造; +- 造价、退款、资源预留和原子扣款; +- 解锁、领地、角色权限和多人所有权; +- 网格、道路、表面、连接点和语义吸附; +- 拆除、升级、复制、蓝图/模板和作品分享; +- 手柄、触摸、键鼠及可访问性输入; +- 操作教学、失败原因和建议修复; +- 大规模增量重建与后台派生数据更新; +- 多人预测、服务器验证、冲突与恶意 payload 限制; +- 存档迁移和旧版本玩家作品兼容。 + +这些能力通过 Policy、Authority、Persistence 和 Presenter 扩展通用编辑协议,不应污染几何工具本身。 + +### 10.7 同构验收矩阵 + +每个计划进入游戏的编辑组件至少通过: + +1. 同一个 Tool 在 developer host 和 runtime host 上操作同一种目标 capability; +2. 两端对相同输入生成等价领域命令/差异; +3. 两端都支持 preview、cancel、commit、undo; +4. 更换 UI presenter 不修改 Tool 或 Target; +5. 更换 Document/SaveGame/Server persistence 不修改 UI 或领域命令; +6. 运行时 profile 无法发现或调用被裁剪的开发者命令; +7. 游戏扩展能在不修改 editor-core/editor-shell 的情况下注册完整专用工作区; +8. Headless 测试可直接执行相同命令验证确定性。 + +## 11. 协作、源控与外部变化 + +第一版不建设实时多人编辑服务器,但现在就要保证协议可演进: + +- 每个事务记录稳定对象/属性路径和 base revision; +- 文档保存检测 disk revision/content hash; +- Git 状态在 Content Browser、文档 Tab 和状态栏显示; +- Source Control provider 为接口,首个实现调用 Git,不把 Git 命令写入 UI; +- 支持 diff、reveal、stage/commit 可后置,第一期至少有 modified/conflict/ignored/read-only; +- 文本资产提供结构化 diff,场景按对象/组件/属性显示,不只展示 JSON 行差异; +- 外部变化:干净文档自动重载;脏文档进入 conflict,提供 compare/reload/keep/save-as; +- 文件锁仅是 provider 能力,不作为核心假设。 + +未来多人协作可传输同一事务 journal,在本地文档快照上形成会话层;“会话内先不写磁盘、显式 persist”是可选上层,不进入一期。 + +## 12. Agent、脚本与自动化 + +EVEngine 在这一点可以优于传统编辑器。建议为每个服务提供 Query 与 Command 两面: + +```text +query: editor.documents, asset.search, scene.snapshot, material.compile_status +command: asset.import, scene.object.create, property.set, build.cook +``` + +规则: + +- MCP/Squirrel 通过命令 ID 执行,自动进入同一事务与验证; +- 查询返回稳定 ID、revision 和分页数据,不暴露裸指针; +- 写命令带 expected revision,防止 Agent 根据过期场景修改; +- 支持 dry-run,返回影响对象、验证警告和预估产物; +- 批量操作在一个 compound transaction 中,要么完整成功,要么回滚; +- Agent 动作在 History 标记 origin,可人工 Undo; +- screenshot/视觉检查作为补充,语义状态是主要控制回路; +- `creative-brain` 生成的项目专用 editor JSON 可以作为 Workspace 中的 Tool Panel,但业务修改仍调用命令。 + +应补充的 MCP 能力:资产查询与引用图、通用属性 schema、文档脏状态/保存、场景批量事务、材质图 CRUD/编译、验证运行、Cook dry-run、任务进度和诊断定位。 + +## 13. 性能、可靠性与测试指标 + +目标规模(初始工程预算): + +- 100k 资产索引,启动扫描异步,Content Browser 首屏不等待完整扫描; +- 50k 场景对象 Outliner 虚拟化,筛选和选择不遍历/重绘所有行; +- 属性连续编辑 60 Hz preview,Undo 只生成一条记录; +- 缩略图、导入、shader compile、Cook 均为可取消任务; +- UI 线程不做模型解析、hash 大文件或同步 shader compile; +- 崩溃后可发现自动保存草稿,原文件保持原子一致。 + +测试分层: + +1. 纯单元:GUID、索引、查询、descriptor、命令路由、事务合并、schema migration; +2. 文档契约:save ticket race、外部冲突、关闭协调、Undo 后 revision; +3. 导入 golden:相同 source/settings/version 得到相同 artifact key; +4. Headless 集成:打开场景、修改、保存、重载、Cook、依赖闭包; +5. UI 交互:选择联动、拖放、快捷键上下文、面板恢复; +6. GPU 预览:在现有 Vulkan/Xvfb/Lavapipe 环境做短测试; +7. MCP 合同:Agent 与 UI 对同一命令得到相同文档结果; +8. 性能门:10k/100k 资产查询、50k Outliner diff、大图编辑和批量导入。 + +每个业务编辑器的新测试放在独立 `test/editor_.cpp`,不扩张共享 test main。UI 截图测试不代替文档/命令层断言。 + +## 14. 分阶段开发路线 + +### Phase 0:冻结协议与样例(2–3 周) + +- 用一个小型样例项目定义 scene/material/texture 的预期目录和格式; +- 决定 GUID sidecar 位置、URI 规范、schema/version 规则; +- 写出 Tool、Target、Command、Transaction、Property、Policy、Authority、Document、AssetType 最小接口和依赖图检查; +- 将现有 `EditorSession`/Gizmo/Brush 明确定位为 Tool 层,不破坏兼容 API。 +- 建立最小 developer host 与 runtime host:不同 UI presenter 操作同一个测试目标; +- 建立 SceneDocument 与 SaveGame 两种 persistence adapter 的契约测试。 + +退出条件:无 UI 也能 headless 做一个属性事务、Undo 和持久化;同一 Tool 在开发宿主与游戏宿主产生等价领域差异。 + +### Phase 1:内容编辑内核(4–6 周,最高优先级) + +- `assetdb`:GUID、索引、查询、依赖、文件监听; +- `authoring`:DocumentRegistry、Transaction/Undo、Command/Selection/Focus; +- PropertyDescriptor 与通用序列化 adapter; +- 原子保存、外部冲突、自动保存; +- Validation 和 Task/Diagnostic 服务; +- 对外 Squirrel/MCP API。 +- 发布安全的 `editing-runtime` profile、命令白名单和运行时 schema 裁剪; +- GameEditorExtension 注册入口与至少一个样例游戏专用工具。 + +退出条件:100k synthetic asset 索引测试通过;所有写操作经命令与事务;MCP 可执行并 Undo 属性修改;发布 profile 无法调用 authoring-only 命令。 + +### Phase 2:官方 Level Workspace(5–8 周) + +- Dock Shell、布局、命令面板、Content Browser、Inspector、Outliner; +- SceneDocument adapter、Scene/Game View、Gizmo、多选、拖放; +- Place Palette、Prefab 基础、Edit/Play/Stop; +- Console、任务、History、Project Settings。 +- 使用同一场景 Tool 构建一个游戏内建造模式示例,改用游戏 HUD、Constraint 和 SaveGame persistence; +- 从样例玩法模块向官方编辑器注入专用 Tool、Overlay、Inspector section 和 Validation。 + +退出条件:可从导入模型开始,创建场景、放置/编辑/Prefab、保存、Play、重开无数据差异;相同放置/变换工具在游戏建造模式可用,玩法扩展无需修改 editor-core/editor-shell。 + +### Phase 3:渲染资产生产(5–8 周) + +- 通用 Graph Framework; +- Material/Function/Instance 编辑器; +- Texture/Static Mesh importer settings 与预览; +- shader compile diagnostics、统计、缩略图; +- Render Effect/Render Pipeline 视图。 + +退出条件:材质图修改只发布成功编译版本;场景实例热更新;Cook 包含正确依赖。 + +### Phase 4:动画、VFX、UI、地图(按产品需求并行) + +- Animation Clip/FSM/Skeleton/Physics Asset; +- Particle Graph; +- UI Canvas; +- Tile/Terrain/Voxel 工具; +- Timeline/Sequencer。 + +各域复用 Graph/Timeline/Property/Preview,不允许自建第二套文档与撤销。 + +### Phase 5:规模化生产 + +- 结构化 diff、源控 provider、变更列表; +- 包体/内存/依赖审计、chunk; +- 世界分区、Data Layer、HLOD; +- 远程设备 deploy/profile; +- 可选多用户事务会话。 + +## 15. 关键接口草案 + +```cpp +class IEditorCommandService { +public: + virtual CommandResult execute(CommandId, const CommandContext&) = 0; + virtual bool canExecute(CommandId, const CommandContext&) const = 0; +}; + +class IEditingHost { +public: + virtual HostProfile profile() const = 0; + virtual IInputRouter& input() = 0; + virtual IPresentationService& presentation() = 0; +}; + +class IEditableTarget { +public: + virtual TargetRevision revision() const = 0; + virtual void* queryCapability(CapabilityId) = 0; + virtual PreviewResult preview(const DomainCommand&) = 0; +}; + +class IEditAuthority { +public: + virtual PreflightResult preflight(const DomainCommand&, const EditContext&) = 0; + virtual CommitResult commit(const DomainCommand&, ExpectedRevision) = 0; +}; + +class IEditPersistence { +public: + virtual PersistenceKind kind() const = 0; + virtual SaveResult persist(const CommittedTransaction&) = 0; + virtual RestoreResult restore(PersistenceKey) = 0; +}; + +class IDocumentController { +public: + virtual DocumentSnapshot snapshot() const = 0; + virtual SaveResult save(const SaveTicket&) = 0; + virtual void discard() = 0; + virtual ReloadResult reload(const DiskRevision&) = 0; +}; + +class IAssetDatabase { +public: + virtual AssetQueryResult query(const AssetQuery&, PageToken) const = 0; + virtual std::optional find(Guid) const = 0; + virtual DependencyResult dependencies(Guid, DependencyDirection) const = 0; +}; + +class IAssetTypeProvider { +public: + virtual AssetTypeDescriptor describe() const = 0; + virtual std::unique_ptr open(Guid) = 0; + virtual IAssetEditorFactory* editorFactory() = 0; +}; + +class IPropertyProvider { +public: + virtual PropertySchema schema(const SelectionSnapshot&) const = 0; + virtual PropertyValue read(PropertyPath) const = 0; + virtual std::unique_ptr makeSetCommand( + PropertyPath, PropertyValue, ExpectedRevision) = 0; +}; + +class IGameEditorExtension { +public: + virtual void registerTools(IToolRegistry&) = 0; + virtual void registerPresentation(IPresentationRegistry&) = 0; + virtual void registerValidation(IValidationRegistry&) = 0; +}; +``` + +接口放置遵循消费者声明原则。`editor` 核心定义通用协议;Scene adapter 所需的场景修改接口由 adapter/`common` 以最小值类型表达,`scene` 模块注册实现。Host、Target、Authority 与 Persistence 彼此不包含具体类型,使同一 Session 能在项目文档和游戏世界间切换。不得为了 Inspector 方便直接把所有模块类暴露给 `editorui`,也不得让游戏 HUD 直接调用 authoring-only 服务。 + +## 16. 风险与明确取舍 + +| 风险 | 对策 | +|---|---| +| 先做 UI 导致业务写死在面板 | Phase 1 headless 验收;UI 只调用命令 | +| 反射系统膨胀成全局对象体系 | 显式 descriptor + adapter,不要求所有运行时对象继承基类 | +| 文本场景在大规模下慢 | authoring 文本 + 增量/分区文档;Cook 为紧凑二进制 | +| GUID sidecar 丢失/复制冲突 | 重复 GUID 扫描、明确复制命令、修复向导,CI 验证 | +| 异步任务覆盖新编辑 | revision/ticket/CAS;过时结果丢弃,成功产物原子发布 | +| Undo 恢复到无效裸指针 | 只记录稳定文档/对象/组件/属性 ID | +| 各专用编辑器重复造轮子 | Graph/Timeline/Preview/Property 作为共享组件和契约测试 | +| 巨型 editor 模块破坏裁剪 | manifest 生成注册,业务模块提供 capability | +| Agent 与 UI 行为分叉 | 同一 CommandService,MCP 不直接写文件或对象 | +| 开发编辑器与游戏建造模式各写一套 | 将双 Host/双 Persistence 契约测试作为 Phase 0/1 退出条件 | +| 为复用而把开发权限带进发布包 | Build profile + 命令白名单 + schema 裁剪 + 服务端复验 | +| 游戏规则侵入通用几何工具 | 成本、解锁、权限和玩法限制只进入 Policy/Constraint/Authority | +| 追求 UE 全功能拖垮项目 | 先闭环基本内容生产;高级工具按运行时成熟度插件化 | + +## 17. 最小可交付产品(MVP) + +MVP 不以面板数量衡量,而以一条完整内容链衡量: + +1. 导入 PNG/glTF,生成 GUID、元数据、缓存产物和缩略图; +2. 在 Content Browser 搜索并拖入场景; +3. Outliner 选择对象,Inspector 修改 TRS、mesh、material; +4. Scene View 使用 gizmo、多选、吸附,所有动作可 Undo/Redo; +5. 从对象创建 Prefab,并显示 override; +6. 打开 Material,修改参数或简单图,编译成功后更新场景预览; +7. 保存、关闭、重开保持稳定引用;外部修改能检测冲突; +8. Play/Stop 不污染编辑场景; +9. Validate 与 Cook 能从 CLI、UI、MCP 三处得到一致结果; +10. 同一放置/变换 Tool 可切换到游戏 HUD、SaveGame persistence 和玩法 Constraint,形成可发布建造模式; +11. 一个样例游戏模块可向官方编辑器注入自己的 Tool、Overlay、Inspector 和 Validation,而无需修改编辑器核心; +12. 发布构建不含源文件、EditorOnly 数据和 authoring-only 模块,只包含项目明确选择的 `editing-runtime` 能力。 + +达到上述闭环后,再增加更多专用编辑器才会形成复利;在此之前添加粒子、行为树或大型地形面板只会扩大基础设施债务。 + +## 18. 与现有文档的关系 + +现有 `docs/dev/编辑器模块设计.md` 描述的“可组合构件而非封闭 Editor App”仍然成立,但需要更新解释: + +- 引擎核心继续只提供可组合协议与工具; +- 仓库可以额外提供一个由这些协议组装的 **官方参考内容编辑器**; +- 游戏作者仍可替换 Shell、裁剪专用编辑器或把同一 Tool 作为正式玩法; +- 游戏玩法通过 extension/capability 注入工具和领域信息,使官方编辑器自然成为项目专用编辑器; +- 官方编辑器不是业务模块反向耦合的理由。 + +因此,本提案不是推翻现有设计,而是补齐“构件 -> 内容协议 -> 可替换宿主 -> 官方工作流/玩家玩法”中缺失的中间层。最终目标是让 EVEngine 的轻量模块化、脚本化与 Agent 工作流,拥有足以支撑真实游戏生产和玩家创造玩法的同一套编辑底座。 diff --git a/docs/dev/编辑器具体API与组件设计.md b/docs/dev/编辑器具体API与组件设计.md new file mode 100644 index 00000000..35002e2e --- /dev/null +++ b/docs/dev/编辑器具体API与组件设计.md @@ -0,0 +1,2219 @@ +# EVEngine 编辑器具体 API 与组件设计 + +日期:2026-08-24 +状态:可发布设计与实现说明 +前置文档:[`游戏内容编辑器组件设计分析`](游戏内容编辑器组件设计分析.md) +目标读者:引擎核心、场景、UI、资源管线、游戏玩法及工具开发者 +目标版本:Editor API V2(兼容现有 `src/modules/editor/` 的渐进演进) + +## 1. 设计目标 + +本设计把 EVEngine 编辑能力定义为一套与宿主无关的运行时协议,使以下场景共享同一业务实现: + +1. 官方开发编辑器编辑项目场景、Prefab、材质和资产; +2. 模拟经营/沙盒游戏中的玩家建造模式编辑运行世界; +3. 游戏模块向官方编辑器注入项目专用工具和诊断; +4. Squirrel、MCP、CLI 和测试直接执行相同命令; +5. 单机 SaveGame、多人服务器世界和用户创作包使用不同持久化/权威后端。 + +核心约束: + +- Tool、Command、Target、Constraint、Transaction 不依赖 UI 框架; +- UI 只消费 descriptor 和 state,不直接修改业务对象; +- 开发编辑器与玩家 UI 共享语义,不共享像素布局; +- 写操作必须可预检、提交、取消、撤销并返回结构化诊断; +- 跨模块调用遵循 capability,禁止新增向上 `#include`; +- 所有可恢复引用使用稳定 ID,不把裸指针写进历史、文档或异步任务; +- `editing-runtime` 可进入发布包,`authoring` 服务默认不能进入发布包; +- C++ 使用强类型枚举;Squirrel/MCP 边界映射为稳定小写字符串。 + +## 2. 与现有 API 的关系 + +现有 API 不删除: + +- `IEditorTool` 与 `EditorSession` 继续承担工具生命周期和输入路由; +- `IEditableTarget`、`IGridTarget`、`IIntFieldTarget`、`IScalarFieldTarget` 继续作为目标 capability; +- `IEditCommand`、`EditorTransactions`、`IEditConstraint` 继续支持局部可撤销编辑; +- `IEditorOverlay`、`IEditorInspector` 继续作为简单宿主适配接口; +- `TransformGizmo`、`FieldBrushTool`、Tile/Heightmap target 继续可用。 + +V2 采用以下增量升级: + +| V1 | V2 演进 | 兼容方式 | +|---|---|---| +| `ToolDescriptor` 仅 id/label/shortcut | 增加 category、icon、requirements、visibility | 原字段保留,默认值兼容 | +| `IEditableTarget::query()` 使用 `dynamic_cast` | 增加稳定 `CapabilityId` 查询 | 进程内旧 target 走 fallback adapter | +| `IEditCommand::apply()` 返回 bool | 增加结构化 `EditResult` 适配层 | `LegacyEditCommandAdapter` 转换 | +| `EditorTransactions` 仅内存栈 | 增加 scope、receipt、origin、revision、authority | 旧 session 使用 local authority | +| `IEditConstraint` 返回一段字符串 | 增加 rule id、severity、quick fix、cost | 旧结果映射为 diagnostic | +| `IEditorInspector` 即时修改引用值 | 增加 Property schema/read/write command | 简单 Tool setting 仍可用旧接口 | +| `IEditorHost` 传递 JSON 字符串 | 新增类型化 `IEditingHost` | DevTools JSON host 成为 presenter adapter | + +不建议一次性重写现有模块。每一阶段必须保持 `test/editor.cpp` 和现有 Squirrel API 通过。 + +## 3. 模块与目录 + +建议新增 `editing-runtime` 和 `authoring` 构建组,不强制新增同名 CMake target。物理代码拆分如下: + +```text +src/modules/editor/ + core/ + EditorIds.h + EditorValue.h + EditorResult.h + EditorContext.h + EditorCommand.h + EditorTransaction.h + EditorSelection.h + EditorProperty.h + EditorRegistry.h + runtime/ + EditorSession.* + EditorAuthority.* + EditorPersistence.* + EditorRuntimeProfile.* + tools/ + EditorTool.h + TransformGizmo.* + FieldBrushTool.* + presentation/ + EditorPresentation.h + EditorDescriptors.h + +src/modules/authoring/ + document/ + DocumentService.* + DocumentController.h + AutosaveService.* + asset/ + AssetDatabase.* + AssetImporter.* + ImportCoordinator.* + command/ + AuthoringCommands.* + validation/ + ValidationService.* + build/ + CookService.* + +src/modules/editorui/ + DeveloperEditingHost.* + WorkspaceService.* + PropertyGrid.* + ContentBrowser.* + OutlinerPanel.* + SceneViewport.* + +src/modules//editor/ + EditorExtension.* + PropertyProvider.* + Tools.* +``` + +如果暂时不新增 target,可先在 `src/modules/editor/` 内按子目录实现;模块依赖仍按最终边界检查。 + +建议 manifest 形态: + +```cmake +eve_declare_module(NAME editor LAYER 0 SCRIPT Editor SLOT editor + GROUP 2d 3d web editing-runtime) +eve_declare_module(NAME authoring LAYER 5 + DEPS editor filesystem data + GROUP authoring) +eve_declare_module(NAME editorui LAYER 6 + DEPS editor authoring ui window input + GROUP authoring) +``` + +现有 `editor` 位于 L6,需要在实际迁移前用 `scripts/module_depgraph.py` 验证是否能下沉;如果现有可选 `procgen` 适配阻止下沉,应把 Heightmap adapter 移回 `procgen/editor/`,而不是让通用编辑协议依赖高层业务模块。 + +## 4. 基础类型与命名 + +### 4.1 稳定 ID + +进程内和持久化边界统一使用语义明确的 ID: + +```cpp +namespace eve::editor { + +using Revision = std::uint64_t; +using Sequence = std::uint64_t; + +template +struct StrongId { + std::string value; + StrongId() = default; + explicit StrongId(std::string value) : value(std::move(value)) {} + bool empty() const noexcept { return value.empty(); } + auto operator<=>(const StrongId&) const = default; +}; + +struct StableIdTag; +struct SessionIdTag; +struct TargetIdTag; +struct ObjectIdTag; +struct ComponentIdTag; +struct DocumentIdTag; +struct TransactionIdTag; +struct CommandIdTag; +struct ToolIdTag; +struct RuleIdTag; +struct AssetGuidTag; +struct CapabilityIdTag; +struct PropertyPathTag; + +using StableId = StrongId; +using SessionId = StrongId; +using TargetId = StrongId; +using ObjectId = StrongId; +using ComponentId = StrongId; +using DocumentId = StrongId; +using TransactionId = StrongId; +using CommandId = StrongId; +using ToolId = StrongId; +using RuleId = StrongId; +using AssetGuid = StrongId; +using CapabilityId = StrongId; +using PropertyPath = StrongId; + +} +``` + +第一版可以用 `std::string` 避免引入 UUID 库;生成器输出 128-bit 随机/时间无关 ID 的小写十六进制文本。ID 规则: + +- 注册类型 ID 使用反向域或模块前缀:`eve.scene.transform.translate`; +- 实例 GUID 不带路径含义; +- `PropertyPath` 使用稳定字段 ID,不使用本地化显示名; +- Script/MCP 看到字符串;C++ 不允许不同 ID 类型隐式互换; +- ID 比较大小写敏感,输入边界统一规范为小写。 + +### 4.2 Value + +命令 payload、属性和自动化使用同一值类型,禁止内部服务互传任意 JSON 字符串: + +```cpp +struct Vec2Value { double x = 0, y = 0; }; +struct Vec3Value { double x = 0, y = 0, z = 0; }; +struct Vec4Value { double x = 0, y = 0, z = 0, w = 0; }; +struct ColorValue { double r = 0, g = 0, b = 0, a = 1; }; +struct AssetRefValue { AssetGuid guid; std::string subresource; }; +struct ObjectRefValue { TargetId target; ObjectId object; ComponentId component; }; + +class EditorValue { +public: + using Array = std::vector; + using Object = std::map; + using Storage = std::variant; + // type(), get(), tryGet(), equality, deterministic encode/decode +}; +``` + +要求: + +- 浮点序列化拒绝 NaN/Infinity; +- Object 使用确定性 key 顺序; +- Asset/Object 引用为独立 variant,不伪装成字符串; +- 大二进制不进入 `EditorValue`,只传 artifact/source handle; +- 数组/对象设深度、数量和字节上限,尤其是网络与 MCP 输入。 + +### 4.3 结果与诊断 + +所有跨服务调用返回结构化结果: + +```cpp +enum class EditorStatus { + Applied, Pending, NoOp, Rejected, Conflict, + NotFound, Unsupported, Cancelled, Failed +}; + +enum class DiagnosticSeverity { Info, Warning, Error }; + +struct DiagnosticLocation { + std::optional document; + std::optional asset; + std::optional object; + std::optional property; + std::string sourceFile; + int line = 0; +}; + +struct EditorDiagnostic { + RuleId rule; + DiagnosticSeverity severity = DiagnosticSeverity::Error; + std::string message; + DiagnosticLocation location; + std::optional quickFix; + EditorValue quickFixPayload; +}; + +template +struct EditorResult { + EditorStatus status = EditorStatus::Failed; + T value{}; + std::vector diagnostics; + bool accepted() const; +}; +``` + +预期失败不抛异常。参数编程错误可用 `EV_PARAM_CHECK`;磁盘冲突、权限拒绝、预算不足和命令不可用必须返回结果。日志是诊断的副本,不能成为调用方判断成功的唯一方式。 + +## 5. Host Profile 与功能裁剪 + +```cpp +enum class HostKind { Developer, RuntimeBuilder, RuntimeAdmin, Automation }; + +enum class HostFeature : std::uint64_t { + None = 0, + ProjectDocuments = 1ull << 0, + SourceAssets = 1ull << 1, + RuntimeWorld = 1ull << 2, + ArbitraryScript = 1ull << 3, + BuildCook = 1ull << 4, + SourceControl = 1ull << 5, + MultiplayerSubmit= 1ull << 6, + UserCreations = 1ull << 7, + DebugOverride = 1ull << 8 +}; + +struct HostProfile { + HostKind kind = HostKind::RuntimeBuilder; + HostFeature features = HostFeature::RuntimeWorld; + std::vector commandAllowList; + std::vector capabilityAllowList; + std::size_t maxUndoBytes = 16 * 1024 * 1024; + std::size_t maxPayloadBytes = 256 * 1024; +}; +``` + +`HostProfile` 是能力上限,不是唯一权限判断。多人世界仍由 Authority 按玩家、对象和命令复验。 + +```cpp +class IEditingHost { +public: + virtual ~IEditingHost() = default; + virtual const HostProfile& profile() const = 0; + virtual IEditorInputRouter& input() = 0; + virtual IEditorPresenter& presenter() = 0; + virtual ITaskFeedback& tasks() = 0; + virtual void requestFrame() = 0; +}; +``` + +`IEditingHost` 是实例对象,由 Host factory/应用入口持有,不注册为单例 capability;同一进程可以同时存在开发窗口、游戏预览和 automation host。可通过 capability 注册的是 `IEditingHostFactory`,而不是某个当前窗口。 + +实现: + +- `DeveloperEditingHost`:Dock、菜单、键鼠、多窗口、文档 Tab; +- `RuntimeEditingHost`:游戏 HUD、手柄/触摸、世界空间 overlay; +- `AutomationEditingHost`:无 UI,直接提交命令并收集快照; +- 现有 `IEditorHost`:保留为 JSON View host,由 `DeclarativeHostPresenterAdapter` 接到 `IEditorPresenter`。 + +发布 Cook 从 manifest 和 `HostProfile` 生成命令/属性白名单。运行时不得只靠 UI 隐藏危险命令;`CommandService` 在执行点再次检查 profile。 + +## 6. Registry 与扩展生命周期 + +编辑类型使用集中 registry,注册发生在模块初始化、项目扩展加载或插件启用阶段;帧中不修改注册表。 + +```cpp +class IEditorRegistry { +public: + virtual EditorResult<> registerTool(ToolDescriptor, ToolFactory) = 0; + virtual EditorResult<> registerCommand(CommandDescriptor, CommandHandler) = 0; + virtual EditorResult<> registerPropertyProvider(PropertyProviderDescriptor, + PropertyProviderFactory) = 0; + virtual EditorResult<> registerPanel(PanelDescriptor, PanelFactory) = 0; + virtual EditorResult<> registerAssetType(AssetTypeDescriptor, + AssetTypeFactory) = 0; + virtual EditorResult<> registerValidator(ValidationDescriptor, + ValidatorFactory) = 0; + virtual EditorResult<> registerOverlay(OverlayDescriptor, OverlayFactory) = 0; + virtual void unregisterOwner(std::string_view ownerModule) = 0; +}; +``` + +Descriptor 通用字段: + +```cpp +struct ExtensionDescriptorBase { + std::string ownerModule; + std::string displayNameKey; + std::string descriptionKey; + std::string iconSemantic; + int order = 0; + std::vector requiredCapabilities; + HostFeature requiredHostFeatures = HostFeature::None; +}; +``` + +规则: + +- 重复 ID 默认拒绝;测试/热重载可显式 `replaceOwnerVersion`; +- registry 拥有 descriptor 和 factory,不拥有 provider 模块; +- 模块析构前必须 `unregisterOwner`,再销毁实现; +- 由 `module_manifest.cmake` 派生注册入口,不手改中央 boot list; +- 项目扩展只能注册 HostProfile 允许的类型; +- 未知组件文档可保真,但 UI 显示 Missing Provider,不执行未知代码。 + +## 7. EditorSession V2 + +### 7.1 配置与依赖 + +```cpp +struct EditorSessionConfig { + SessionId id; + HostProfile profile; + std::string workspace; + bool restoreSelectionOnUndo = true; + bool enableLocalUndo = true; +}; + +struct EditorSessionServices { + IEditorCommandService* commands = nullptr; + ISelectionService* selection = nullptr; + IFocusService* focus = nullptr; + IEditAuthority* authority = nullptr; + IEditPersistence* persistence = nullptr; + IEditorHistory* history = nullptr; + IValidationService* validation = nullptr; +}; + +class EditorSession { +public: + static std::unique_ptr create( + EditorSessionConfig, EditorSessionServices); + + EditorResult<> bindTarget(IEditableTarget*); + EditorResult<> unbindTarget(); + EditorResult<> addTool(IEditorTool*); // V1 non-owning + EditorResult<> createTool(const ToolId&); // V2 factory-owned + EditorResult<> activateTool(const ToolId&); + EditorResult<> cancelGesture(CancelReason); + EditorResult execute(const CommandRequest&); + void update(float dt); + void drawOverlay(IEditorOverlay&); +}; +``` + +依赖要求: + +- Session 拥有 factory 创建的 Tool;外部 `addTool` 保持非拥有兼容语义; +- Target、Authority、Persistence、服务均为非拥有,必须比 Session 活得久; +- Session 解绑 Target 前先 cancel gesture 和 rollback preview; +- Host 可以销毁重建,Session 不依赖 Host 窗口生命周期; +- 一个 Target 可被多个只读 Session 观察;写权限由 Authority 决定; +- 默认不允许两个本地写 Session 同时持有同一 Document lease。 + +### 7.2 Context + +```cpp +struct EditorContextSnapshot { + SessionId session; + HostKind host; + TargetId target; + Revision targetRevision = 0; + SelectionSnapshot selection; + FocusSnapshot focus; + UserIdentity user; + std::vector inputContexts; +}; + +class EditorContext { +public: + EditorSession& session() const; + IEditableTarget* target() const; + IEditAuthority& authority() const; + IEditPersistence& persistence() const; + ISelectionService& selection() const; + const HostProfile& profile() const; + EditorContextSnapshot snapshot() const; + + template Capability* targetCapability() const; + EditorResult execute(const CommandRequest&) const; +}; +``` + +所有延迟动作保存 `EditorContextSnapshot` 和 expected revision;不得在异步完成或上下文菜单点击时重新读取“当前选择”。 + +## 8. 输入、手势和工具 API + +### 8.1 归一化输入 + +现有 Pointer/Key 事件扩展为设备无关事件: + +```cpp +enum class InputDevice { Mouse, Touch, Pen, Gamepad, XR, Automation }; +enum class PointerPhase { Down, Move, Up, Cancel, Hover, Wheel }; + +struct EditorPointerEvent { + PointerPhase phase; + InputDevice device; + std::uint32_t pointerId; + std::uint32_t buttonMask; + Vec2Value screen; + Vec2Value viewport; + Vec2Value delta; + double pressure = 1.0; + double wheel = 0.0; + ModifierMask modifiers; + std::uint64_t timestampUs; +}; + +struct EditorActionEvent { + std::string action; // confirm/cancel/delete/duplicate/nudge... + double value = 1.0; + InputDevice device; +}; +``` + +Tool 优先响应语义 action,必要时响应原始 pointer/key。Runtime Host 将手柄和触摸映射为相同 action;不要在 Tool 内判断键码 `W`、鼠标右键或平台。 + +### 8.2 ToolDescriptor + +```cpp +enum class ToolMultiplicity { SingletonPerSession, MultipleInstances }; + +struct ToolDescriptor : ExtensionDescriptorBase { + ToolId id; + std::string category; + std::string defaultAction; + ToolMultiplicity multiplicity = ToolMultiplicity::SingletonPerSession; + std::vector targetRequirements; + PropertySchema settings; + bool supportsContinuousGesture = false; + bool supportsPlanningMode = false; +}; +``` + +### 8.3 IEditorToolV2 + +```cpp +class IEditorToolV2 : public IEditorTool { +public: + virtual ~IEditorToolV2() = default; + virtual ToolResponse actionEvent(EditorContext&, + const EditorActionEvent&) { + return ToolResponse::ignored(); + } + virtual PropertyObjectRef settingsObject() { return {}; } + virtual EditorResult<> cancelV2(EditorContext& context, + CancelReason reason) { + (void)reason; + cancel(context); // 调用 V1 virtual + return {EditorStatus::Applied}; + } +}; +``` + +`ToolDescriptor` 直接以向后兼容的默认字段扩展;V1 `IEditorTool` 的 activate/deactivate/pointerEvent/update/drawOverlay/inspect/cancel 保持不变。Session 对 `IEditorToolV2` 做可选查询以获得语义 action、声明式 settings 和结构化 cancel;旧工具自动使用 V1 路径。V2 工具手势状态机固定为: + +```text +Inactive -> Ready -> Previewing -> Committing -> Ready + | | + +-> Cancelling +-> Failed/Conflict -> Ready +``` + +Tool 只保存临时输入状态和 preview handle,不保存跨文档业务权威。工具切换、Target 解绑、窗口失焦、权限撤销、Play 状态切换都必须触发 cancel。 + +## 9. Target 与 Capability + +### 9.1 IEditableTargetV2 + +```cpp +struct TargetDescriptor { + TargetId id; + std::string type; + Revision revision = 0; + bool readOnly = false; + std::vector capabilities; +}; + +class IEditableTargetV2 : public virtual IEditableTarget { +public: + virtual ~IEditableTargetV2() = default; + virtual TargetDescriptor describe() const = 0; + virtual void* queryCapability(const CapabilityId&) = 0; + virtual EditorResult preview(const DomainOperation&) = 0; + virtual EditorResult<> updatePreview(PreviewHandle, + const DomainOperation&) = 0; + virtual EditorResult<> cancelPreview(PreviewHandle) = 0; + virtual EditorResult snapshot(const SnapshotQuery&) const = 0; + + template + Capability* query() { + return static_cast( + queryCapability(Capability::editorCapabilityId())); + } +}; +``` + +现有 `IEditableTarget` 的 `targetId/revision/dirtyRegion/query()` 不修改。Session 先查询 `IEditableTargetV2`;旧 target 由 `LegacyEditableTargetAdapter` 补充 describe/preview/snapshot。迁移期 capability 查询先尝试稳定 ID,再 fallback 到现有 `dynamic_cast`。跨 DLL/插件边界最终只用显式 capability ID,避免依赖 RTTI ABI。 + +### 9.2 标准 capability + +```text +eve.editor.target.grid +eve.editor.target.int-field +eve.editor.target.scalar-field +eve.editor.target.scene-hierarchy +eve.editor.target.transform +eve.editor.target.component-set +eve.editor.target.property +eve.editor.target.spline-network +eve.editor.target.asset-reference +eve.editor.target.graph +eve.editor.target.timeline +``` + +场景示例: + +```cpp +class ITransformEditTarget { +public: + static CapabilityId editorCapabilityId(); + virtual EditorResult readTransform(ObjectId) const = 0; + virtual EditorResult makeSetTransform( + ObjectId, TransformValue, TransformSpace) const = 0; + virtual EditorResult worldBounds(ObjectId) const = 0; +}; + +class ISceneHierarchyEditTarget { +public: + static CapabilityId editorCapabilityId(); + virtual EditorResult get(ObjectId) const = 0; + virtual PagedResult children( + ObjectId parent, PageRequest) const = 0; + virtual EditorResult makeCreate(const CreateObjectRequest&) = 0; + virtual EditorResult makeDelete(std::span) = 0; + virtual EditorResult makeReparent(const ReparentRequest&) = 0; +}; +``` + +读取返回值快照;不能返回内部 vector 引用或 SceneNode 裸指针。Scene module 使用现有 host name + node ID 的稳定思想实现 ObjectId adapter。 + +## 10. DomainOperation、Command 与 Handler + +需要区分两种“命令”: + +- **Editor Command**:用户意图入口,例如 `scene.place.start`、`edit.undo`; +- **Domain Operation**:可验证、可序列化的业务变化,例如 `scene.object.create.v1`。 + +一个 Editor Command handler 可以生成一个或多个 Domain Operation,并在一个事务中提交。 + +```cpp +struct CommandDescriptor : ExtensionDescriptorBase { + CommandId id; + std::string category; + std::string defaultShortcut; + PropertySchema payloadSchema; + bool paletteVisible = true; + bool createsTransaction = true; + bool automationAllowed = true; +}; + +struct CommandRequest { + CommandId id; + EditorValue payload; + CommandSource source; + EditorContextSnapshot context; + std::optional expectedRevision; + bool dryRun = false; +}; + +class IEditorCommandHandler { +public: + virtual EditorResult plan(const CommandRequest&) = 0; + virtual EditorResult execute( + const CommandRequest&, const CommandPlan&) = 0; +}; +``` + +`plan()` 必须无持久副作用,返回将影响的对象、文档、成本、权限和诊断。普通 UI 可直接 plan+execute;危险/高成本操作展示确认;MCP dry-run 只返回 plan。 + +Domain Operation: + +```cpp +struct DomainOperation { + std::string type; // scene.transform.set.v1 + TargetId target; + EditorValue payload; + EditorValue inverse; // 可选;也可由 target 在 commit 时产生 + std::vector affectedObjects; + std::vector affectedProperties; + std::string mergeKey; +}; +``` + +Operation type 必须带 schema 版本。网络/历史/自动保存只传已注册类型;未知类型拒绝执行但可保留在只读诊断中。 + +## 11. Transaction、Preview 与 Undo + +### 11.1 状态模型 + +```cpp +enum class TransactionState { + Planning, Previewing, PendingAuthority, Committed, + Rejected, Conflicted, RolledBack, Failed +}; + +enum class ActionOrigin { User, Game, Script, Automation, Importer, Network }; + +struct TransactionSpec { + TransactionId id; + std::string label; + ActionOrigin origin; + TargetId target; + Revision baseRevision; + std::string mergeKey; + bool restoreSelection = true; +}; + +struct TransactionReceipt { + TransactionId id; + TransactionState state; + Revision beforeRevision; + Revision afterRevision; + std::vector affectedObjects; + CostSummary cost; + std::vector diagnostics; + std::string authorityReceipt; +}; +``` + +### 11.2 服务 + +```cpp +class ITransactionService { +public: + virtual EditorResult begin(const TransactionSpec&) = 0; + virtual EditorResult preview( + TransactionHandle, const DomainOperation&) = 0; + virtual EditorResult<> updatePreview( + TransactionHandle, PreviewHandle, const DomainOperation&) = 0; + virtual EditorResult commit(TransactionHandle) = 0; + virtual EditorResult<> rollback(TransactionHandle, CancelReason) = 0; + virtual EditorResult undo(const UndoRequest&) = 0; + virtual EditorResult redo(const RedoRequest&) = 0; +}; +``` + +连续拖动: + +1. Pointer Down 创建 transaction,并捕获 before; +2. Move 更新同一 preview,不推进正式 revision; +3. Pointer Up 由 Authority preflight/commit,生成一个 receipt; +4. Cancel 恢复 before,不进入 history; +5. 多次数值输入使用 `mergeKey` 在时间窗内合并。 + +Undo 策略: + +- Developer/单机本地:执行 inverse operation; +- 多人服务器:Undo 是一条新的补偿事务,不删除历史; +- 预算命令:inverse 同时退款/恢复资源,Authority 再校验; +- 派生数据刷新不进入用户 Undo,但 receipt 记录 task links; +- 历史只保存稳定 ID/值,不保存 target 指针; +- 超出内存预算时按事务边界淘汰最旧条目,并标记 checkpoint。 + +现有 `EditorTransactions` 成为 `LocalTransactionBackend`,`IEditCommand` 通过 adapter 包装成 Domain Operation receipt;新功能不得继续扩张旧 bool-only 接口。 + +## 12. Constraint、Policy 与 Authority + +三者职责不同: + +- Constraint:几何/数据规则,例如坡度、碰撞、边界、类型兼容; +- Policy:玩法/产品规则,例如解锁、造价、领地、对象数量、用户等级; +- Authority:最终提交者,例如 Document、SaveGame、本地世界或服务器。 + +```cpp +enum class Decision { Allow, AllowWithWarning, Reject, RequireConfirmation }; + +struct EditDecision { + RuleId rule; + Decision decision = Decision::Allow; + std::vector diagnostics; + std::optional normalizedOperation; + CostSummary cost; +}; + +class IEditRule { +public: + virtual ~IEditRule() = default; + virtual EditDecision evaluate( + const EditorContextSnapshot&, const DomainOperation&) const = 0; +}; + +class IEditAuthority { +public: + virtual ~IEditAuthority() = default; + virtual EditorResult preflight( + const TransactionSpec&, std::span) = 0; + virtual EditorResult commit( + const AuthorityPlan&) = 0; + virtual EditorResult<> compensate(const TransactionReceipt&) = 0; +}; +``` + +Rule pipeline 顺序固定:schema -> target capability -> geometry/data constraint -> gameplay policy -> permission -> cost reservation -> authority revision。Normalized Operation 必须显式返回,禁止规则在调用者不知情时原地篡改输入。 + +标准 Authority: + +```text +DocumentAuthority 写 SceneDocument,检查 document revision +LocalWorldAuthority 单机运行世界,内存提交 +SaveGameAuthority 运行世界 + SaveGame journal/checkpoint +ServerWorldAuthority RPC 提交,服务端重跑所有规则 +UserCreationAuthority 白名单资产/属性,导出前安全验证 +ReadOnlyAuthority 只允许 preview/query +``` + +## 13. Selection、Focus 与 Clipboard + +```cpp +enum class SelectionDomain { Scene, Asset, Graph, Timeline, UI, Custom }; + +struct SelectionItem { + SelectionDomain domain; + TargetId target; + StableId item; + std::string type; +}; + +struct SelectionSnapshot { + std::string channel; + std::vector items; + std::optional primary; + Sequence sequence = 0; +}; + +class ISelectionService { +public: + virtual SelectionSnapshot snapshot(std::string_view channel) const = 0; + virtual EditorResult<> replace(std::string_view channel, + std::span, + SelectionReason) = 0; + virtual EditorResult<> toggle(std::string_view channel, + const SelectionItem&) = 0; + virtual Subscription subscribe(SelectionCallback) = 0; +}; +``` + +推荐 channel:`global.scene`、`global.assets`、`document..graph`、`document..timeline`。Scene View、Outliner 和 Inspector 使用同一 scene channel;材质图内部选择不覆盖场景选择。 + +Focus snapshot 包含 host、workspace、panel、document、input contexts。Shortcut 从高到低解析:Modal -> TextInput -> Gesture -> Tool -> Document -> Workspace -> Global。 + +Clipboard 使用带 MIME-like type 的内部 payload: + +```text +application/x-eve-scene-objects+json;version=1 +application/x-eve-graph-nodes+json;version=1 +application/x-eve-properties+json;version=1 +text/plain +``` + +粘贴必须重新生成实例 ID,保留资产 GUID,外部数据经过 schema/大小/权限验证。 + +## 14. Property 系统 + +### 14.1 Schema + +```cpp +enum class PropertyType { + Bool, Int, Float, String, Enum, Color, + Vec2, Vec3, Vec4, Transform, AssetRef, ObjectRef, + Struct, Array, Map, Action, ReadOnlyText +}; + +enum class PropertyFlag : std::uint64_t { + None = 0, + ReadOnly = 1ull << 0, + Advanced = 1ull << 1, + EditorOnly = 1ull << 2, + Runtime = 1ull << 3, + Replicated = 1ull << 4, + Transient = 1ull << 5, + Dangerous = 1ull << 6, + MultiEdit = 1ull << 7 +}; + +struct NumericMetadata { + std::optional minimum; + std::optional maximum; + std::optional step; + std::string units; + int precision = 3; +}; + +struct PropertyDescriptor { + PropertyPath path; + std::string displayNameKey; + std::string descriptionKey; + std::string category; + PropertyType type; + PropertyFlag flags; + EditorValue defaultValue; + NumericMetadata numeric; + std::vector enumItems; + std::vector assetTypeFilters; + std::string visibleWhen; + std::string enabledWhen; + std::string presenterHint; + std::vector validators; +}; + +struct PropertySchema { + std::string typeId; + std::uint32_t version = 1; + std::vector properties; +}; +``` + +`visibleWhen/enabledWhen` 使用受限表达式,只能读取同一对象的属性和 HostFeature,不能执行脚本。 + +### 14.2 读写 + +```cpp +enum class PropertyReadState { Value, Mixed, Missing, Error }; + +struct PropertyReadResult { + PropertyReadState state; + EditorValue value; + std::vector diagnostics; +}; + +class IPropertyProvider { +public: + virtual PropertySchema schema(const SelectionSnapshot&) const = 0; + virtual PropertyReadResult read(const SelectionSnapshot&, + const PropertyPath&) const = 0; + virtual EditorResult makeSet( + const SelectionSnapshot&, const PropertyPath&, + const EditorValue&, PropertySetMode) const = 0; + virtual EditorResult makeReset( + const SelectionSnapshot&, const PropertyPath&) const = 0; +}; +``` + +Presenter 先 read,再通过命令提交 makeSet;不得获得 setter 函数。多选只显示 provider 声明 `MultiEdit` 且 schema 兼容的交集。连续滑杆使用 preview transaction;文本输入在确认或失焦时提交。 + +开发 Inspector 与游戏属性面板消费相同 schema: + +- Developer presenter 展示 Advanced/EditorOnly/debug; +- Runtime presenter 只展示 Runtime 且 profile 允许的字段; +- Agent presenter 导出 JSON Schema 和命令 payload; +- 属性显示名本地化,但 `PropertyPath` 永不本地化。 + +## 15. Presentation Descriptor 与 UI Adapter + +共享的是语义描述,不是 UI widget tree: + +```cpp +struct ActionDescriptor { + CommandId command; + std::string labelKey; + std::string iconSemantic; + std::string group; + int order = 0; + std::string confirmationKey; +}; + +struct PaletteDescriptor { + StableId id; + std::string titleKey; + std::vector tools; + std::vector actions; +}; + +struct PanelDescriptor : ExtensionDescriptorBase { + StableId panelType; + std::string defaultRegion; + bool singleton = true; + std::vector selectionChannels; +}; + +class IEditorPresenter { +public: + virtual void presentPalette(const PaletteDescriptor&, + const PaletteState&) = 0; + virtual void presentProperties(const PropertySchema&, + const PropertyState&) = 0; + virtual void presentDiagnostics(std::span) = 0; + virtual ConfirmationToken confirm(const ConfirmationDescriptor&) = 0; +}; +``` + +UI adapter: + +- `DockPresenter`:Menu/Toolbar/Inspector/Content Browser; +- `GameHudPresenter`:建造 palette、成本栏、确认/取消、手柄焦点; +- `TouchPresenter`:大触点、世界空间 handle; +- `JsonViewPresenter`:映射到现有 `IEditorHost::applyEditor`; +- `AutomationPresenter`:不绘制,只保存状态快照。 + +Presenter 产生的事件必须回到 CommandService,不得直接调用 Target。 + +## 16. Overlay、Viewport 与 Gizmo + +现有 `IEditorOverlay` 的 line/circle/rectangle/text 保留,扩展语义 primitive: + +```cpp +class IEditorOverlayV2 : public IEditorOverlay { +public: + virtual void polyline(std::span, const OverlayStyle&) = 0; + virtual void mesh(const OverlayMeshRef&, const TransformValue&, + const OverlayStyle&) = 0; + virtual void icon(const OverlayPoint&, std::string_view semantic, + const OverlayStyle&) = 0; + virtual void billboard(const OverlayPoint&, const OverlayBillboard&) = 0; + virtual void heatmap(const OverlayFieldRef&, const OverlayStyle&) = 0; + virtual void beginPickScope(StableId pickId) = 0; + virtual void endPickScope() = 0; +}; +``` + +OverlayStyle 增加 depth mode、layer、interaction state、accessibility label。Developer 可显示 debug overlay;Runtime profile 只注册玩家可见 overlay。 + +Viewport adapter 负责 screen/ray/world/grid 转换: + +```cpp +class IEditorViewport { +public: + virtual ViewportId id() const = 0; + virtual Ray screenToRay(Vec2Value) const = 0; + virtual Vec2Value worldToScreen(Vec3Value) const = 0; + virtual EditorResult pick(const PickRequest&) = 0; + virtual CameraSnapshot camera() const = 0; + virtual void frame(const BoundsValue&) = 0; +}; +``` + +Tool 输入采用 viewport coordinates,并按需要查询 `IEditorViewport`。Gizmo 只生成 Transform preview operation;不直接写 SceneNode。开发与游戏可替换 gizmo 美术、手柄大小和输入映射。 + +## 17. Document 组件 + +Document 只属于 `authoring`;运行时建造模式使用 Target + Persistence,不要求伪装成项目文档。 + +### 17.1 身份和状态 + +```cpp +enum class DocumentKind { + Scene, Prefab, Material, MaterialFunction, MaterialInstance, + AnimationClip, AnimationGraph, ParticleGraph, UiDocument, + Timeline, ProjectSettings, ImportSettings, Generic +}; + +struct DocumentKey { + DocumentKind kind; + AssetGuid asset; + auto operator<=>(const DocumentKey&) const = default; +}; + +enum class DocumentState { + Loading, Ready, Saving, Conflict, Failed, Closed +}; + +struct DocumentRevision { + Revision edit = 0; + Revision saved = 0; + Revision disk = 0; + Revision preview = 0; +}; + +struct DocumentSnapshot { + DocumentId id; + DocumentKey key; + std::string title; + std::string resourceUri; + DocumentState state; + DocumentRevision revision; + std::vector viewIds; + std::vector diagnostics; + bool dirty() const { return revision.edit != revision.saved; } +}; +``` + +Asset GUID 是文档身份,路径只是位置。Save As 新资产生成新 GUID;重命名/移动不改变 DocumentKey。 + +### 17.2 Controller 和 Service + +```cpp +struct SaveTicket { + StableId id; + DocumentId document; + Revision capturedEditRevision; + Revision expectedDiskRevision; + std::string expectedContentHash; + std::optional destinationUri; +}; + +class IDocumentController { +public: + virtual ~IDocumentController() = default; + virtual EditorResult capture() const = 0; + virtual EditorResult save(const SaveTicket&) = 0; + virtual EditorResult<> discard() = 0; + virtual EditorResult<> reload(const DiskState&) = 0; + virtual EditorResult<> migrate(std::uint32_t fromVersion) = 0; +}; + +class IDocumentService { +public: + static constexpr const char* capabilityName = "IDocumentService"; + virtual EditorResult open(const DocumentKey&) = 0; + virtual EditorResult<> attachView(DocumentId, StableId view) = 0; + virtual EditorResult<> detachView(DocumentId, StableId view) = 0; + virtual EditorResult requestSave(DocumentId, + SaveRequest) = 0; + virtual EditorResult executeSave(const SaveTicket&) = 0; + virtual EditorResult<> close(DocumentId, ClosePolicy) = 0; + virtual std::vector documents() const = 0; + virtual Subscription subscribe(DocumentEventCallback) = 0; +}; +``` + +保存时序: + +```mermaid +sequenceDiagram + participant UI as Host/Command + participant DS as DocumentService + participant DC as DocumentController + participant FS as Atomic File Store + UI->>DS: requestSave(document, expected edit revision) + DS-->>UI: SaveTicket + DS->>DC: capture(ticket) + DC->>FS: CAS temp write + atomic replace + FS-->>DC: new disk revision/hash + DC-->>DS: completion + DS->>DS: only advance saved revision to captured revision + DS-->>UI: saved or still dirty +``` + +保存期间继续编辑时,新 edit revision 仍保持 dirty,不能因旧 ticket 成功被误清除。磁盘 content hash/revision 不匹配进入 Conflict,不自动覆盖。 + +### 17.3 自动保存和崩溃恢复 + +Autosave 写 `Saved/Autosave//.draft`,不覆盖正式资产。草稿包含 base disk hash、document schema、edit revision 和内容;启动时仅对比并提示恢复。关闭时统一由 `CloseCoordinator` 收集所有 dirty/conflict/pending 文档,一次性完成 Save/Discard/Cancel,不由各面板弹各自对话框。 + +## 18. AssetDB 与资产组件 + +### 18.1 AssetRecord + +```cpp +enum class AssetStatus { + Ready, Scanning, Importing, Stale, MissingSource, + MissingArtifact, Warning, Error, Deleted +}; + +struct AssetRecord { + AssetGuid guid; + std::string logicalUri; // content://World/Park.evscene + std::string typeId; + std::uint32_t schemaVersion; + std::string sourceUri; + std::string sourceHash; + std::string importerId; + std::uint32_t importerVersion; + std::string settingsHash; + std::vector artifacts; + std::vector tags; + AssetStatus status; + std::vector diagnostics; +}; + +enum class DependencyKind { Hard, Soft, EditorOnly, Build, Source }; + +struct AssetDependency { + AssetGuid from; + AssetGuid to; + DependencyKind kind; + PropertyPath sourceProperty; +}; +``` + +### 18.2 查询 API + +```cpp +struct AssetQuery { + std::vector typeIds; + std::vector pathPrefixes; + std::vector tagsAll; + std::string text; + std::optional status; + std::optional modified; + AssetSort sort = AssetSort::Name; +}; + +class IAssetDatabase { +public: + static constexpr const char* capabilityName = "IAssetDatabase"; + virtual EditorResult find(AssetGuid) const = 0; + virtual EditorResult findByUri(std::string_view) const = 0; + virtual PagedResult query(const AssetQuery&, + PageRequest) const = 0; + virtual PagedResult dependencies( + AssetGuid, DependencyDirection, PageRequest) const = 0; + virtual EditorResult planDelete( + std::span) const = 0; + virtual Subscription subscribe(AssetEventCallback) = 0; +}; +``` + +查询线程安全,不加载完整资产。Record 是值快照;分页 token 绑定 index generation,索引变化时返回 Conflict/expired token,由 UI 重新查询。 + +### 18.3 资产类型与编辑器工厂 + +```cpp +struct AssetTypeDescriptor : ExtensionDescriptorBase { + std::string typeId; + std::vector sourceExtensions; + std::vector authoringExtensions; + std::string iconSemantic; + bool creatable = false; + bool runtimeVisible = true; + HostFeature requiredFeatures; +}; + +class IAssetTypeProvider { +public: + virtual AssetTypeDescriptor descriptor() const = 0; + virtual EditorResult create(const CreateAssetRequest&) = 0; + virtual EditorResult> open( + AssetGuid) = 0; + virtual IAssetPreviewProvider* previewProvider() = 0; + virtual IAssetEditorFactory* editorFactory() = 0; +}; +``` + +双击资产流程:AssetDB type ID -> AssetTypeRegistry -> editor factory -> DocumentService open -> Workspace attach view。没有专用 editor 时使用 GenericProperty/Metadata viewer;未知 type 只读显示元数据。 + +## 19. Import、Artifact 与 Cook API + +### 19.1 Importer + +```cpp +struct ImporterDescriptor : ExtensionDescriptorBase { + std::string importerId; + std::uint32_t version; + std::vector sourceExtensions; + std::vector outputTypes; + PropertySchema settingsSchema; + bool supportsHeadless = true; +}; + +struct ImportRequest { + std::string sourceUri; + std::string destinationUri; + std::optional existingAsset; + EditorValue settings; + BuildTarget target; +}; + +struct ImportProduct { + AssetRecord record; + std::vector artifacts; + std::vector dependencies; + ThumbnailRequest thumbnail; +}; + +class IAssetImporter { +public: + virtual const ImporterDescriptor& descriptor() const = 0; + virtual EditorResult plan(const ImportRequest&) = 0; + virtual EditorResult execute( + const ImportRequest&, IImportContext&) = 0; +}; +``` + +ImportCoordinator 状态机: + +```text +Queued -> Inspecting -> Importing -> Validating -> Publishing -> Ready + \-> Cancelled/Error (保留上一成功产物) +``` + +Importer 在 worker thread 生成临时文件,不能直接发布 AssetDB。Coordinator 验证 GUID、依赖和 artifact hash 后原子发布,再在主线程发送 AssetChanged/HotReload。过时任务根据 request generation 丢弃。 + +### 19.2 Artifact + +```cpp +struct ArtifactKey { + AssetGuid asset; + std::string kind; + BuildTarget target; + std::string contentHash; + std::string toolchainVersion; +}; + +class IArtifactStore { +public: + virtual bool contains(const ArtifactKey&) const = 0; + virtual EditorResult open(const ArtifactKey&) const = 0; + virtual EditorResult<> publish(const ArtifactKey&, TempArtifact) = 0; + virtual EditorResult<> prune(const ArtifactPruneQuery&) = 0; +}; +``` + +Artifact 可删除重建,不是业务权威。Editor preview 和 runtime load 通过 key 读取成功发布产物,绝不读取 importer 的临时输出。 + +### 19.3 Cook + +```cpp +struct CookRequest { + BuildTarget target; + std::vector roots; + std::vector includeTags; + std::vector excludeTags; + bool dryRun = false; + bool incremental = true; +}; + +struct CookReport { + std::vector closure; + std::vector chunks; + std::vector diagnostics; + std::uint64_t compressedBytes = 0; + std::uint64_t uncompressedBytes = 0; +}; + +class ICookService { +public: + virtual EditorResult plan(const CookRequest&) = 0; + virtual EditorResult execute(const CookPlan&) = 0; + virtual EditorResult report(TaskHandle) const = 0; +}; +``` + +Cook 根必须显式。遍历 Hard/Build 依赖,Soft 依赖按规则,排除 EditorOnly;检查发布 profile 不包含 authoring-only 命令/schema/provider。 + +## 20. 场景编辑组件 + +### 20.1 SceneDocument 数据契约 + +```cpp +struct SceneObjectRecord { + ObjectId id; + ObjectId parent; + std::string name; + TransformValue localTransform; + bool visible = true; + bool editorOnly = false; + std::vector tags; + std::vector components; + std::optional prefab; +}; + +struct ComponentRecord { + ComponentId id; + std::string typeId; + std::uint32_t schemaVersion; + EditorValue fields; + EditorValue unknownFields; +}; + +struct SceneDocumentData { + std::uint32_t schemaVersion; + AssetGuid scene; + std::vector objects; // deterministic object-id order + EditorValue environment; + EditorValue editorMetadata; +}; +``` + +运行时 handle/指针、GPU handle、临时 selection 不入文档。组件字段按稳定 field ID 排序;未知字段保真。SceneDocument adapter 发布到预览 Scene host,预览 runtime 的对象 ID 与文档 ObjectId 保持映射。 + +### 20.2 SceneEditingTarget + +```cpp +class SceneEditingTarget final : public IEditableTargetV2, + public ISceneHierarchyEditTarget, + public ITransformEditTarget, + public IComponentEditTarget, + public IPropertyEditTarget { +public: + static std::unique_ptr forDocument( + DocumentId, IScenePreviewBridge&); + static std::unique_ptr forRuntimeWorld( + RuntimeWorldId, ISceneRuntimeBridge&); +}; +``` + +两个 factory 产生相同 capability,区别在 authority/persistence: + +- forDocument -> DocumentAuthority + SceneDocumentPersistence; +- forRuntimeWorld -> Local/ServerWorldAuthority + SaveGame/Server persistence。 + +这正是场景 Tool 同构复用的关键。Tool 不能向下转型判断是哪一种 target。 + +### 20.3 OutlinerDataSource + +```cpp +struct OutlinerQuery { + ObjectId root; + std::string text; + std::vector typeFilters; + std::vector tags; + bool includeHidden = true; +}; + +struct OutlinerRow { + ObjectId id; + ObjectId parent; + std::string name; + std::string type; + bool hasChildren; + bool visible; + bool locked; + PrefabState prefabState; + Revision rowRevision; +}; + +class IOutlinerDataSource { +public: + virtual PagedResult rows(const OutlinerQuery&, + PageRequest) const = 0; + virtual Subscription subscribe(OutlinerDiffCallback) = 0; +}; +``` + +数据源推增量 diff:Insert/Remove/Move/Update/Reset。UI 虚拟化,只为可见行请求数据。重命名、重父级、显示切换均发 Command,不从 row 直接写对象。 + +### 20.4 标准场景命令 + +```text +scene.object.create +scene.object.delete +scene.object.duplicate +scene.object.rename +scene.object.reparent +scene.object.set-visible +scene.transform.set +scene.transform.nudge +scene.selection.frame +scene.component.add +scene.component.remove +scene.component.reorder +scene.asset.place +scene.prefab.create +scene.prefab.apply-overrides +scene.prefab.revert-overrides +scene.prefab.unpack +``` + +命令 payload 例: + +```json +{ + "id": "scene.transform.set", + "payload": { + "objects": ["obj-7f...", "obj-92..."], + "space": "world", + "pivot": "selection-center", + "transform": { + "position": [12.0, 0.0, 5.0], + "rotationEulerDeg": [0.0, 30.0, 0.0], + "scale": [1.0, 1.0, 1.0] + } + }, + "expectedRevision": 104 +} +``` + +JSON 仅表示 Script/MCP 边界;C++ 内部使用强类型 `TransformCommandPayload`。 + +### 20.5 Place Tool + +```cpp +struct PlaceableDescriptor { + StableId archetype; + std::string labelKey; + std::string iconSemantic; + std::optional asset; + std::vector requirements; + PropertySchema initialProperties; +}; + +class IPlaceableProvider { +public: + virtual PagedResult query( + const PlaceableQuery&, PageRequest) const = 0; + virtual EditorResult plan( + const PlaceableDescriptor&, const PlacementProbe&) = 0; +}; +``` + +Place Tool 负责光标、吸附、旋转、连续建造和 preview;Provider 负责 archetype;Constraint 负责碰撞/坡度;Policy 负责造价/解锁;Authority 负责最终创建。开发 Editor 与游戏建造 HUD 只更换 Presenter。 + +## 21. Prefab API + +```cpp +struct PrefabObjectKey { + AssetGuid prefab; + ObjectId sourceObject; +}; + +struct PropertyOverride { + PrefabObjectKey object; + ComponentId sourceComponent; + PropertyPath property; + EditorValue value; +}; + +struct PrefabInstanceRecord { + AssetGuid prefab; + std::map sourceToInstance; + std::vector overrides; + std::vector structuralOverrides; +}; + +class IPrefabService { +public: + virtual EditorResult createFromSelection( + const SelectionSnapshot&, const CreatePrefabRequest&) = 0; + virtual EditorResult diffInstance(ObjectId root) const = 0; + virtual EditorResult makeApplyOverrides( + ObjectId root, std::span) = 0; + virtual EditorResult makeRevertOverrides( + ObjectId root, std::span) = 0; + virtual EditorResult makeUnpack(ObjectId root, + UnpackMode) = 0; +}; +``` + +Prefab 内部稳定 ID 不因实例化改变;实例保存映射。Prefab 资产变化传播时按 source ID 三方合并:old base、new base、instance override。无法迁移的 override 标记 orphan,不能静默丢弃。 + +玩家蓝图可以复用 Prefab 数据结构,但使用 UserCreationAuthority:只允许白名单 component/type/property,不可包含 EditorOnly、任意脚本或源路径。 + +## 22. Material 与通用 Graph API + +### 22.1 Graph Core + +```cpp +struct GraphPinId { StableId node; StableId pin; }; + +struct GraphNodeRecord { + StableId id; + std::string typeId; + std::uint32_t schemaVersion; + Vec2Value position; + EditorValue properties; +}; + +struct GraphEdgeRecord { + StableId id; + GraphPinId from; + GraphPinId to; +}; + +struct GraphDocumentData { + std::string domain; + std::uint32_t schemaVersion; + std::vector nodes; + std::vector edges; + EditorValue parameters; +}; + +class IGraphDomainProvider { +public: + virtual GraphDomainDescriptor descriptor() const = 0; + virtual PagedResult nodeTypes( + const GraphNodeQuery&, PageRequest) const = 0; + virtual ConnectionDecision canConnect(GraphPinId, GraphPinId) const = 0; + virtual EditorResult compile( + const GraphDocumentData&, const GraphCompileOptions&) = 0; +}; +``` + +通用命令: + +```text +graph.node.create / delete / move / set-property +graph.edge.connect / disconnect +graph.selection.duplicate +graph.comment.create +graph.layout.align / distribute +graph.parameter.create / rename / delete / set-default +``` + +Graph Canvas 只关心节点、pin、连线和选择;业务域 provider 决定节点目录、类型系统和编译。 + +### 22.2 Material Domain + +```cpp +struct MaterialDomainDescriptor { + std::vector shadingModels; + std::vector outputs; + std::vector previewMeshes; +}; + +struct MaterialCompileResult { + EditorStatus status; + Revision documentRevision; + ArtifactKey shaderArtifact; + MaterialStats stats; + std::vector diagnostics; +}; + +class IMaterialEditorService { +public: + virtual EditorResult compile( + DocumentId, MaterialCompileOptions) = 0; + virtual EditorResult result(TaskHandle) const = 0; + virtual EditorResult<> publishPreview( + DocumentId, Revision, const ArtifactKey&) = 0; + virtual EditorResult instances( + AssetGuid material) const = 0; +}; +``` + +编译使用 revision token。若任务完成时文档已更新,artifact 可进入缓存但不能发布为当前 preview。场景只接收最近一次成功编译;失败时保留上一成功材质并显示错误。 + +Material Instance 使用 parent GUID + parameter overrides;参数 ID 来自 graph parameter stable ID,重命名不破坏实例引用。 + +## 23. 游戏玩法扩展 API + +### 23.1 扩展入口 + +```cpp +class IGameEditorExtension { +public: + static constexpr const char* capabilityName = "IGameEditorExtension"; + virtual ~IGameEditorExtension() = default; + virtual std::string ownerModule() const = 0; + virtual void registerTypes(IEditorRegistry&) {} + virtual void registerCommands(IEditorRegistry&) {} + virtual void registerTools(IEditorRegistry&) {} + virtual void registerProperties(IEditorRegistry&) {} + virtual void registerValidation(IEditorRegistry&) {} + virtual void registerPresentation(IEditorRegistry&) {} + virtual void configureSession(EditorSessionBuilder&, + const ProjectContext&) {} +}; +``` + +游戏模块构造时 `cap::addListener(&extension, priority)`;Editor bootstrap 遍历扩展。发布游戏若使用 runtime builder,也遍历同一扩展,但 registry 根据 HostProfile 过滤 Developer-only descriptor。 + +### 23.2 Descriptor 可见性 + +```cpp +enum class ExtensionAudience : std::uint32_t { + Developer = 1u << 0, + Player = 1u << 1, + Admin = 1u << 2, + Automation= 1u << 3 +}; + +struct ExtensionVisibility { + ExtensionAudience audience; + std::string predicate; // 受限表达式:feature/unlock/role/platform +}; +``` + +业务扩展可以为同一 Tool 注册多个 PaletteDescriptor:开发版放入 `Level/Ride`,玩家版放入 `Build/Rides`;ToolId 不变。 + +### 23.3 过山车轨道示例 + +领域 capability: + +```cpp +class ITrackNetworkEditTarget { +public: + static CapabilityId editorCapabilityId(); + virtual EditorResult track(StableId track) const = 0; + virtual EditorResult probeSegment( + StableId track, const TrackControlPoint&) const = 0; + virtual EditorResult makeAppendSegment( + StableId track, const TrackSegmentSpec&) const = 0; + virtual EditorResult makeMoveControlPoint( + StableId point, const TrackControlPoint&) const = 0; +}; +``` + +Tool: + +```cpp +class TrackBuildTool final : public IEditorTool { +public: + ToolResponse pointerEvent(EditorContext&, const EditorPointerEvent&) override; + ToolResponse actionEvent(EditorContext&, const EditorActionEvent&) override; + void emitOverlay(EditorContext&, IEditorOverlay&) override; +private: + std::optional preview_; + TrackSegmentSpec draft_; +}; +``` + +规则组合: + +```text +TrackContinuityConstraint 切线连续、轨距、最小半径 +TerrainCollisionConstraint 地形/建筑/其他轨道碰撞 +ParkBoundaryPolicy 园区所有权 +RideUnlockPolicy 科技和设施解锁 +ConstructionCostPolicy 造价计算与资源预留 +ServerPermissionPolicy 多人角色权限 +``` + +Developer Host: + +```cpp +auto session = makeDeveloperSession(sceneDocumentTarget); +session->addRule(trackContinuity); +session->addRule(terrainCollision); +session->activateTool(ToolId{"park.track.build"}); +// Dock presenter 显示精确曲率、G-force 预测、调试线。 +``` + +Runtime Host: + +```cpp +auto session = makeRuntimeBuilderSession(serverWorldTarget, playerIdentity); +session->addRule(trackContinuity); +session->addRule(terrainCollision); +session->addRule(parkBoundary); +session->addRule(rideUnlock); +session->addRule(constructionCost); +session->activateTool(ToolId{"park.track.build"}); +// Game HUD 显示价格、解锁状态、确认/取消;服务器复验并扣款。 +``` + +两端使用相同 `TrackBuildTool`、target capability 和 Domain Operation。开发版不是一套近似轨道算法,玩家版也不复制 gizmo/preview/undo。 + +### 23.4 玩法注入官方编辑器 + +```cpp +class ParkEditorExtension final : public IGameEditorExtension { +public: + void registerTools(IEditorRegistry& r) override { + r.registerTool(trackToolDescriptor(), [] { return makeTrackBuildTool(); }); + r.registerTool(pathToolDescriptor(), [] { return makeGuestPathTool(); }); + } + void registerPresentation(IEditorRegistry& r) override { + r.registerPanel(guestHeatmapPanel(), makeGuestHeatmapPanel); + r.registerOverlay(rideEnvelopeOverlay(), makeRideEnvelopeOverlay); + } + void registerValidation(IEditorRegistry& r) override { + r.registerValidator(rideEntranceValidator(), makeRideEntranceValidator); + } +}; +``` + +扩展使用正式游戏模拟接口读取 G-force、游客寻路、经济数据;不得复制一份 editor-only 计算。必要时运行系统暴露只读快照 capability。 + +## 24. Persistence API + +```cpp +enum class PersistenceKind { + ProjectDocument, SaveGame, ServerWorld, + UserCreation, Transient +}; + +struct PersistenceKey { + PersistenceKind kind; + std::string namespaceId; + StableId object; +}; + +class IEditPersistence { +public: + virtual ~IEditPersistence() = default; + virtual PersistenceKind kind() const = 0; + virtual EditorResult persist( + const TransactionReceipt&, const TargetSnapshot&) = 0; + virtual EditorResult restore( + const PersistenceKey&, const RestoreOptions&) = 0; + virtual EditorResult planMigration( + const PersistenceKey&, std::uint32_t fromVersion) = 0; +}; +``` + +具体后端: + +- `SceneDocumentPersistence`:确定性文本、atomic save、Git 友好; +- `SaveGamePersistence`:checkpoint + journal,按游戏存档策略压缩; +- `ServerWorldPersistence`:服务端 event log + snapshot,receipt 是幂等键; +- `UserCreationPersistence`:白名单 schema、依赖闭包、安全 Cook、签名/版本; +- `TransientPersistence`:教程、规划、不落盘。 + +Persistence 只接收 committed receipt/snapshot,不参与 preview。Authority commit 成功但异步 persistence 失败时事务仍是 committed,状态进入 `PersistencePending/Error` 并可重试;不得偷偷回滚已经被服务器/玩法接受的世界状态。 + +## 25. Squirrel API + +### 25.1 设计规则 + +- C++ enum 映射为小写 kebab-case 字符串; +- 返回 table:`{ status, value, diagnostics }`,预期失败不只返回 bool; +- 对象 handle 使用 stable ID + generation,不暴露指针生命周期; +- 回调异常捕获为 diagnostic,事务自动 rollback; +- 脚本工具可以进入 runtime build,但任意 `runScript` 只属于 Developer profile; +- payload 经过 PropertySchema 校验和大小限制。 + +### 25.2 创建 Session + +```squirrel +local editor = eve.Editor(); + +local session = editor.createSession({ + id = "park-builder", + profile = "runtime-builder", + workspace = "park-build", + undoBytes = 16 * 1024 * 1024 +}); + +session.bindTarget(world.getEditingTarget()); +session.setAuthority(world.getServerAuthority()); +session.setPersistence(saveGame.getEditPersistence()); +session.activateTool("park.track.build"); +``` + +### 25.3 注册脚本工具 + +```squirrel +editor.registerTool({ + id = "park.scenery.place", + label = "editor.park.scenery.place", + category = "park.scenery", + icon = "place-scenery", + audiences = ["developer", "player"], + requires = ["eve.editor.target.scene-hierarchy"], + settings = { + rotationStep = { type="float", default=15.0, min=1.0, max=90.0 }, + randomYaw = { type="bool", default=false } + } +}, function() { + return ParkSceneryTool(); +}); +``` + +### 25.4 执行和 dry-run + +```squirrel +local plan = session.plan({ + id = "scene.asset.place", + expectedRevision = session.targetRevision(), + payload = { + asset = "asset-guid", + position = [10.0, 0.0, 20.0] + } +}); + +if (plan.status == "applied" && plan.value.cost.money <= player.money) { + local result = session.executePlan(plan.value.id); + if (result.status != "applied") { + foreach (d in result.diagnostics) print(d.message + "\n"); + } +} +``` + +### 25.5 Property + +```squirrel +local schema = session.properties("global.scene"); +local value = session.getProperty("transform.position"); +session.setProperty("transform.position", [1.0, 2.0, 3.0], { + expectedRevision = session.targetRevision(), + mode = "absolute" +}); +``` + +现有 `newSession/newScriptTool` 继续保留;V2 API 首先在新名字下提供,稳定后再给旧 API 文档添加迁移提示。 + +### 25.6 当前可运行的 V2 脚本桥(2026-08-24) + +当前实现先在兼容的 `newSession()` 上提供共享命令协议,避免在完整 Session Builder 落地前制造第二套执行路径: + +```squirrel +local editor = eve.Editor(); +local session = editor.newSession(); + +for (local i = 0; i < session.getCommandCount(); ++i) { + print(session.getCommandId(i) + "\n"); +} + +local planned = session.planCommand("park.attraction.place", { + asset = "asset-guid", + position = [10.0, 0.0, 20.0], + options = { snap = true } +}); + +if (planned.accepted) { + local receipt = session.executePlan(planned.planId, {}); + print(receipt.transactionState + "\n"); +} +``` + +已实现方法: + +- `getCommandCount/getCommandId/getCommandName/getCommandCategory`:按当前 `HostProfile` 过滤发现; +- `planCommand(id, payload)`:Squirrel table/array 与 `EditorValue` 递归映射,来源标记为 `Script`; +- `executePlan(planId, context)`:使用 Session 内保留的不可变 plan + payload,成功后一次性消费; +- `executeCommand(id, payload)`:兼容非 plan 命令,仍经过同一 HostProfile、payload budget 和异常诊断; +- 返回 `{status, accepted, diagnostics, ...}`,事务执行额外返回 transaction id/state/revision/authority receipt。 + +这里的第二个 `executePlan` 参数暂作为脚本上下文 table 和 VM 句柄载体,后续 Session Builder 完成时合并 expected revision、authority token 和 cancellation 等选项。它不参与业务 mutation。 + +## 26. MCP/Automation API 映射 + +MCP 只需要少量通用工具,不按每个 UI 按钮造工具: + +```text +eve_editor_capabilities +eve_editor_sessions +eve_editor_snapshot +eve_editor_commands +eve_editor_command_plan +eve_editor_command_execute +eve_editor_undo +eve_editor_redo +eve_editor_properties +eve_editor_property_get +eve_editor_property_set +eve_editor_diagnostics +eve_asset_query +eve_asset_dependencies +eve_document_list +eve_document_save +eve_task_status +``` + +游戏扩展命令通过 `eve_editor_commands` 的 schema 自动被发现。示例: + +```json +{ + "command": "park.track.append-segment", + "session": "park-authoring", + "expectedRevision": 812, + "dryRun": true, + "payload": { + "track": "track-main", + "end": [38.0, 12.0, 50.0], + "bankDeg": 20.0 + } +} +``` + +执行返回 plan/receipt、cost、affected objects、diagnostics 和新 revision。Automation origin 在 History 可见并可 Undo。MCP 不调用 `IEditorHost::setEditorValue` 来间接触发业务修改;该 API 只用于测试 presenter。 + +## 27. 线程、异步与事件 + +线程规则: + +- Registry 修改、Session、Target preview/commit 默认主线程; +- Asset query 可任意线程; +- Import、hash、thumbnail decode、shader compile、Cook 在 worker; +- GPU resource publish 在 graphics 指定线程; +- worker 不持有 Document/Scene 裸指针,只持 stable ID、revision 和不可变快照; +- 异步完成通过 task queue 回主线程,根据 generation/revision 决定发布或丢弃。 + +统一 Task: + +```cpp +enum class TaskState { Queued, Running, Succeeded, Failed, Cancelled }; + +struct TaskSnapshot { + TaskHandle id; + std::string type; + TaskState state; + double progress; // 0..1,未知时 optional + std::string stage; + std::vector diagnostics; +}; +``` + +事件必须有 sequence: + +```cpp +struct EditorEvent { + Sequence sequence; + std::string type; + StableId subject; + Revision revision; + EditorValue payload; +}; +``` + +Subscriber 检测 sequence gap 后请求 snapshot,不猜漏掉的 diff。UI 订阅使用 RAII `Subscription`,Panel 销毁自动退订。 + +## 28. 生命周期与所有权 + +推荐启动顺序: + +```text +Capability registry +-> editor core registries +-> runtime/domain modules +-> game editor extensions +-> authoring services (developer only) +-> host/presenters +-> sessions/workspaces +``` + +关闭顺序反向: + +```text +block new commands +-> cancel gestures/previews +-> resolve pending authority/tasks +-> coordinate dirty documents +-> destroy sessions/panels +-> unregister extensions/providers +-> destroy domain modules +``` + +所有权表: + +| 对象 | 拥有者 | 引用方式 | +|---|---|---| +| Descriptor/Factory | Registry | 值/可调用对象 | +| Factory 创建的 Tool | Session | `unique_ptr` | +| V1 外部 Tool | 调用方 | Session 非拥有指针 | +| Target | Document/World adapter owner | Session 非拥有 + stable TargetId | +| DocumentController | DocumentService | `unique_ptr` | +| Panel | WorkspaceService | `unique_ptr` | +| Task | TaskService | TaskHandle | +| History entries | HistoryService | 值快照/operation bytes | +| Capability provider | 业务模块 | `cap::provide/addListener` 非拥有 | + +Capability provider 必须在模块销毁前 revoke/removeListener。Session 不跨模块卸载继续调用 provider;扩展卸载事件先取消相应 Tool 并关闭/降级 Panel。 + +## 29. 安全与发布边界 + +Runtime builder 的威胁模型包括恶意客户端、修改后的 UI、超大 payload、非法资产 GUID、递归 Prefab/图、越界数值和未授权命令。必须在服务层保证: + +- HostProfile allowlist 在 CommandService 执行点检查; +- Server Authority 重新验证 schema、revision、权限、Constraint、Policy 和 cost; +- 客户端 preview 不作为权威; +- 运行时 Property schema 排除 EditorOnly/Dangerous/任意脚本字段; +- UserCreation 只引用 Cook manifest 白名单资产; +- 数组、图节点、对象数、文本、深度、操作频率均限额; +- Operation type 和版本必须注册; +- receipt/request ID 幂等,重放不重复扣款/创建; +- 路径使用逻辑 URI,禁止 `..`、绝对路径和任意文件协议; +- 游戏脚本扩展受发布 manifest 约束,不提供 developer `runScript`。 + +开发编辑器可拥有更高权限,但外部文件变化和保存仍使用 CAS,不因“本机”而忽略冲突。 + +## 30. 错误处理与可观测性 + +每个命令/事务/任务生成 trace context:session ID、command ID、transaction ID、target ID、base/new revision、origin、duration。默认日志只记录摘要,不记录可能敏感的完整文档/payload。 + +Metrics(开发构建): + +```text +editor.command.count/duration/failure +editor.transaction.preview_ms/commit_ms/rollback_count +editor.history.bytes/entries +editor.asset.query_ms/result_count +editor.import.duration/cache_hit +editor.document.save_ms/conflict_count +editor.graph.compile_ms/stale_result_count +editor.runtime.authority_rtt_ms/rejection_count +``` + +History Panel 展示 receipt 摘要;Diagnostics Panel 按 rule/severity/document/task 聚合。Quick Fix 仍是普通 Command,受 profile 与 authority 检查。 + +## 31. C++ 注册示例 + +```cpp +namespace eve::park { + +class ParkEditorExtension final : public editor::IGameEditorExtension { +public: + std::string ownerModule() const override { return "park"; } + + void registerCommands(editor::IEditorRegistry& registry) override { + registry.registerCommand( + makeAppendTrackCommandDescriptor(), + [](const editor::CommandRequest& request) { + return planAndExecuteAppendTrack(request); + }); + } + + void registerTools(editor::IEditorRegistry& registry) override { + registry.registerTool( + makeTrackBuildToolDescriptor(), + [] { return std::make_unique(); }); + } + + void registerValidation(editor::IEditorRegistry& registry) override { + registry.registerValidator( + makeRideConnectivityDescriptor(), + [] { return std::make_unique(); }); + } +}; + +ParkEditorExtension g_editorExtension; + +void ParkModule::registerCapabilities() { + eve::cap::addListener(&g_editorExtension); +} + +void ParkModule::unregisterCapabilities() { + eve::cap::removeListener(&g_editorExtension); +} + +} +``` + +公开 C++ API 按仓库规范添加完整 Doxygen `@brief/@param/@return`。新 provider 不通过 include 访问 editorui;它只依赖 editor protocol。 + +## 32. ABI 与版本策略 + +第一阶段所有模块同仓库同工具链构建,可使用 STL 类型。为未来二进制插件预留: + +- capabilityName、CommandId、ToolId、type ID 永久稳定; +- descriptor 带 `apiVersion` 和 `structSize`; +- Domain Operation type 带 schema version; +- 文档/SaveGame 必须有 migration; +- 插件边界最终提供 C ABI 或 Pimpl span/view,不能承诺当前 STL virtual ABI 稳定; +- 已发布 Squirrel/MCP 字符串 ID 视为兼容 API,重命名需 alias 和弃用期。 + +```cpp +struct ApiHeader { + std::uint32_t apiVersion = 2; + std::uint32_t structSize = sizeof(ApiHeader); +}; +``` + +V2 不承诺让旧二进制插件直接加载;目标是源代码兼容和数据/命令 ID 兼容。 + +## 33. 测试契约 + +### 33.1 Core + +- 不同强类型 ID 不可隐式互换; +- EditorValue 确定性 round-trip、深度/大小限制; +- Command payload schema 拒绝未知/非法字段; +- context snapshot 在选择变化后仍指向原目标; +- duplicate registry ID 被拒绝,owner unload 完整移除; +- HostProfile 隐藏和执行层都拒绝 authoring-only 命令。 + +### 33.2 Transaction + +- preview update 不推进正式 revision; +- cancel 完整恢复 before 且不进入 History; +- commit 只生成一个 receipt; +- stale expected revision 返回 Conflict; +- continuous edit 正确 merge; +- compound operation 中途失败完整 rollback; +- server undo 产生补偿事务且不会重复退款。 + +### 33.3 Document/Asset + +- 保存期间继续编辑仍 dirty; +- 外部同内容 timestamp 变化不产生冲突; +- 外部不同内容 + 本地 dirty 进入 Conflict; +- atomic save 失败保留旧文件; +- GUID 在 move/rename 后保持; +- duplicate GUID 被诊断; +- import 失败保留上一 artifact; +- stale worker result 不覆盖新 generation; +- asset delete plan 列出全部 referencer。 + +### 33.4 同构测试 + +同一 fixture 运行两次: + +```cpp +runToolContract(DeveloperHostFixture{}, SceneDocumentTargetFixture{}); +runToolContract(RuntimeHostFixture{}, RuntimeWorldTargetFixture{}); +``` + +断言: + +- 相同输入生成相同 Domain Operation type/payload; +- Presenter 不影响业务差异; +- Document/SaveGame persistence 恢复后目标语义等价; +- Developer-only rule 缺失不改变核心几何规则; +- Runtime gameplay Policy 能增加 cost/permission 而不修改 Tool; +- Game extension 在不链接 editorui 的情况下注册; +- Server Authority 拒绝伪造客户端已通过结果。 + +### 33.5 性能门 + +- 100k AssetRecord 分页/过滤; +- 50k Outliner row 的增量 diff 和虚拟列表; +- 60 Hz preview 下无历史膨胀; +- 10k 节点图局部编辑不全量复制; +- 大场景 snapshot 分页/区域化; +- runtime command payload/authority rate limit。 + +## 34. 实施切片 + +### Slice A:核心值类型与命令(不改 UI) + +新增 ID、EditorValue、EditorResult、CommandDescriptor/Registry、ContextSnapshot。用 adapter 包住现有 `IEditCommand`。验收:旧 editor 测试不变,新命令可 headless 调用。 + +### Slice B:Session/Transaction/Authority + +扩展 EditorSession 依赖注入;实现 LocalWorldAuthority、LocalTransactionBackend、receipt/history。验收:FieldBrushTool 在旧 bool API 与新 receipt API 都能 Undo。 + +### Slice C:Property 与双 Presenter + +实现 PropertySchema/Provider、Developer 简单 Inspector presenter、Runtime HUD fixture presenter。验收:同一 Transform property 在两套 UI 通过相同命令修改。 + +### Slice D:SceneTarget 双后端 + +实现 SceneDocumentTarget 与 RuntimeWorldTarget,均暴露 hierarchy/transform/property capability。验收:Place/Transform Tool 同构 fixture。 + +### Slice E:Document/AssetDB + +实现 DocumentService、atomic save、Asset GUID/index/query/dependency、Content Browser 数据源。验收:导入 -> 放置 -> 保存 -> 重开闭环。 + +### Slice F:游戏扩展 + +选一个仓库示例(建议 building/road/tile 或新增最小 park-builder fixture)注册 Tool、Policy、Overlay 和 Panel。验收:官方开发 Editor 和游戏内 HUD 使用同一 Tool。 + +### Slice G:Graph/Material + +实现 Graph Core、Material domain、revision-aware compile/publish。验收:编译失败保留上一材质,成功结果热更新预览。 + +### 34.1 当前实现状态(2026-08-24) + +- Slice A:强类型 ID、`EditorValue/EditorResult`、`HostProfile`、命令注册/过滤/执行和旧事务适配已完成; +- Slice B:plan/dry-run、Local/ReadOnly Authority、可逆 Domain Operation、Transaction receipt、Undo/Redo 已完成; +- Slice C:Property Schema/Provider、Developer/Runtime Presenter、共享 Property Edit Intent 已完成; +- Slice D:`SceneDocumentTarget` 与 `RuntimeWorldTarget`、Hierarchy/Transform capability、共享 Place/Transform Tool Logic 已完成; +- Slice E:内存 AssetDB、磁盘 `.evmeta` GUID sidecar 扫描/轮询、导入协调、依赖查询、真实磁盘 atomic save、双 revision/content-hash 冲突检测、autosave/recovery 和导入到重开闭环已完成; +- Slice F:游戏扩展注册表、audience/profile 过滤、工具/面板/规则注入、owner-scoped unload 和异常回滚已完成; +- Slice G:领域无关 Graph Core、Material 类型连接/确定性编译、可取消后台任务队列、revision-aware preview publish 已完成; +- Squirrel:共享命令发现、结构化 payload、retain plan、execute plan 和结构化 receipt 已完成。 +- MCP:通过 `IEditorAutomation` capability 暴露 commands/plan/commit/execute/cancel/undo/redo/diagnostics;DevTools 不反向依赖 editor 模块; +- Runtime Publish:显式 deny-by-default manifest、运行时 command/tool/palette/rule 裁剪、开发者 feature 拒绝和非 EditorOnly/Source 资产闭包已完成; +- Persistence:SceneDocument 与 SaveGame 通过同一 checkpoint + Domain Operation journal 契约持久化; +- Shared State:presentation-neutral Selection/Focus channel、extension-owned Validation 和统一 Diagnostic channel 已完成。 + +本轮新增/回归的 `editor.v2` 测试与 MCP 实际调用共 40/40 通过,MCP tools/list 与真实 `eve_editor_execute` TCP 调用也纳入测试,模块依赖图无新增 back-edge。P0/P1 原先明确列出的实现缺口(MCP 映射、真实磁盘 atomic store、后台编译队列/取消、发布清单裁剪、SaveGame 同构持久化、磁盘资产 sidecar/watch、Selection/Focus/Validation/Task/Diagnostic)已补齐。AssetDB 的 URI 唯一索引已由 O(n) 发布检查改为哈希索引,并通过 100k AssetRecord 建库、过滤、分页和 URI 查询契约(本机 Debug 单 case 3.74 秒)。全仓公开头 Doxygen 完整度审计继续作为质量维护项,不再作为 P0/P1 功能阻塞项。 + +## 35. 首批决策记录 + +以下决策作为实现默认值,除非出现实证阻碍不再反复讨论: + +1. 编辑协议可发布,开发 authoring 服务不可发布; +2. Host、Target、Authority、Persistence 分离; +3. UI 不拥有业务状态; +4. Tool 产生/请求 Domain Operation,不直接持久化; +5. 同一 Tool 可挂开发 Presenter 与游戏 Presenter; +6. 游戏扩展通过 registry/capability 注入官方编辑器; +7. Script/MCP/UI 走同一 CommandService; +8. 稳定 ID + revision 是异步、Undo、网络和持久化基础; +9. Source/Authoring/Artifact 三层资产分离; +10. 当前 V1 API 通过 adapter 渐进迁移,不做破坏式重写。 + +## 36. 完成定义 + +Editor API V2 的基础层只有在以下闭环全部成立时才算完成: + +1. C++、Squirrel、MCP 均能发现并执行同一注册命令; +2. 命令支持 plan/dry-run、preview、commit、cancel、undo、diagnostic; +3. 同一 Scene Tool 同时操作 SceneDocumentTarget 和 RuntimeWorldTarget; +4. Developer Presenter 与 Game Presenter 不包含业务 setter; +5. 游戏模块无需修改 editor/editorui 即可注入完整专用编辑能力; +6. SaveGame/Server/UserCreation 后端不能访问 authoring-only 能力; +7. Document 保存、外部冲突和异步 revision race 有契约测试; +8. Asset GUID、依赖、导入产物和 Cook 闭环可 headless 验证; +9. 发布包只包含 manifest 明确选择的 runtime editing provider; +10. 所有公开头按 Doxygen 规范记录参数、返回、所有权、线程和生命周期。 + +这套 API 的最终衡量标准不是“编辑器面板数量”,而是某项编辑能力能否在不复制业务代码的情况下,从开发编辑器自然迁移为玩家玩法,并能把真实游戏系统反向带入项目编辑器。 diff --git a/docs/usr/modules/editor.md b/docs/usr/modules/editor.md index 50502674..6b00d759 100644 --- a/docs/usr/modules/editor.md +++ b/docs/usr/modules/editor.md @@ -117,6 +117,15 @@ session.dispatchPointer(0, 0, 0, mapX, mapY, 0.0, 0.0, 1.0); `EditorSession` 和工具都是非拥有关系;脚本必须像上例一样持有 `road`,直到从会话移除。 +### 注册并执行项目命令 + +项目可用 `registerScriptCommand(id, name, category, callback)` 把游戏专用操作注入统一命令 +服务,退出插件或编辑器时用 `unregisterScriptCommand(id)` 清理。会话通过 +`getCommandCount()` 与 `getCommandId/Name/Category(index)` 枚举命令; +`planCommand(id, payload)` 只生成并保留计划,随后用 `executePlan(planId, context)` 执行; +不需要预览时可直接 `executeCommand(id, payload)`。这些入口和 C++ 命令服务共享约束、 +事务及 HostProfile 策略,因此游戏内建造玩法和开发编辑器可以复用同一条命令路径。 + ## 地图笔刷 旧的 `Brush` / `EditorHistory` API 为兼容已有脚本保留。新编辑器优先使用上面的协议式会话;旧 API 适合很小的纯 tile 工具。 @@ -183,8 +192,8 @@ insp.addFloat3("pos", "Position", 0, 0, 0); ## API 快查 -- 模块:`newSession` / `newScriptTool` / `newGizmo` / `newGizmoManager` / `newTileBuffer` / `newBrush` / `newToolbar` / `newInspector` / `newDock` / `newHistory` / `applyHeightmapBrush` / `newHeightmapMesh` / `updateHeightmapMesh` / `newHeightmapMeshSmooth` / `updateHeightmapMeshSmooth` -- 会话:`addTool` / `removeTool` / `clearTools` / `activateTool` / `getActiveToolId` / `dispatchPointer` / `hasPointerCapture` / `update` / `cancelActiveTool` / `undo` / `redo` +- 模块:`newSession` / `newScriptTool` / `registerScriptCommand` / `unregisterScriptCommand` / `newGizmo` / `newGizmoManager` / `newTileBuffer` / `newBrush` / `newToolbar` / `newInspector` / `newDock` / `newHistory` / `applyHeightmapBrush` / `newHeightmapMesh` / `updateHeightmapMesh` / `newHeightmapMeshSmooth` / `updateHeightmapMeshSmooth` +- 会话:`addTool` / `removeTool` / `clearTools` / `activateTool` / `getActiveToolId` / `dispatchPointer` / `hasPointerCapture` / `update` / `cancelActiveTool` / `undo` / `redo` / `getCommandCount` / `getCommandId` / `getCommandName` / `getCommandCategory` / `planCommand` / `executePlan` / `executeCommand` - 脚本工具:`setShortcut` / `setActivateCallback` / `setDeactivateCallback` / `setPointerCallback` / `setKeyCallback` / `setUpdateCallback` / `setCancelCallback` - Gizmo:`setMode` / `setSpace` / `setPosition` / `setRotationEuler` / `setScale` / `setBounds` / `setSnap*` / `pick` / `beginDrag` / `updateDrag` / `endDrag` / `getPart*` - Manager:`set*Enabled` / `attach` / `detach` / `getGizmo` / `pick` / `beginDrag` / `updateDrag` diff --git a/docs/usr/modules/ui.md b/docs/usr/modules/ui.md index 2dfb623b..a13ae828 100644 --- a/docs/usr/modules/ui.md +++ b/docs/usr/modules/ui.md @@ -76,6 +76,19 @@ ui.end(); MouseY/DragDX/DragDY/Wheel(id)` 每帧读取。完整示例见 `examples/terrain-editor`(高度图地形 + orbit 相机 + 抬高/压低笔刷)。 +## MCP EditorHost 脚本接口 + +`eve mcp` 会在根表建立 `eve.host`,供项目脚本创建和维护 AI 编辑器。它与 +`eve.UI()` 的游戏内 retained UI 相互独立,但都运行在同一个 Squirrel VM 中。 + +- 窗口与状态:`status()`、`openWindow()`、`closeWindow()`、`windowState()`。 +- View 与交互:`applyEditor()`、`removeEditor()`、`setValue()`、`events()`、 + `widgetRect()`、`capture()`、`save()`。 +- ViewModel:`registerVM()`、`unregisterVM()`、`runScript()`。 +- 热更新:`reloadResource(path)` 手动重载项目内的 `mcp.nut`、`mcp/*.nut`、 + `editors/*.vm.nut` 或 `editors/*.editor.json`;`hotReloadStatus()` 返回 watcher、 + 成功/失败计数与最近诊断。正常保存会由 MCP 主机自动触发,无需重启。 + ## 使用要点 - 模块对象和它创建的资源对象应保存在全局或实体状态中,不要在每帧重复创建。 diff --git a/examples/ai-editor/README.md b/examples/ai-editor/README.md index c519516e..1059c51a 100644 --- a/examples/ai-editor/README.md +++ b/examples/ai-editor/README.md @@ -38,6 +38,16 @@ python examples/ai-editor/editor_demo.py 7531 `eve_host_capture` 等工具现场生成并迭代编辑器(工具全表见 [`docs/dev/AI与MCP支持.md`](../../docs/dev/AI与MCP支持.md))。 +## 保存即生效 + +主机运行期间可直接修改 `editors/terrain.editor.json` 或 +`editors/terrain.vm.nut`。`eve mcp` 会自动重载文件,不需要重新编译或重启测试;当前 +MCP 连接和编辑器控件值会保留。若新脚本编译失败,旧 ViewModel 继续工作,可通过 +`eve_host_hot_reload_status` 查看错误,修复并再次保存即可恢复。 + +项目还可以添加 `mcp.nut` 或 `mcp/*.nut` 注入专用工具逻辑。Agent 需要确定性刷新时可调用 +`eve_host_resource_reload {"path":"editors/terrain.vm.nut"}`。 + ## 文件 - `editors/terrain.editor.json`:编辑器 View(控件树 + 主题 + 布局)。 diff --git a/src/engine/cmdline/Mcp.cpp b/src/engine/cmdline/Mcp.cpp index 1091ca7a..11dafbf8 100644 --- a/src/engine/cmdline/Mcp.cpp +++ b/src/engine/cmdline/Mcp.cpp @@ -6,6 +6,7 @@ #include "common/Runtime.h" #include "common/config.h" #include "filesystem/Filesystem.h" +#include "filesystem/HotReload.h" #include "filesystem/physfs/FileApi.h" #if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) && !defined(EVENGINE_WEBGPU) @@ -22,6 +23,7 @@ #include #include #include +#include #include #include @@ -38,6 +40,18 @@ void hostPrintFunc(HSQUIRRELVM, const SQChar* s, ...) { fputs(buf, stderr); } +bool endsWith(const std::string& value, const std::string& suffix) { + return value.size() >= suffix.size() && + value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +bool isEditorHostResource(const std::string& path) { + if (path == "mcp.nut") return true; + if (path.rfind("mcp/", 0) == 0 && endsWith(path, ".nut")) return true; + if (path.rfind("editors/", 0) != 0) return false; + return endsWith(path, ".vm.nut") || endsWith(path, ".editor.json"); +} + } // namespace struct McpArgs : Handler { @@ -83,6 +97,7 @@ int Cmdline::McpHost(std::string path, int port) { gameDir = std::filesystem::absolute(path, ec).string(); if (ec) gameDir = path; } + if (!gameDir.empty() && gameDir != ".") { std::error_code ec; std::filesystem::current_path(gameDir, ec); @@ -101,14 +116,15 @@ int Cmdline::McpHost(std::string path, int port) { // Optional for the headless host: keep running when the environment // cannot initialize PhysFS (sandboxed/CI shells), scripts just lose // the PhysFS-backed file API. + eve::filesystem::Filesystem* projectFs = nullptr; try { - auto* fs = eve::filesystem::Filesystem::create(); - if (fs) { + projectFs = eve::filesystem::Filesystem::create(); + if (projectFs) { std::error_code ec; auto cwd = std::filesystem::current_path(ec); if (!ec) { try { - fs->setSource(cwd.string()); + projectFs->setSource(cwd.string()); } catch (...) { } } @@ -141,6 +157,19 @@ int Cmdline::McpHost(std::string path, int port) { return 3; } + // Unlike `eve run`, the MCP host does not execute load.nut. Start the + // same project watcher here and route editor/host scripts through the + // EditorHost while other assets keep using the shared asset reloaders. + auto* hot = eve::ModuleManager::getInstance("HotReload"); + const int watchCount = projectFs && hot ? hot->watchTree(".") : 0; + host->setHotReloadWatchCount(watchCount); + if (std::filesystem::is_regular_file(std::filesystem::path(gameDir) / "mcp.nut")) { + const std::string initial = host->reloadResource("mcp.nut"); + if (initial.rfind("error:", 0) == 0) + std::cerr << "MCP host script load failed: " << initial << std::endl; + } + std::cerr << "MCP hot-reload: watching " << watchCount << " path(s)" << std::endl; + auto& mcp = dt.mcp(); mcp.setGameRoot(gameDir); @@ -161,8 +190,35 @@ int Cmdline::McpHost(std::string path, int port) { << ")" << std::endl; } + std::map pendingReloads; + constexpr auto reloadDebounce = std::chrono::milliseconds(100); while (!host->exitRequested()) { dt.poll(); // drains MCP requests on the main thread + if (projectFs) { + while (true) { + const std::string kind = projectFs->pollWatch(); + if (kind.empty()) break; + if (kind != "modified" && kind != "added" && kind != "movedTo") continue; + std::string changed = projectFs->getLastWatchPath(); + changed = eve::filesystem::HotReload::normalizePath(std::move(changed)); + if (!changed.empty()) pendingReloads[changed] = std::chrono::steady_clock::now(); + } + } + const auto now = std::chrono::steady_clock::now(); + for (auto it = pendingReloads.begin(); it != pendingReloads.end();) { + if (now - it->second < reloadDebounce) { + ++it; + continue; + } + const std::string changed = it->first; + if (isEditorHostResource(changed)) { + const std::string result = host->reloadResource(changed); + std::cerr << "MCP hot-reload: " << changed << ": " << result << std::endl; + } else if (hot && hot->tryReload(changed)) { + std::cerr << "MCP hot-reload asset: " << changed << std::endl; + } + it = pendingReloads.erase(it); + } host->frame(); if (mcp.transport() == eve::dev::McpServer::Transport::Stdio && mcp.stdinClosed()) { diff --git a/src/engine/common/EditorAutomation.h b/src/engine/common/EditorAutomation.h new file mode 100644 index 00000000..f57da57f --- /dev/null +++ b/src/engine/common/EditorAutomation.h @@ -0,0 +1,29 @@ +#pragma once + +#include "common/Export.h" + +#include + +namespace eve { + +/** + * @brief Module-neutral bridge from DevTools/MCP to the editor command protocol. + * + * JSON keeps the lower-layer DevTools module independent from editor C++ types. + * Implementations must return a JSON object for every operation, including + * rejected requests. + */ +class EVENGINE_API IEditorAutomation { +public: + static constexpr const char* capabilityName = "IEditorAutomation"; + virtual ~IEditorAutomation() = default; + /** + * @brief Invoke commands, plan, commit, cancel, undo, redo or diagnostics. + * @param operation Stable automation operation name. + * @param requestJson UTF-8 JSON request object. + * @return UTF-8 JSON result object with status and diagnostics. + */ + virtual std::string invoke(const std::string& operation, const std::string& requestJson) = 0; +}; + +} // namespace eve diff --git a/src/engine/common/EditorHost.h b/src/engine/common/EditorHost.h index bc6af402..0ab1f19a 100644 --- a/src/engine/common/EditorHost.h +++ b/src/engine/common/EditorHost.h @@ -39,6 +39,12 @@ public: virtual std::string unloadEditor(const std::string& id) = 0; virtual std::string capture(const std::string& path) = 0; virtual std::string runScript(const std::string& source) = 0; + /** @brief Reload one project-scoped MCP host script or editor resource. */ + virtual std::string reloadResource(const std::string& path) = 0; + /** @brief Hot-reload watcher and latest reload diagnostic as compact JSON. */ + virtual std::string hotReloadStatus() const = 0; + /** @brief Report the number of active project filesystem watches. */ + virtual void setHotReloadWatchCount(int count) = 0; virtual void requestExit() = 0; /** @brief True after requestExit() (main loop exit condition). */ virtual bool exitRequested() = 0; diff --git a/src/engine/common/UIAutomation.h b/src/engine/common/UIAutomation.h new file mode 100644 index 00000000..41a2b762 --- /dev/null +++ b/src/engine/common/UIAutomation.h @@ -0,0 +1,43 @@ +#pragma once + +// Capability interface: semantic inspection and interaction for retained game +// UI. DevTools consumes this without depending on the ui module. + +#include "common/Export.h" + +#include + +namespace eve { + +/** @brief Semantic retained-UI automation provided by the ui module. */ +class EVENGINE_API IUIAutomation { +public: + static constexpr const char* capabilityName = "IUIAutomation"; + + virtual ~IUIAutomation() = default; + + /** + * @brief Serialize visible and hidden retained UI hosts and their widget trees. + * @param host Optional exact host name; empty returns all hosts. + * @return Compact JSON, or an error string beginning with `error:`. + */ + virtual std::string tree(const std::string& host) const = 0; + + /** + * @brief Read one widget's semantic state. + * @param host Optional exact host name used to disambiguate widget ids. + * @param widget Widget id. + * @return Compact JSON, or an error string beginning with `error:`. + */ + virtual std::string get(const std::string& host, const std::string& widget) const = 0; + + /** + * @brief Queue a semantic click through the normal UI event path. + * @param host Optional exact host name used to disambiguate widget ids. + * @param widget Clickable widget id. + * @return Compact acknowledgement JSON, or an error string beginning with `error:`. + */ + virtual std::string click(const std::string& host, const std::string& widget) = 0; +}; + +} // namespace eve diff --git a/src/engine/devtools/McpServer.cpp b/src/engine/devtools/McpServer.cpp index c62d617a..9b594a17 100644 --- a/src/engine/devtools/McpServer.cpp +++ b/src/engine/devtools/McpServer.cpp @@ -1,4 +1,6 @@ #include "devtools/McpServer.hpp" + +#include "common/EditorAutomation.h" #include "devtools/Immortal.hpp" #include "devtools/AiPanel.hpp" @@ -6,8 +8,8 @@ #include "devtools/Debugger.hpp" #include "devtools/DevTool.hpp" #include "devtools/McpDevBridge.hpp" -#include "devtools/SceneInspect.hpp" #include "devtools/RenderVision.hpp" +#include "devtools/SceneInspect.hpp" #include "devtools/Snapshot.hpp" #include "scripts.h" @@ -22,6 +24,7 @@ #include "common/RenderCapture.h" #include "common/SceneQuery.h" #include "common/ScriptError.h" +#include "common/UIAutomation.h" #include #include @@ -59,21 +62,11 @@ std::string mcpJsonEscape(const std::string& s) { out.reserve(s.size() + 8); for (char c : s) { switch (c) { - case '"': - out += "\\\""; - break; - case '\\': - out += "\\\\"; - break; - case '\n': - out += "\\n"; - break; - case '\r': - out += "\\r"; - break; - case '\t': - out += "\\t"; - break; + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; default: if (static_cast(c) < 0x20) { char buf[8]; @@ -106,9 +99,8 @@ std::string makeResult(const std::string& idJson, const std::string& resultJson) } std::string makeError(const std::string& idJson, int code, const std::string& message) { - return std::string("{\"jsonrpc\":\"2.0\",\"id\":") + idJson + - ",\"error\":{\"code\":" + std::to_string(code) + ",\"message\":\"" + mcpJsonEscape(message) + - "\"}}"; + return std::string("{\"jsonrpc\":\"2.0\",\"id\":") + idJson + ",\"error\":{\"code\":" + std::to_string(code) + + ",\"message\":\"" + mcpJsonEscape(message) + "\"}}"; } std::string textContentResult(const std::string& text, bool isError = false) { @@ -125,24 +117,18 @@ std::string textContentResult(const std::string& text, bool isError = false) { std::string pauseReasonName(PauseReason r) { switch (r) { - case PauseReason::Breakpoint: - return "breakpoint"; - case PauseReason::Step: - return "step"; - case PauseReason::Exception: - return "exception"; - case PauseReason::Snapshot: - return "snapshot"; - case PauseReason::PauseKey: - return "pause"; - default: - return "none"; + case PauseReason::Breakpoint: return "breakpoint"; + case PauseReason::Step: return "step"; + case PauseReason::Exception: return "exception"; + case PauseReason::Snapshot: return "snapshot"; + case PauseReason::PauseKey: return "pause"; + default: return "none"; } } std::string engineStatusJson(const McpServer& mcp) { - auto& dbg = Debugger::instance(); - Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); + auto& dbg = Debugger::instance(); + Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); o->set("attached", mcpDevAttached()); o->set("paused", dbg.isPaused()); o->set("pauseReason", pauseReasonName(dbg.lastPauseReason())); @@ -153,15 +139,9 @@ std::string engineStatusJson(const McpServer& mcp) { o->set("mcpPort", mcp.port()); o->set("mcpConnected", mcp.hasClient()); switch (mcp.transport()) { - case McpServer::Transport::Stdio: - o->set("transport", "stdio"); - break; - case McpServer::Transport::Tcp: - o->set("transport", "tcp"); - break; - default: - o->set("transport", "none"); - break; + case McpServer::Transport::Stdio: o->set("transport", "stdio"); break; + case McpServer::Transport::Tcp: o->set("transport", "tcp"); break; + default: o->set("transport", "none"); break; } o->set("dapPort", DebugAdapter::instance().port()); o->set("gameRoot", mcp.gameRoot()); @@ -257,12 +237,11 @@ std::string sqLiteralValue(const Poco::Dynamic::Var& v) { if (v.isInteger()) return std::to_string(v.convert()); if (v.isNumeric()) { const double d = v.convert(); - char buf[64]; + char buf[64]; std::snprintf(buf, sizeof(buf), "%g", d); return buf; } - if (v.isString()) - return std::string("\"") + sqStringLiteralEscape(v.convert()) + "\""; + if (v.isString()) return std::string("\"") + sqStringLiteralEscape(v.convert()) + "\""; if (v.isArray()) { std::string out = "["; try { @@ -277,8 +256,8 @@ std::string sqLiteralValue(const Poco::Dynamic::Var& v) { return out; } if (v.isStruct()) { - std::string out = "{"; - bool first = true; + std::string out = "{"; + bool first = true; try { auto obj = v.extract(); for (const auto& kv : *obj) { @@ -295,8 +274,8 @@ std::string sqLiteralValue(const Poco::Dynamic::Var& v) { } std::string snippetErrorText(HSQUIRRELVM vm, bool compile) { - eve::script::ScriptErrorContext ctx = compile ? eve::script::captureCompileError(vm) - : eve::script::takeLastScriptError(vm); + eve::script::ScriptErrorContext ctx = + compile ? eve::script::captureCompileError(vm) : eve::script::takeLastScriptError(vm); if (!ctx.empty()) return eve::script::formatScriptError(ctx); return compile ? "compile failed" : "runtime failed"; } @@ -304,8 +283,8 @@ std::string snippetErrorText(HSQUIRRELVM vm, bool compile) { // Compile + run a snippet against the live VM (no return value captured). bool runVmSnippet(HSQUIRRELVM vm, const std::string& source, std::string* err) { const SQInteger top = sq_gettop(vm); - if (SQ_FAILED(sq_compilebuffer(vm, source.c_str(), static_cast(source.size()), - _SC("mcp_snippet.nut"), SQTrue))) { + if (SQ_FAILED(sq_compilebuffer(vm, source.c_str(), static_cast(source.size()), _SC("mcp_snippet.nut"), + SQTrue))) { sq_settop(vm, top); if (err) *err = snippetErrorText(vm, true); return false; @@ -324,8 +303,7 @@ bool runVmSnippet(HSQUIRRELVM vm, const std::string& source, std::string* err) { std::string sqValueToJson(HSQUIRRELVM vm, SQInteger idx) { if (idx < 0) idx = sq_gettop(vm) + idx + 1; // normalize relative -> absolute switch (sq_gettype(vm, idx)) { - case OT_NULL: - return "null"; + case OT_NULL: return "null"; case OT_BOOL: { SQBool b = SQFalse; sq_getbool(vm, idx, &b); @@ -349,8 +327,8 @@ std::string sqValueToJson(HSQUIRRELVM vm, SQInteger idx) { return std::string("\"") + mcpJsonEscape(s ? s : "") + "\""; } case OT_ARRAY: { - std::string out = "["; - bool first = true; + std::string out = "["; + bool first = true; sq_pushnull(vm); while (SQ_SUCCEEDED(sq_next(vm, idx))) { if (!first) out += ","; @@ -363,8 +341,8 @@ std::string sqValueToJson(HSQUIRRELVM vm, SQInteger idx) { return out; } case OT_TABLE: { - std::string out = "{"; - bool first = true; + std::string out = "{"; + bool first = true; sq_pushnull(vm); while (SQ_SUCCEEDED(sq_next(vm, idx))) { if (!first) out += ","; @@ -378,15 +356,13 @@ std::string sqValueToJson(HSQUIRRELVM vm, SQInteger idx) { out += "}"; return out; } - default: - return "\"\""; + default: return "\"\""; } } // Compile a snippet that returns a value, run it, and return the JSON of the // return value (e.g. `return ::scene_director.info();`). -std::string callSceneDirectorReturn(HSQUIRRELVM vm, const std::string& snippet, - std::string* err) { +std::string callSceneDirectorReturn(HSQUIRRELVM vm, const std::string& snippet, std::string* err) { const SQInteger top = sq_gettop(vm); if (SQ_FAILED(sq_compilebuffer(vm, snippet.c_str(), static_cast(snippet.size()), _SC("mcp_scene_director.nut"), SQTrue))) { @@ -399,9 +375,7 @@ std::string callSceneDirectorReturn(HSQUIRRELVM vm, const std::string& snippet, sq_settop(vm, top); std::string text = snippetErrorText(vm, false); if (err) - *err = text == "runtime failed" - ? "runtime failed (is the scene_director kit installed?)" - : std::move(text); + *err = text == "runtime failed" ? "runtime failed (is the scene_director kit installed?)" : std::move(text); return {}; } std::string json = sqValueToJson(vm, -1); @@ -411,9 +385,8 @@ std::string callSceneDirectorReturn(HSQUIRRELVM vm, const std::string& snippet, // Install the scene_director kit into the live VM (idempotent). bool ensureSceneDirectorInstalled(HSQUIRRELVM vm, std::string* err) { - const std::string check = - "return (\"scene_director\" in getroottable()) ? (scene_director != null) : false;"; - const std::string out = callSceneDirectorReturn(vm, check, nullptr); + const std::string check = "return (\"scene_director\" in getroottable()) ? (scene_director != null) : false;"; + const std::string out = callSceneDirectorReturn(vm, check, nullptr); if (!out.empty() && out.find("true") != std::string::npos) return true; const char* kit = eve::scene_director_content; if (!kit || !*kit) { @@ -445,13 +418,15 @@ std::string renderStatusText(eve::IRenderCapture* cap) { return mcpStringify(Poco::Dynamic::Var(o)); } -eve::IRenderCapture* mcpCapture() { return eve::cap::query(); } -eve::ISceneQuery* mcpScene() { return eve::cap::query(); } -eve::IPhysicsQuery* mcpPhysics() { return eve::cap::query(); } -eve::IProcgenQuery* mcpProcgen() { return eve::cap::query(); } -eve::IParticlesQuery* mcpParticles() { return eve::cap::query(); } -eve::IAudioQuery* mcpAudio() { return eve::cap::query(); } -eve::IEditorHost* mcpHost() { return eve::cap::query(); } +eve::IRenderCapture* mcpCapture() { return eve::cap::query(); } +eve::ISceneQuery* mcpScene() { return eve::cap::query(); } +eve::IPhysicsQuery* mcpPhysics() { return eve::cap::query(); } +eve::IProcgenQuery* mcpProcgen() { return eve::cap::query(); } +eve::IParticlesQuery* mcpParticles() { return eve::cap::query(); } +eve::IAudioQuery* mcpAudio() { return eve::cap::query(); } +eve::IEditorHost* mcpHost() { return eve::cap::query(); } +eve::IUIAutomation* mcpUI() { return eve::cap::query(); } +eve::IEditorAutomation* mcpEditor() { return eve::cap::query(); } std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object::Ptr args) { auto& dbg = Debugger::instance(); @@ -459,10 +434,37 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object if (name == "eve_status") return engineStatusJson(mcp); + // ============================= Game UI ============================= + if (name == "eve_ui_tree") { + return mcpUI() ? mcpUI()->tree(argString(args, "host")) : "error: ui module not available"; + } + if (name == "eve_ui_get") { + return mcpUI() ? mcpUI()->get(argString(args, "host"), argString(args, "widget")) + : "error: ui module not available"; + } + if (name == "eve_ui_click") { + return mcpUI() ? mcpUI()->click(argString(args, "host"), argString(args, "widget")) + : "error: ui module not available"; + } + + // ============================= Editor command protocol ============================= + const auto editorInvoke = [&](const char* operation) { + return mcpEditor() ? mcpEditor()->invoke(operation, mcpStringify(Poco::Dynamic::Var(args))) + : std::string("error: editor module not available"); + }; + if (name == "eve_editor_commands") return editorInvoke("commands"); + if (name == "eve_editor_plan") return editorInvoke("plan"); + if (name == "eve_editor_commit") return editorInvoke("commit"); + if (name == "eve_editor_execute") return editorInvoke("execute"); + if (name == "eve_editor_cancel") return editorInvoke("cancel"); + if (name == "eve_editor_undo") return editorInvoke("undo"); + if (name == "eve_editor_redo") return editorInvoke("redo"); + if (name == "eve_editor_diagnostics") return editorInvoke("diagnostics"); + // ============================= Scene / Entity ============================= if (name == "eve_scene_status") { auto* scene = mcpScene(); - Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); + Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); if (!scene) { o->set("error", "Scene module not available"); return mcpStringify(Poco::Dynamic::Var(o)); @@ -482,8 +484,8 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object if (name == "eve_scene_nodes") { auto* scene = mcpScene(); if (!scene || scene->activeHost().empty()) return "error: no active scene host"; - const int limit = argInt(args, "limit", 500); - Poco::JSON::Array::Ptr arr = Poco::JSON::Array::Ptr(new Poco::JSON::Array()); + const int limit = argInt(args, "limit", 500); + Poco::JSON::Array::Ptr arr = Poco::JSON::Array::Ptr(new Poco::JSON::Array()); for (const auto& n : scene->nodes(limit)) { Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); o->set("id", n.id); @@ -498,7 +500,7 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object if (name == "eve_scene_node_get") { auto* scene = mcpScene(); if (!scene || scene->activeHost().empty()) return "error: no active scene host"; - const std::string id = argString(args, "id"); + const std::string id = argString(args, "id"); eve::SceneNodeInfo n; if (id.empty() || !scene->getNode(id, &n)) return "error: node not found: " + id; Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); @@ -541,7 +543,7 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object if (name == "eve_procgen_recipes") { auto* pg = mcpProcgen(); if (!pg) return "error: Procgen module not available"; - Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); + Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); auto addList = [&](const char* key, const std::vector& items) { Poco::JSON::Array::Ptr a = Poco::JSON::Array::Ptr(new Poco::JSON::Array()); for (const auto& it : items) a->add(it); @@ -560,8 +562,7 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object const std::string algorithm = argString(args, "algorithm"); if (algorithm.empty()) return "error: missing algorithm"; std::vector> params; - for (const auto& key : {"roomCount", "roomMin", "roomMax", "corridorWidth", "autotile", - "scale", "octaves"}) { + for (const auto& key : {"roomCount", "roomMin", "roomMax", "corridorWidth", "autotile", "scale", "octaves"}) { if (args && args->has(key)) params.emplace_back(key, std::to_string(argInt(args, key))); } if (args && args->has("corridorStyle")) params.emplace_back("corridorStyle", argString(args, "corridorStyle")); @@ -659,7 +660,7 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object } if (name == "eve_render_describe") { - const bool fresh = argBool(args, "fresh", false); + const bool fresh = argBool(args, "fresh", false); const std::string reason = argString(args, "reason"); return RenderVision::instance().describe(mcpCapture(), renderStatusText(mcpCapture()), fresh, reason); } @@ -692,7 +693,7 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object // ============================= Particles / Weather ============================= if (name == "eve_particles_status") { auto* part = mcpParticles(); - Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); + Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); if (!part) { o->set("error", "Particles module not available"); return mcpStringify(Poco::Dynamic::Var(o)); @@ -719,7 +720,7 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object // ============================= Audio ============================= if (name == "eve_audio_status") { auto* audio = mcpAudio(); - Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); + Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); if (!audio) { o->set("error", "Audio module not available"); return mcpStringify(Poco::Dynamic::Var(o)); @@ -814,6 +815,12 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object if (name == "eve_host_script") { return mcpHost() ? mcpHost()->runScript(argString(args, "source")) : "error: ui module not available"; } + if (name == "eve_host_resource_reload") { + return mcpHost() ? mcpHost()->reloadResource(argString(args, "path")) : "error: ui module not available"; + } + if (name == "eve_host_hot_reload_status") { + return mcpHost() ? mcpHost()->hotReloadStatus() : "error: ui module not available"; + } if (name == "eve_host_shutdown") { if (mcpHost()) mcpHost()->requestExit(); return "ok"; @@ -822,8 +829,8 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object if (name == "eve_eval") { const std::string expr = argString(args, "expression"); if (expr.empty()) return "error: missing expression"; - auto info = dbg.evaluate(expr); - Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); + auto info = dbg.evaluate(expr); + Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); o->set("expression", expr); o->set("name", info.name); o->set("value", info.value); @@ -864,8 +871,8 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object } if (name == "eve_stack") { - auto frames = dbg.stackTrace(); - Poco::JSON::Array::Ptr arr = Poco::JSON::Array::Ptr(new Poco::JSON::Array()); + auto frames = dbg.stackTrace(); + Poco::JSON::Array::Ptr arr = Poco::JSON::Array::Ptr(new Poco::JSON::Array()); for (const auto& f : frames) { Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); o->set("id", f.id); @@ -879,9 +886,9 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object } if (name == "eve_locals") { - const int level = argInt(args, "level", 1); - auto vars = dbg.locals(level); - Poco::JSON::Array::Ptr arr = Poco::JSON::Array::Ptr(new Poco::JSON::Array()); + const int level = argInt(args, "level", 1); + auto vars = dbg.locals(level); + Poco::JSON::Array::Ptr arr = Poco::JSON::Array::Ptr(new Poco::JSON::Array()); for (const auto& v : vars) { Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); o->set("name", v.name); @@ -983,8 +990,7 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object const std::string source = argString(args, "source"); if (source.empty()) return "error: missing source"; const SQInteger top = sq_gettop(vm); - if (SQ_FAILED(sq_compilebuffer(vm, source.c_str(), - static_cast(source.size()), + if (SQ_FAILED(sq_compilebuffer(vm, source.c_str(), static_cast(source.size()), _SC("mcp_snippet.nut"), SQTrue))) { sq_settop(vm, top); return "error: " + snippetErrorText(vm, true); @@ -1039,8 +1045,8 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object if (!vm) return "error: no VM"; std::string err; if (!ensureSceneDirectorInstalled(vm, &err)) return sceneDirectorToolError(name, err); - const std::string action = argString(args, "action"); - const std::string target = argString(args, "target"); + const std::string action = argString(args, "action"); + const std::string target = argString(args, "target"); Poco::Dynamic::Var paramsVar; if (args && args->has("params")) { try { @@ -1048,10 +1054,10 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object } catch (...) { } } - const std::string snippet = - "return ::scene_director.modify(" + sqLiteralValue(Poco::Dynamic::Var(action)) + "," + - sqLiteralValue(Poco::Dynamic::Var(target)) + "," + sqLiteralValue(paramsVar) + ");"; - const std::string out = callSceneDirectorReturn(vm, snippet, &err); + const std::string snippet = "return ::scene_director.modify(" + sqLiteralValue(Poco::Dynamic::Var(action)) + + "," + sqLiteralValue(Poco::Dynamic::Var(target)) + "," + sqLiteralValue(paramsVar) + + ");"; + const std::string out = callSceneDirectorReturn(vm, snippet, &err); if (!err.empty()) return sceneDirectorToolError(name, err); return out; } @@ -1061,10 +1067,9 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object if (!vm) return "error: no VM"; std::string err; if (!ensureSceneDirectorInstalled(vm, &err)) return sceneDirectorToolError(name, err); - const int count = argInt(args, "count", 6); - const std::string snippet = - "return ::scene_director.cameras(" + std::to_string(count) + ");"; - const std::string out = callSceneDirectorReturn(vm, snippet, &err); + const int count = argInt(args, "count", 6); + const std::string snippet = "return ::scene_director.cameras(" + std::to_string(count) + ");"; + const std::string out = callSceneDirectorReturn(vm, snippet, &err); if (!err.empty()) return sceneDirectorToolError(name, err); return out; } @@ -1081,11 +1086,11 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object // ---- 场景巡检工具集(图像与 3D 几何数据严格同步) ---- if (name == "inspect_generate_scene_camera_views") { - const glm::vec3 center = argVec3(args, "center"); - const float fov = argFloat(args, "fov", 60.f); - auto views = SceneInspect::instance().generateViews(center, fov); - Poco::JSON::Object::Ptr root = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); - Poco::JSON::Array::Ptr arr = Poco::JSON::Array::Ptr(new Poco::JSON::Array()); + const glm::vec3 center = argVec3(args, "center"); + const float fov = argFloat(args, "fov", 60.f); + auto views = SceneInspect::instance().generateViews(center, fov); + Poco::JSON::Object::Ptr root = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); + Poco::JSON::Array::Ptr arr = Poco::JSON::Array::Ptr(new Poco::JSON::Array()); for (const auto& v : views) { Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); o->set("name", v.name); @@ -1100,11 +1105,11 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object } if (name == "set_camera_pose") { - const glm::vec3 pos = argVec3(args, "pos", glm::vec3(0.f, 1.8f, 0.f)); - const glm::vec3 rot = argVec3(args, "rot", glm::vec3(0.f)); - const float fov = argFloat(args, "fov", 0.f); - const bool ok = SceneInspect::instance().setCameraPose(pos, rot, fov); - Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); + const glm::vec3 pos = argVec3(args, "pos", glm::vec3(0.f, 1.8f, 0.f)); + const glm::vec3 rot = argVec3(args, "rot", glm::vec3(0.f)); + const float fov = argFloat(args, "fov", 0.f); + const bool ok = SceneInspect::instance().setCameraPose(pos, rot, fov); + Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); o->set("ok", ok); if (!ok) { o->set("error", "failed to set camera pose (no Graphics/Camera3D)"); @@ -1119,22 +1124,21 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object } if (name == "capture_render_frame") { - const std::string dir = argString(args, "dir"); - const std::string tag = argString(args, "tag", "frame"); + const std::string dir = argString(args, "dir"); + const std::string tag = argString(args, "tag", "frame"); std::vector buffers; if (args && args->has("buffers")) { try { auto arr = args->getArray("buffers"); if (arr) { for (size_t i = 0; i < arr->size(); ++i) - buffers.push_back( - arr->get(static_cast(i)).convert()); + buffers.push_back(arr->get(static_cast(i)).convert()); } } catch (...) { } } - const auto res = SceneInspect::instance().capture(dir, tag, buffers); - Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); + const auto res = SceneInspect::instance().capture(dir, tag, buffers); + Poco::JSON::Object::Ptr o = Poco::JSON::Object::Ptr(new Poco::JSON::Object()); o->set("ok", res.ok); if (res.ok) { o->set("png", res.pngPath); @@ -1173,19 +1177,16 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object return "error: unknown tool " + name; } -std::string handleInitialize(McpServer& mcp, const std::string& idJson, - Poco::JSON::Object::Ptr params) { +std::string handleInitialize(McpServer& mcp, const std::string& idJson, Poco::JSON::Object::Ptr params) { std::string clientName = "mcp-client"; std::string protocol = "2025-06-18"; if (params) { try { if (params->has("clientInfo")) { auto info = params->getObject("clientInfo"); - if (info && info->has("name")) - clientName = info->get("name").convert(); + if (info && info->has("name")) clientName = info->get("name").convert(); } - if (params->has("protocolVersion")) - protocol = params->get("protocolVersion").convert(); + if (params->has("protocolVersion")) protocol = params->get("protocolVersion").convert(); } catch (...) { } } @@ -1198,7 +1199,8 @@ std::string handleInitialize(McpServer& mcp, const std::string& idJson, std::string("{\"protocolVersion\":\"") + mcpJsonEscape(protocol) + "\",\"capabilities\":{\"tools\":{},\"resources\":{},\"prompts\":{}}," "\"serverInfo\":{\"name\":\"evengine\",\"title\":\"EVEngine MCP\",\"version\":\"0.2.1\"}," - "\"instructions\":\"EVEngine MCP for AI-assisted game development. eve_host_* tools create JSON-defined editor windows bound to Squirrel ViewModels (MVVM) for AI-crafted terrain/material/event editors.\"}"; + "\"instructions\":\"EVEngine MCP for AI-assisted game development. eve_host_* tools create JSON-defined editor " + "windows bound to Squirrel ViewModels (MVVM) for AI-crafted terrain/material/event editors.\"}"; return makeResult(idJson, resultJson); } @@ -1207,8 +1209,44 @@ std::string handleToolsList(const std::string& idJson) { "{\"tools\":[" "{\"name\":\"eve_status\",\"description\":\"Runtime + debugger + MCP/DAP status JSON.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," + "{\"name\":\"eve_ui_tree\",\"description\":\"Inspect retained game UI hosts and semantic widget trees (not " + "only eve_host editors).\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"host\":{\"type\":\"string\",\"description\":\"optional " + "exact host name\"}}}}," + "{\"name\":\"eve_ui_get\",\"description\":\"Read one retained game UI widget by semantic id.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"host\":{\"type\":\"string\"},\"widget\":{\"type\":" + "\"string\"}},\"required\":[\"widget\"]}}," + "{\"name\":\"eve_ui_click\",\"description\":\"Queue a semantic click by retained game UI widget id through the " + "normal event path.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"host\":{\"type\":\"string\"},\"widget\":{\"type\":" + "\"string\"}},\"required\":[\"widget\"]}}," + "{\"name\":\"eve_editor_commands\",\"description\":\"List editor commands allowed for automation in the active " + "game/editor.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," + "{\"name\":\"eve_editor_plan\",\"description\":\"Validate and retain a side-effect-free editor command plan.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\"},\"payload\":{}," + "\"expectedRevision\":{\"type\":\"integer\"}},\"required\":[\"command\"]}}," + "{\"name\":\"eve_editor_commit\",\"description\":\"Commit a retained editor command plan.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"planId\":{\"type\":\"string\"}},\"required\":[" + "\"planId\"]}}," + "{\"name\":\"eve_editor_execute\",\"description\":\"Execute an editor command immediately through the same " + "transaction path as UI and scripts.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\"},\"payload\":{}}," + "\"required\":[\"command\"]}}," + "{\"name\":\"eve_editor_cancel\",\"description\":\"Discard a retained editor command plan without side " + "effects.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"planId\":{\"type\":\"string\"}},\"required\":[" + "\"planId\"]}}," + "{\"name\":\"eve_editor_undo\",\"description\":\"Undo the last editor transaction in the automation session.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," + "{\"name\":\"eve_editor_redo\",\"description\":\"Redo the last editor transaction in the automation session.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," + "{\"name\":\"eve_editor_diagnostics\",\"description\":\"Read structured diagnostics from the last editor " + "automation operation.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," "{\"name\":\"eve_eval\",\"description\":\"Evaluate a Squirrel expression (local or roottable).\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"expression\":{\"type\":\"string\"}},\"required\":[\"expression\"]}}," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"expression\":{\"type\":\"string\"}},\"required\":[" + "\"expression\"]}}," "{\"name\":\"eve_pause\",\"description\":\"Pause the game / script.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," "{\"name\":\"eve_continue\",\"description\":\"Continue from pause.\"," @@ -1226,133 +1264,216 @@ std::string handleToolsList(const std::string& idJson) { "{\"name\":\"eve_locals\",\"description\":\"List locals at a stack level (default 1).\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," "{\"name\":\"eve_set_breakpoint\",\"description\":\"Set a script breakpoint.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"string\"},\"line\":{\"type\":\"integer\"}},\"required\":[\"source\",\"line\"]}}," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"string\"},\"line\":{\"type\":" + "\"integer\"}},\"required\":[\"source\",\"line\"]}}," "{\"name\":\"eve_clear_breakpoint\",\"description\":\"Clear a script breakpoint.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"string\"},\"line\":{\"type\":\"integer\"}},\"required\":[\"source\",\"line\"]}}," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"string\"},\"line\":{\"type\":" + "\"integer\"}},\"required\":[\"source\",\"line\"]}}," "{\"name\":\"eve_list_breakpoints\",\"description\":\"List breakpoints.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," "{\"name\":\"eve_watch_add\",\"description\":\"Add a watch expression.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"expression\":{\"type\":\"string\"}},\"required\":[\"expression\"]}}," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"expression\":{\"type\":\"string\"}},\"required\":[" + "\"expression\"]}}," "{\"name\":\"eve_watch_list\",\"description\":\"List watches with last values.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," "{\"name\":\"eve_snapshot_capture\",\"description\":\"Capture script-state snapshot JSON.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," "{\"name\":\"eve_snapshot_restore\",\"description\":\"Restore script-state from snapshot JSON.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"json\":{\"type\":\"string\"}},\"required\":[\"json\"]}}," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"json\":{\"type\":\"string\"}},\"required\":[\"json\"]}}" + "," "{\"name\":\"eve_snapshot_save\",\"description\":\"Save snapshot to a file path.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}}," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}}" + "," "{\"name\":\"eve_snapshot_load\",\"description\":\"Load snapshot from a file path.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}}," - "{\"name\":\"eve_error_slice\",\"description\":\"Return last error report / backward slice (script + render).\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}}" + "," + "{\"name\":\"eve_error_slice\",\"description\":\"Return last error report / backward slice (script + " + "render).\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," "{\"name\":\"eve_run_script\",\"description\":\"Compile and run a short Squirrel snippet in the live VM.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"string\"}},\"required\":[\"source\"]}}," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"string\"}},\"required\":[" + "\"source\"]}}," "{\"name\":\"eve_ai_note\",\"description\":\"Append a note to the DevTools AI session log.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}},\"required\":[\"text\"]}}," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}},\"required\":[\"text\"]}}" + "," "{\"name\":\"eve_ai_log\",\"description\":\"Read the DevTools AI session log.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," - "{\"name\":\"eve_scene_director_install\",\"description\":\"Install the scene-director authoring kit into the live VM (idempotent).\"," + "{\"name\":\"eve_scene_director_install\",\"description\":\"Install the scene-director authoring kit into the " + "live VM (idempotent).\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," - "{\"name\":\"eve_scene_director_status\",\"description\":\"Scene-director kit status (installed / propCount / hasCamera).\"," + "{\"name\":\"eve_scene_director_status\",\"description\":\"Scene-director kit status (installed / propCount / " + "hasCamera).\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," - "{\"name\":\"eve_scene_reset\",\"description\":\"Clear all staged props, camera and reset lighting to defaults.\"," + "{\"name\":\"eve_scene_reset\",\"description\":\"Clear all staged props, camera and reset lighting to " + "defaults.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," - "{\"name\":\"eve_scene_modify\",\"description\":\"Agent scene action: action in add_object|spawn|place|move_object|move|scale|rotate|rotation|remove_object|remove|visibility|material|lighting|set_lighting|camera|cameras|info|list|reset. spawn/move params: {id,kind,x,y,z,sx,sy,sz,yaw_deg,scale,pos,tint,seed,mesh_params,...}; lighting params: {timeOfDay,atmosphere,intensity,background}; camera params: {eye,target,fov}. Returns JSON.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\"},\"target\":{\"type\":\"string\"},\"params\":{\"type\":\"object\"}},\"required\":[\"action\"]}}," - "{\"name\":\"eve_camera_generate\",\"description\":\"Generate standardized QC camera rigs (eye/target/fov) orbiting the staged scene.\"," + "{\"name\":\"eve_scene_modify\",\"description\":\"Agent scene action: action in " + "add_object|spawn|place|move_object|move|scale|rotate|rotation|remove_object|remove|visibility|material|" + "lighting|set_lighting|camera|cameras|info|list|reset. spawn/move params: " + "{id,kind,x,y,z,sx,sy,sz,yaw_deg,scale,pos,tint,seed,mesh_params,...}; lighting params: " + "{timeOfDay,atmosphere,intensity,background}; camera params: {eye,target,fov}. Returns JSON.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\"},\"target\":{\"type\":" + "\"string\"},\"params\":{\"type\":\"object\"}},\"required\":[\"action\"]}}," + "{\"name\":\"eve_camera_generate\",\"description\":\"Generate standardized QC camera rigs (eye/target/fov) " + "orbiting the staged scene.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"count\":{\"type\":\"integer\"}}}}," - "{\"name\":\"eve_scene_info\",\"description\":\"Authoritative staged-scene truth JSON: props (id/kind/pos/scale/yaw_deg/tint) + count.\"," + "{\"name\":\"eve_scene_info\",\"description\":\"Authoritative staged-scene truth JSON: props " + "(id/kind/pos/scale/yaw_deg/tint) + count.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," - "{\"name\":\"inspect_generate_scene_camera_views\",\"description\":\"Generate a standard set of inspection camera views (road-level, bird's-eye, corner close-up, vista) around a center point.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"center\":{\"type\":\"array\",\"items\":{\"type\":\"number\"},\"description\":\"[x,y,z] center point to orbit (default [0,0,0])\"},\"fov\":{\"type\":\"number\",\"description\":\"base vertical FOV in degrees (default 60)\"}}}}" + "{\"name\":\"inspect_generate_scene_camera_views\",\"description\":\"Generate a standard set of inspection " + "camera views (road-level, bird's-eye, corner close-up, vista) around a center point.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"center\":{\"type\":\"array\",\"items\":{\"type\":" + "\"number\"},\"description\":\"[x,y,z] center point to orbit (default " + "[0,0,0])\"},\"fov\":{\"type\":\"number\",\"description\":\"base vertical FOV in degrees (default 60)\"}}}}" "," - "{\"name\":\"set_camera_pose\",\"description\":\"Set the active camera pose from position + Euler rotation + optional FOV.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"pos\":{\"type\":\"array\",\"items\":{\"type\":\"number\"},\"description\":\"[x,y,z] camera position\"},\"rot\":{\"type\":\"array\",\"items\":{\"type\":\"number\"},\"description\":\"[yawDeg,pitchDeg] facing\"},\"fov\":{\"type\":\"number\",\"description\":\"vertical FOV in degrees (0=keep current)\"}},\"required\":[\"pos\",\"rot\"]}}" + "{\"name\":\"set_camera_pose\",\"description\":\"Set the active camera pose from position + Euler rotation + " + "optional FOV.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"pos\":{\"type\":\"array\",\"items\":{\"type\":" + "\"number\"},\"description\":\"[x,y,z] camera " + "position\"},\"rot\":{\"type\":\"array\",\"items\":{\"type\":\"number\"},\"description\":\"[yawDeg,pitchDeg] " + "facing\"},\"fov\":{\"type\":\"number\",\"description\":\"vertical FOV in degrees (0=keep " + "current)\"}},\"required\":[\"pos\",\"rot\"]}}" "," - "{\"name\":\"capture_render_frame\",\"description\":\"Atomically capture the current view and export matching buffers. PNG color frame + geometry JSON always; 'buffers' may add depth/normal (GBuffer), id (per-pixel render ID mask with JSON mapping) — shadow is unsupported on current backend.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"dir\":{\"type\":\"string\",\"description\":\"output directory (default: cache dir)\"},\"tag\":{\"type\":\"string\",\"description\":\"file name tag (default 'frame')\"},\"buffers\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"optional: 'color'|'depth'|'normal'|'id' (default ['color'])\"}}}}" + "{\"name\":\"capture_render_frame\",\"description\":\"Atomically capture the current view and export matching " + "buffers. PNG color frame + geometry JSON always; 'buffers' may add depth/normal (GBuffer), id (per-pixel " + "render ID mask with JSON mapping) — shadow is unsupported on current backend.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"dir\":{\"type\":\"string\",\"description\":\"output " + "directory (default: cache dir)\"},\"tag\":{\"type\":\"string\",\"description\":\"file name tag (default " + "'frame')\"},\"buffers\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"optional: " + "'color'|'depth'|'normal'|'id' (default ['color'])\"}}}}" "," - "{\"name\":\"get_visible_entities_screen_bbox\",\"description\":\"Return visible scene entities in the frustum with their screen-space bbox, world AABB, id and asset label.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"pos\":{\"type\":\"array\",\"items\":{\"type\":\"number\"},\"description\":\"optional [x,y,z] camera eye override\"},\"target\":{\"type\":\"array\",\"items\":{\"type\":\"number\"},\"description\":\"optional [x,y,z] look target override\"},\"fov\":{\"type\":\"number\",\"description\":\"optional FOV override (degrees)\"}}}}" + "{\"name\":\"get_visible_entities_screen_bbox\",\"description\":\"Return visible scene entities in the frustum " + "with their screen-space bbox, world AABB, id and asset label.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"pos\":{\"type\":\"array\",\"items\":{\"type\":" + "\"number\"},\"description\":\"optional [x,y,z] camera eye " + "override\"},\"target\":{\"type\":\"array\",\"items\":{\"type\":\"number\"},\"description\":\"optional [x,y,z] " + "look target override\"},\"fov\":{\"type\":\"number\",\"description\":\"optional FOV override (degrees)\"}}}}" "," "{\"name\":\"eve_scene_status\",\"description\":\"Active scene host name, node count and root id.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," "{\"name\":\"eve_scene_nodes\",\"description\":\"List nodes of the active scene host (id/name/path/visible).\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"limit\":{\"type\":\"integer\"}}}}," - "{\"name\":\"eve_scene_node_get\",\"description\":\"Read transform/visibility/parent/children of a scene node by id.\"," + "{\"name\":\"eve_scene_node_get\",\"description\":\"Read transform/visibility/parent/children of a scene node " + "by id.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"}},\"required\":[\"id\"]}}," - "{\"name\":\"eve_scene_node_set\",\"description\":\"Set position (x,y,z) or visibility of a scene node by id.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"},\"z\":{\"type\":\"number\"},\"visible\":{\"type\":\"boolean\"}},\"required\":[\"id\"]}}," - "{\"name\":\"eve_procgen_recipes\",\"description\":\"List available procgen map algorithms and mesh/texture/PBR recipes.\"," + "{\"name\":\"eve_scene_node_set\",\"description\":\"Set position (x,y,z) or visibility of a scene node by " + "id.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"x\":{\"type\":\"number\"}," + "\"y\":{\"type\":\"number\"},\"z\":{\"type\":\"number\"},\"visible\":{\"type\":\"boolean\"}},\"required\":[" + "\"id\"]}}," + "{\"name\":\"eve_procgen_recipes\",\"description\":\"List available procgen map algorithms and " + "mesh/texture/PBR recipes.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," "{\"name\":\"eve_procgen_map\",\"description\":\"Generate a semantic tile grid with a procgen map algorithm.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"algorithm\":{\"type\":\"string\"},\"width\":{\"type\":\"integer\"},\"height\":{\"type\":\"integer\"},\"seed\":{\"type\":\"integer\"}},\"required\":[\"algorithm\"]}}," - "{\"name\":\"eve_procgen_mesh\",\"description\":\"Build a procedural CPU mesh (mesh.* recipe) and return stats.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"recipe\":{\"type\":\"string\"},\"seed\":{\"type\":\"integer\"}},\"required\":[\"recipe\"]}}," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"algorithm\":{\"type\":\"string\"},\"width\":{\"type\":" + "\"integer\"},\"height\":{\"type\":\"integer\"},\"seed\":{\"type\":\"integer\"}},\"required\":[\"algorithm\"]}}" + "," + "{\"name\":\"eve_procgen_mesh\",\"description\":\"Build a procedural CPU mesh (mesh.* recipe) and return " + "stats.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"recipe\":{\"type\":\"string\"},\"seed\":{\"type\":" + "\"integer\"}},\"required\":[\"recipe\"]}}," "{\"name\":\"eve_physics_new_world\",\"description\":\"Create a 2D physics world and return its id.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"gravityX\":{\"type\":\"number\"},\"gravityY\":{\"type\":\"number\"}}}}," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"gravityX\":{\"type\":\"number\"},\"gravityY\":{" + "\"type\":\"number\"}}}}," "{\"name\":\"eve_physics_list_worlds\",\"description\":\"List live 2D physics worlds with gravity.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," - "{\"name\":\"eve_physics_raycast\",\"description\":\"Raycast a segment in a physics world and report the closest hit.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},\"x1\":{\"type\":\"number\"},\"y1\":{\"type\":\"number\"},\"x2\":{\"type\":\"number\"},\"y2\":{\"type\":\"number\"}},\"required\":[\"world\",\"x1\",\"y1\",\"x2\",\"y2\"]}}," + "{\"name\":\"eve_physics_raycast\",\"description\":\"Raycast a segment in a physics world and report the " + "closest hit.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},\"x1\":{\"type\":" + "\"number\"},\"y1\":{\"type\":\"number\"},\"x2\":{\"type\":\"number\"},\"y2\":{\"type\":\"number\"}}," + "\"required\":[\"world\",\"x1\",\"y1\",\"x2\",\"y2\"]}}," "{\"name\":\"eve_physics_remove_world\",\"description\":\"Destroy a 2D physics world by id.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"}},\"required\":[\"world\"]}}," - "{\"name\":\"eve_render_status\",\"description\":\"Render window size, 3D frame flag, readback state and RenderFlow event count.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"}},\"required\":[" + "\"world\"]}}," + "{\"name\":\"eve_render_status\",\"description\":\"Render window size, 3D frame flag, readback state and " + "RenderFlow event count.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," "{\"name\":\"eve_screenshot\",\"description\":\"Capture the current frame to a PNG file (enables readback).\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}," - "{\"name\":\"eve_render_describe\",\"description\":\"Capture the current frame and ask the configured vision model to describe it and relate it to render parameters. Cached unless fresh=true.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"fresh\":{\"type\":\"boolean\"},\"reason\":{\"type\":\"string\"}}}}," - "{\"name\":\"eve_render_vision_config\",\"description\":\"Set/read the vision model config (baseUrl/apiKey/model/path/timeoutMs). No args returns current config (key masked).\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"baseUrl\":{\"type\":\"string\"},\"apiKey\":{\"type\":\"string\"},\"model\":{\"type\":\"string\"},\"path\":{\"type\":\"string\"},\"timeoutMs\":{\"type\":\"integer\"}}}}," + "{\"name\":\"eve_render_describe\",\"description\":\"Capture the current frame and ask the configured vision " + "model to describe it and relate it to render parameters. Cached unless fresh=true.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"fresh\":{\"type\":\"boolean\"},\"reason\":{\"type\":" + "\"string\"}}}}," + "{\"name\":\"eve_render_vision_config\",\"description\":\"Set/read the vision model config " + "(baseUrl/apiKey/model/path/timeoutMs). No args returns current config (key masked).\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"baseUrl\":{\"type\":\"string\"},\"apiKey\":{\"type\":" + "\"string\"},\"model\":{\"type\":\"string\"},\"path\":{\"type\":\"string\"},\"timeoutMs\":{\"type\":" + "\"integer\"}}}}," "{\"name\":\"eve_particles_status\",\"description\":\"Report live particle emitter count.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," - "{\"name\":\"eve_particles_emit\",\"description\":\"Spawn a particle emitter at a position (optionally a preset) and emit particles.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"},\"preset\":{\"type\":\"string\"},\"count\":{\"type\":\"integer\"}}}}," + "{\"name\":\"eve_particles_emit\",\"description\":\"Spawn a particle emitter at a position (optionally a " + "preset) and emit particles.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"}," + "\"preset\":{\"type\":\"string\"},\"count\":{\"type\":\"integer\"}}}}," "{\"name\":\"eve_audio_status\",\"description\":\"Report master audio volume.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}," "{\"name\":\"eve_audio_set_volume\",\"description\":\"Set master audio volume (0..1).\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"volume\":{\"type\":\"number\"}},\"required\":[\"volume\"]}}," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"volume\":{\"type\":\"number\"}},\"required\":[" + "\"volume\"]}}," "{\"name\":\"eve_audio_stop_all\",\"description\":\"Stop all playing audio sources.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}},", - "{\"name\":\"eve_host_status\",\"description\":\"Headless editor host status: window, editors, registered ViewModels, project root.\"," + "{\"name\":\"eve_host_status\",\"description\":\"Headless editor host status: window, editors, registered " + "ViewModels, project root.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}},", - "{\"name\":\"eve_host_window_open\",\"description\":\"Create the host OS window (lazy; editors can also auto-open it).\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"},\"width\":{\"type\":\"integer\"},\"height\":{\"type\":\"integer\"}}}}," + "{\"name\":\"eve_host_window_open\",\"description\":\"Create the host OS window (lazy; editors can also " + "auto-open it).\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"},\"width\":{\"type\":" + "\"integer\"},\"height\":{\"type\":\"integer\"}}}}," "{\"name\":\"eve_host_window_close\",\"description\":\"Close the host OS window (MCP server stays alive).\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}},", "{\"name\":\"eve_host_window_state\",\"description\":\"Host window open state + size.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}},", - "{\"name\":\"eve_host_editor_apply\",\"description\":\"Apply an editor View (JSON widget tree). Auto-opens the host window on first use.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"editor\":{\"type\":\"object\"}},\"required\":[\"editor\"]}},", + "{\"name\":\"eve_host_editor_apply\",\"description\":\"Apply an editor View (JSON widget tree). Auto-opens the " + "host window on first use.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"editor\":{\"type\":\"object\"}},\"required\":[" + "\"editor\"]}},", "{\"name\":\"eve_host_editor_remove\",\"description\":\"Remove an editor panel from the session.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"}},\"required\":[\"id\"]}},", "{\"name\":\"eve_host_editor_list\",\"description\":\"List editors (id/title/vm).\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}},", - "{\"name\":\"eve_host_editor_state\",\"description\":\"Editor values + pending events (id omitted = all editors).\"," + "{\"name\":\"eve_host_editor_state\",\"description\":\"Editor values + pending events (id omitted = all " + "editors).\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"}}}}," - "{\"name\":\"eve_host_editor_set_value\",\"description\":\"Set a widget value (JSON value). Writes the bound ViewModel and emits a change event.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"editor\":{\"type\":\"string\"},\"widget\":{\"type\":\"string\"},\"value\":{}},\"required\":[\"editor\",\"widget\",\"value\"]}},", - "{\"name\":\"eve_host_editor_save\",\"description\":\"Persist editor as editors/.editor.json + .vm.nut in the project.\"," + "{\"name\":\"eve_host_editor_set_value\",\"description\":\"Set a widget value (JSON value). Writes the bound " + "ViewModel and emits a change event.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"editor\":{\"type\":\"string\"},\"widget\":{\"type\":" + "\"string\"},\"value\":{}},\"required\":[\"editor\",\"widget\",\"value\"]}},", + "{\"name\":\"eve_host_editor_save\",\"description\":\"Persist editor as editors/.editor.json + .vm.nut " + "in the project.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"}},\"required\":[\"id\"]}},", - "{\"name\":\"eve_host_editor_unload\",\"description\":\"Remove an editor from the session (files stay on disk).\"," + "{\"name\":\"eve_host_editor_unload\",\"description\":\"Remove an editor from the session (files stay on " + "disk).\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"}},\"required\":[\"id\"]}},", - "{\"name\":\"eve_host_vm_register\",\"description\":\"Compile a Squirrel ViewModel and register it by table name.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"source\":{\"type\":\"string\"}},\"required\":[\"name\",\"source\"]}},", + "{\"name\":\"eve_host_vm_register\",\"description\":\"Compile a Squirrel ViewModel and register it by table " + "name.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"source\":{\"type\":" + "\"string\"}},\"required\":[\"name\",\"source\"]}},", "{\"name\":\"eve_host_vm_unregister\",\"description\":\"Unregister a ViewModel table.\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}},\"required\":[\"name\"]}},", - "{\"name\":\"eve_host_events\",\"description\":\"Read and clear interaction events (human clicks/sliders or AI set_value).\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}},\"required\":[\"name\"]}}" + ",", + "{\"name\":\"eve_host_events\",\"description\":\"Read and clear interaction events (human clicks/sliders or AI " + "set_value).\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"editor\":{\"type\":\"string\"}}}}," - "{\"name\":\"eve_host_widget_rect\",\"description\":\"Last-frame screen rect of a widget (for script drawing in viewports).\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"editor\":{\"type\":\"string\"},\"widget\":{\"type\":\"string\"}},\"required\":[\"editor\",\"widget\"]}},", + "{\"name\":\"eve_host_widget_rect\",\"description\":\"Last-frame screen rect of a widget (for script drawing " + "in viewports).\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"editor\":{\"type\":\"string\"},\"widget\":{\"type\":" + "\"string\"}},\"required\":[\"editor\",\"widget\"]}},", "{\"name\":\"eve_host_capture\",\"description\":\"Capture the host window to a PNG and return path/size.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}," - "{\"name\":\"eve_host_script\",\"description\":\"Run a Squirrel snippet in the host VM (full engine API + eve.host).\"," - "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"string\"}},\"required\":[\"source\"]}},", + "{\"name\":\"eve_host_script\",\"description\":\"Run a Squirrel snippet in the host VM (full engine API + " + "eve.host).\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"string\"}},\"required\":[" + "\"source\"]}},", + "{\"name\":\"eve_host_resource_reload\",\"description\":\"Reload a project-scoped MCP host script or editor " + "View/ViewModel resource without restarting.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}}" + ",", + "{\"name\":\"eve_host_hot_reload_status\",\"description\":\"MCP host watcher count, reload counters and latest " + "reload diagnostic.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}},", "{\"name\":\"eve_host_shutdown\",\"description\":\"Exit the headless MCP host process.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}", - "]}" - }; + "]}"}; static const std::string kToolsJson = [] { std::string out; for (const char* p : kToolsParts) out += p; @@ -1361,10 +1482,8 @@ std::string handleToolsList(const std::string& idJson) { return makeResult(idJson, kToolsJson); } -std::string handleToolsCall(McpServer& mcp, const std::string& idJson, - Poco::JSON::Object::Ptr params) { - if (!params || !params->has("name")) - return makeError(idJson, -32602, "tools/call requires params.name"); +std::string handleToolsCall(McpServer& mcp, const std::string& idJson, Poco::JSON::Object::Ptr params) { + if (!params || !params->has("name")) return makeError(idJson, -32602, "tools/call requires params.name"); std::string name; try { name = params->get("name").convert(); @@ -1398,25 +1517,28 @@ std::string handleToolsCall(McpServer& mcp, const std::string& idJson, std::string handleResourcesList(const std::string& idJson) { return makeResult(idJson, - "{\"resources\":[" - "{\"uri\":\"eve://status\",\"name\":\"status\",\"description\":\"Debugger / MCP / DAP status\",\"mimeType\":\"application/json\"}," - "{\"uri\":\"eve://error-report\",\"name\":\"error-report\",\"description\":\"Last DevTools error slice report\",\"mimeType\":\"text/plain\"}," - "{\"uri\":\"eve://ai-session\",\"name\":\"ai-session\",\"description\":\"AI / MCP session log\",\"mimeType\":\"text/plain\"}," - "{\"uri\":\"eve://callgraph\",\"name\":\"callgraph\",\"description\":\"CallGraph event summary\",\"mimeType\":\"application/json\"}" - "]}"); + "{\"resources\":[" + "{\"uri\":\"eve://status\",\"name\":\"status\",\"description\":\"Debugger / MCP / DAP " + "status\",\"mimeType\":\"application/json\"}," + "{\"uri\":\"eve://error-report\",\"name\":\"error-report\",\"description\":\"Last DevTools error " + "slice report\",\"mimeType\":\"text/plain\"}," + "{\"uri\":\"eve://ai-session\",\"name\":\"ai-session\",\"description\":\"AI / MCP session " + "log\",\"mimeType\":\"text/plain\"}," + "{\"uri\":\"eve://callgraph\",\"name\":\"callgraph\",\"description\":\"CallGraph event " + "summary\",\"mimeType\":\"application/json\"}" + "]}"); } std::string handleResourcesRead(const std::string& idJson, Poco::JSON::Object::Ptr params) { - if (!params || !params->has("uri")) - return makeError(idJson, -32602, "resources/read requires params.uri"); + if (!params || !params->has("uri")) return makeError(idJson, -32602, "resources/read requires params.uri"); std::string uri; try { uri = params->get("uri").convert(); } catch (...) { return makeError(idJson, -32602, "resources/read params.uri must be a string"); } - std::string text; - std::string mime = "text/plain"; + std::string text; + std::string mime = "text/plain"; if (uri == "eve://status") { text = engineStatusJson(McpServer::instance()); mime = "application/json"; @@ -1448,24 +1570,26 @@ std::string handleResourcesRead(const std::string& idJson, Poco::JSON::Object::P } std::string handlePromptsList(const std::string& idJson) { - return makeResult(idJson, + return makeResult( + idJson, "{\"prompts\":[" - "{\"name\":\"debug_failure\",\"description\":\"Investigate the latest script/render error using MCP tools and the error slice.\"}," - "{\"name\":\"test_scenario\",\"description\":\"Drive a reproducible in-engine test: pause, snapshot, eval assertions, continue.\"}," + "{\"name\":\"debug_failure\",\"description\":\"Investigate the latest script/render error using MCP tools and " + "the error slice.\"}," + "{\"name\":\"test_scenario\",\"description\":\"Drive a reproducible in-engine test: pause, snapshot, eval " + "assertions, continue.\"}," "{\"name\":\"ai_game_review\",\"description\":\"Review live game state for AI-generated content issues.\"}" "]}"); } std::string handlePromptsGet(const std::string& idJson, Poco::JSON::Object::Ptr params) { - if (!params || !params->has("name")) - return makeError(idJson, -32602, "prompts/get requires params.name"); + if (!params || !params->has("name")) return makeError(idJson, -32602, "prompts/get requires params.name"); std::string name; try { name = params->get("name").convert(); } catch (...) { return makeError(idJson, -32602, "prompts/get params.name must be a string"); } - std::string text; + std::string text; if (name == "debug_failure") { text = "You are debugging an EVEngine game via MCP.\n" @@ -1567,8 +1691,7 @@ int McpServer::listen(uint16_t port) { } AiPanel::instance().setMcpPort(bound); AiPanel::instance().setMcpConnected(false); - AiPanel::instance().addLog("system", "mcp.listen", - "listening on 127.0.0.1:" + std::to_string(bound)); + AiPanel::instance().addLog("system", "mcp.listen", "listening on 127.0.0.1:" + std::to_string(bound)); transport_.store(Transport::Tcp); return bound; } catch (...) { @@ -1593,8 +1716,8 @@ bool McpServer::listenStdio(std::istream& in, std::ostream& out) { initialized_ = false; stdinClosed_.store(false); hasClient_.store(true); - stdioIn_ = ∈ - stdioOut_ = &out; + stdioIn_ = ∈ + stdioOut_ = &out; joinReader_ = (&in != &std::cin); try { stdioReader_ = std::thread([this, &in]() { @@ -1618,8 +1741,7 @@ bool McpServer::listenStdio(std::istream& in, std::ostream& out) { } AiPanel::instance().setMcpPort(0); AiPanel::instance().setMcpConnected(true); - AiPanel::instance().addLog("system", "mcp.listen", - "stdio transport ready (newline JSON-RPC)"); + AiPanel::instance().addLog("system", "mcp.listen", "stdio transport ready (newline JSON-RPC)"); return true; } @@ -1653,8 +1775,8 @@ void McpServer::stop() { port_.store(0); transport_.store(Transport::None); initialized_ = false; - stdioIn_ = nullptr; - stdioOut_ = nullptr; + stdioIn_ = nullptr; + stdioOut_ = nullptr; AiPanel::instance().setMcpPort(0); AiPanel::instance().setMcpConnected(false); AiPanel::instance().setClientName({}); @@ -1779,7 +1901,7 @@ void McpServer::handleMessage(const std::string& json) { return; } } - const bool hasId = obj->has("id"); + const bool hasId = obj->has("id"); Poco::Dynamic::Var idVar; if (hasId) idVar = obj->get("id"); std::string idJson = "null"; diff --git a/src/modules/editor/Editor.cpp b/src/modules/editor/Editor.cpp index cde41a4e..cb48fde5 100644 --- a/src/modules/editor/Editor.cpp +++ b/src/modules/editor/Editor.cpp @@ -1,11 +1,14 @@ #include "editor/Editor.h" +#include "common/Capability.h" +#include "common/EditorAutomation.h" #include "editor/Brush.h" #include "editor/EditorDock.h" #include "editor/EditorHistory.h" #include "editor/EditorInspector.h" #include "editor/EditorSession.h" #include "editor/EditorToolbar.h" +#include "editor/EditorValueJson.h" #include "editor/FieldTargets.h" #include "editor/GizmoManager.h" #include "editor/ScriptEditorTool.h" @@ -61,6 +64,27 @@ const char* transactionStateName(TransactionState state) { return "failed"; } +EditorValue diagnosticsValue(const std::vector& diagnostics) { + EditorValue::Array values; + for (const EditorDiagnostic& diagnostic : diagnostics) { + EditorValue::Object value; + value["rule"] = EditorValue(diagnostic.rule.value()); + value["severity"] = EditorValue(static_cast(diagnostic.severity)); + value["message"] = EditorValue(diagnostic.message); + values.emplace_back(std::move(value)); + } + return EditorValue(std::move(values)); +} + +EditorValue resultValue(EditorStatus status, const std::vector& diagnostics) { + EditorValue::Object result; + result["status"] = EditorValue(statusName(status)); + result["accepted"] = + EditorValue(status == EditorStatus::Applied || status == EditorStatus::Pending || status == EditorStatus::NoOp); + result["diagnostics"] = diagnosticsValue(diagnostics); + return EditorValue(std::move(result)); +} + bool squirrelToEditorValue(HSQUIRRELVM vm, SQInteger index, EditorValue& out, size_t depth = 0) { if (!vm || depth > 32) return false; const SQInteger absolute = index > 0 ? index : sq_gettop(vm) + index + 1; @@ -398,6 +422,136 @@ void buildHeightmapArrays(const eve::procgen::Heightmap& hm, float cell, float h } // namespace #endif +class EditorAutomationProvider final : public eve::IEditorAutomation { +public: + explicit EditorAutomationProvider(EditorCommandService* commands) : commands_(commands) { + session_.setCommandService(commands_); + session_.setSessionId(SessionId("editor.mcp")); + } + + std::string invoke(const std::string& operation, const std::string& requestJson) override { + refreshProfile(); + EditorResult parsed = editorValueFromJson(requestJson.empty() ? "{}" : requestJson); + if (!parsed.accepted() || !parsed.value || parsed.value->type() != EditorValue::Type::Object) + return errorJson(EditorStatus::Rejected, "editor.automation.invalid-json", "Request must be a JSON object"); + const auto& request = *parsed.value->getIf(); + if (operation == "commands") return commandsJson(); + if (operation == "plan") { + const std::string command = stringField(request, "command"); + std::optional expected; + if (const auto* value = integerField(request, "expectedRevision")) expected = static_cast(*value); + auto result = session_.retainPlan(CommandId(command), valueField(request, "payload"), + CommandSource::Automation, expected); + lastDiagnostics_ = result.diagnostics; + EditorValue response = resultValue(result.status, result.diagnostics); + if (result.value) (*response.getIf())["planId"] = EditorValue(result.value->value()); + return editorValueToJson(response); + } + if (operation == "commit") { + auto result = + session_.executeRetainedPlan(PlanId(stringField(request, "planId")), CommandSource::Automation); + lastDiagnostics_ = result.diagnostics; + EditorValue response = resultValue(result.status, result.diagnostics); + if (result.value) { + auto* object = response.getIf(); + (*object)["transactionId"] = EditorValue(result.value->id.value()); + (*object)["state"] = EditorValue(transactionStateName(result.value->state)); + (*object)["beforeRevision"] = EditorValue(static_cast(result.value->beforeRevision)); + (*object)["afterRevision"] = EditorValue(static_cast(result.value->afterRevision)); + } + return editorValueToJson(response); + } + if (operation == "execute") { + auto result = session_.executeCommandReceipt(CommandId(stringField(request, "command")), + valueField(request, "payload"), CommandSource::Automation); + lastDiagnostics_ = result.diagnostics; + EditorValue response = resultValue(result.status, result.diagnostics); + if (result.value) { + auto* object = response.getIf(); + (*object)["transactionId"] = EditorValue(result.value->id.value()); + (*object)["state"] = EditorValue(transactionStateName(result.value->state)); + (*object)["beforeRevision"] = EditorValue(static_cast(result.value->beforeRevision)); + (*object)["afterRevision"] = EditorValue(static_cast(result.value->afterRevision)); + } + return editorValueToJson(response); + } + if (operation == "cancel") { + auto result = session_.cancelRetainedPlan(PlanId(stringField(request, "planId"))); + lastDiagnostics_ = result.diagnostics; + return editorValueToJson(resultValue(result.status, result.diagnostics)); + } + if (operation == "undo" || operation == "redo") { + const bool changed = operation == "undo" ? session_.transactions().undo() : session_.transactions().redo(); + EditorValue response = resultValue(changed ? EditorStatus::Applied : EditorStatus::NoOp, {}); + (*response.getIf())["changed"] = EditorValue(changed); + return editorValueToJson(response); + } + if (operation == "diagnostics") { + EditorValue response = resultValue(EditorStatus::Applied, lastDiagnostics_); + return editorValueToJson(response); + } + return errorJson(EditorStatus::Unsupported, "editor.automation.unsupported-operation", + "Unsupported editor automation operation: " + operation); + } + +private: + void refreshProfile() { + HostProfile profile = HostProfile::automation(); + if (commands_) + for (const CommandDescriptor& command : commands_->commands(HostProfile::developer())) + if (command.automationAllowed && profile.hasFeatures(command.requiredFeatures)) + profile.allowCommand(command.id); + session_.setHostProfile(std::move(profile)); + } + + std::string commandsJson() { + EditorValue::Array descriptors; + for (const CommandDescriptor& command : session_.availableCommands()) { + EditorValue::Object descriptor; + descriptor["id"] = EditorValue(command.id.value()); + descriptor["displayName"] = EditorValue(command.displayName); + descriptor["category"] = EditorValue(command.category); + descriptor["owner"] = EditorValue(command.ownerModule); + descriptor["planned"] = EditorValue(command.createsTransaction); + descriptors.emplace_back(std::move(descriptor)); + } + EditorValue response = resultValue(EditorStatus::Applied, {}); + (*response.getIf())["commands"] = EditorValue(std::move(descriptors)); + return editorValueToJson(response); + } + + static const EditorValue* findField(const EditorValue::Object& request, const char* key) { + const auto found = request.find(key); + return found == request.end() ? nullptr : &found->second; + } + static std::string stringField(const EditorValue::Object& request, const char* key) { + const EditorValue* value = findField(request, key); + const auto* text = value ? value->getIf() : nullptr; + return text ? *text : std::string{}; + } + static const std::int64_t* integerField(const EditorValue::Object& request, const char* key) { + const EditorValue* value = findField(request, key); + return value ? value->getIf() : nullptr; + } + static EditorValue valueField(const EditorValue::Object& request, const char* key) { + const EditorValue* value = findField(request, key); + return value ? *value : EditorValue{}; + } + static std::string errorJson(EditorStatus status, const char* rule, std::string message) { + return editorValueToJson(resultValue(status, {{RuleId(rule), DiagnosticSeverity::Error, std::move(message)}})); + } + + EditorCommandService* commands_ = nullptr; + EditorSession session_; + std::vector lastDiagnostics_; +}; + +Editor::Editor() : automation_(std::make_unique(&commandService_)) { + eve::cap::provide(automation_.get()); +} + +Editor::~Editor() { eve::cap::revoke(automation_.get()); } + TransformGizmo* Editor::newGizmo() { return new TransformGizmo(); } GizmoManager* Editor::newGizmoManager() { return new GizmoManager(); } diff --git a/src/modules/editor/Editor.h b/src/modules/editor/Editor.h index 43a4b2fb..0110cfd9 100644 --- a/src/modules/editor/Editor.h +++ b/src/modules/editor/Editor.h @@ -3,6 +3,7 @@ #include "common/Module.h" #include "editor/EditorCommandService.h" +#include #include namespace eve { @@ -31,6 +32,7 @@ class EditorSession; class TileBufferTarget; class HeightmapTarget; class ScriptEditorTool; +class EditorAutomationProvider; /** * @brief Editor building blocks — not a shipped 3D/map editor app. @@ -42,8 +44,8 @@ class ScriptEditorTool; class Editor : public Module { public: Module_REG(Editor); - Editor() = default; - ~Editor() override = default; + Editor(); + ~Editor() override; TransformGizmo* newGizmo(); GizmoManager* newGizmoManager(); @@ -103,7 +105,8 @@ public: #endif private: - EditorCommandService commandService_; + EditorCommandService commandService_; + std::unique_ptr automation_; }; } // namespace eve::editor diff --git a/src/modules/editor/EditorAssetDatabase.cpp b/src/modules/editor/EditorAssetDatabase.cpp index b43a6547..d8341579 100644 --- a/src/modules/editor/EditorAssetDatabase.cpp +++ b/src/modules/editor/EditorAssetDatabase.cpp @@ -24,17 +24,20 @@ EditorResult MemoryAssetDatabase::publish(AssetRecord record, std:: if (record.guid.empty() || record.logicalUri.empty() || record.typeId.empty()) return assetError(EditorStatus::Rejected, "editor.asset.invalid-record", "Asset GUID, logical URI and type are required"); - for (const auto& [guid, existing] : records_) { - if (guid != record.guid && existing.logicalUri == record.logicalUri) - return assetError(EditorStatus::Conflict, "editor.asset.uri-conflict", - "Another asset already owns this logical URI"); - } + const auto uriOwner = uriToGuid_.find(record.logicalUri); + if (uriOwner != uriToGuid_.end() && uriOwner->second != record.guid) + return assetError(EditorStatus::Conflict, "editor.asset.uri-conflict", + "Another asset already owns this logical URI"); for (const AssetDependency& dependency : dependencies) { if (dependency.from != record.guid || dependency.to.empty()) return assetError(EditorStatus::Rejected, "editor.asset.invalid-dependency", "Published dependencies must originate from the product asset"); } - record.status = AssetStatus::Ready; + record.status = AssetStatus::Ready; + const auto existing = records_.find(record.guid); + if (existing != records_.end() && existing->second.logicalUri != record.logicalUri) + uriToGuid_.erase(existing->second.logicalUri); + uriToGuid_.insert_or_assign(record.logicalUri, record.guid); records_.insert_or_assign(record.guid, record); std::erase_if(dependencies_, [&](const AssetDependency& dependency) { return dependency.from == record.guid; }); dependencies_.insert(dependencies_.end(), dependencies.begin(), dependencies.end()); @@ -51,10 +54,8 @@ EditorResult MemoryAssetDatabase::find(const AssetGuid& guid) const } EditorResult MemoryAssetDatabase::findByUri(const std::string& logicalUri) const { - for (const auto& [guid, record] : records_) { - (void)guid; - if (record.logicalUri == logicalUri) return EditorResult::applied(record); - } + const auto guid = uriToGuid_.find(logicalUri); + if (guid != uriToGuid_.end()) return find(guid->second); return assetError(EditorStatus::NotFound, "editor.asset.not-found", "Asset URI is not indexed: " + logicalUri); } diff --git a/src/modules/editor/EditorAssetDatabase.h b/src/modules/editor/EditorAssetDatabase.h index 2f63ed64..ae3d1fb2 100644 --- a/src/modules/editor/EditorAssetDatabase.h +++ b/src/modules/editor/EditorAssetDatabase.h @@ -77,6 +77,7 @@ public: private: std::unordered_map> records_; + std::unordered_map uriToGuid_; std::vector dependencies_; std::uint64_t generation_ = 0; }; diff --git a/src/modules/editor/EditorDiagnostics.cpp b/src/modules/editor/EditorDiagnostics.cpp new file mode 100644 index 00000000..8ba1c5a1 --- /dev/null +++ b/src/modules/editor/EditorDiagnostics.cpp @@ -0,0 +1,48 @@ +#include "editor/EditorDiagnostics.h" + +namespace eve::editor { + +EditorResult EditorValidationService::registerRule(std::string owner, RuleId id, Rule rule) { + if (owner.empty() || id.empty() || !rule) + return EditorResult::error(EditorStatus::Rejected, RuleId("editor.validation.invalid-rule"), + "Validation owner, rule id and callback are required"); + rules_.insert_or_assign(std::move(id), RegisteredRule{std::move(owner), std::move(rule)}); + return EditorResult::applied(); +} + +std::size_t EditorValidationService::unregisterOwner(const std::string& owner) { + return std::erase_if(rules_, [&](const auto& entry) { return entry.second.owner == owner; }); +} + +std::vector EditorValidationService::validate(const ValidationRequest& request) const { + std::vector diagnostics; + for (const auto& [id, registered] : rules_) { + (void)id; + auto current = registered.rule(request); + diagnostics.insert(diagnostics.end(), std::make_move_iterator(current.begin()), + std::make_move_iterator(current.end())); + } + return diagnostics; +} + +void EditorDiagnosticService::publish(std::string source, std::vector diagnostics) { + std::lock_guard lock(mutex_); + channels_.insert_or_assign(std::move(source), std::move(diagnostics)); +} + +void EditorDiagnosticService::clear(const std::string& source) { + std::lock_guard lock(mutex_); + channels_.erase(source); +} + +std::vector EditorDiagnosticService::snapshot() const { + std::lock_guard lock(mutex_); + std::vector result; + for (const auto& [source, diagnostics] : channels_) { + (void)source; + result.insert(result.end(), diagnostics.begin(), diagnostics.end()); + } + return result; +} + +} // namespace eve::editor diff --git a/src/modules/editor/EditorDiagnostics.h b/src/modules/editor/EditorDiagnostics.h new file mode 100644 index 00000000..4480f349 --- /dev/null +++ b/src/modules/editor/EditorDiagnostics.h @@ -0,0 +1,55 @@ +#pragma once + +#include "editor/EditorResult.h" +#include "editor/EditorValue.h" + +#include +#include +#include +#include +#include + +namespace eve::editor { + +/** @brief Context supplied to project- and document-level validation rules. */ +struct ValidationRequest { + std::string subject; + EditorValue value; +}; + +/** @brief Registry of composable validation rules owned by extensions. */ +class EditorValidationService { +public: + using Rule = std::function(const ValidationRequest&)>; + + /** @brief Register or replace a stable rule owned by an extension. */ + EditorResult registerRule(std::string owner, RuleId id, Rule rule); + /** @brief Remove every rule owned by an unloading extension. */ + std::size_t unregisterOwner(const std::string& owner); + /** @brief Execute all registered rules in deterministic rule-id order. */ + std::vector validate(const ValidationRequest& request) const; + +private: + struct RegisteredRule { + std::string owner; + Rule rule; + }; + std::map rules_; +}; + +/** @brief Thread-safe diagnostic channels consumed uniformly by UI, scripts and MCP. */ +class EditorDiagnosticService { +public: + /** @brief Replace one producer's current diagnostics atomically. */ + void publish(std::string source, std::vector diagnostics); + /** @brief Clear one producer channel. */ + void clear(const std::string& source); + /** @brief Return all channels in deterministic source order. */ + std::vector snapshot() const; + +private: + mutable std::mutex mutex_; + std::map> channels_; +}; + +} // namespace eve::editor diff --git a/src/modules/editor/EditorDiskAssetCatalog.cpp b/src/modules/editor/EditorDiskAssetCatalog.cpp new file mode 100644 index 00000000..970e58a4 --- /dev/null +++ b/src/modules/editor/EditorDiskAssetCatalog.cpp @@ -0,0 +1,156 @@ +#include "editor/EditorDiskAssetCatalog.h" + +#include "editor/EditorValueJson.h" + +#include +#include +#include +#include +#include + +namespace eve::editor { +namespace { + +EditorResult scanError(EditorStatus status, const char* rule, std::string message) { + return EditorResult::error(status, RuleId(rule), std::move(message)); +} + +std::string textMember(const EditorValue::Object& object, const char* key) { + const auto found = object.find(key); + const auto* value = found == object.end() ? nullptr : found->second.getIf(); + return value ? *value : std::string{}; +} + +} // namespace + +DiskAssetCatalog::DiskAssetCatalog(std::filesystem::path projectRoot, MemoryAssetDatabase* database) + : database_(database) { + std::error_code ec; + contentRoot_ = std::filesystem::weakly_canonical(std::move(projectRoot), ec) / "Content"; + if (ec) contentRoot_.clear(); +} + +std::filesystem::path DiskAssetCatalog::resolveContent(const std::filesystem::path& relative) const { + if (contentRoot_.empty() || relative.empty() || relative.is_absolute()) return {}; + const auto candidate = (contentRoot_ / relative).lexically_normal(); + const auto scoped = candidate.lexically_relative(contentRoot_); + if (scoped.empty()) return {}; + for (const auto& part : scoped) + if (part == "..") return {}; + return candidate; +} + +EditorResult DiskAssetCatalog::writeSidecar(const std::filesystem::path& contentRelativePath, AssetGuid guid, + std::string typeId, std::uint32_t schemaVersion) { + const auto source = resolveContent(contentRelativePath); + if (source.empty() || guid.empty() || typeId.empty()) + return EditorResult::error(EditorStatus::Rejected, RuleId("editor.asset.sidecar-invalid"), + "Content path, GUID and type are required"); + EditorValue::Object metadata; + metadata["guid"] = EditorValue(guid.value()); + metadata["type"] = EditorValue(std::move(typeId)); + metadata["schemaVersion"] = EditorValue(static_cast(schemaVersion)); + const std::filesystem::path sidecar = source.string() + ".evmeta"; + std::error_code ec; + std::filesystem::create_directories(sidecar.parent_path(), ec); + if (ec) + return EditorResult::error(EditorStatus::Failed, RuleId("editor.asset.sidecar-directory"), ec.message()); + const std::filesystem::path temporary = sidecar.string() + ".tmp"; + { + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + if (!output) + return EditorResult::error(EditorStatus::Failed, RuleId("editor.asset.sidecar-write"), + "Cannot open asset sidecar temp file"); + output << editorValueToJson(EditorValue(std::move(metadata))); + } + std::filesystem::rename(temporary, sidecar, ec); + if (ec) { + std::filesystem::remove(sidecar, ec); + ec.clear(); + std::filesystem::rename(temporary, sidecar, ec); + } + if (ec) + return EditorResult::error(EditorStatus::Failed, RuleId("editor.asset.sidecar-replace"), ec.message()); + return EditorResult::applied(); +} + +EditorResult DiskAssetCatalog::scan() { + if (!database_ || contentRoot_.empty()) + return scanError(EditorStatus::Rejected, "editor.asset.catalog-invalid", + "Asset database and Content root are required"); + DiskAssetScanResult result; + std::error_code ec; + if (!std::filesystem::exists(contentRoot_)) return EditorResult::applied(std::move(result)); + std::set discovered; + for (std::filesystem::recursive_directory_iterator iterator(contentRoot_, ec), end; iterator != end && !ec; + iterator.increment(ec)) { + if (!iterator->is_regular_file() || iterator->path().extension() != ".evmeta") continue; + std::ifstream input(iterator->path(), std::ios::binary); + const std::string json{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + auto parsed = editorValueFromJson(json); + const auto* metadata = parsed.value ? parsed.value->getIf() : nullptr; + const AssetGuid guid(metadata ? textMember(*metadata, "guid") : std::string{}); + const std::string type = metadata ? textMember(*metadata, "type") : std::string{}; + if (!parsed.accepted() || !metadata || guid.empty() || type.empty()) { + result.diagnostics.push_back({RuleId("editor.asset.sidecar-invalid"), DiagnosticSeverity::Error, + "Invalid asset sidecar: " + iterator->path().generic_string()}); + continue; + } + if (!discovered.emplace(guid).second) { + result.diagnostics.push_back({RuleId("editor.asset.duplicate-guid"), DiagnosticSeverity::Error, + "Duplicate asset GUID: " + guid.value()}); + continue; + } + std::filesystem::path source = iterator->path(); + source.replace_extension(); + if (!std::filesystem::is_regular_file(source)) { + result.diagnostics.push_back({RuleId("editor.asset.source-missing"), DiagnosticSeverity::Error, + "Asset sidecar source is missing: " + source.generic_string()}); + continue; + } + const auto relative = source.lexically_relative(contentRoot_); + const std::string hash = fileHash(source); + const std::string fingerprint = relative.generic_string() + ":" + hash + ":" + json; + AssetRecord record; + record.guid = guid; + record.logicalUri = "content://" + relative.generic_string(); + record.typeId = type; + record.sourceUri = source.generic_string(); + record.sourceHash = hash; + record.schemaVersion = 1; + auto published = database_->publish(std::move(record)); + if (!published.accepted()) { + result.diagnostics.insert(result.diagnostics.end(), published.diagnostics.begin(), + published.diagnostics.end()); + continue; + } + ++result.indexed; + const auto previous = fingerprints_.find(guid); + if (previous == fingerprints_.end() || previous->second != fingerprint) ++result.changed; + fingerprints_.insert_or_assign(guid, fingerprint); + } + if (ec) return scanError(EditorStatus::Failed, "editor.asset.scan-failed", ec.message()); + EditorResult completed = EditorResult::applied(std::move(result)); + completed.diagnostics = completed.value->diagnostics; + return completed; +} + +EditorResult DiskAssetCatalog::poll() { return scan(); } + +std::string DiskAssetCatalog::fileHash(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + std::uint64_t hash = 1469598103934665603ULL; + char buffer[8192]; + while (input) { + input.read(buffer, sizeof(buffer)); + for (std::streamsize index = 0; index < input.gcount(); ++index) { + hash ^= static_cast(buffer[index]); + hash *= 1099511628211ULL; + } + } + std::ostringstream text; + text << std::hex << std::setfill('0') << std::setw(16) << hash; + return text.str(); +} + +} // namespace eve::editor diff --git a/src/modules/editor/EditorDiskAssetCatalog.h b/src/modules/editor/EditorDiskAssetCatalog.h new file mode 100644 index 00000000..a3785f1f --- /dev/null +++ b/src/modules/editor/EditorDiskAssetCatalog.h @@ -0,0 +1,50 @@ +#pragma once + +#include "editor/EditorAssetDatabase.h" + +#include +#include + +namespace eve::editor { + +/** @brief Result of one disk asset sidecar scan/poll. */ +struct DiskAssetScanResult { + std::size_t indexed = 0; + std::size_t changed = 0; + std::vector diagnostics; +}; + +/** + * @brief Project Content scanner using stable `.evmeta` GUID sidecars. + * + * A sidecar named `Tree.png.evmeta` describes `Tree.png`. Moving both files + * changes the logical URI while retaining identity. poll() is deliberately + * host-driven so desktop editors, games and MCP hosts can choose their own + * watcher/event loop without changing indexing semantics. + */ +class DiskAssetCatalog { +public: + /** + * @brief Create a host-driven disk asset catalog. + * @param projectRoot Project directory containing Content/. + * @param database Non-owning destination index; it must outlive the catalog. + */ + DiskAssetCatalog(std::filesystem::path projectRoot, MemoryAssetDatabase* database); + /** @brief Create/replace a sidecar for a Content-relative source file. */ + EditorResult writeSidecar(const std::filesystem::path& contentRelativePath, AssetGuid guid, + std::string typeId, std::uint32_t schemaVersion = 1); + /** @brief Scan every sidecar and atomically publish valid records. */ + EditorResult scan(); + /** @brief Rescan and report only content/sidecar fingerprints that changed. */ + EditorResult poll(); + +private: + std::filesystem::path resolveContent(const std::filesystem::path& relative) const; + static std::string fileHash(const std::filesystem::path& path); + + std::filesystem::path contentRoot_; + MemoryAssetDatabase* database_ = nullptr; + std::map fingerprints_; +}; + +} // namespace eve::editor diff --git a/src/modules/editor/EditorDiskDocumentStore.cpp b/src/modules/editor/EditorDiskDocumentStore.cpp new file mode 100644 index 00000000..640eebf6 --- /dev/null +++ b/src/modules/editor/EditorDiskDocumentStore.cpp @@ -0,0 +1,226 @@ +#include "editor/EditorDiskDocumentStore.h" + +#include "editor/EditorValueJson.h" + +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#endif + +namespace eve::editor { +namespace { + +const EditorValue* member(const EditorValue& value, const char* key) { + const auto* object = value.getIf(); + if (!object) return nullptr; + const auto found = object->find(key); + return found == object->end() ? nullptr : &found->second; +} + +std::string stringMember(const EditorValue& value, const char* key) { + const EditorValue* field = member(value, key); + const auto* text = field ? field->getIf() : nullptr; + return text ? *text : std::string{}; +} + +Revision revisionMember(const EditorValue& value, const char* key) { + const EditorValue* field = member(value, key); + const auto* integer = field ? field->getIf() : nullptr; + return integer && *integer >= 0 ? static_cast(*integer) : 0; +} + +EditorResult draftFailure(const char* rule, std::string message) { + return EditorResult::error(EditorStatus::Rejected, RuleId(rule), std::move(message)); +} + +} // namespace + +DiskAtomicDocumentStore::DiskAtomicDocumentStore(std::filesystem::path projectRoot) { + std::error_code ec; + root_ = std::filesystem::weakly_canonical(std::move(projectRoot), ec); + if (ec) root_.clear(); +} + +std::filesystem::path DiskAtomicDocumentStore::resolve(const std::string& resourceUri) const { + if (root_.empty()) return {}; + std::string relative; + if (resourceUri.rfind("content://", 0) == 0) + relative = "Content/" + resourceUri.substr(10); + else if (resourceUri.rfind("autosave://", 0) == 0) + relative = "Saved/Autosave/" + resourceUri.substr(11); + else + return {}; + if (relative.empty()) return {}; + + std::filesystem::path candidate(relative); + if (candidate.is_absolute()) return {}; + for (const auto& part : candidate) + if (part == "..") return {}; + + candidate = (root_ / candidate).lexically_normal(); + const std::filesystem::path scoped = candidate.lexically_relative(root_); + if (scoped.empty()) return {}; + for (const auto& part : scoped) + if (part == "..") return {}; + return candidate; +} + +EditorResult DiskAtomicDocumentStore::read(const std::string& resourceUri) const { + const std::filesystem::path path = resolve(resourceUri); + if (path.empty()) return failure(EditorStatus::Rejected, "editor.document.invalid-uri", "Rejected resource URI"); + std::ifstream input(path, std::ios::binary); + if (!input) + return failure(EditorStatus::NotFound, "editor.document.store-not-found", + "Document resource is not persisted: " + resourceUri); + const std::string json{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + EditorResult parsed = editorValueFromJson(json); + if (!parsed.accepted() || !parsed.value) + return failure(EditorStatus::Failed, "editor.document.invalid-envelope", "Document JSON envelope is invalid"); + + const EditorValue* content = member(*parsed.value, "content"); + const Revision revision = revisionMember(*parsed.value, "revision"); + const auto* schema = member(*parsed.value, "schemaVersion"); + const auto* schemaInteger = schema ? schema->getIf() : nullptr; + if (!content || !schemaInteger || *schemaInteger != 1) + return failure(EditorStatus::Failed, "editor.document.unsupported-schema", + "Document envelope schemaVersion must be 1"); + + StoredDocument stored; + stored.content = *content; + stored.revision = revision; + stored.contentHash = editorValueContentHash(stored.content); + return EditorResult::applied(std::move(stored)); +} + +EditorResult DiskAtomicDocumentStore::compareAndSwap(const std::string& resourceUri, + Revision expectedRevision, + const std::string& expectedContentHash, + const EditorValue& content) { + const std::filesystem::path path = resolve(resourceUri); + if (path.empty()) return failure(EditorStatus::Rejected, "editor.document.invalid-uri", "Rejected resource URI"); + + StoredDocument current; + EditorResult existing = read(resourceUri); + if (existing.accepted() && existing.value) + current = *existing.value; + else if (existing.status != EditorStatus::NotFound) + return existing; + else + current.contentHash.clear(); + if (current.revision != expectedRevision || current.contentHash != expectedContentHash) + return failure(EditorStatus::Conflict, "editor.document.disk-conflict", + "Document content or revision changed before save"); + + StoredDocument next; + next.content = content; + next.revision = current.revision + 1; + next.contentHash = editorValueContentHash(content); + EditorValue::Object envelope; + envelope["schemaVersion"] = EditorValue(1); + envelope["revision"] = EditorValue(static_cast(next.revision)); + envelope["contentHash"] = EditorValue(next.contentHash); + envelope["content"] = content; + + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + if (ec) return failure(EditorStatus::Failed, "editor.document.create-directory", ec.message()); + const std::filesystem::path temporary = path.string() + ".tmp." + std::to_string(++sequence_); + { + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + if (!output) return failure(EditorStatus::Failed, "editor.document.temp-open", "Cannot open temp file"); + output << editorValueToJson(EditorValue(std::move(envelope))); + output.flush(); + if (!output.good()) { + output.close(); + std::filesystem::remove(temporary, ec); + return failure(EditorStatus::Failed, "editor.document.temp-write", "Cannot flush temp file"); + } + } + if ((beforeReplace_ && !beforeReplace_(path)) || !replace(temporary, path)) { + std::filesystem::remove(temporary, ec); + return failure(EditorStatus::Failed, "editor.document.atomic-replace", "Atomic replacement failed"); + } + return EditorResult::applied(std::move(next)); +} + +EditorResult DiskAtomicDocumentStore::failure(EditorStatus status, const char* rule, + std::string message) { + return EditorResult::error(status, RuleId(rule), std::move(message)); +} + +bool DiskAtomicDocumentStore::replace(const std::filesystem::path& temporary, + const std::filesystem::path& destination) const { +#if defined(_WIN32) + return MoveFileExW(temporary.c_str(), destination.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; +#else + std::error_code ec; + std::filesystem::rename(temporary, destination, ec); + return !ec; +#endif +} + +std::string AutosaveService::draftUri(const DocumentId& document) const { + std::string safeDocument = document.value(); + std::replace_if( + safeDocument.begin(), safeDocument.end(), + [](unsigned char character) { return !std::isalnum(character) && character != '-' && character != '_'; }, '_'); + return "autosave://" + projectId_ + "/" + safeDocument + ".draft"; +} + +EditorResult AutosaveService::writeDraft(const DocumentSnapshot& snapshot, const EditorValue& content) { + if (!store_ || snapshot.id.empty()) + return EditorResult::error(EditorStatus::Rejected, RuleId("editor.autosave.invalid"), + "Autosave store and document are required"); + const std::string uri = draftUri(snapshot.id); + Revision revision = 0; + std::string hash; + const EditorResult existing = store_->read(uri); + if (existing.accepted() && existing.value) { + revision = existing.value->revision; + hash = existing.value->contentHash; + } else if (existing.status != EditorStatus::NotFound) { + return existing; + } + EditorValue::Object draft; + draft["document"] = EditorValue(snapshot.id.value()); + draft["baseDiskHash"] = EditorValue(snapshot.diskContentHash); + draft["schemaVersion"] = EditorValue(1); + draft["editRevision"] = EditorValue(static_cast(snapshot.revision.edit)); + draft["content"] = content; + return store_->compareAndSwap(uri, revision, hash, EditorValue(std::move(draft))); +} + +EditorResult AutosaveService::readDraft(const DocumentId& document) const { + if (!store_ || document.empty()) return draftFailure("editor.autosave.invalid", "Autosave store is required"); + const EditorResult stored = store_->read(draftUri(document)); + if (!stored.accepted() || !stored.value) { + EditorResult result; + result.status = stored.status; + result.diagnostics = stored.diagnostics; + return result; + } + const EditorValue& root = stored.value->content; + const EditorValue* content = member(root, "content"); + if (!content || revisionMember(root, "schemaVersion") != 1) + return draftFailure("editor.autosave.unsupported-schema", "Autosave draft schemaVersion must be 1"); + AutosaveDraft draft; + draft.document = DocumentId(stringMember(root, "document")); + draft.baseDiskHash = stringMember(root, "baseDiskHash"); + draft.schemaVersion = 1; + draft.editRevision = revisionMember(root, "editRevision"); + draft.content = *content; + if (draft.document != document) + return draftFailure("editor.autosave.document-mismatch", "Autosave draft belongs to another document"); + return EditorResult::applied(std::move(draft)); +} + +bool AutosaveService::shouldOfferRecovery(const AutosaveDraft& draft, const DocumentSnapshot& formal) const { + return draft.document == formal.id && draft.editRevision > formal.revision.saved && + draft.baseDiskHash == formal.diskContentHash; +} + +} // namespace eve::editor diff --git a/src/modules/editor/EditorDiskDocumentStore.h b/src/modules/editor/EditorDiskDocumentStore.h new file mode 100644 index 00000000..890a6f9b --- /dev/null +++ b/src/modules/editor/EditorDiskDocumentStore.h @@ -0,0 +1,80 @@ +#pragma once + +#include "editor/EditorDocumentService.h" + +#include +#include +#include + +namespace eve::editor { + +/** + * @brief Project-rooted JSON document store with sibling-temp atomic replacement. + * + * `content://` resolves under Content/ and `autosave://` under Saved/Autosave/. + * Paths escaping the configured root are rejected. The store is thread-safe at + * the file-operation level only; callers serialize writes per resource URI. + */ +class DiskAtomicDocumentStore final : public IAtomicDocumentStore { +public: + /** @brief Create a store rooted at a project directory. @param projectRoot Project root containing Content/. */ + explicit DiskAtomicDocumentStore(std::filesystem::path projectRoot); + + /** @brief Read and validate the latest atomic document envelope. */ + EditorResult read(const std::string& resourceUri) const override; + /** @brief CAS and atomically replace one document envelope on disk. */ + EditorResult compareAndSwap(const std::string& resourceUri, Revision expectedRevision, + const std::string& expectedContentHash, + const EditorValue& content) override; + + /** @brief Resolve a logical URI to a canonical project path, or an empty path when rejected. */ + std::filesystem::path resolve(const std::string& resourceUri) const; + /** @brief Test seam invoked after temp write and before atomic replacement. */ + void setBeforeReplaceHook(std::function hook) { + beforeReplace_ = std::move(hook); + } + +private: + static EditorResult failure(EditorStatus status, const char* rule, std::string message); + bool replace(const std::filesystem::path& temporary, const std::filesystem::path& destination) const; + + std::filesystem::path root_; + std::function beforeReplace_; + std::uint64_t sequence_ = 0; +}; + +/** @brief Persisted draft metadata used to decide whether recovery should be offered. */ +struct AutosaveDraft { + DocumentId document; + std::string baseDiskHash; + std::uint32_t schemaVersion = 1; + Revision editRevision = 0; + EditorValue content; +}; + +/** @brief Autosave coordinator that never overwrites the formal document URI. */ +class AutosaveService { +public: + /** + * @brief Create an autosave namespace. + * @param store Non-owning atomic store that outlives this service. + * @param projectId Stable project identity used in draft URIs. + */ + AutosaveService(IAtomicDocumentStore* store, std::string projectId) + : store_(store), projectId_(std::move(projectId)) {} + + /** @brief Write one revisioned draft under autosave://project/document.draft. */ + EditorResult writeDraft(const DocumentSnapshot& snapshot, const EditorValue& content); + /** @brief Read a draft and validate its schema. */ + EditorResult readDraft(const DocumentId& document) const; + /** @brief True when a newer draft differs from the current formal disk base. */ + bool shouldOfferRecovery(const AutosaveDraft& draft, const DocumentSnapshot& formal) const; + /** @brief Return the stable draft URI for one document. */ + std::string draftUri(const DocumentId& document) const; + +private: + IAtomicDocumentStore* store_ = nullptr; + std::string projectId_; +}; + +} // namespace eve::editor diff --git a/src/modules/editor/EditorDocumentService.cpp b/src/modules/editor/EditorDocumentService.cpp index 7549c3b4..a080ff1a 100644 --- a/src/modules/editor/EditorDocumentService.cpp +++ b/src/modules/editor/EditorDocumentService.cpp @@ -1,30 +1,9 @@ #include "editor/EditorDocumentService.h" +#include "editor/EditorValueJson.h" #include -#include -#include namespace eve::editor { -namespace { - -std::size_t hashValue(const EditorValue& value) { - std::size_t seed = static_cast(value.type()); - auto combine = [&](std::size_t next) { seed ^= next + 0x9e3779b9 + (seed << 6) + (seed >> 2); }; - if (const auto* boolean = value.getIf()) combine(std::hash{}(*boolean)); - if (const auto* integer = value.getIf()) combine(std::hash{}(*integer)); - if (const auto* number = value.getIf()) combine(std::hash{}(*number)); - if (const auto* string = value.getIf()) combine(std::hash{}(*string)); - if (const auto* array = value.getIf()) - for (const EditorValue& child : *array) combine(hashValue(child)); - if (const auto* object = value.getIf()) - for (const auto& [key, child] : *object) { - combine(std::hash{}(key)); - combine(hashValue(child)); - } - return seed; -} - -} // namespace EditorResult MemoryAtomicDocumentStore::read(const std::string& resourceUri) const { auto found = documents_.find(resourceUri); @@ -36,18 +15,18 @@ EditorResult MemoryAtomicDocumentStore::read(const std::string& EditorResult MemoryAtomicDocumentStore::compareAndSwap(const std::string& resourceUri, Revision expectedRevision, + const std::string& expectedContentHash, const EditorValue& content) { - auto found = documents_.find(resourceUri); - const Revision current = found == documents_.end() ? 0 : found->second.revision; - if (current != expectedRevision) + auto found = documents_.find(resourceUri); + const Revision current = found == documents_.end() ? 0 : found->second.revision; + const std::string currentHash = found == documents_.end() ? std::string{} : found->second.contentHash; + if (current != expectedRevision || currentHash != expectedContentHash) return EditorResult::error(EditorStatus::Conflict, RuleId("editor.document.disk-conflict"), "Document store revision changed before save"); StoredDocument stored; - stored.content = content; - stored.revision = current + 1; - std::ostringstream hash; - hash << std::hex << hashValue(content); - stored.contentHash = hash.str(); + stored.content = content; + stored.revision = current + 1; + stored.contentHash = editorValueContentHash(content); documents_.insert_or_assign(resourceUri, stored); return EditorResult::applied(std::move(stored)); } @@ -68,10 +47,11 @@ EditorResult DocumentService::open(DocumentKey key, std::strin document.snapshot.state = DocumentState::Ready; EditorResult stored = store_->read(document.snapshot.resourceUri); if (stored.accepted() && stored.value) { - document.content = stored.value->content; - document.snapshot.revision.disk = stored.value->revision; - document.snapshot.revision.edit = stored.value->revision; - document.snapshot.revision.saved = stored.value->revision; + document.content = stored.value->content; + document.snapshot.revision.disk = stored.value->revision; + document.snapshot.revision.edit = stored.value->revision; + document.snapshot.revision.saved = stored.value->revision; + document.snapshot.diskContentHash = stored.value->contentHash; } else if (stored.status == EditorStatus::NotFound) { document.content = std::move(initialContent); } else { @@ -105,7 +85,7 @@ EditorResult DocumentService::requestSave(const DocumentId& document ticket.document = document; ticket.capturedEditRevision = found->second.snapshot.revision.edit; ticket.expectedDiskRevision = found->second.snapshot.revision.disk; - ticket.expectedContentHash = contentHash(found->second.content); + ticket.expectedContentHash = found->second.snapshot.diskContentHash; pendingSaves_.emplace(ticket.id, PendingSave{ticket, found->second.content}); return EditorResult::applied(std::move(ticket)); } @@ -116,9 +96,10 @@ EditorResult DocumentService::executeSave(const SaveTicket& ti if (pending == pendingSaves_.end() || document == open_.end()) return error(EditorStatus::NotFound, "editor.document.save-ticket-not-found", "Save ticket or document is no longer available"); - document->second.snapshot.state = DocumentState::Saving; - EditorResult stored = store_->compareAndSwap(document->second.snapshot.resourceUri, - ticket.expectedDiskRevision, pending->second.content); + document->second.snapshot.state = DocumentState::Saving; + EditorResult stored = + store_->compareAndSwap(document->second.snapshot.resourceUri, ticket.expectedDiskRevision, + ticket.expectedContentHash, pending->second.content); pendingSaves_.erase(pending); if (!stored.accepted() || !stored.value) { document->second.snapshot.state = @@ -130,9 +111,10 @@ EditorResult DocumentService::executeSave(const SaveTicket& ti result.diagnostics = std::move(stored.diagnostics); return result; } - document->second.snapshot.revision.disk = stored.value->revision; - document->second.snapshot.revision.saved = ticket.capturedEditRevision; - document->second.snapshot.state = DocumentState::Ready; + document->second.snapshot.revision.disk = stored.value->revision; + document->second.snapshot.revision.saved = ticket.capturedEditRevision; + document->second.snapshot.diskContentHash = stored.value->contentHash; + document->second.snapshot.state = DocumentState::Ready; document->second.snapshot.diagnostics.clear(); return EditorResult::applied(document->second.snapshot); } @@ -171,12 +153,6 @@ EditorResult DocumentService::close(const DocumentId& document) { return EditorResult::applied(); } -std::string DocumentService::contentHash(const EditorValue& value) { - std::ostringstream stream; - stream << std::hex << hashValue(value); - return stream.str(); -} - EditorResult DocumentService::error(EditorStatus status, const char* rule, std::string message) { return EditorResult::error(status, RuleId(rule), std::move(message)); } diff --git a/src/modules/editor/EditorDocumentService.h b/src/modules/editor/EditorDocumentService.h index ac63df62..6857f9fd 100644 --- a/src/modules/editor/EditorDocumentService.h +++ b/src/modules/editor/EditorDocumentService.h @@ -53,6 +53,7 @@ struct DocumentSnapshot { std::string resourceUri; DocumentState state = DocumentState::Loading; DocumentRevision revision; + std::string diskContentHash; std::vector viewIds; std::vector diagnostics; @@ -84,6 +85,7 @@ public: virtual EditorResult read(const std::string& resourceUri) const = 0; /** @brief Replace a value only when the expected disk revision still matches. */ virtual EditorResult compareAndSwap(const std::string& resourceUri, Revision expectedRevision, + const std::string& expectedContentHash, const EditorValue& content) = 0; }; @@ -92,6 +94,7 @@ class MemoryAtomicDocumentStore final : public IAtomicDocumentStore { public: EditorResult read(const std::string& resourceUri) const override; EditorResult compareAndSwap(const std::string& resourceUri, Revision expectedRevision, + const std::string& expectedContentHash, const EditorValue& content) override; private: @@ -138,7 +141,6 @@ private: EditorValue content; }; - static std::string contentHash(const EditorValue& value); static EditorResult error(EditorStatus status, const char* rule, std::string message); IAtomicDocumentStore* store_ = nullptr; diff --git a/src/modules/editor/EditorGraph.cpp b/src/modules/editor/EditorGraph.cpp index 19aaee15..7e25df85 100644 --- a/src/modules/editor/EditorGraph.cpp +++ b/src/modules/editor/EditorGraph.cpp @@ -149,15 +149,87 @@ EditorResult MaterialEditorService::compile(const DocumentId& document, return graphError(EditorStatus::Rejected, "editor.material.missing-document", "Material document id is required"); TaskId task(document.value() + ".compile." + std::to_string(++taskSequence_)); - tasks_.emplace(task, Task{document, domain.compile(graph)}); + tasks_.emplace(task, Task{document, domain.compile(graph), nullptr}); return EditorResult::applied(std::move(task)); } +EditorResult MaterialEditorService::compileAsync(const DocumentId& document, GraphDocumentData graph, + const MaterialGraphDomain& domain, EditorTaskService& tasks) { + if (document.empty()) + return graphError(EditorStatus::Rejected, "editor.material.missing-document", + "Material document id is required"); + const MaterialGraphDomain domainCopy = domain; + auto queued = tasks.submit("Compile material " + document.value(), [graph = std::move(graph), + domainCopy](const EditorTaskContext& context) { + EditorTaskOutcome outcome; + if (context.cancelled()) { + outcome.status = EditorStatus::Cancelled; + return outcome; + } + context.reportProgress(0.25F); + const MaterialCompileResult compiled = domainCopy.compile(graph); + EditorValue::Object value; + value["status"] = EditorValue(static_cast(compiled.status)); + value["revision"] = EditorValue(static_cast(compiled.documentRevision)); + value["artifact"] = EditorValue(compiled.shaderArtifact); + outcome.status = compiled.status; + outcome.output = EditorValue(std::move(value)); + outcome.diagnostics = compiled.diagnostics; + context.reportProgress(1.0F); + return outcome; + }); + if (!queued.accepted() || !queued.value) return queued; + tasks_.emplace(*queued.value, Task{document, {}, &tasks}); + return queued; +} + +EditorResult MaterialEditorService::cancel(const TaskId& task) { + auto found = tasks_.find(task); + if (found == tasks_.end() || !found->second.service) + return graphError(EditorStatus::NotFound, "editor.material.async-task-not-found", + "Asynchronous material task does not exist"); + return found->second.service->cancel(task); +} + EditorResult MaterialEditorService::result(const TaskId& task) const { auto found = tasks_.find(task); if (found == tasks_.end()) return graphError(EditorStatus::NotFound, "editor.material.task-not-found", "Material compile task does not exist"); + if (found->second.service) { + const auto snapshot = found->second.service->snapshot(task); + if (!snapshot.accepted() || !snapshot.value) + return graphError(EditorStatus::NotFound, "editor.material.task-not-found", + "Material compile task does not exist"); + if (snapshot.value->state == EditorTaskState::Queued || snapshot.value->state == EditorTaskState::Running) { + EditorResult pending; + pending.status = EditorStatus::Pending; + return pending; + } + MaterialCompileResult compiled; + compiled.diagnostics = snapshot.value->diagnostics; + if (snapshot.value->state == EditorTaskState::Cancelled) { + compiled.status = EditorStatus::Cancelled; + return EditorResult::applied(std::move(compiled)); + } + const auto* object = snapshot.value->output.getIf(); + if (!object) + return graphError(EditorStatus::Failed, "editor.material.invalid-task-output", + "Material worker returned an invalid result"); + const auto status = object->find("status"); + const auto revision = object->find("revision"); + const auto artifact = object->find("artifact"); + const auto* statusValue = status == object->end() ? nullptr : status->second.getIf(); + const auto* revisionValue = revision == object->end() ? nullptr : revision->second.getIf(); + const auto* artifactValue = artifact == object->end() ? nullptr : artifact->second.getIf(); + if (!statusValue || !revisionValue || !artifactValue) + return graphError(EditorStatus::Failed, "editor.material.invalid-task-output", + "Material worker result fields are invalid"); + compiled.status = static_cast(*statusValue); + compiled.documentRevision = static_cast(*revisionValue); + compiled.shaderArtifact = *artifactValue; + return EditorResult::applied(std::move(compiled)); + } return EditorResult::applied(found->second.result); } @@ -167,7 +239,14 @@ EditorResult MaterialEditorService::publishPreview(const DocumentId& docum if (found == tasks_.end() || found->second.document != document) return graphError(EditorStatus::NotFound, "editor.material.task-not-found", "Material compile task does not belong to this document"); - const MaterialCompileResult& compiled = found->second.result; + const auto compileResult = result(task); + if (compileResult.status == EditorStatus::Pending) + return graphError(EditorStatus::Conflict, "editor.material.compile-pending", + "Material compile task has not completed"); + if (!compileResult.accepted() || !compileResult.value) + return graphError(EditorStatus::Failed, "editor.material.compile-result-unavailable", + "Material compile result is unavailable"); + const MaterialCompileResult& compiled = *compileResult.value; if (compiled.status != EditorStatus::Applied) return graphError(EditorStatus::Rejected, "editor.material.compile-not-successful", "Failed material output cannot replace the current preview"); diff --git a/src/modules/editor/EditorGraph.h b/src/modules/editor/EditorGraph.h index 2efef0bc..cfcd8d2f 100644 --- a/src/modules/editor/EditorGraph.h +++ b/src/modules/editor/EditorGraph.h @@ -1,6 +1,7 @@ #pragma once #include "editor/EditorDocumentService.h" +#include "editor/EditorTaskService.h" #include #include @@ -107,14 +108,19 @@ public: /** * @brief Revision-aware material compile result and preview publication service. * - * Compilation is synchronous in this slice but still returns TaskId so the API - * can move to workers without changing callers. + * Both synchronous compatibility and real background compilation share the + * same result/publication contract. */ class MaterialEditorService { public: /** @brief Compile and cache a task result. */ EditorResult compile(const DocumentId& document, const GraphDocumentData& graph, const MaterialGraphDomain& domain); + /** @brief Queue a cancellable compile using an immutable graph snapshot. */ + EditorResult compileAsync(const DocumentId& document, GraphDocumentData graph, + const MaterialGraphDomain& domain, EditorTaskService& tasks); + /** @brief Request cancellation of an asynchronous compile task. */ + EditorResult cancel(const TaskId& task); /** @brief Return one cached compile task result. */ EditorResult result(const TaskId& task) const; /** @brief Publish only a successful artifact compiled from the current revision. */ @@ -126,6 +132,7 @@ private: struct Task { DocumentId document; MaterialCompileResult result; + EditorTaskService* service = nullptr; }; std::unordered_map> tasks_; diff --git a/src/modules/editor/EditorPersistence.cpp b/src/modules/editor/EditorPersistence.cpp new file mode 100644 index 00000000..0c1ec0c4 --- /dev/null +++ b/src/modules/editor/EditorPersistence.cpp @@ -0,0 +1,110 @@ +#include "editor/EditorPersistence.h" + +namespace eve::editor { +namespace { + +EditorValue operationValue(const DomainOperation& operation) { + EditorValue::Object value; + value["type"] = EditorValue(operation.type); + value["inverseType"] = EditorValue(operation.inverseType); + value["target"] = EditorValue(operation.target.value()); + value["payload"] = operation.payload; + value["inverse"] = operation.inverse; + value["hasInverse"] = EditorValue(operation.hasInverse); + value["mergeKey"] = EditorValue(operation.mergeKey); + return EditorValue(std::move(value)); +} + +DomainOperation decodeOperation(const EditorValue& value) { + DomainOperation operation; + const auto* object = value.getIf(); + if (!object) return operation; + const auto text = [&](const char* key) { + auto found = object->find(key); + const auto* result = found == object->end() ? nullptr : found->second.getIf(); + return result ? *result : std::string{}; + }; + operation.type = text("type"); + operation.inverseType = text("inverseType"); + operation.target = TargetId(text("target")); + operation.mergeKey = text("mergeKey"); + auto payload = object->find("payload"); + if (payload != object->end()) operation.payload = payload->second; + auto inverse = object->find("inverse"); + if (inverse != object->end()) operation.inverse = inverse->second; + auto hasInverse = object->find("hasInverse"); + const auto* flag = hasInverse == object->end() ? nullptr : hasInverse->second.getIf(); + operation.hasInverse = flag && *flag; + return operation; +} + +EditorResult persistenceError(EditorStatus status, const char* rule, std::string message) { + return EditorResult::error(status, RuleId(rule), std::move(message)); +} + +} // namespace + +EditorResult EditorPersistenceAdapter::load() const { + if (!store_ || resourceUri_.empty()) + return persistenceError(EditorStatus::Rejected, "editor.persistence.invalid", + "Persistence store and URI are required"); + auto stored = store_->read(resourceUri_); + if (!stored.accepted() || !stored.value) { + if (stored.status == EditorStatus::NotFound) { + EditorPersistenceSnapshot empty; + empty.kind = kind_; + return EditorResult::applied(std::move(empty)); + } + EditorResult result; + result.status = stored.status; + result.diagnostics = std::move(stored.diagnostics); + return result; + } + return decode(kind_, *stored.value); +} + +EditorResult EditorPersistenceAdapter::commit(const EditorPersistenceSnapshot& base, + EditorValue content, + std::vector journal) { + if (!store_ || base.kind != kind_) + return persistenceError(EditorStatus::Rejected, "editor.persistence.kind-mismatch", + "Persistence checkpoint belongs to another backend kind"); + EditorValue::Array serializedJournal; + for (const DomainOperation& operation : journal) serializedJournal.push_back(operationValue(operation)); + EditorValue::Object envelope; + envelope["schemaVersion"] = EditorValue(1); + envelope["content"] = content; + envelope["journal"] = EditorValue(std::move(serializedJournal)); + auto stored = + store_->compareAndSwap(resourceUri_, base.revision, base.contentHash, EditorValue(std::move(envelope))); + if (!stored.accepted() || !stored.value) { + EditorResult result; + result.status = stored.status; + result.diagnostics = std::move(stored.diagnostics); + return result; + } + return decode(kind_, *stored.value); +} + +EditorResult EditorPersistenceAdapter::decode(EditorPersistenceKind kind, + const StoredDocument& stored) { + const auto* envelope = stored.content.getIf(); + if (!envelope) + return persistenceError(EditorStatus::Failed, "editor.persistence.invalid-envelope", + "Persistence checkpoint must be an object"); + const auto content = envelope->find("content"); + const auto journal = envelope->find("journal"); + const auto* journalValues = journal == envelope->end() ? nullptr : journal->second.getIf(); + if (content == envelope->end() || !journalValues) + return persistenceError(EditorStatus::Failed, "editor.persistence.invalid-envelope", + "Persistence checkpoint content or journal is missing"); + EditorPersistenceSnapshot result; + result.kind = kind; + result.revision = stored.revision; + result.contentHash = stored.contentHash; + result.content = content->second; + for (const EditorValue& value : *journalValues) result.journal.push_back(decodeOperation(value)); + return EditorResult::applied(std::move(result)); +} + +} // namespace eve::editor diff --git a/src/modules/editor/EditorPersistence.h b/src/modules/editor/EditorPersistence.h new file mode 100644 index 00000000..7ffb3c14 --- /dev/null +++ b/src/modules/editor/EditorPersistence.h @@ -0,0 +1,51 @@ +#pragma once + +#include "editor/EditorDocumentService.h" +#include "editor/EditorProtocol.h" + +namespace eve::editor { + +/** @brief Persistence target selected by a game/editor host. */ +enum class EditorPersistenceKind { SceneDocument, SaveGame }; + +/** @brief Loaded checkpoint plus the domain operations recorded with it. */ +struct EditorPersistenceSnapshot { + EditorPersistenceKind kind = EditorPersistenceKind::SceneDocument; + Revision revision = 0; + std::string contentHash; + EditorValue content; + std::vector journal; +}; + +/** + * @brief Maps the same editor transaction output to a document URI or save slot. + * + * The injected atomic store is the only backend-specific piece: projects may + * use DiskAtomicDocumentStore for scene documents and adapt their game save + * service to IAtomicDocumentStore for savegame slots. + */ +class EditorPersistenceAdapter { +public: + /** + * @brief Bind one logical persistence target. + * @param kind Scene document or savegame semantics. + * @param store Non-owning atomic backend that outlives this adapter. + * @param resourceUri Backend-specific stable resource identity. + */ + EditorPersistenceAdapter(EditorPersistenceKind kind, IAtomicDocumentStore* store, std::string resourceUri) + : kind_(kind), store_(store), resourceUri_(std::move(resourceUri)) {} + + /** @brief Load the last committed checkpoint and journal. */ + EditorResult load() const; + /** @brief Atomically commit a checkpoint against its loaded revision/hash. */ + EditorResult commit(const EditorPersistenceSnapshot& base, EditorValue content, + std::vector journal); + +private: + static EditorResult decode(EditorPersistenceKind kind, const StoredDocument& stored); + EditorPersistenceKind kind_; + IAtomicDocumentStore* store_ = nullptr; + std::string resourceUri_; +}; + +} // namespace eve::editor diff --git a/src/modules/editor/EditorRuntimeManifest.cpp b/src/modules/editor/EditorRuntimeManifest.cpp new file mode 100644 index 00000000..7e515728 --- /dev/null +++ b/src/modules/editor/EditorRuntimeManifest.cpp @@ -0,0 +1,86 @@ +#include "editor/EditorRuntimeManifest.h" + +#include +#include +#include + +namespace eve::editor { +namespace { + +template +bool selected(const std::vector& values, const Id& id) { + return std::find(values.begin(), values.end(), id) != values.end(); +} + +EditorResult manifestError(const char* rule, std::string message) { + return EditorResult::error(EditorStatus::Rejected, RuleId(rule), std::move(message)); +} + +} // namespace + +EditorResult RuntimeEditorPublisher::publish(const RuntimeEditorManifest& manifest, + const EditorCommandService& commands, + const EditorExtensionRegistry& extensions, + const MemoryAssetDatabase& assets) const { + constexpr HostFeature forbidden = HostFeature::SourceAssets | HostFeature::ArbitraryScript | + HostFeature::BuildCook | HostFeature::SourceControl | HostFeature::DebugOverride; + if ((manifest.features & forbidden) != HostFeature::None) + return manifestError("editor.runtime.forbidden-feature", + "Runtime editor manifest requests a developer-only feature"); + + RuntimeEditorPackage package; + package.profile = HostProfile::runtimeBuilder(); + package.profile.setFeatures(manifest.features); + package.profile.setMaxPayloadBytes(manifest.maxPayloadBytes); + for (const CommandId& id : manifest.commands) { + const CommandDescriptor* descriptor = commands.find(id); + if (!descriptor) + return manifestError("editor.runtime.command-not-found", + "Manifest command is not registered: " + id.value()); + if (!descriptor->automationAllowed) + return manifestError("editor.runtime.command-not-publishable", + "Manifest command is not publishable: " + id.value()); + if (!package.profile.hasFeatures(descriptor->requiredFeatures)) + return manifestError("editor.runtime.command-feature-missing", + "Manifest omits a feature required by command: " + id.value()); + package.profile.allowCommand(id); + package.commands.push_back(*descriptor); + } + + HostProfile audienceProfile = HostProfile::runtimeBuilder(); + audienceProfile.setAllowAllCommands(true); + for (const auto& descriptor : extensions.tools(audienceProfile)) + if (selected(manifest.tools, descriptor.id)) package.tools.push_back(descriptor); + for (const ToolId& id : manifest.tools) + if (std::none_of(package.tools.begin(), package.tools.end(), [&](const auto& value) { return value.id == id; })) + return manifestError("editor.runtime.tool-not-found", "Manifest tool is unavailable: " + id.value()); + for (const auto& descriptor : extensions.palettes(audienceProfile)) + if (selected(manifest.palettes, descriptor.id)) package.palettes.push_back(descriptor); + for (const StableId& id : manifest.palettes) + if (std::none_of(package.palettes.begin(), package.palettes.end(), + [&](const auto& value) { return value.id == id; })) + return manifestError("editor.runtime.palette-not-found", "Manifest palette is unavailable: " + id.value()); + for (const auto& descriptor : extensions.rules(audienceProfile)) + if (selected(manifest.rules, descriptor.id)) package.rules.push_back(descriptor); + for (const RuleId& id : manifest.rules) + if (std::none_of(package.rules.begin(), package.rules.end(), [&](const auto& value) { return value.id == id; })) + return manifestError("editor.runtime.rule-not-found", "Manifest rule is unavailable: " + id.value()); + + std::deque pending(manifest.rootAssets.begin(), manifest.rootAssets.end()); + std::unordered_set> visited; + while (!pending.empty()) { + AssetGuid current = pending.front(); + pending.pop_front(); + if (!visited.emplace(current).second) continue; + if (!assets.find(current).accepted()) + return manifestError("editor.runtime.asset-not-found", "Manifest asset is unavailable: " + current.value()); + package.assetClosure.push_back(current); + for (const AssetDependency& dependency : assets.dependencies(current)) + if (dependency.kind != DependencyKind::EditorOnly && dependency.kind != DependencyKind::Source) + pending.push_back(dependency.to); + } + std::sort(package.assetClosure.begin(), package.assetClosure.end()); + return EditorResult::applied(std::move(package)); +} + +} // namespace eve::editor diff --git a/src/modules/editor/EditorRuntimeManifest.h b/src/modules/editor/EditorRuntimeManifest.h new file mode 100644 index 00000000..1ff3645d --- /dev/null +++ b/src/modules/editor/EditorRuntimeManifest.h @@ -0,0 +1,46 @@ +#pragma once + +#include "editor/EditorAssetDatabase.h" +#include "editor/EditorExtension.h" + +namespace eve::editor { + +/** @brief Explicit publish allow-list for a player-facing editor build. */ +struct RuntimeEditorManifest { + HostFeature features = HostFeature::RuntimeWorld | HostFeature::UserCreations; + std::vector commands; + std::vector tools; + std::vector palettes; + std::vector rules; + std::vector rootAssets; + std::size_t maxPayloadBytes = 64 * 1024; +}; + +/** @brief Resolved editor package embedded into a game build. */ +struct RuntimeEditorPackage { + HostProfile profile; + std::vector commands; + std::vector tools; + std::vector palettes; + std::vector rules; + std::vector assetClosure; +}; + +/** @brief Validates an explicit manifest and resolves its runtime dependency closure. */ +class RuntimeEditorPublisher { +public: + /** + * @brief Build a deny-by-default runtime package. + * @param manifest Explicit components and root assets selected by the game. + * @param commands Command registry used to validate command identities. + * @param extensions Extension registry used to resolve presentation metadata. + * @param assets Asset index used to close hard/build/soft runtime dependencies. + * @return A package or a diagnostic when any selected identity is unavailable. + */ + EditorResult publish(const RuntimeEditorManifest& manifest, + const EditorCommandService& commands, + const EditorExtensionRegistry& extensions, + const MemoryAssetDatabase& assets) const; +}; + +} // namespace eve::editor diff --git a/src/modules/editor/EditorSelection.cpp b/src/modules/editor/EditorSelection.cpp new file mode 100644 index 00000000..5290d1b5 --- /dev/null +++ b/src/modules/editor/EditorSelection.cpp @@ -0,0 +1,70 @@ +#include "editor/EditorSelection.h" + +#include + +namespace eve::editor { + +EditorResult EditorSelectionService::set(std::string channel, std::vector items, + std::optional primary) { + if (channel.empty()) + return EditorResult::error( + EditorStatus::Rejected, RuleId("editor.selection.channel-required"), "Selection channel is required"); + if (primary && std::find(items.begin(), items.end(), *primary) == items.end()) + return EditorResult::error(EditorStatus::Rejected, + RuleId("editor.selection.primary-missing"), + "Primary selection must be included in items"); + SelectionSnapshot value; + value.channel = std::move(channel); + value.items = std::move(items); + value.primary = std::move(primary); + value.sequence = ++sequence_; + selections_.insert_or_assign(value.channel, value); + for (const auto& [owner, listener] : listeners_) { + (void)owner; + listener(value); + } + return EditorResult::applied(std::move(value)); +} + +EditorResult EditorSelectionService::clear(const std::string& channel) { return set(channel, {}); } + +SelectionSnapshot EditorSelectionService::snapshot(const std::string& channel) const { + const auto found = selections_.find(channel); + if (found != selections_.end()) return found->second; + SelectionSnapshot empty; + empty.channel = channel; + return empty; +} + +EditorResult EditorSelectionService::subscribe(std::string owner, Listener listener) { + if (owner.empty() || !listener) + return EditorResult::error(EditorStatus::Rejected, RuleId("editor.selection.invalid-listener"), + "Selection listener owner and callback are required"); + listeners_.insert_or_assign(std::move(owner), std::move(listener)); + return EditorResult::applied(); +} + +bool EditorSelectionService::unsubscribe(const std::string& owner) { return listeners_.erase(owner) != 0; } + +EditorResult EditorFocusService::focus(std::string channel, StableId surface, StableId item) { + if (channel.empty() || surface.empty()) + return EditorResult::error(EditorStatus::Rejected, RuleId("editor.focus.invalid"), + "Focus channel and surface are required"); + EditorFocusSnapshot value; + value.channel = std::move(channel); + value.surface = std::move(surface); + value.item = std::move(item); + value.sequence = ++sequence_; + focus_.insert_or_assign(value.channel, value); + return EditorResult::applied(std::move(value)); +} + +EditorFocusSnapshot EditorFocusService::snapshot(const std::string& channel) const { + const auto found = focus_.find(channel); + if (found != focus_.end()) return found->second; + EditorFocusSnapshot empty; + empty.channel = channel; + return empty; +} + +} // namespace eve::editor diff --git a/src/modules/editor/EditorSelection.h b/src/modules/editor/EditorSelection.h index e8d11d1b..0ba58614 100644 --- a/src/modules/editor/EditorSelection.h +++ b/src/modules/editor/EditorSelection.h @@ -1,8 +1,11 @@ #pragma once #include "editor/EditorIds.h" +#include "editor/EditorResult.h" #include +#include +#include #include #include #include @@ -30,4 +33,48 @@ struct SelectionSnapshot { std::uint64_t sequence = 0; }; +/** @brief Semantic focus independent from keyboard focus of a particular UI toolkit. */ +struct EditorFocusSnapshot { + std::string channel; + StableId surface; + StableId item; + std::uint64_t sequence = 0; +}; + +/** @brief Channelled selection/focus state shared by editor and in-game presentations. */ +class EditorSelectionService { +public: + using Listener = std::function; + + /** @brief Atomically replace a channel selection and optional primary item. */ + EditorResult set(std::string channel, std::vector items, + std::optional primary = std::nullopt); + /** @brief Clear a channel while retaining a monotonic sequence. */ + EditorResult clear(const std::string& channel); + /** @brief Read one channel, returning an empty sequence-zero snapshot when absent. */ + SelectionSnapshot snapshot(const std::string& channel) const; + /** @brief Subscribe an owner callback; owner replacement and unload are deterministic. */ + EditorResult subscribe(std::string owner, Listener listener); + /** @brief Remove one listener owner. */ + bool unsubscribe(const std::string& owner); + +private: + std::map selections_; + std::map listeners_; + std::uint64_t sequence_ = 0; +}; + +/** @brief Shared semantic focus service for docked editor and game HUD surfaces. */ +class EditorFocusService { +public: + /** @brief Set the focused surface/item for one channel. */ + EditorResult focus(std::string channel, StableId surface, StableId item = {}); + /** @brief Read the current focus of one channel. */ + EditorFocusSnapshot snapshot(const std::string& channel) const; + +private: + std::map focus_; + std::uint64_t sequence_ = 0; +}; + } // namespace eve::editor diff --git a/src/modules/editor/EditorSession.cpp b/src/modules/editor/EditorSession.cpp index 865448df..01df645a 100644 --- a/src/modules/editor/EditorSession.cpp +++ b/src/modules/editor/EditorSession.cpp @@ -142,6 +142,16 @@ EditorResult EditorSession::executeRetainedPlan(const PlanId return result; } +EditorResult EditorSession::cancelRetainedPlan(const PlanId& id) { + const auto found = std::find_if(retainedPlans_.begin(), retainedPlans_.end(), + [&](const RetainedPlan& retained) { return retained.plan.id == id; }); + if (found == retainedPlans_.end()) + return EditorResult::error(EditorStatus::NotFound, RuleId("editor.command.plan-not-found"), + "Retained command plan was not found"); + retainedPlans_.erase(found); + return EditorResult::applied(); +} + void EditorSession::clearRetainedPlans() { retainedPlans_.clear(); } EditorSession::~EditorSession() { deactivateCurrent(); } diff --git a/src/modules/editor/EditorSession.h b/src/modules/editor/EditorSession.h index efb9959b..d603f9a9 100644 --- a/src/modules/editor/EditorSession.h +++ b/src/modules/editor/EditorSession.h @@ -115,6 +115,8 @@ public: std::optional expectedRevision = std::nullopt); /** @brief Execute a retained plan. Successful plans are consumed exactly once. */ EditorResult executeRetainedPlan(const PlanId& id, CommandSource source = CommandSource::Api); + /** @brief Discard one retained plan without executing it. */ + EditorResult cancelRetainedPlan(const PlanId& id); /** @brief Discard all retained plans, for example after replacing a document or target. */ void clearRetainedPlans(); diff --git a/src/modules/editor/EditorTaskService.cpp b/src/modules/editor/EditorTaskService.cpp new file mode 100644 index 00000000..c045d381 --- /dev/null +++ b/src/modules/editor/EditorTaskService.cpp @@ -0,0 +1,147 @@ +#include "editor/EditorTaskService.h" + +#include +#include + +namespace eve::editor { + +struct EditorTaskService::TaskRecord { + EditorTaskSnapshot snapshot; + Work work; + std::shared_ptr cancelled = std::make_shared(false); +}; + +EditorTaskContext::EditorTaskContext(std::shared_ptr cancelled, std::function progress) + : cancelled_(std::move(cancelled)), progress_(std::move(progress)) {} + +bool EditorTaskContext::cancelled() const { return cancelled_ && cancelled_->load(std::memory_order_relaxed); } + +void EditorTaskContext::reportProgress(float progress) const { + if (progress_) progress_(std::clamp(progress, 0.0F, 1.0F)); +} + +EditorTaskService::EditorTaskService() : worker_([this] { workerLoop(); }) {} + +EditorTaskService::~EditorTaskService() { + { + std::lock_guard lock(mutex_); + stopping_ = true; + for (auto& [id, task] : tasks_) { + (void)id; + task->cancelled->store(true, std::memory_order_relaxed); + } + } + workReady_.notify_all(); + if (worker_.joinable()) worker_.join(); +} + +EditorResult EditorTaskService::submit(std::string name, Work work) { + if (name.empty() || !work) + return EditorResult::error(EditorStatus::Rejected, RuleId("editor.task.invalid"), + "Task name and worker are required"); + auto task = std::make_shared(); + task->snapshot.name = std::move(name); + task->work = std::move(work); + TaskId id; + { + std::lock_guard lock(mutex_); + if (stopping_) + return EditorResult::error(EditorStatus::Rejected, RuleId("editor.task.stopping"), + "Task service is stopping"); + task->snapshot.id = TaskId("editor.task." + std::to_string(++sequence_)); + id = task->snapshot.id; + tasks_.emplace(id, task); + queue_.push_back(std::move(task)); + } + workReady_.notify_one(); + return EditorResult::applied(id); +} + +EditorResult EditorTaskService::cancel(const TaskId& task) { + std::lock_guard lock(mutex_); + auto found = tasks_.find(task); + if (found == tasks_.end()) + return EditorResult::error(EditorStatus::NotFound, RuleId("editor.task.not-found"), + "Task does not exist"); + auto& record = *found->second; + if (record.snapshot.state == EditorTaskState::Succeeded || record.snapshot.state == EditorTaskState::Failed || + record.snapshot.state == EditorTaskState::Cancelled) + return EditorResult::error(EditorStatus::Conflict, RuleId("editor.task.already-finished"), + "Finished tasks cannot be cancelled"); + record.cancelled->store(true, std::memory_order_relaxed); + if (record.snapshot.state == EditorTaskState::Queued) record.snapshot.state = EditorTaskState::Cancelled; + workReady_.notify_all(); + return EditorResult::applied(); +} + +EditorResult EditorTaskService::snapshot(const TaskId& task) const { + std::lock_guard lock(mutex_); + auto found = tasks_.find(task); + if (found == tasks_.end()) + return EditorResult::error(EditorStatus::NotFound, RuleId("editor.task.not-found"), + "Task does not exist"); + return EditorResult::applied(found->second->snapshot); +} + +bool EditorTaskService::waitIdle(std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return idle_.wait_for(lock, timeout, [this] { + if (!queue_.empty()) return false; + return std::none_of(tasks_.begin(), tasks_.end(), + [](const auto& entry) { return entry.second->snapshot.state == EditorTaskState::Running; }); + }); +} + +void EditorTaskService::workerLoop() { + while (true) { + std::shared_ptr task; + { + std::unique_lock lock(mutex_); + workReady_.wait(lock, [this] { return stopping_ || !queue_.empty(); }); + if (stopping_ && queue_.empty()) return; + task = queue_.front(); + queue_.pop_front(); + if (task->cancelled->load(std::memory_order_relaxed)) { + task->snapshot.state = EditorTaskState::Cancelled; + idle_.notify_all(); + continue; + } + task->snapshot.state = EditorTaskState::Running; + } + + EditorTaskOutcome outcome; + try { + EditorTaskContext context(task->cancelled, [this, weak = std::weak_ptr(task)](float value) { + if (auto record = weak.lock()) { + std::lock_guard lock(mutex_); + record->snapshot.progress = value; + } + }); + outcome = task->work(context); + } catch (const std::exception& error) { + outcome.status = EditorStatus::Failed; + outcome.diagnostics.push_back({RuleId("editor.task.exception"), DiagnosticSeverity::Error, error.what()}); + } catch (...) { + outcome.status = EditorStatus::Failed; + outcome.diagnostics.push_back( + {RuleId("editor.task.exception"), DiagnosticSeverity::Error, "Unknown worker exception"}); + } + + { + std::lock_guard lock(mutex_); + task->snapshot.output = std::move(outcome.output); + task->snapshot.diagnostics = std::move(outcome.diagnostics); + task->snapshot.progress = 1.0F; + if (task->cancelled->load(std::memory_order_relaxed) || outcome.status == EditorStatus::Cancelled) + task->snapshot.state = EditorTaskState::Cancelled; + else if (outcome.status == EditorStatus::Applied || outcome.status == EditorStatus::NoOp) + task->snapshot.state = EditorTaskState::Succeeded; + else + task->snapshot.state = EditorTaskState::Failed; + task->work = {}; + } + idle_.notify_all(); + } +} + +} // namespace eve::editor diff --git a/src/modules/editor/EditorTaskService.h b/src/modules/editor/EditorTaskService.h new file mode 100644 index 00000000..b01c0911 --- /dev/null +++ b/src/modules/editor/EditorTaskService.h @@ -0,0 +1,95 @@ +#pragma once + +#include "editor/EditorResult.h" +#include "editor/EditorValue.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace eve::editor { + +/** @brief Observable lifecycle of a background editor task. */ +enum class EditorTaskState { Queued, Running, Succeeded, Failed, Cancelled }; + +/** @brief Immutable status returned to UI, scripts and automation clients. */ +struct EditorTaskSnapshot { + TaskId id; + std::string name; + EditorTaskState state = EditorTaskState::Queued; + float progress = 0.0F; + EditorValue output; + std::vector diagnostics; +}; + +/** @brief Cooperative cancellation and progress API passed to worker code. */ +class EditorTaskContext { +public: + /** @brief True after cancellation was requested. */ + bool cancelled() const; + /** @brief Publish normalized progress in the inclusive range 0..1. */ + void reportProgress(float progress) const; + +private: + friend class EditorTaskService; + EditorTaskContext(std::shared_ptr cancelled, std::function progress); + std::shared_ptr cancelled_; + std::function progress_; +}; + +/** @brief Type-erased result produced by a background editor task. */ +struct EditorTaskOutcome { + EditorStatus status = EditorStatus::Applied; + EditorValue output; + std::vector diagnostics; +}; + +/** + * @brief Single-worker background queue shared by editor domains. + * + * Worker functions receive only immutable captures. State publication is + * synchronized and can be queried from the host/UI thread without callbacks + * into game objects from the worker. + */ +class EditorTaskService { +public: + using Work = std::function; + + /** @brief Start the editor background worker. */ + EditorTaskService(); + /** @brief Cooperatively cancel pending work and join the worker. */ + ~EditorTaskService(); + EditorTaskService(const EditorTaskService&) = delete; + EditorTaskService& operator=(const EditorTaskService&) = delete; + + /** @brief Queue background work and return its stable task identity. */ + EditorResult submit(std::string name, Work work); + /** @brief Request cooperative cancellation of queued or running work. */ + EditorResult cancel(const TaskId& task); + /** @brief Return a thread-safe task snapshot. */ + EditorResult snapshot(const TaskId& task) const; + /** @brief Wait until no task is queued or running; intended for shutdown and tests. */ + bool waitIdle(std::chrono::milliseconds timeout); + +private: + struct TaskRecord; + void workerLoop(); + + mutable std::mutex mutex_; + std::condition_variable workReady_; + std::condition_variable idle_; + std::unordered_map, StrongEditorIdHash> tasks_; + std::deque> queue_; + std::thread worker_; + bool stopping_ = false; + std::uint64_t sequence_ = 0; +}; + +} // namespace eve::editor diff --git a/src/modules/editor/EditorValueJson.cpp b/src/modules/editor/EditorValueJson.cpp new file mode 100644 index 00000000..14df8fa3 --- /dev/null +++ b/src/modules/editor/EditorValueJson.cpp @@ -0,0 +1,123 @@ +#include "editor/EditorValueJson.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace eve::editor { +namespace { + +Poco::Dynamic::Var toPoco(const EditorValue& value) { + return std::visit( + [](const auto& current) -> Poco::Dynamic::Var { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return Poco::Dynamic::Var{}; + } else if constexpr (std::is_same_v) { + Poco::JSON::Array::Ptr array(new Poco::JSON::Array()); + for (const EditorValue& entry : current) array->add(toPoco(entry)); + return Poco::Dynamic::Var(array); + } else if constexpr (std::is_same_v) { + Poco::JSON::Object::Ptr object(new Poco::JSON::Object()); + for (const auto& [key, entry] : current) object->set(key, toPoco(entry)); + return Poco::Dynamic::Var(object); + } else { + return Poco::Dynamic::Var(current); + } + }, + value.storage()); +} + +bool fromPoco(const Poco::Dynamic::Var& value, EditorValue& out, std::size_t depth = 0) { + if (depth > 64) return false; + if (value.isEmpty()) { + out = EditorValue{}; + return true; + } + if (value.type() == typeid(Poco::JSON::Object::Ptr)) { + const Poco::JSON::Object::Ptr object = value.extract(); + EditorValue::Object result; + for (const std::string& key : object->getNames()) { + EditorValue child; + if (!fromPoco(object->get(key), child, depth + 1)) return false; + result.emplace(key, std::move(child)); + } + out = EditorValue(std::move(result)); + return true; + } + if (value.type() == typeid(Poco::JSON::Array::Ptr)) { + const Poco::JSON::Array::Ptr array = value.extract(); + EditorValue::Array result; + result.reserve(array->size()); + for (std::size_t i = 0; i < array->size(); ++i) { + EditorValue child; + if (!fromPoco(array->get(static_cast(i)), child, depth + 1)) return false; + result.push_back(std::move(child)); + } + out = EditorValue(std::move(result)); + return true; + } + if (value.isBoolean()) { + out = EditorValue(value.convert()); + return true; + } + if (value.isInteger() || value.isSigned()) { + out = EditorValue(value.convert()); + return true; + } + if (value.isNumeric()) { + out = EditorValue(value.convert()); + return true; + } + if (value.isString()) { + out = EditorValue(value.convert()); + return true; + } + return false; +} + +std::uint64_t fnv1a(const std::string& text) { + std::uint64_t hash = 1469598103934665603ull; + for (const unsigned char byte : text) { + hash ^= byte; + hash *= 1099511628211ull; + } + return hash; +} + +} // namespace + +std::string editorValueToJson(const EditorValue& value) { + std::ostringstream stream; + Poco::JSON::Stringifier::stringify(toPoco(value), stream, 0, -1); + return stream.str(); +} + +EditorResult editorValueFromJson(const std::string& json) { + try { + Poco::JSON::Parser parser; + EditorValue value; + if (!fromPoco(parser.parse(json), value)) + return EditorResult::error(EditorStatus::Rejected, RuleId("editor.value.unsupported-json"), + "JSON contains a value outside the EditorValue protocol"); + return EditorResult::applied(std::move(value)); + } catch (const std::exception& error) { + return EditorResult::error(EditorStatus::Rejected, RuleId("editor.value.invalid-json"), + error.what()); + } +} + +std::string editorValueContentHash(const EditorValue& value) { + std::ostringstream stream; + stream << std::hex << std::setfill('0') << std::setw(16) << fnv1a(editorValueToJson(value)); + return stream.str(); +} + +} // namespace eve::editor diff --git a/src/modules/editor/EditorValueJson.h b/src/modules/editor/EditorValueJson.h new file mode 100644 index 00000000..f4932e0e --- /dev/null +++ b/src/modules/editor/EditorValueJson.h @@ -0,0 +1,23 @@ +#pragma once + +#include "editor/EditorResult.h" +#include "editor/EditorValue.h" + +#include + +namespace eve::editor { + +/** @brief Serialize an EditorValue to deterministic compact JSON. */ +std::string editorValueToJson(const EditorValue& value); + +/** + * @brief Parse JSON into the pointer-free EditorValue protocol tree. + * @param json UTF-8 JSON text. + * @return Parsed value or a structured parse diagnostic. + */ +EditorResult editorValueFromJson(const std::string& json); + +/** @brief Stable content hash derived from deterministic JSON serialization. */ +std::string editorValueContentHash(const EditorValue& value); + +} // namespace eve::editor diff --git a/src/modules/ui/EditorHost.cpp b/src/modules/ui/EditorHost.cpp index 5688a8aa..0c5c0d84 100644 --- a/src/modules/ui/EditorHost.cpp +++ b/src/modules/ui/EditorHost.cpp @@ -1168,12 +1168,9 @@ void EditorHost::frame() { I.inFrame = false; } -std::string EditorHost::setEditorValue(const std::string& editorId, const std::string& widgetId, - const std::string& jsonValue) { - if (!impl_) return "error: host not started"; - auto it = impl_->editors.find(editorId); - if (it == impl_->editors.end()) return "error: editor not found: " + editorId; - if (widgetId.empty()) return "error: missing widget"; +namespace { + +Var parseEditorValue(const std::string& jsonValue) { Var v; try { Poco::JSON::Parser p; @@ -1181,26 +1178,58 @@ std::string EditorHost::setEditorValue(const std::string& editorId, const std::s } catch (...) { // Poco's parser wants a top-level JSON value; some clients pass bare // numbers/booleans without JSON quoting. Handle those explicitly. - if (jsonValue == "true") v = Var(true); - else if (jsonValue == "false") v = Var(false); - else if (jsonValue == "null") v = Var(); + if (jsonValue == "true") + v = Var(true); + else if (jsonValue == "false") + v = Var(false); + else if (jsonValue == "null") + v = Var(); else { - char* end = nullptr; - const double d = std::strtod(jsonValue.c_str(), &end); + char* end = nullptr; + const double d = std::strtod(jsonValue.c_str(), &end); if (end && end != jsonValue.c_str() && *end == '\0') v = Var(d); else v = Var(jsonValue); // plain text } } + return v; +} + +} // namespace + +std::string EditorHost::setEditorValue(const std::string& editorId, const std::string& widgetId, + const std::string& jsonValue) { + if (!impl_) return "error: host not started"; + auto it = impl_->editors.find(editorId); + if (it == impl_->editors.end()) return "error: editor not found: " + editorId; + if (widgetId.empty()) return "error: missing widget"; + const Var v = parseEditorValue(jsonValue); setWidgetValue(*impl_, it->second, widgetId, v); return "ok"; } +std::string EditorHost::restoreEditorValue(const std::string& editorId, const std::string& widgetId, + const std::string& jsonValue) { + if (!impl_ || !impl_->vm) return "error: host not started"; + auto it = impl_->editors.find(editorId); + if (it == impl_->editors.end()) return "error: editor not found: " + editorId; + if (widgetId.empty()) return "error: missing widget"; + const Var v = parseEditorValue(jsonValue); + it->second.values[widgetId] = v; + auto vmIt = impl_->vms.find(it->second.vmName); + const auto widget = findWidget(it->second.view, widgetId); + if (vmIt != impl_->vms.end() && widget) { + const std::string bind = strOf(widget, "bind"); + if (!bind.empty()) writeVMVar(*impl_->vm, vmIt->second, bind, v); + } + return "ok"; +} + std::string EditorHost::consumeEvents(const std::string& editorId) { if (!impl_) return "[]"; - Array::Ptr arr = new Array(); - auto drain = [&](Editor& ed) { + Array::Ptr arr = new Array(); + auto drain = [&](Editor& ed) { for (const auto& e : ed.events) { Object::Ptr eo = new Object(); eo->set("editor", ed.id); @@ -1460,6 +1489,12 @@ void EditorHost::exposeScriptApi(ssq::VM& vm) { host.addFunc("runScript", [](std::string source) { return EditorHost::instance().runScript(source); }); + host.addFunc("reloadResource", [](std::string path) { + return EditorHost::instance().reloadResource(path); + }); + host.addFunc("hotReloadStatus", []() { + return EditorHost::instance().hotReloadStatus(); + }); } catch (...) { // eve table missing — the host surface is optional. } diff --git a/src/modules/ui/EditorHost.h b/src/modules/ui/EditorHost.h index 97e0ac41..fc784532 100644 --- a/src/modules/ui/EditorHost.h +++ b/src/modules/ui/EditorHost.h @@ -62,6 +62,9 @@ public: void syncBindings(); std::string setEditorValue(const std::string& editorId, const std::string& widgetId, const std::string& jsonValue); + /** @brief Restore a widget and its binding without emitting events or onChange. */ + std::string restoreEditorValue(const std::string& editorId, const std::string& widgetId, + const std::string& jsonValue); /** @brief Read and clear pending interaction events for one editor ("" = all). */ std::string consumeEvents(const std::string& editorId); /** @brief Last-frame widget rect JSON (for script drawing inside viewports). */ @@ -83,6 +86,12 @@ public: // ---- misc -------------------------------------------------------------- std::string runScript(const std::string& source); std::string capture(const std::string& path); + /** @brief Reload mcp.nut, mcp/*.nut, or editors/*.vm.nut|*.editor.json. */ + std::string reloadResource(const std::string& path); + /** @brief Watch count, reload counters and latest diagnostic as compact JSON. */ + std::string hotReloadStatus() const; + /** @brief Set active project watch count reported by hotReloadStatus(). */ + void setHotReloadWatchCount(int count); std::string status() const; void requestExit() { exitRequested_ = true; } bool exitRequested() const { return exitRequested_; } diff --git a/src/modules/ui/EditorHostCapabilities.cpp b/src/modules/ui/EditorHostCapabilities.cpp index 159846c7..70cf26dc 100644 --- a/src/modules/ui/EditorHostCapabilities.cpp +++ b/src/modules/ui/EditorHostCapabilities.cpp @@ -54,6 +54,15 @@ public: std::string runScript(const std::string &source) override { return EditorHost::instance().runScript(source); } + std::string reloadResource(const std::string &path) override { + return EditorHost::instance().reloadResource(path); + } + std::string hotReloadStatus() const override { + return EditorHost::instance().hotReloadStatus(); + } + void setHotReloadWatchCount(int count) override { + EditorHost::instance().setHotReloadWatchCount(count); + } void requestExit() override { EditorHost::instance().requestExit(); } bool exitRequested() override { return EditorHost::instance().exitRequested(); } void frame() override { EditorHost::instance().frame(); } diff --git a/src/modules/ui/EditorHostHotReload.cpp b/src/modules/ui/EditorHostHotReload.cpp new file mode 100644 index 00000000..891bff01 --- /dev/null +++ b/src/modules/ui/EditorHostHotReload.cpp @@ -0,0 +1,224 @@ +#include "ui/EditorHost.h" + +#if defined(EVENGINE_WEBGPU) && defined(__EMSCRIPTEN__) + +namespace eve::ui { + +std::string EditorHost::reloadResource(const std::string&) { + return "error: editor host hot reload unavailable on this platform"; +} + +std::string EditorHost::hotReloadStatus() const { + return "{\"enabled\":false,\"watchCount\":0,\"reloadCount\":0,\"failureCount\":0}"; +} + +void EditorHost::setHotReloadWatchCount(int) {} + +} // namespace eve::ui + +#else + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace eve::ui { +namespace { + +struct HotReloadState { + int watchCount = 0; + int reloadCount = 0; + int failureCount = 0; + std::string lastPath; + std::string lastResult = "idle"; +}; + +std::mutex g_hotReloadMutex; +HotReloadState g_hotReloadState; + +std::string stringify(const Poco::Dynamic::Var& value) { + std::ostringstream out; + Poco::JSON::Stringifier::stringify(value, out, 0, 0); + return out.str(); +} + +bool endsWith(const std::string& value, const std::string& suffix) { + return value.size() >= suffix.size() && value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +std::string normalizedRelativePath(const std::string& root, const std::string& input, std::filesystem::path* absolute) { + if (input.empty()) return {}; + std::error_code ec; + const std::filesystem::path canonicalRoot = std::filesystem::weakly_canonical(root, ec); + if (ec) return {}; + std::filesystem::path candidate(input); + if (!candidate.is_absolute()) candidate = canonicalRoot / candidate; + candidate = std::filesystem::weakly_canonical(candidate, ec); + if (ec) return {}; + + std::filesystem::path relative = std::filesystem::relative(candidate, canonicalRoot, ec); + if (ec || relative.empty()) return {}; + for (const auto& part : relative) { + if (part == "..") return {}; + } + if (absolute) *absolute = candidate; + std::string path = relative.generic_string(); + while (path.rfind("./", 0) == 0) path.erase(0, 2); + return path; +} + +std::string readText(const std::filesystem::path& path) { + std::ifstream in(path, std::ios::binary); + if (!in) return {}; + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); +} + +std::map editorValues(EditorHost& host, const std::string& editorId) { + std::map values; + try { + Poco::JSON::Parser parser; + Poco::JSON::Object::Ptr root = parser.parse(host.editorState(editorId)).extract(); + Poco::JSON::Array::Ptr editors = root ? root->getArray("editors") : nullptr; + Poco::JSON::Object::Ptr editor = editors && editors->size() ? editors->getObject(0) : nullptr; + Poco::JSON::Object::Ptr object = editor ? editor->getObject("values") : nullptr; + if (object) { + for (const auto& key : object->getNames()) values[key] = object->get(key); + } + } catch (...) { + } + return values; +} + +void restoreEditorValues(EditorHost& host, const std::string& editorId, + const std::map& values) { + for (const auto& [widget, value] : values) host.restoreEditorValue(editorId, widget, stringify(value)); +} + +std::string editorVmName(EditorHost& host, const std::string& editorId) { + try { + Poco::JSON::Parser parser; + Poco::JSON::Object::Ptr root = parser.parse(host.listEditors()).extract(); + Poco::JSON::Array::Ptr editors = root ? root->getArray("editors") : nullptr; + if (!editors) return {}; + for (size_t i = 0; i < editors->size(); ++i) { + Poco::JSON::Object::Ptr editor = editors->getObject(static_cast(i)); + if (editor && editor->optValue("id", "") == editorId) + return editor->optValue("vm", ""); + } + } catch (...) { + } + return {}; +} + +std::string editorIdFromPath(const std::string& path, const std::string& suffix) { + const std::string prefix = "editors/"; + if (path.rfind(prefix, 0) != 0 || !endsWith(path, suffix)) return {}; + const std::string id = path.substr(prefix.size(), path.size() - prefix.size() - suffix.size()); + return id.find('/') == std::string::npos ? id : std::string(); +} + +std::string editorIdFromSource(const std::string& source) { + try { + Poco::JSON::Parser parser; + const Poco::JSON::Object::Ptr root = parser.parse(source).extract(); + return root ? root->optValue("id", "") : std::string(); + } catch (...) { + return {}; + } +} + +void recordReload(const std::string& path, const std::string& result) { + std::lock_guard lock(g_hotReloadMutex); + g_hotReloadState.lastPath = path; + g_hotReloadState.lastResult = result; + if (result.rfind("error:", 0) == 0) + ++g_hotReloadState.failureCount; + else + ++g_hotReloadState.reloadCount; +} + +} // namespace + +std::string EditorHost::reloadResource(const std::string& input) { + if (!running()) return "error: host not started"; + std::filesystem::path absolute; + const std::string path = normalizedRelativePath(gameRoot(), input, &absolute); + if (path.empty()) return "error: resource path is outside project root"; + if (!std::filesystem::is_regular_file(absolute)) { + const std::string result = "error: resource not found: " + path; + recordReload(path, result); + return result; + } + + const std::string source = readText(absolute); + if (source.empty()) { + const std::string result = "error: resource is empty: " + path; + recordReload(path, result); + return result; + } + + std::string result; + const std::string editorSuffix = ".editor.json"; + const std::string vmSuffix = ".vm.nut"; + const std::string editorId = editorIdFromPath(path, editorSuffix); + const std::string vmEditorId = editorIdFromPath(path, vmSuffix); + if (!editorId.empty()) { + const std::string sourceId = editorIdFromSource(source); + if (sourceId.empty()) { + result = "error: editor resource requires a valid id"; + } else if (sourceId != editorId) { + result = "error: editor id must match resource filename: " + editorId; + } else { + const auto values = editorValues(*this, editorId); + result = applyEditor(source); + if (result.rfind("error:", 0) != 0) restoreEditorValues(*this, editorId, values); + } + } else if (!vmEditorId.empty()) { + const std::string vmName = editorVmName(*this, vmEditorId); + if (vmName.empty()) { + result = "error: editor ViewModel not found for: " + vmEditorId; + } else { + const auto values = editorValues(*this, vmEditorId); + result = registerVM(vmName, source); + if (result.rfind("error:", 0) != 0) restoreEditorValues(*this, vmEditorId, values); + } + } else if (path == "mcp.nut" || (path.rfind("mcp/", 0) == 0 && endsWith(path, ".nut"))) { + result = runScript(source); + } else { + result = "error: unsupported MCP host resource: " + path; + } + recordReload(path, result); + return result; +} + +std::string EditorHost::hotReloadStatus() const { + std::lock_guard lock(g_hotReloadMutex); + Poco::JSON::Object::Ptr out(new Poco::JSON::Object()); + out->set("enabled", g_hotReloadState.watchCount > 0); + out->set("watchCount", g_hotReloadState.watchCount); + out->set("reloadCount", g_hotReloadState.reloadCount); + out->set("failureCount", g_hotReloadState.failureCount); + out->set("lastPath", g_hotReloadState.lastPath); + out->set("lastResult", g_hotReloadState.lastResult); + return stringify(Poco::Dynamic::Var(out)); +} + +void EditorHost::setHotReloadWatchCount(int count) { + std::lock_guard lock(g_hotReloadMutex); + g_hotReloadState.watchCount = std::max(0, count); +} + +} // namespace eve::ui + +#endif diff --git a/src/modules/ui/UI.cpp b/src/modules/ui/UI.cpp index cd175de8..a4c4facd 100644 --- a/src/modules/ui/UI.cpp +++ b/src/modules/ui/UI.cpp @@ -2,6 +2,7 @@ #include "ui/DatabasePanel.h" #include "ui/EditorShell.h" #include "ui/EditorHostCapabilities.h" +#include "ui/UIAutomationCapabilities.h" #include "ui/Inspector.h" #include "ui/ScenePanel.h" @@ -148,7 +149,10 @@ void injectUIComponentClass(ssq::Table &eveTable) { Module_IMPL(UI, new UI()); -UI::UI() : backend_(createImGuiBackend()) { registerEditorHostCapabilities(); } +UI::UI() : backend_(createImGuiBackend()) { + registerEditorHostCapabilities(); + registerUIAutomationCapabilities(); +} UI::~UI() { shutdownBackend(); } bool UI::isBackendReady() const { return backend_ && backend_->isInitialized(); } diff --git a/src/modules/ui/UIAutomationCapabilities.cpp b/src/modules/ui/UIAutomationCapabilities.cpp new file mode 100644 index 00000000..aa448457 --- /dev/null +++ b/src/modules/ui/UIAutomationCapabilities.cpp @@ -0,0 +1,199 @@ +#include "ui/UIAutomationCapabilities.h" + +#include "common/Capability.h" +#include "common/ECS.h" +#include "common/UIAutomation.h" +#include "ui/UIHost.h" +#include "ui/UISystem.h" + +#include +#include +#include +#include + +#include +#include +#include + +namespace eve::ui { +namespace { + +const char* nodeTypeName(NodeType type) { + switch (type) { + case NodeType::Window: return "window"; + case NodeType::Text: return "text"; + case NodeType::Button: return "button"; + case NodeType::SameLine: return "sameLine"; + case NodeType::Group: return "group"; + case NodeType::Separator: return "separator"; + case NodeType::Checkbox: return "checkbox"; + case NodeType::Slider: return "slider"; + case NodeType::Progress: return "progress"; + case NodeType::InputText: return "inputText"; + case NodeType::CollapsingHeader: return "collapsingHeader"; + case NodeType::Child: return "child"; + case NodeType::Flex: return "flex"; + case NodeType::Spacer: return "spacer"; + case NodeType::Image: return "image"; + case NodeType::ImageButton: return "imageButton"; + case NodeType::Combo: return "combo"; + case NodeType::ScrollList: return "scrollList"; + case NodeType::Viewport: return "viewport"; + } + return "unknown"; +} + +bool isClickable(const UINode& node) { + return node.type == NodeType::Button || node.type == NodeType::ImageButton || node.handlerClick != 0; +} + +std::string stringify(const Poco::Dynamic::Var& value) { + std::ostringstream out; + Poco::JSON::Stringifier::stringify(value, out, 0, 0); + return out.str(); +} + +Poco::JSON::Object::Ptr nodeJson(const UIHost::Tree& tree, int index) { + Poco::JSON::Object::Ptr out(new Poco::JSON::Object()); + if (index < 0 || index >= static_cast(tree.nodes.size())) return out; + + const UINode& node = tree.nodes[static_cast(index)]; + out->set("type", nodeTypeName(node.type)); + out->set("id", node.id); + if (!node.key.empty()) out->set("key", node.key); + out->set("text", node.text); + out->set("visible", node.visible); + out->set("clickable", isClickable(node)); + if (node.type == NodeType::Checkbox) out->set("checked", node.checked); + if (node.type == NodeType::Slider || node.type == NodeType::Progress || node.type == NodeType::Combo) { + out->set("value", node.value); + out->set("min", node.minValue); + out->set("max", node.maxValue); + } + if (node.type == NodeType::InputText || node.type == NodeType::Combo) out->set("valueText", node.valueText); + + Poco::JSON::Array::Ptr children(new Poco::JSON::Array()); + for (int child = node.firstChild; child >= 0; child = tree.nodes[static_cast(child)].nextSibling) { + if (child >= static_cast(tree.nodes.size())) break; + children->add(nodeJson(tree, child)); + } + if (children->size() != 0) out->set("children", children); + return out; +} + +Poco::JSON::Object::Ptr hostJson(UIHost* host) { + Poco::JSON::Object::Ptr out(new Poco::JSON::Object()); + const auto meta = host->meta(); + const auto tree = host->tree(); + out->set("name", meta->name); + out->set("visible", meta->visible); + out->set("layer", meta->layer); + out->set("modal", meta->modal); + out->set("overlay", meta->overlay); + out->set("nodeCount", static_cast(tree->nodes.size())); + if (tree->root >= 0) out->set("root", nodeJson(*tree, tree->root)); + return out; +} + +std::vector hosts() { + std::vector result; + if (ecs::current()->getManager() == nullptr) return result; + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [meta, tree] = *it; + (void)tree; + if (meta->entity) result.push_back(meta->entity); + } + return result; +} + +struct WidgetMatch { + UIHost* host = nullptr; + UINode* node = nullptr; +}; + +std::vector findWidgets(const std::string& hostName, const std::string& widgetId) { + std::vector result; + for (UIHost* host : hosts()) { + if (!hostName.empty() && host->getName() != hostName) continue; + if (UINode* node = host->findById(widgetId)) result.push_back({host, node}); + } + return result; +} + +std::string matchError(const std::string& host, const std::string& widget, size_t matchCount) { + if (widget.empty()) return "error: missing widget"; + if (matchCount == 0) { + if (!host.empty()) return "error: widget not found: " + host + "/" + widget; + return "error: widget not found: " + widget; + } + return "error: widget id is ambiguous; specify host: " + widget; +} + +class UIAutomationImpl final : public eve::IUIAutomation { +public: + std::string tree(const std::string& hostName) const override { + Poco::JSON::Array::Ptr items(new Poco::JSON::Array()); + for (UIHost* host : hosts()) { + if (!hostName.empty() && host->getName() != hostName) continue; + items->add(hostJson(host)); + } + if (!hostName.empty() && items->size() == 0) return "error: UI host not found: " + hostName; + + Poco::JSON::Object::Ptr out(new Poco::JSON::Object()); + out->set("hostCount", static_cast(items->size())); + out->set("hosts", items); + return stringify(Poco::Dynamic::Var(out)); + } + + std::string get(const std::string& hostName, const std::string& widgetId) const override { + const std::vector matches = findWidgets(hostName, widgetId); + if (matches.size() != 1) return matchError(hostName, widgetId, matches.size()); + + Poco::JSON::Object::Ptr out = + nodeJson(*matches[0].host->tree(), nodeIndex(*matches[0].host->tree(), matches[0].node)); + out->set("host", matches[0].host->getName()); + out->set("hostVisible", matches[0].host->meta()->visible); + return stringify(Poco::Dynamic::Var(out)); + } + + std::string click(const std::string& hostName, const std::string& widgetId) override { + const std::vector matches = findWidgets(hostName, widgetId); + if (matches.size() != 1) return matchError(hostName, widgetId, matches.size()); + UIHost* host = matches[0].host; + UINode* node = matches[0].node; + if (!host->meta()->visible || !node->visible) + return "error: widget is not visible: " + host->getName() + "/" + widgetId; + if (!isClickable(*node)) return "error: widget is not clickable: " + host->getName() + "/" + widgetId; + + UIEvent event; + event.host = host; + event.hostName = host->getName(); + event.nodeId = node->id; + event.kind = "click"; + event.handlerIndex = node->handlerClick; + UISystem::pendingEvents().push_back(std::move(event)); + + Poco::JSON::Object::Ptr out(new Poco::JSON::Object()); + out->set("queued", true); + out->set("host", host->getName()); + out->set("widget", widgetId); + out->set("event", "click"); + return stringify(Poco::Dynamic::Var(out)); + } + +private: + static int nodeIndex(const UIHost::Tree& tree, const UINode* node) { + if (!node || tree.nodes.empty()) return -1; + return static_cast(node - tree.nodes.data()); + } +}; + +} // namespace + +void registerUIAutomationCapabilities() { + static UIAutomationImpl impl; + eve::cap::provide(&impl); +} + +} // namespace eve::ui diff --git a/src/modules/ui/UIAutomationCapabilities.h b/src/modules/ui/UIAutomationCapabilities.h new file mode 100644 index 00000000..bde68da3 --- /dev/null +++ b/src/modules/ui/UIAutomationCapabilities.h @@ -0,0 +1,5 @@ +#pragma once + +namespace eve::ui { +void registerUIAutomationCapabilities(); +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index c60f8cc2..5d6d7b09 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -178,6 +178,7 @@ file(GLOB all_test_cpp reload_script.cpp dap.cpp mcp.cpp + mcp_hot_reload.cpp console.cpp cmdline.cpp main.cpp diff --git a/test/UISystem.cpp b/test/UISystem.cpp index 2cbe681b..c23d3b2a 100644 --- a/test/UISystem.cpp +++ b/test/UISystem.cpp @@ -1,6 +1,9 @@ #include "zeroerr/assert.h" #include "zeroerr/unittest.h" +#include "common/Capability.h" +#include "common/UIAutomation.h" +#include "ui/UIAutomationCapabilities.h" #include "ui/UIHost.h" #include "ui/UISystem.h" #include "ui/Widget.h" @@ -77,6 +80,36 @@ TEST_CASE("UIHost.setPropsAndClickHandler") { CHECK_EQ(clicks, 1); } +TEST_CASE("UIAutomation.semanticTreeGetAndClick") { + int clicks = 0; + UIHost *host = UIHost::createHost("mcp-ui-test"); + host->setTree(window("Automation", {text("Ready", "status"), + button("Add Tree", "asset-tree", [&]() { ++clicks; })})); + + registerUIAutomationCapabilities(); + auto *automation = eve::cap::query(); + REQUIRE(automation != nullptr); + + const std::string tree = automation->tree("mcp-ui-test"); + CHECK(tree.find("\"name\":\"mcp-ui-test\"") != std::string::npos); + CHECK(tree.find("\"id\":\"asset-tree\"") != std::string::npos); + CHECK(tree.find("\"clickable\":true") != std::string::npos); + + const std::string widget = automation->get("mcp-ui-test", "asset-tree"); + CHECK(widget.find("\"type\":\"button\"") != std::string::npos); + CHECK(widget.find("\"host\":\"mcp-ui-test\"") != std::string::npos); + + const std::string queued = automation->click("mcp-ui-test", "asset-tree"); + CHECK(queued.find("\"queued\":true") != std::string::npos); + CHECK_EQ(clicks, 0); + UISystem::dispatchEvents(); + CHECK_EQ(clicks, 1); + CHECK(UISystem::consumeClickFor("mcp-ui-test") == "asset-tree"); + + CHECK(automation->click("mcp-ui-test", "status").find("error: widget is not clickable") == + 0); +} + TEST_CASE("UISystem.render.headlessImGuiWalk") { UIHost *h = UIHost::createHost("walk"); h->setTree(window("Panel", {text("Label", "label"), button("Btn", "btn")})); diff --git a/test/editor_automation_v2.cpp b/test/editor_automation_v2.cpp new file mode 100644 index 00000000..f6271e96 --- /dev/null +++ b/test/editor_automation_v2.cpp @@ -0,0 +1,63 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "common/Capability.h" +#include "common/EditorAutomation.h" +#include "editor/Editor.h" + +using namespace eve::editor; + +TEST_CASE("editor.v2.automation_capability_discovers_executes_plans_and_cancels") { + Editor editor; + CommandDescriptor immediate; + immediate.id = CommandId("park.add-tree"); + immediate.ownerModule = "park"; + immediate.displayName = "Add tree"; + REQUIRE(editor.commandService() + .registerCommand(std::move(immediate), + [](const CommandContext&, const EditorValue&) { + return EditorResult::applied(EditorValue("tree-added")); + }) + .accepted()); + + CommandDescriptor planned; + planned.id = CommandId("park.build-path"); + planned.ownerModule = "park"; + planned.displayName = "Build path"; + REQUIRE(editor.commandService() + .registerPlannedCommand( + std::move(planned), + [](const CommandRequest&) { + CommandPlan plan; + plan.summary = EditorValue("path-plan"); + return EditorResult::applied(std::move(plan)); + }, + [](const CommandRequest&, const CommandPlan&) { + TransactionReceipt receipt; + receipt.id = TransactionId("path-transaction"); + receipt.state = TransactionState::Committed; + return EditorResult::applied(std::move(receipt)); + }) + .accepted()); + + auto* automation = eve::cap::query(); + REQUIRE(automation != nullptr); + const std::string commands = automation->invoke("commands", "{}"); + CHECK(commands.find("park.add-tree") != std::string::npos); + CHECK(commands.find("park.build-path") != std::string::npos); + + const std::string executed = automation->invoke("execute", R"({"command":"park.add-tree","payload":{}})"); + CHECK(executed.find("\"status\":\"applied\"") != std::string::npos); + CHECK(executed.find("transactionId") != std::string::npos); + + const std::string plannedJson = automation->invoke("plan", R"({"command":"park.build-path","payload":{}})"); + CHECK(plannedJson.find("planId") != std::string::npos); + const std::size_t valueStart = plannedJson.find("park.build-path.plan."); + REQUIRE(valueStart != std::string::npos); + const std::size_t valueEnd = plannedJson.find('"', valueStart); + const std::string planId = plannedJson.substr(valueStart, valueEnd - valueStart); + const std::string cancelled = automation->invoke("cancel", "{\"planId\":\"" + planId + "\"}"); + CHECK(cancelled.find("\"status\":\"applied\"") != std::string::npos); + const std::string commitAfterCancel = automation->invoke("commit", "{\"planId\":\"" + planId + "\"}"); + CHECK(commitAfterCancel.find("not-found") != std::string::npos); +} diff --git a/test/editor_disk_asset_catalog_v2.cpp b/test/editor_disk_asset_catalog_v2.cpp new file mode 100644 index 00000000..7a826854 --- /dev/null +++ b/test/editor_disk_asset_catalog_v2.cpp @@ -0,0 +1,41 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "editor/EditorDiskAssetCatalog.h" + +#include +#include +#include + +using namespace eve::editor; + +TEST_CASE("editor.v2.disk_asset_sidecar_preserves_guid_across_move_and_poll") { + const auto root = + std::filesystem::temp_directory_path() / + ("eve_asset_catalog_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(root / "Content" / "Trees"); + { + std::ofstream source(root / "Content" / "Trees" / "Oak.asset", std::ios::binary); + source << "oak-v1"; + } + MemoryAssetDatabase database; + DiskAssetCatalog catalog(root, &database); + REQUIRE(catalog.writeSidecar("Trees/Oak.asset", AssetGuid("guid-oak"), "park.tree").accepted()); + auto first = catalog.scan(); + REQUIRE(first.accepted()); + CHECK_EQ(first.value->indexed, static_cast(1)); + CHECK_EQ(first.value->changed, static_cast(1)); + CHECK_EQ(database.find(AssetGuid("guid-oak")).value->logicalUri, "content://Trees/Oak.asset"); + + std::filesystem::create_directories(root / "Content" / "Plants"); + std::filesystem::rename(root / "Content" / "Trees" / "Oak.asset", root / "Content" / "Plants" / "Oak.asset"); + std::filesystem::rename(root / "Content" / "Trees" / "Oak.asset.evmeta", + root / "Content" / "Plants" / "Oak.asset.evmeta"); + auto moved = catalog.poll(); + REQUIRE(moved.accepted()); + CHECK_EQ(moved.value->changed, static_cast(1)); + CHECK_EQ(database.find(AssetGuid("guid-oak")).value->logicalUri, "content://Plants/Oak.asset"); + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} diff --git a/test/editor_disk_document_v2.cpp b/test/editor_disk_document_v2.cpp new file mode 100644 index 00000000..9c60a97c --- /dev/null +++ b/test/editor_disk_document_v2.cpp @@ -0,0 +1,104 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "editor/EditorDiskDocumentStore.h" +#include "editor/EditorValueJson.h" + +#include +#include +#include +#include +#include + +using namespace eve::editor; + +namespace { + +std::filesystem::path testRoot() { + return std::filesystem::temp_directory_path() / + ("eve_editor_disk_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); +} + +EditorValue versionValue(int version) { return EditorValue(EditorValue::Object{{"version", EditorValue(version)}}); } + +} // namespace + +TEST_CASE("editor.v2.disk_document_atomic_conflict_and_autosave") { + const std::filesystem::path root = testRoot(); + std::filesystem::create_directories(root); + DiskAtomicDocumentStore store(root); + CHECK(store.resolve("content://World/Park.evscene").generic_string() == + (root / "Content" / "World" / "Park.evscene").generic_string()); + CHECK(store.resolve("content://../outside.evscene").empty()); + + DocumentService documents(&store); + const DocumentKey key{DocumentKind::Scene, AssetGuid("park-disk")}; + auto opened = documents.open(key, "Park", "content://World/Park.evscene", versionValue(0)); + REQUIRE(opened.accepted()); + const DocumentId id = opened.value->id; + REQUIRE(documents.edit(id, versionValue(1)).accepted()); + const auto firstTicket = documents.requestSave(id); + REQUIRE(firstTicket.accepted()); + REQUIRE(documents.executeSave(*firstTicket.value).accepted()); + + const std::filesystem::path path = store.resolve("content://World/Park.evscene"); + CHECK(std::filesystem::is_regular_file(path)); + const auto savedOne = store.read("content://World/Park.evscene"); + REQUIRE(savedOne.accepted()); + CHECK(savedOne.value->content == versionValue(1)); + + // A timestamp-only external touch does not create a false conflict. + std::error_code ec; + std::filesystem::last_write_time(path, std::filesystem::file_time_type::clock::now(), ec); + CHECK(!ec); + REQUIRE(documents.edit(id, versionValue(2)).accepted()); + auto sameContentBase = documents.requestSave(id); + REQUIRE(documents.executeSave(*sameContentBase.value).accepted()); + + // Manual external content mutation with a stale envelope revision is still + // detected because CAS validates the computed disk content hash. + std::ifstream input(path, std::ios::binary); + const std::string json{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + auto envelope = editorValueFromJson(json); + REQUIRE(envelope.accepted()); + auto* envelopeObject = envelope.value->getIf(); + REQUIRE(envelopeObject != nullptr); + (*envelopeObject)["content"] = versionValue(99); + input.close(); + std::ofstream output(path, std::ios::binary | std::ios::trunc); + output << editorValueToJson(*envelope.value); + output.close(); + + REQUIRE(documents.edit(id, versionValue(3)).accepted()); + auto stale = documents.requestSave(id); + const auto conflict = documents.executeSave(*stale.value); + CHECK(static_cast(conflict.status) == static_cast(EditorStatus::Conflict)); + CHECK(static_cast(conflict.value->state) == static_cast(DocumentState::Conflict)); + + // A failed atomic replace leaves the previously persisted file intact. + DiskAtomicDocumentStore failureStore(root); + const auto beforeFailure = failureStore.read("content://World/Park.evscene"); + REQUIRE(beforeFailure.accepted()); + failureStore.setBeforeReplaceHook([](const std::filesystem::path&) { return false; }); + const auto failed = failureStore.compareAndSwap("content://World/Park.evscene", beforeFailure.value->revision, + beforeFailure.value->contentHash, versionValue(4)); + CHECK(static_cast(failed.status) == static_cast(EditorStatus::Failed)); + const auto afterFailure = store.read("content://World/Park.evscene"); + REQUIRE(afterFailure.accepted()); + CHECK(afterFailure.value->content == beforeFailure.value->content); + + // Drafts live outside Content and are recoverable only against the same base. + const auto formal = documents.snapshot(id); + REQUIRE(formal.value.has_value()); + AutosaveService autosave(&store, "park-project"); + const auto working = documents.content(id); + REQUIRE(working.value.has_value()); + REQUIRE(autosave.writeDraft(*formal.value, *working.value).accepted()); + const auto draft = autosave.readDraft(id); + REQUIRE(draft.accepted()); + CHECK(draft.value->content == versionValue(3)); + CHECK(autosave.shouldOfferRecovery(*draft.value, *formal.value)); + + documents.close(id); + std::filesystem::remove_all(root, ec); +} diff --git a/test/editor_document_asset_v2.cpp b/test/editor_document_asset_v2.cpp index 3dac3812..2bb2a35d 100644 --- a/test/editor_document_asset_v2.cpp +++ b/test/editor_document_asset_v2.cpp @@ -100,8 +100,12 @@ TEST_CASE("editor.v2.document_save_detects_external_revision_conflict") { CHECK(documents.executeSave(*firstTicket.value).accepted()); CHECK(documents.edit(id, EditorValue("local-v2")).accepted()); - auto staleTicket = documents.requestSave(id); - CHECK(store.compareAndSwap("content://Materials/Test.evmaterial", 1, EditorValue("external-v2")).accepted()); + auto staleTicket = documents.requestSave(id); + const auto persisted = store.read("content://Materials/Test.evmaterial"); + CHECK(store + .compareAndSwap("content://Materials/Test.evmaterial", 1, persisted.value->contentHash, + EditorValue("external-v2")) + .accepted()); auto conflict = documents.executeSave(*staleTicket.value); CHECK_EQ(static_cast(conflict.status), static_cast(EditorStatus::Conflict)); CHECK_EQ(static_cast(conflict.value->state), static_cast(DocumentState::Conflict)); @@ -152,3 +156,23 @@ TEST_CASE("editor.v2.import_place_save_and_reopen_headless_loop") { CHECK(reopenedDocument.accepted()); CHECK(reopened.content(reopenedDocument.value->id).value == std::optional(savedScene)); } + +TEST_CASE("editor.v2.asset_database_indexes_and_pages_100k_records") { + MemoryAssetDatabase database; + constexpr int count = 100000; + for (int index = 0; index < count; ++index) { + AssetRecord record; + record.guid = AssetGuid("asset-" + std::to_string(index)); + record.logicalUri = "content://Bulk/asset-" + std::to_string(index); + record.typeId = index % 2 == 0 ? "bulk.even" : "bulk.odd"; + REQUIRE(database.publish(std::move(record)).accepted()); + } + CHECK_EQ(database.generation(), static_cast(count)); + AssetQuery query; + query.typeIds = {"bulk.odd"}; + auto page = database.query(query, 1000, 128, database.generation()); + REQUIRE(page.accepted()); + CHECK_EQ(page.value->values.size(), static_cast(128)); + CHECK(page.value->hasMore); + CHECK(database.findByUri("content://Bulk/asset-99999").accepted()); +} diff --git a/test/editor_persistence_v2.cpp b/test/editor_persistence_v2.cpp new file mode 100644 index 00000000..df56ee86 --- /dev/null +++ b/test/editor_persistence_v2.cpp @@ -0,0 +1,36 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "editor/EditorPersistence.h" + +using namespace eve::editor; + +TEST_CASE("editor.v2.scene_document_and_savegame_share_one_persistence_contract") { + MemoryAtomicDocumentStore documentStore; + MemoryAtomicDocumentStore saveGameStore; + EditorPersistenceAdapter document(EditorPersistenceKind::SceneDocument, &documentStore, + "content://World/Park.evscene"); + EditorPersistenceAdapter saveGame(EditorPersistenceKind::SaveGame, &saveGameStore, "savegame://player-1/park"); + auto documentBase = document.load(); + auto saveBase = saveGame.load(); + REQUIRE(documentBase.accepted()); + REQUIRE(saveBase.accepted()); + + DomainOperation operation; + operation.type = "park.place-tree"; + operation.inverseType = "park.remove-tree"; + operation.target = TargetId("park-world"); + operation.payload = EditorValue::Object{{"species", EditorValue("oak")}}; + operation.hasInverse = true; + const EditorValue checkpoint(EditorValue::Object{{"treeCount", EditorValue(1)}}); + auto documentCommit = document.commit(*documentBase.value, checkpoint, {operation}); + auto saveCommit = saveGame.commit(*saveBase.value, checkpoint, {operation}); + REQUIRE(documentCommit.accepted()); + REQUIRE(saveCommit.accepted()); + CHECK(documentCommit.value->content == saveCommit.value->content); + CHECK_EQ(documentCommit.value->journal.size(), saveCommit.value->journal.size()); + CHECK_EQ(documentCommit.value->journal.front().type, saveCommit.value->journal.front().type); + + auto stale = saveGame.commit(*saveBase.value, checkpoint, {operation}); + CHECK_EQ(static_cast(stale.status), static_cast(EditorStatus::Conflict)); +} diff --git a/test/editor_runtime_manifest_v2.cpp b/test/editor_runtime_manifest_v2.cpp new file mode 100644 index 00000000..70d8ed88 --- /dev/null +++ b/test/editor_runtime_manifest_v2.cpp @@ -0,0 +1,76 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "editor/EditorRuntimeManifest.h" + +using namespace eve::editor; + +namespace { + +class RuntimeParkExtension final : public IGameEditorExtension { +public: + std::string ownerModule() const override { return "park.runtime"; } + void registerEditor(IEditorExtensionRegistry& registry) override { + CommandDescriptor command; + command.id = CommandId("park.place-tree"); + command.displayName = "Place tree"; + command.requiredFeatures = HostFeature::RuntimeWorld; + registry.registerCommand( + std::move(command), + [](const CommandContext&, const EditorValue&) { + return EditorResult::applied(EditorValue("tree")); + }, + ExtensionAudience::Player); + registry.registerTool({ToolId("park.tree-tool"), {}, "Tree", "Build", {}, ExtensionAudience::Player}); + registry.registerRule({RuleId("park.path-rule"), {}, ExtensionAudience::Player}); + } +}; + +AssetRecord runtimeAsset(const char* guid, const char* uri) { + AssetRecord record; + record.guid = AssetGuid(guid); + record.logicalUri = uri; + record.typeId = "park.asset"; + return record; +} + +} // namespace + +TEST_CASE("editor.v2.runtime_manifest_is_explicit_and_closes_asset_dependencies") { + EditorCommandService commands; + EditorExtensionRegistry extensions(&commands); + RuntimeParkExtension park; + REQUIRE(extensions.load(park).accepted()); + + MemoryAssetDatabase assets; + REQUIRE(assets + .publish(runtimeAsset("park", "content://Park"), + {{AssetGuid("park"), AssetGuid("tree"), DependencyKind::Hard, {}}, + {AssetGuid("park"), AssetGuid("source"), DependencyKind::EditorOnly, {}}}) + .accepted()); + REQUIRE(assets.publish(runtimeAsset("tree", "content://Tree")).accepted()); + REQUIRE(assets.publish(runtimeAsset("source", "content://TreeSource")).accepted()); + + RuntimeEditorManifest manifest; + manifest.commands = {CommandId("park.place-tree")}; + manifest.tools = {ToolId("park.tree-tool")}; + manifest.rules = {RuleId("park.path-rule")}; + manifest.rootAssets = {AssetGuid("park")}; + auto package = RuntimeEditorPublisher().publish(manifest, commands, extensions, assets); + REQUIRE(package.accepted()); + CHECK(package.value->profile.allowsCommand(CommandId("park.place-tree"))); + CHECK(!package.value->profile.allowsCommand(CommandId("developer.delete-source"))); + CHECK_EQ(package.value->tools.size(), static_cast(1)); + CHECK_EQ(package.value->rules.size(), static_cast(1)); + CHECK_EQ(package.value->assetClosure.size(), static_cast(2)); +} + +TEST_CASE("editor.v2.runtime_manifest_rejects_developer_features") { + EditorCommandService commands; + EditorExtensionRegistry extensions(&commands); + MemoryAssetDatabase assets; + RuntimeEditorManifest manifest; + manifest.features = HostFeature::RuntimeWorld | HostFeature::ArbitraryScript; + auto result = RuntimeEditorPublisher().publish(manifest, commands, extensions, assets); + CHECK_EQ(static_cast(result.status), static_cast(EditorStatus::Rejected)); +} diff --git a/test/editor_selection_v2.cpp b/test/editor_selection_v2.cpp new file mode 100644 index 00000000..18cc0b29 --- /dev/null +++ b/test/editor_selection_v2.cpp @@ -0,0 +1,27 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "editor/EditorSelection.h" + +using namespace eve::editor; + +TEST_CASE("editor.v2.selection_and_focus_are_presentation_neutral_channels") { + EditorSelectionService selection; + int notifications = 0; + REQUIRE(selection.subscribe("park.hud", [&](const SelectionSnapshot&) { ++notifications; }).accepted()); + SelectionItem tree{SelectionDomain::Scene, TargetId("park"), StableId("tree-1"), "park.tree"}; + auto selected = selection.set("world", {tree}, tree); + REQUIRE(selected.accepted()); + CHECK_EQ(selected.value->sequence, static_cast(1)); + CHECK(selection.snapshot("world").primary == tree); + CHECK_EQ(notifications, 1); + CHECK_EQ(static_cast(selection.set("world", {}, tree).status), static_cast(EditorStatus::Rejected)); + REQUIRE(selection.clear("world").accepted()); + CHECK_EQ(notifications, 2); + CHECK(selection.unsubscribe("park.hud")); + + EditorFocusService focus; + auto focused = focus.focus("world", StableId("viewport"), StableId("tree-1")); + REQUIRE(focused.accepted()); + CHECK(focus.snapshot("world").surface == StableId("viewport")); +} diff --git a/test/editor_task_diagnostic_v2.cpp b/test/editor_task_diagnostic_v2.cpp new file mode 100644 index 00000000..899bd182 --- /dev/null +++ b/test/editor_task_diagnostic_v2.cpp @@ -0,0 +1,86 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "editor/EditorDiagnostics.h" +#include "editor/EditorGraph.h" +#include "editor/EditorTaskService.h" + +#include +#include + +using namespace eve::editor; +using namespace std::chrono_literals; + +namespace { + +GraphNodeRecord asyncOutputNode() { + GraphNodeRecord node; + node.id = GraphNodeId("async-output"); + node.type = "material.output"; + node.properties = EditorValue::Object{}; + return node; +} + +} // namespace + +TEST_CASE("editor.v2.task_queue_is_queryable_and_cancellable") { + EditorTaskService tasks; + auto blocker = tasks.submit("blocker", [](const EditorTaskContext& context) { + while (!context.cancelled()) std::this_thread::sleep_for(1ms); + EditorTaskOutcome result; + result.status = EditorStatus::Cancelled; + return result; + }); + REQUIRE(blocker.accepted()); + auto queued = tasks.submit("queued", [](const EditorTaskContext&) { return EditorTaskOutcome{}; }); + REQUIRE(queued.accepted()); + CHECK(tasks.cancel(*queued.value).accepted()); + CHECK(tasks.cancel(*blocker.value).accepted()); + CHECK(tasks.waitIdle(2s)); + CHECK_EQ(static_cast(tasks.snapshot(*queued.value).value->state), + static_cast(EditorTaskState::Cancelled)); + CHECK_EQ(static_cast(tasks.snapshot(*blocker.value).value->state), + static_cast(EditorTaskState::Cancelled)); +} + +TEST_CASE("editor.v2.material_compile_runs_on_background_task_service") { + EditorTaskService tasks; + MaterialEditorService materials; + MaterialGraphDomain domain; + GraphDocument graph; + REQUIRE(graph.createNode(asyncOutputNode()).accepted()); + const DocumentId document("document:async-material"); + auto task = materials.compileAsync(document, graph.snapshot(domain.domain()), domain, tasks); + REQUIRE(task.accepted()); + CHECK(tasks.waitIdle(2s)); + auto result = materials.result(*task.value); + REQUIRE(result.accepted()); + CHECK_EQ(static_cast(result.value->status), static_cast(EditorStatus::Applied)); + CHECK(materials.publishPreview(document, graph.revision(), *task.value).accepted()); + CHECK(!materials.previewArtifact(document).empty()); +} + +TEST_CASE("editor.v2.validation_and_diagnostics_are_extension_owned") { + EditorValidationService validation; + REQUIRE(validation + .registerRule("park.plugin", RuleId("park.name-required"), + [](const ValidationRequest& request) { + if (request.subject.empty()) + return std::vector{{RuleId("park.name-required"), + DiagnosticSeverity::Error, + "Name is required"}}; + return std::vector{}; + }) + .accepted()); + auto found = validation.validate({"", EditorValue()}); + CHECK_EQ(found.size(), static_cast(1)); + + EditorDiagnosticService diagnostics; + diagnostics.publish("validation", found); + diagnostics.publish("compiler", {{RuleId("park.compile-warning"), DiagnosticSeverity::Warning, "Warning"}}); + CHECK_EQ(diagnostics.snapshot().size(), static_cast(2)); + diagnostics.clear("validation"); + CHECK_EQ(diagnostics.snapshot().size(), static_cast(1)); + CHECK_EQ(validation.unregisterOwner("park.plugin"), static_cast(1)); + CHECK(validation.validate({"", EditorValue()}).empty()); +} diff --git a/test/mcp.cpp b/test/mcp.cpp index 48168a67..55920441 100644 --- a/test/mcp.cpp +++ b/test/mcp.cpp @@ -5,6 +5,7 @@ #include "devtools/Debugger.hpp" #include "devtools/DevTool.hpp" #include "devtools/McpServer.hpp" +#include "editor/Editor.h" #include "ui/EditorHost.h" #include @@ -47,7 +48,7 @@ public: void send(const std::string& json) { const std::string frame = json + "\n"; - const int n = sock_.sendBytes(frame.data(), static_cast(frame.size())); + const int n = sock_.sendBytes(frame.data(), static_cast(frame.size())); REQUIRE(n == static_cast(frame.size())); } @@ -68,8 +69,7 @@ public: } Poco::JSON::Object::Ptr recv(int timeoutMs = 2000) { - const auto deadline = - std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); while (std::chrono::steady_clock::now() < deadline) { McpServer::instance().poll(); char buf[8192]; @@ -90,15 +90,14 @@ public: if (!line.empty() && line.back() == '\r') line.pop_back(); if (line.empty()) continue; Poco::JSON::Parser parser; - auto var = parser.parse(line); + auto var = parser.parse(line); return var.extract(); } return nullptr; } Poco::JSON::Object::Ptr expectResult(int id, int timeoutMs = 2000) { - const auto deadline = - std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); while (std::chrono::steady_clock::now() < deadline) { auto msg = recv(50); if (!msg) continue; @@ -113,9 +112,8 @@ public: if (msgId == id && msg->has("result")) return msg; if (msgId == id && msg->has("error")) { // Fail with the server error message visible in the assertion. - auto err = msg->getObject("error"); - const std::string em = - err ? err->optValue("message", "error") : "error"; + auto err = msg->getObject("error"); + const std::string em = err ? err->optValue("message", "error") : "error"; REQUIRE(em.empty()); // expected no error; message shown if present return nullptr; } @@ -143,6 +141,19 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") { dt.attach(vm, false); dt.exposeScriptApi(vm); + eve::editor::Editor editor; + eve::editor::CommandDescriptor editorCommand; + editorCommand.id = eve::editor::CommandId("mcp.test-command"); + editorCommand.ownerModule = "mcp.test"; + editorCommand.displayName = "MCP test command"; + REQUIRE(editor.commandService() + .registerCommand(std::move(editorCommand), + [](const eve::editor::CommandContext&, const eve::editor::EditorValue&) { + return eve::editor::EditorResult::applied( + eve::editor::EditorValue("executed")); + }) + .accepted()); + const int port = mcp.listen(0); REQUIRE(port > 0); CHECK(ai.mcpPort() == port); @@ -168,24 +179,31 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") { REQUIRE(toolsMsg); auto tools = toolsMsg->getObject("result")->getArray("tools"); REQUIRE(tools); - bool foundStatus = false; - bool foundEval = false; - bool foundScene = false; - bool foundProc = false; - bool foundPhys = false; - bool foundShot = false; - bool foundParticles = false; - bool foundAudio = false; - bool foundVision = false; - bool foundVcfg = false; - bool foundSdMod = false; - bool foundSdInfo = false; - bool foundCamGen = false; - bool foundReset = false; - bool foundSdStat = false; - bool foundSdInst = false; + bool foundStatus = false; + bool foundEval = false; + bool foundScene = false; + bool foundProc = false; + bool foundPhys = false; + bool foundShot = false; + bool foundParticles = false; + bool foundAudio = false; + bool foundVision = false; + bool foundVcfg = false; + bool foundSdMod = false; + bool foundSdInfo = false; + bool foundCamGen = false; + bool foundReset = false; + bool foundSdStat = false; + bool foundSdInst = false; + bool foundUiTree = false; + bool foundUiGet = false; + bool foundUiClick = false; + bool foundHostReload = false; + bool foundHotReloadStatus = false; + bool foundEditorCommands = false; + bool foundEditorPlan = false; for (size_t i = 0; i < tools->size(); ++i) { - auto t = tools->getObject(static_cast(i)); + auto t = tools->getObject(static_cast(i)); const std::string name = t->getValue("name"); if (name == "eve_status") foundStatus = true; if (name == "eve_eval") foundEval = true; @@ -203,6 +221,13 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") { if (name == "eve_scene_reset") foundReset = true; if (name == "eve_scene_director_status") foundSdStat = true; if (name == "eve_scene_director_install") foundSdInst = true; + if (name == "eve_ui_tree") foundUiTree = true; + if (name == "eve_ui_get") foundUiGet = true; + if (name == "eve_ui_click") foundUiClick = true; + if (name == "eve_host_resource_reload") foundHostReload = true; + if (name == "eve_host_hot_reload_status") foundHotReloadStatus = true; + if (name == "eve_editor_commands") foundEditorCommands = true; + if (name == "eve_editor_plan") foundEditorPlan = true; } CHECK(foundStatus); CHECK(foundEval); @@ -220,9 +245,15 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") { CHECK(foundReset); CHECK(foundSdStat); CHECK(foundSdInst); + CHECK(foundUiTree); + CHECK(foundUiGet); + CHECK(foundUiClick); + CHECK(foundHostReload); + CHECK(foundHotReloadStatus); + CHECK(foundEditorCommands); + CHECK(foundEditorPlan); - client.sendRequest(3, "tools/call", - "{\"name\":\"eve_status\",\"arguments\":{}}"); + client.sendRequest(3, "tools/call", "{\"name\":\"eve_status\",\"arguments\":{}}"); auto statusMsg = client.expectResult(3); REQUIRE(statusMsg); auto content = statusMsg->getObject("result")->getArray("content"); @@ -232,8 +263,7 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") { CHECK(text.find("\"attached\":true") != std::string::npos); CHECK(text.find("\"mcpPort\":") != std::string::npos); - client.sendRequest(4, "tools/call", - "{\"name\":\"eve_ai_note\",\"arguments\":{\"text\":\"hello agent\"}}"); + client.sendRequest(4, "tools/call", "{\"name\":\"eve_ai_note\",\"arguments\":{\"text\":\"hello agent\"}}"); REQUIRE(client.expectResult(4)); const std::string log = ai.formatLog(16); CHECK(log.find("hello agent") != std::string::npos); @@ -244,8 +274,7 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") { REQUIRE(resMsg); auto contents = resMsg->getObject("result")->getArray("contents"); REQUIRE(contents); - CHECK(contents->getObject(0)->getValue("text").find("hello agent") != - std::string::npos); + CHECK(contents->getObject(0)->getValue("text").find("hello agent") != std::string::npos); client.sendRequest(6, "prompts/list"); auto promptsMsg = client.expectResult(6); @@ -270,41 +299,39 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") { CHECK(sceneText.find("{") != std::string::npos); const std::string procText = textOf(8, "{\"name\":\"eve_procgen_recipes\",\"arguments\":{}}"); CHECK(procText.find("{") != std::string::npos); - const std::string physText = - textOf(9, "{\"name\":\"eve_physics_list_worlds\",\"arguments\":{}}"); + const std::string physText = textOf(9, "{\"name\":\"eve_physics_list_worlds\",\"arguments\":{}}"); CHECK(physText.find("[") != std::string::npos); // Vision config tool returns JSON config (no network call, no crash). - const std::string vcfgText = - textOf(10, "{\"name\":\"eve_render_vision_config\",\"arguments\":{}}"); + const std::string vcfgText = textOf(10, "{\"name\":\"eve_render_vision_config\",\"arguments\":{}}"); CHECK(vcfgText.find("{") != std::string::npos); CHECK(vcfgText.find("apiKeySet") != std::string::npos); // Scene-director tools: auto-install the Squirrel kit into the plain test VM // and drive it over MCP (no Graphics / modules mounted -> geometry-only ops). - const std::string sdStat = - textOf(11, "{\"name\":\"eve_scene_director_status\",\"arguments\":{}}"); + const std::string sdStat = textOf(11, "{\"name\":\"eve_scene_director_status\",\"arguments\":{}}"); CHECK(sdStat.find("\"installed\":true") != std::string::npos); const std::string resetText = textOf(12, "{\"name\":\"eve_scene_reset\",\"arguments\":{}}"); CHECK(resetText.find("ok") != std::string::npos); - const std::string infoText = - textOf(13, "{\"name\":\"eve_scene_info\",\"arguments\":{}}"); + const std::string infoText = textOf(13, "{\"name\":\"eve_scene_info\",\"arguments\":{}}"); CHECK(infoText.find("\"count\":0") != std::string::npos); - const std::string camText = - textOf(14, "{\"name\":\"eve_camera_generate\",\"arguments\":{\"count\":2}}"); + const std::string camText = textOf(14, "{\"name\":\"eve_camera_generate\",\"arguments\":{\"count\":2}}"); CHECK(camText.find("\"cam_0\"") != std::string::npos); - const std::string modInfo = - textOf(15, "{\"name\":\"eve_scene_modify\",\"arguments\":{\"action\":\"info\"}}"); + const std::string modInfo = textOf(15, "{\"name\":\"eve_scene_modify\",\"arguments\":{\"action\":\"info\"}}"); CHECK(modInfo.find("\"count\":0") != std::string::npos); // Unknown action must surface as a controlled error, not crash the server. - const std::string modBad = - textOf(16, "{\"name\":\"eve_scene_modify\",\"arguments\":{\"action\":\"no_such\"}}"); - const bool modBadHasError = modBad.find("error") != std::string::npos || - modBad.find("unknown action") != std::string::npos; + const std::string modBad = textOf(16, "{\"name\":\"eve_scene_modify\",\"arguments\":{\"action\":\"no_such\"}}"); + const bool modBadHasError = + modBad.find("error") != std::string::npos || modBad.find("unknown action") != std::string::npos; CHECK(modBadHasError); + const std::string editorCommands = textOf(17, "{\"name\":\"eve_editor_commands\",\"arguments\":{}}"); + CHECK(editorCommands.find("mcp.test-command") != std::string::npos); + const std::string editorExecuted = + textOf(18, "{\"name\":\"eve_editor_execute\",\"arguments\":{\"command\":\"mcp.test-command\",\"payload\":{}}}"); + CHECK(editorExecuted.find("\"status\":\"applied\"") != std::string::npos); mcp.stop(); dt.detach(); @@ -334,13 +361,11 @@ TEST_CASE("devtools.mcp.evalAndPause") { REQUIRE(client.expectResult(1)); client.sendNotification("notifications/initialized"); - client.sendRequest(2, "tools/call", - "{\"name\":\"eve_eval\",\"arguments\":{\"expression\":\"score\"}}"); + client.sendRequest(2, "tools/call", "{\"name\":\"eve_eval\",\"arguments\":{\"expression\":\"score\"}}"); auto evalMsg = client.expectResult(2); REQUIRE(evalMsg); const std::string evalText = - evalMsg->getObject("result")->getArray("content")->getObject(0)->getValue( - "text"); + evalMsg->getObject("result")->getArray("content")->getObject(0)->getValue("text"); CHECK(evalText.find("42") != std::string::npos); client.sendRequest(3, "tools/call", "{\"name\":\"eve_pause\",\"arguments\":{}}"); @@ -403,16 +428,15 @@ TEST_CASE("devtools.mcp.stdioTransport") { } TEST_CASE("devtools.mcp.hostEditorBinding") { - auto& mcp = McpServer::instance(); - auto& dt = DevTool::instance(); + auto& mcp = McpServer::instance(); + auto& dt = DevTool::instance(); auto& host = eve::ui::EditorHost::instance(); mcp.stop(); dt.detach(); host.stop(); const auto tmp = std::filesystem::temp_directory_path() / - ("eve_host_test_" + - std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + ("eve_host_test_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); std::filesystem::create_directories(tmp); ssq::VM vm(1024, ssq::Libs::ALL); @@ -451,14 +475,17 @@ TEST_CASE("devtools.mcp.hostEditorBinding") { "};"; std::string vmSourceEscaped; for (char c : vmSource) { - if (c == '\\') vmSourceEscaped += "\\\\"; - else if (c == '"') vmSourceEscaped += "\\\""; - else vmSourceEscaped += c; + if (c == '\\') + vmSourceEscaped += "\\\\"; + else if (c == '"') + vmSourceEscaped += "\\\""; + else + vmSourceEscaped += c; } - const std::string vmText = textOf( - 2, "{\"name\":\"eve_host_vm_register\",\"arguments\":{\"name\":\"TerrainVM\"," - "\"source\":\"" + - vmSourceEscaped + "\"}}"); + const std::string vmText = textOf(2, + "{\"name\":\"eve_host_vm_register\",\"arguments\":{\"name\":\"TerrainVM\"," + "\"source\":\"" + + vmSourceEscaped + "\"}}"); CHECK(vmText == "ok"); const std::string editorJson = @@ -468,52 +495,46 @@ TEST_CASE("devtools.mcp.hostEditorBinding") { "\"value\":5,\"bind\":\"vm.brushSize\",\"onChange\":\"vm.onChange\"}," "{\"type\":\"button\",\"id\":\"apply\",\"label\":\"Apply\",\"command\":\"vm.apply\"}" "]}"; - const std::string applyText = textOf( - 3, "{\"name\":\"eve_host_editor_apply\",\"arguments\":{\"editor\":" + editorJson + "}}"); + const std::string applyText = + textOf(3, "{\"name\":\"eve_host_editor_apply\",\"arguments\":{\"editor\":" + editorJson + "}}"); CHECK(applyText.find("\"id\":\"terrain\"") != std::string::npos); CHECK(applyText.find("\"brushSize\"") != std::string::npos); // set_value must write the VM, fire onChange, and emit a change event. - const std::string setText = textOf( - 4, "{\"name\":\"eve_host_editor_set_value\",\"arguments\":{" - "\"editor\":\"terrain\",\"widget\":\"brushSize\",\"value\":12}}"); + const std::string setText = textOf(4, + "{\"name\":\"eve_host_editor_set_value\",\"arguments\":{" + "\"editor\":\"terrain\",\"widget\":\"brushSize\",\"value\":12}}"); CHECK(setText == "ok"); - const std::string stateText = textOf( - 5, "{\"name\":\"eve_host_editor_state\",\"arguments\":{\"id\":\"terrain\"}}"); + const std::string stateText = textOf(5, "{\"name\":\"eve_host_editor_state\",\"arguments\":{\"id\":\"terrain\"}}"); CHECK(stateText.find("\"brushSize\":12") != std::string::npos); - const std::string evalText = textOf( - 6, "{\"name\":\"eve_eval\",\"arguments\":{\"expression\":\"TerrainVM.brushSize\"}}"); + const std::string evalText = + textOf(6, "{\"name\":\"eve_eval\",\"arguments\":{\"expression\":\"TerrainVM.brushSize\"}}"); CHECK(evalText.find("12") != std::string::npos); - const std::string countText = textOf( - 7, "{\"name\":\"eve_eval\",\"arguments\":{\"expression\":\"TerrainVM.changedCount\"}}"); + const std::string countText = + textOf(7, "{\"name\":\"eve_eval\",\"arguments\":{\"expression\":\"TerrainVM.changedCount\"}}"); CHECK(countText.find("1") != std::string::npos); - const std::string eventsText = textOf( - 8, "{\"name\":\"eve_host_events\",\"arguments\":{\"editor\":\"terrain\"}}"); + const std::string eventsText = textOf(8, "{\"name\":\"eve_host_events\",\"arguments\":{\"editor\":\"terrain\"}}"); CHECK(eventsText.find("\"type\":\"change\"") != std::string::npos); CHECK(eventsText.find("\"brushSize\"") != std::string::npos); // Persistence: save -> unload -> reload from disk. - CHECK(textOf(9, "{\"name\":\"eve_host_editor_save\",\"arguments\":{\"id\":\"terrain\"}}") == - "ok"); + CHECK(textOf(9, "{\"name\":\"eve_host_editor_save\",\"arguments\":{\"id\":\"terrain\"}}") == "ok"); CHECK(std::filesystem::is_regular_file(tmp / "editors" / "terrain.editor.json")); CHECK(std::filesystem::is_regular_file(tmp / "editors" / "terrain.vm.nut")); - CHECK(textOf(10, "{\"name\":\"eve_host_editor_unload\",\"arguments\":{\"id\":\"terrain\"}}") == - "ok"); - const std::string listText = - textOf(11, "{\"name\":\"eve_host_editor_list\",\"arguments\":{}}"); + CHECK(textOf(10, "{\"name\":\"eve_host_editor_unload\",\"arguments\":{\"id\":\"terrain\"}}") == "ok"); + const std::string listText = textOf(11, "{\"name\":\"eve_host_editor_list\",\"arguments\":{}}"); CHECK(listText.find("terrain") == std::string::npos); host.loadEditorsFromDisk(); - const std::string reloadList = - textOf(12, "{\"name\":\"eve_host_editor_list\",\"arguments\":{}}"); + const std::string reloadList = textOf(12, "{\"name\":\"eve_host_editor_list\",\"arguments\":{}}"); CHECK(reloadList.find("terrain") != std::string::npos); - const std::string reloadState = textOf( - 13, "{\"name\":\"eve_host_editor_state\",\"arguments\":{\"id\":\"terrain\"}}"); + const std::string reloadState = + textOf(13, "{\"name\":\"eve_host_editor_state\",\"arguments\":{\"id\":\"terrain\"}}"); // Persistence stores the original View + VM source; runtime values reset. CHECK(reloadState.find("\"brushSize\":5") != std::string::npos); diff --git a/test/mcp_hot_reload.cpp b/test/mcp_hot_reload.cpp new file mode 100644 index 00000000..a3c14008 --- /dev/null +++ b/test/mcp_hot_reload.cpp @@ -0,0 +1,103 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "ui/EditorHost.h" + +#include +#include + +#include + +#include +#include +#include +#include + +namespace { + +void writeText(const std::filesystem::path& path, const std::string& text) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + REQUIRE(out.is_open()); + out << text; + REQUIRE(out.good()); +} + +Poco::JSON::Object::Ptr parseObject(const std::string& json) { + Poco::JSON::Parser parser; + return parser.parse(json).extract(); +} + +} // namespace + +TEST_CASE("devtools.mcp.hostResourceHotReload") { + auto& host = eve::ui::EditorHost::instance(); + host.stop(); + + const auto root = + std::filesystem::temp_directory_path() / + ("eve_mcp_hot_reload_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + const auto editorPath = root / "editors" / "terrain.editor.json"; + const auto vmPath = root / "editors" / "terrain.vm.nut"; + const auto mcpPath = root / "mcp.nut"; + + writeText( + editorPath, + R"({"id":"terrain","title":"Terrain v1","vm":"TerrainVM","children":[{"type":"slider","id":"brushSize","value":5,"bind":"vm.brushSize","onChange":"vm.onChange"}]})"); + writeText( + vmPath, + R"(::TerrainVM <- { version = 1, brushSize = 5, changedCount = 0, onChange = function(widget, value) { this.changedCount++; } };)"); + writeText(mcpPath, R"(::mcpReloadVersion <- 1;)"); + + ssq::VM vm(1024, ssq::Libs::ALL); + host.start(vm, root.string(), /*allowWindow=*/false); + + REQUIRE(host.listEditors().find("terrain") != std::string::npos); + REQUIRE(host.setEditorValue("terrain", "brushSize", "12") == "ok"); + CHECK(vm.find("TerrainVM").toTable().find("brushSize").toInt() == 12); + CHECK(vm.find("TerrainVM").toTable().find("changedCount").toInt() == 1); + CHECK(host.consumeEvents("terrain").find("change") != std::string::npos); + + writeText( + vmPath, + R"(::TerrainVM <- { version = 2, brushSize = 5, changedCount = 0, onChange = function(widget, value) { this.changedCount++; } };)"); + CHECK(host.reloadResource("editors/terrain.vm.nut") == "ok"); + auto terrainVm = vm.find("TerrainVM").toTable(); + CHECK(terrainVm.find("version").toInt() == 2); + CHECK(terrainVm.find("brushSize").toInt() == 12); + CHECK(terrainVm.find("changedCount").toInt() == 0); + CHECK(host.consumeEvents("terrain") == "[]"); + + writeText(vmPath, "::TerrainVM <- { broken = ; };"); + CHECK(host.reloadResource("editors/terrain.vm.nut").rfind("error:", 0) == 0); + terrainVm = vm.find("TerrainVM").toTable(); + CHECK(terrainVm.find("version").toInt() == 2); + CHECK(terrainVm.find("brushSize").toInt() == 12); + + writeText( + editorPath, + R"({"id":"terrain","title":"Terrain v2","vm":"TerrainVM","children":[{"type":"slider","id":"brushSize","value":3,"bind":"vm.brushSize","onChange":"vm.onChange"},{"type":"text","id":"status","value":"ready"}]})"); + CHECK(host.reloadResource("editors/terrain.editor.json").rfind("error:", 0) != 0); + CHECK(host.listEditors().find("Terrain v2") != std::string::npos); + CHECK(host.editorState("terrain").find("\"brushSize\":12") != std::string::npos); + CHECK(host.consumeEvents("terrain") == "[]"); + + writeText(editorPath, R"({"id":"other","title":"Wrong id","children":[]})"); + CHECK(host.reloadResource("editors/terrain.editor.json").find("must match") != std::string::npos); + CHECK(host.listEditors().find("Terrain v2") != std::string::npos); + + CHECK(host.reloadResource("mcp.nut") == "ok"); + CHECK(vm.find("mcpReloadVersion").toInt() == 1); + CHECK(host.reloadResource("../outside.nut").find("outside project root") != std::string::npos); + + host.setHotReloadWatchCount(1); + const auto status = parseObject(host.hotReloadStatus()); + REQUIRE(status); + CHECK(status->getValue("enabled")); + CHECK(status->getValue("watchCount") == 1); + CHECK(status->getValue("reloadCount") >= 3); + CHECK(status->getValue("failureCount") >= 2); + + host.stop(); + std::filesystem::remove_all(root); +}