Compare commits

..

42 Commits

Author SHA1 Message Date
menghao 6dca066a87 Merge branch 'master' into codex/game-agent-run
Project CI / Repository checks (pull_request) Successful in 1m6s
Project CI / Frontend tests (pull_request) Successful in 3m15s
Project CI / Backend tests (pull_request) Successful in 4m11s
Project CI / Native shell tests (pull_request) Successful in 16m43s
2026-08-07 19:53:30 +08:00
menghao 02ae3d3cad 合并最新主线并解决共享记忆冲突
Project CI / Backend tests (pull_request) Failing after 8s
Project CI / Repository checks (pull_request) Failing after 7s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Successful in 3m0s
同步 origin/master 的 CI 与生成结果原子提交改动

保留双方 pitfalls 排障记录
2026-08-07 19:50:14 +08:00
kdletters 1bc8ccd077 合并外部 MCP 项目选择修复
Project CI / Frontend tests (push) Successful in 3m2s
Project CI / Backend tests (push) Successful in 3m29s
Project CI / Repository checks (push) Successful in 1m3s
Project CI / Native shell tests (push) Failing after 9m33s
合并紧凑项目列表与封面稳定引用
合并 MCP 请求体参数提示与回归测试
同步 External v1 契约、Skill 与项目记忆
2026-08-07 19:41:27 +08:00
kdletters e099a519ba 修复外部 MCP 项目选择与参数提示
项目列表新增紧凑摘要和最新封面稳定引用
MCP 固定摘要视图并前置校验必填请求体
同步 External v1 OpenAPI、Skill 与项目记忆
补充十九项目超限和四个 415 工具回归测试
2026-08-07 19:21:34 +08:00
menghao d04339ad7f 修复自主完成合同测试竞态
Project CI / Repository checks (pull_request) Successful in 1m23s
Project CI / Native shell tests (pull_request) Successful in 12m15s
Project CI / Frontend tests (pull_request) Successful in 3m45s
Project CI / Backend tests (pull_request) Successful in 4m12s
隔离 readiness 状态投影,避免测试重复调度 preview-playtest child
补充 Runtime 终态投影测试的竞态避坑说明
2026-08-07 13:46:16 +08:00
menghao 6fe52a92bb 修复合并后的仓库检查失败
Project CI / Repository checks (pull_request) Successful in 1m8s
Project CI / Frontend tests (pull_request) Successful in 3m2s
Project CI / Backend tests (pull_request) Successful in 3m42s
Project CI / Native shell tests (pull_request) Failing after 9m28s
删除重复维护页原子替换函数并恢复符号链接防护
清理开发栈未使用变量与资源投影正则告警
统一相关测试导入与开发栈导出顺序
2026-08-07 12:44:29 +08:00
menghao 75e3f1d041 合并远端主线并解决游戏创作冲突
Project CI / Repository checks (pull_request) Failing after 33s
Project CI / Backend tests (pull_request) Successful in 3m45s
Project CI / Native shell tests (pull_request) Successful in 13m59s
Project CI / Frontend tests (pull_request) Successful in 3m1s
融合主线 Runtime、安全修复与提示词资源
保留可运行版本、资源替换和数值微调能力
修复资源画布布局、焦点与依赖关系融合
补齐 manifest 并发保护与 Tetris 场景兼容
同步前端测试、Rust 回归测试与权威文档
2026-08-07 12:15:12 +08:00
menghao 8a4b71bf8e 修复资源画布溢出与模板资源投影
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m3s
Project CI / Native shell tests (pull_request) Successful in 10m23s
闭合资源画布横向收缩链并增加内部滚动条

过滤任务产物通配模板避免生成无效资源卡

补充布局与资源投影回归测试

同步工作台文档与共享排障记忆
2026-08-07 10:53:41 +08:00
menghao 53e37ea361 支持App Run并行开发并修复资源画布
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m28s
Project CI / Native shell tests (pull_request) Failing after 9m27s
新增App Run独立开发profile并隔离端口、数据库、Tauri身份与AppData
常驻资源排列切换并避免布局状态挤压操作入口
显式扩展资源画布横向滚动范围并收敛依赖图可见连线
补充启动器、资源布局、界面回归测试与项目文档
2026-08-07 10:13:25 +08:00
menghao dc7c7dc6cc 完成P7验收修复与CI稳定性加固
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Failing after 18s
Project CI / Native shell tests (pull_request) Failing after 7m24s
修正工作台前端 lint 与正式测试切片初始化依赖
补齐可调参数完成态测试夹具并稳定生成恢复 HTTP 读取
修正 macOS Mach-O release 产物识别与原生壳门禁
同步阶段七验收文档与长期排障记忆
2026-08-05 10:00:09 +08:00
menghao 3ec3a09380 完成游戏工作台资源替换与数值微调
实现可信资源规格校验与不可变资源替换子版本
新增登记参数读取、暂停态写入和会话失效链路
补齐前后端合同、生成产物约束与回归测试
同步工作台 PRD、技术方案和共享决策记录
2026-08-04 18:38:41 +08:00
menghao 752505547d 实现运行工作台正式测试切片与悬停信息
Project CI / Frontend tests (pull_request) Failing after 23s
Project CI / Repository checks (pull_request) Failing after 1m4s
Project CI / Backend tests (pull_request) Successful in 4m59s
Project CI / Native shell tests (pull_request) Successful in 13m17s
新增正式测试切片合同、状态机与上一项/播放暂停/下一项控制
新增可信悬停资源解析与资源管理画布定位
扩展 Tauri 版本登记、运行 Bridge 和 Agent 生成合同
补齐 TypeScript、Rust 与 AppSurface 回归测试
同步工作台 PRD、技术方案与共享决策记录
保持 P2 资源替换、参数写入和下一版本能力未接线
2026-08-04 17:06:24 +08:00
menghao 203ce5b9e7 实现游戏运行会话与桥接协议
Project CI / Backend tests (pull_request) Successful in 3m49s
Project CI / Native shell tests (pull_request) Failing after 8m26s
Project CI / Frontend tests (pull_request) Failing after 25s
Project CI / Repository checks (pull_request) Failing after 54s
冻结 Host/Game Bridge 双向消息合同与安全校验
为 PreviewRegistry 增加预览身份并注入宿主 bootstrap
接入 iframe 一次性会话、状态投影和暂停继续控制
补充协议、AppSurface、Rust 生命周期与共享合同测试
同步更新工作台 PRD、技术方案和共享决策记录
2026-08-04 16:16:04 +08:00
menghao d1a47c0fe7 实现游戏工作台P0/P1可运行版本能力
Project CI / Frontend tests (pull_request) Successful in 3m43s
Project CI / Repository checks (pull_request) Failing after 1m27s
Project CI / Backend tests (pull_request) Successful in 4m26s
Project CI / Native shell tests (pull_request) Failing after 7m40s
冻结工作台运行版本、会话、参数、资源与消息跨端合同
新增可信 Runtime 验证后的不可变可运行版本登记与快照校验
支持 Tauri 客户端选择、切换并运行历史版本快照
收紧普通预览授权边界并同步资源高亮与明确错误展示
补充前后端测试、PRD、技术方案和项目决策记录
2026-08-04 15:21:55 +08:00
menghao 2e0ef02fda 修复CI依赖准备顺序
Project CI / Repository checks (pull_request) Successful in 1m1s
Project CI / Frontend tests (pull_request) Successful in 3m7s
Project CI / Backend tests (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Successful in 10m55s
修复依赖图测试import排序门禁

在后端构建门禁前预取锁定Cargo依赖

新增Backend-CI执行顺序回归测试

同步更新Gitea-CI开发运维约定
2026-08-04 12:00:04 +08:00
menghao ab16c87c5d 调整资源依赖视图连线规则
Project CI / Repository checks (pull_request) Failing after 48s
Project CI / Backend tests (pull_request) Failing after 29s
Project CI / Frontend tests (pull_request) Successful in 2m54s
Project CI / Native shell tests (pull_request) Successful in 10m33s
限制任务虚线仅在同资源类型分区内渲染
调亮精确资源引用橙线并保持持续展示
取消资源点击后的依赖高亮与无关连线弱化
补充依赖图单元测试与工作台集成测试
同步更新项目开发工作台 PRD
2026-08-04 11:20:58 +08:00
menghao 6d8c7ae496 修复前端异步读取卸载竞态
Project CI / Repository checks (pull_request) Successful in 55s
Project CI / Backend tests (pull_request) Successful in 4m13s
Project CI / Native shell tests (pull_request) Successful in 11m16s
Project CI / Frontend tests (pull_request) Successful in 2m29s
未登录画布不再触发受保护的钱包刷新
充值中心读取在换代与卸载时中止并失效
补充卸载取消回归测试与共享排障经验
2026-08-04 10:38:28 +08:00
menghao 80bcb4ba0e 完成资源管理阶段七验收
Project CI / Repository checks (pull_request) Successful in 1m1s
Project CI / Backend tests (pull_request) Successful in 3m43s
Project CI / Native shell tests (pull_request) Successful in 10m47s
Project CI / Frontend tests (pull_request) Failing after 1m41s
补齐视频与资源读取失败空态测试
完成全量前后端和 Rust CI 验收
修复 macOS/BSD 跨平台门禁与测试隔离
更新阶段七验收与共享项目记忆
2026-08-04 10:06:12 +08:00
menghao df4e61a208 合并最新主分支
同步 Runtime、外部 API 与统一弹窗等主分支更新

解决开发栈进程探活、Runtime continuation 与启动测试冲突

保留资源依赖图排障记忆并验证冲突路径
2026-08-03 19:21:12 +08:00
menghao fef53b634e 完成正式项目版本阶段六
Project CI / Repository checks (pull_request) Failing after 43s
Project CI / Frontend tests (pull_request) Failing after 2m6s
Project CI / Backend tests (pull_request) Successful in 3m37s
Project CI / Native shell tests (pull_request) Successful in 11m49s
扩展 manifest 正式迭代版本合同并强制历史记录追加不可变
接入版本卡、父子关系与绑定资源高亮
补齐共享契约、Tauri 存储与工作台回归测试
同步工作台 PRD、技术方案与项目记忆
2026-08-03 19:04:01 +08:00
menghao ba996aad80 完成资源依赖排列阶段五
Project CI / Frontend tests (pull_request) Failing after 21s
Project CI / Repository checks (pull_request) Successful in 1m1s
Project CI / Backend tests (pull_request) Successful in 3m54s
Project CI / Native shell tests (pull_request) Successful in 11m49s
计算资源引用 SCC 与任务深度下限
拆分 producer assignment 和布局深度合同
补齐依赖布局回归测试
同步工作台 PRD、技术方案与项目记忆
2026-08-03 17:51:59 +08:00
menghao d374f3292a 完成资源聚焦阶段四
Project CI / Repository checks (pull_request) Successful in 1m16s
Project CI / Frontend tests (pull_request) Failing after 2m37s
Project CI / Backend tests (pull_request) Successful in 3m29s
Project CI / Native shell tests (pull_request) Successful in 11m21s
新增项目文档与媒体资源安全读取命令
补齐Markdown、扩展美术媒体与音频聚焦展示
增加安全边界与工作台回归测试
同步PRD、技术方案与共享决策
2026-08-03 17:20:14 +08:00
menghao 2c65878b60 完成资源分类投影与中央聚焦
Project CI / Repository checks (pull_request) Failing after 49s
Project CI / Frontend tests (pull_request) Failing after 2m16s
Project CI / Backend tests (pull_request) Successful in 3m55s
Project CI / Native shell tests (pull_request) Successful in 11m17s
收口文档、版本、美术和音频资源分类
将资源详情改为中央主视窗聚焦并恢复列表上下文
补充资源身份、聚焦状态和依赖图回归测试
同步工作台PRD、技术方案和项目决策记录
2026-08-03 16:39:43 +08:00
menghao 7339bb5da1 暂缓资源卡拖动并统一资源管理合同
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Failing after 2m18s
Project CI / Native shell tests (pull_request) Successful in 11m37s
移除资源卡 Pointer 拖动入口并保留点击详情
改写交互与布局测试,覆盖指针不改坐标且不提交 CAS
同步工作台 PRD、技术方案和项目决策记录
2026-08-03 15:35:54 +08:00
menghao c24e3010f8 Merge remote-tracking branch 'origin/master' into codex/dependency-graph
Project CI / Repository checks (pull_request) Successful in 1m3s
Project CI / Frontend tests (pull_request) Failing after 2m7s
Project CI / Backend tests (pull_request) Successful in 3m25s
Project CI / Native shell tests (pull_request) Successful in 10m41s
2026-08-03 14:24:46 +08:00
menghao d8064eff49 同步自主首批任务图测试夹具
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Successful in 2m46s
Project CI / Native shell tests (pull_request) Successful in 10m54s
补齐可信 Supervisor 根任务与三 Leader 首批合同测试数据

更新视觉失败和 Ready 任务调度断言以匹配最新任务图

修复 Provider 重试恢复响应仍使用旧底层 Agent 合同的问题
2026-08-03 14:21:30 +08:00
menghao bda0d0d398 修复Linux进程树探活与Runtime默认栈溢出
Project CI / Repository checks (pull_request) Successful in 1m7s
Project CI / Frontend tests (pull_request) Successful in 3m0s
Project CI / Backend tests (pull_request) Successful in 3m32s
Project CI / Native shell tests (pull_request) Failing after 7m38s
Linux进程组探活忽略已终止但未回收的zombie成员

pending恢复与后续队列统一跨越装箱的Tokio任务边界

补充Tauri生命周期和Runtime默认栈回归测试与文档
2026-08-03 13:34:25 +08:00
menghao b95da30721 Merge branch 'master' into codex/dependency-graph
Project CI / Repository checks (pull_request) Successful in 54s
Project CI / Frontend tests (pull_request) Failing after 2m9s
Project CI / Backend tests (pull_request) Successful in 3m46s
Project CI / Native shell tests (pull_request) Failing after 3m4s
2026-08-03 11:55:21 +08:00
menghao 7f038490f1 修复运行时恢复路径栈溢出
Project CI / Repository checks (pull_request) Failing after 7s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 2m51s
Project CI / Native shell tests (pull_request) Failing after 7m51s
为 pending continuation 与后台主循环增加可取消的 Tokio 任务边界
保留 durable batch 恢复防重和父任务取消语义
补充运行时技术约束与共享排障记录
2026-08-03 11:53:59 +08:00
menghao 05c608214e 修复资源依赖图布局初始化与拖动性能
Project CI / Repository checks (pull_request) Successful in 1m0s
Project CI / Frontend tests (pull_request) Successful in 3m4s
Project CI / Backend tests (pull_request) Successful in 3m29s
Project CI / Native shell tests (pull_request) Failing after 5m34s
将任务依赖深度迁移到 Rust SCC 压缩图并返回稳定结果

为 dependency 模式增加图加载屏障并重派生自动布局

通过 DOM 热路径和局部 SVG 缓存避免拖动全量 React 重渲染

补充 4096 资源、异步初始化及布局保持回归测试

同步工作台 PRD、技术方案与共享项目记忆
2026-08-03 11:16:02 +08:00
menghao 37f4a63112 合并最新master并解决工作台冲突
Project CI / Repository checks (pull_request) Successful in 1m17s
Project CI / Frontend tests (pull_request) Successful in 3m13s
Project CI / Backend tests (pull_request) Successful in 3m45s
Project CI / Native shell tests (pull_request) Failing after 5m39s
合并 master 最新游戏聊天前端 Lint 修复。

保留依赖图分支的 Hook 稳定化逻辑并统一注释格式。
2026-08-03 10:35:49 +08:00
menghao fe34eee052 合并最新master并解决工作台冲突
合并game-chat五分钟首版与平台美术硬门更新
解决App响应流Hook的等价内容冲突
稳定新增消息追加回调并整理合并后的导入顺序
通过前端完整测试、Lint、类型检查和Rust tests编译
2026-08-03 10:30:48 +08:00
menghao cced839da5 修复合并后的前端Lint门禁
Project CI / Repository checks (pull_request) Successful in 55s
Project CI / Frontend tests (pull_request) Successful in 2m55s
Project CI / Native shell tests (pull_request) Successful in 11m45s
Project CI / Backend tests (pull_request) Successful in 3m54s
稳定项目总控响应流更新回调并补齐Effect依赖
调整Agent Runtime模型测试的导入顺序
通过完整ESLint、类型检查和AppSurface回归测试
2026-07-31 19:31:24 +08:00
menghao e77d187497 合并最新master并解决资源图冲突
Project CI / Repository checks (pull_request) Failing after 47s
Project CI / Frontend tests (pull_request) Successful in 3m21s
Project CI / Backend tests (pull_request) Successful in 3m49s
Project CI / Native shell tests (pull_request) Successful in 11m42s
合并远端master的运行收口、聊天输出与画布交互更新
保留资源依赖图只读命令注册和Rust测试隔离配置
解决main.rs入口附近的内容冲突
完成依赖图、退出保护及完整前端回归验证
2026-07-31 19:19:05 +08:00
menghao 29dce59b35 将资源依赖图计算迁移到Rust
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 7s
Project CI / Frontend tests (pull_request) Successful in 2m50s
Project CI / Native shell tests (pull_request) Successful in 11m33s
新增只读资源依赖图模型,完成过滤、去重、环检测和任务流聚合
前端SVG仅负责几何渲染、搜索联动、选中高亮和拖动预览
补充资源图、覆盖层和工作台回归测试
同步更新工作台PRD、技术方案与项目记忆
2026-07-31 19:09:57 +08:00
menghao f582ecf032 优化资源依赖图连线外观
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Native shell tests (pull_request) Successful in 10m26s
将任务流分支改为平滑贝塞尔曲线
调整两类连线的线宽、虚线和箭头样式
补充连线几何测试与相关文档
2026-07-31 18:36:21 +08:00
menghao a431ab47cd 合并最新 master 并解决依赖图文档冲突
Project CI / Frontend tests (pull_request) Successful in 3m7s
Project CI / Backend tests (pull_request) Successful in 3m37s
Project CI / Native shell tests (pull_request) Successful in 12m20s
Project CI / Repository checks (pull_request) Successful in 59s
保留资源依赖图的纯前端 SVG 决策记录
保留主分支新增的抠图元数据与 OpenAPI 决策记录
同步 master 最新代码并保持依赖图与后端测试修复
2026-07-31 16:50:59 +08:00
menghao 11140b9fb6 修复后端源码约束测试
Project CI / Native shell tests (pull_request) Successful in 11m56s
Project CI / Repository checks (pull_request) Successful in 59s
Project CI / Frontend tests (pull_request) Successful in 3m35s
Project CI / Backend tests (pull_request) Successful in 3m49s
同步 UI 素材切片持久化失败断言至现有 warning 映射函数
保持生产逻辑和持久层不变
2026-07-31 16:31:09 +08:00
menghao 246fd1d9d6 同步最新 master 修复 CI 基线
Project CI / Repository checks (pull_request) Successful in 1m15s
Project CI / Frontend tests (pull_request) Successful in 3m23s
Project CI / Native shell tests (pull_request) Successful in 11m51s
Project CI / Backend tests (pull_request) Failing after 2m47s
合并画板素材上传后的私有图片预览修复。

确保 PR 分支包含当前 master 基础提交。
2026-07-31 16:05:05 +08:00
menghao 68ea9bdff3 合并最新 master 并解决资源依赖图冲突
Project CI / Repository checks (pull_request) Failing after 7s
Project CI / Frontend tests (pull_request) Failing after 2m19s
Project CI / Backend tests (pull_request) Failing after 8s
Project CI / Native shell tests (pull_request) Successful in 12m34s
同步 mentor 已合入 master 的最新工程改动。

保留资源依赖图决策记录并整合 master 的新增决策。

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
2026-07-31 15:54:40 +08:00
menghao 0b6be155dd 合并最新 AI 游戏创作 App 分支
Project CI / Frontend tests (pull_request) Successful in 3m18s
Project CI / Repository checks (pull_request) Failing after 22s
Project CI / Backend tests (pull_request) Failing after 22s
Project CI / Native shell tests (pull_request) Failing after 22s
2026-07-31 15:34:36 +08:00
menghao 8531a9af3e 实现资源依赖关系图层
Project CI / Frontend tests (pull_request) Successful in 2m40s
Project CI / Native shell tests (pull_request) Successful in 10m40s
Project CI / Repository checks (pull_request) Failing after 7s
Project CI / Backend tests (pull_request) Failing after 7s
新增资源依赖图模型、去重、环检测与上下游查询。

使用原生 SVG 渲染资源引用和聚合任务流,并接入搜索、高亮、拖动及生命周期清理。

修复资源自引用连线被卡片遮挡的问题。

补充前端回归测试并同步工作台 PRD、技术方案和决策记录。
2026-07-31 15:13:18 +08:00
66 changed files with 9771 additions and 11424 deletions
@@ -8,7 +8,7 @@ All paths below are relative to `https://www.genarrative.world`. Discovery and S
| Operation | Method and path | Minimum input |
| --- | --- | --- |
| List projects | `GET /api/external/v1/editor/projects` | Authentication |
| List projects | `GET /api/external/v1/editor/projects` | Authentication; optional `view=full\|summary` (default `full`) |
| Create project | `POST /api/external/v1/editor/projects` | Optional `title` |
| Load recent project | `GET /api/external/v1/editor/projects/recent` | Authentication |
| Get project | `GET /api/external/v1/editor/projects/{projectId}` | `projectId` |
@@ -19,6 +19,13 @@ All paths below are relative to `https://www.genarrative.world`. Discovery and S
Canvas save uses optimistic revision control. Pass the last authoritative `expectedRevision`; on conflict, reload instead of replaying a stale full layout.
Project listing supports two views:
- `view=full` is the REST default and returns the complete project, canvas, layers, and resources.
- `view=summary` returns only `projectId`, `title`, `updatedAt`, and nullable `cover`, so callers can display, search, disambiguate same-name projects, and select a safe target without loading every canvas snapshot.
- Hosted MCP `list_editor_projects` always uses `summary`; call `get_editor_project` after selecting a `projectId` when complete authoritative state is required.
- `cover` contains only `resourceId`, stable `objectKey`, dimensions, and `updatedAt`. It never embeds image bytes, a Data URL, or a signed URL. To display it, pass `cover.objectKey` to `get_external_asset_read_url`; signed URLs are temporary and must not be persisted or reused as generation references.
## Asset and Upload Operations
| Operation | Method and path | Minimum input |
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "node scripts/start-tauri-dev.mjs",
"dev:app-run": "node scripts/start-tauri-dev.mjs --app-run",
"game-chat": "node scripts/start-tauri-dev.mjs --game-chat",
"dev-server": "node scripts/start-dev-server.mjs",
"dev-stack": "node scripts/start-dev-stack.mjs",
@@ -27,6 +27,12 @@ const tauriConfig = JSON.parse(
'utf8',
),
);
const appRunTauriConfig = JSON.parse(
fs.readFileSync(
new URL('../src-tauri/tauri.app-run-dev.conf.json', import.meta.url),
'utf8',
),
);
const gameChatReleaseTauriConfig = JSON.parse(
fs.readFileSync(
new URL('../src-tauri/tauri.game-chat-release.conf.json', import.meta.url),
@@ -1263,6 +1269,33 @@ if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') {
);
}
if (
packageConfig.scripts?.['dev:app-run'] !==
'node scripts/start-tauri-dev.mjs --app-run' ||
rootPackageConfig.scripts?.['agc:app-run'] !==
'npm --prefix apps/ai-game-creator-shell run dev:app-run'
) {
throw new Error(
'AI game creator app-run dev profile must use the managed Tauri dev launcher',
);
}
const appRunWindow = appRunTauriConfig.app?.windows?.[0];
if (
appRunTauriConfig.productName !==
'Genarrative AI Game Creator App Run' ||
appRunTauriConfig.identifier !==
'world.genarrative.ai-game-creator.app-run' ||
appRunTauriConfig.identifier === tauriConfig.identifier ||
appRunTauriConfig.build?.devUrl !== 'http://127.0.0.1:3081/' ||
appRunWindow?.label !== 'client' ||
appRunWindow?.title !== 'AI 游戏创作 · App Run'
) {
throw new Error(
'AI game creator app-run profile must keep an independent identity, title, and Vite port',
);
}
if (
packageConfig.scripts?.['game-chat'] !==
'node scripts/start-tauri-dev.mjs --game-chat'
@@ -8,17 +8,55 @@ import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const viteHost = '127.0.0.1';
const vitePort = 3080;
const devProfileName = process.env.GENARRATIVE_AGC_DEV_PROFILE || 'default';
function resolveDevStackProfile(name = 'default') {
switch (name) {
case 'default':
return {
name,
viteHost: '127.0.0.1',
vitePort: 3080,
apiPort: 8082,
bgfilterWorkerPort: 8083,
spacetimePort: 3101,
backendDatabase: 'genarrative-game-creator-dev',
backendSpacetimeDataDir: resolve(
repoRoot,
'server-rs/.spacetimedb/ai-game-creator/data',
),
};
case 'app-run':
return {
name,
viteHost: '127.0.0.1',
vitePort: 3081,
apiPort: 8084,
bgfilterWorkerPort: 8085,
spacetimePort: 3103,
backendDatabase: 'genarrative-game-creator-app-run-dev',
backendSpacetimeDataDir: resolve(
repoRoot,
'server-rs/.spacetimedb/ai-game-creator-app-run/data',
),
};
default:
throw new Error(`未知 AI 游戏创作开发 profile: ${name}`);
}
}
const devProfile = resolveDevStackProfile(devProfileName);
const {
viteHost,
vitePort,
apiPort,
spacetimePort,
backendDatabase,
backendSpacetimeDataDir,
} = devProfile;
const viteUrl = `http://${viteHost}:${vitePort}/`;
const viteMarkerUrl = `${viteUrl}__agc_dev_server.json`;
const defaultApiTarget =
process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
const backendDatabase = 'genarrative-game-creator-dev';
const backendSpacetimeDataDir = resolve(
repoRoot,
'server-rs/.spacetimedb/ai-game-creator/data',
);
process.env.RUST_SERVER_TARGET || `http://127.0.0.1:${apiPort}`;
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const childLifecycles = new WeakMap();
@@ -72,6 +110,7 @@ function resolveBackendTargetsFromState(
expectedDatabase = backendDatabase,
expectedSpacetimeDataDir = backendSpacetimeDataDir,
fallbackApiTarget = defaultApiTarget,
fallbackSpacetimeTarget = `http://127.0.0.1:${spacetimePort}`,
} = {},
) {
const apiServer = state?.services?.['api-server'];
@@ -100,7 +139,7 @@ function resolveBackendTargetsFromState(
? spacetime.url
: requireAgcBackend
? ''
: 'http://127.0.0.1:3101';
: fallbackSpacetimeTarget;
return {
apiUrl,
spacetimeUrl,
@@ -131,13 +170,24 @@ async function isBackendReady() {
);
}
async function readExistingViteServer() {
return httpGetText(viteUrl);
function resolveViteProfileUrls(profile = devProfile) {
const profileViteUrl = `http://${profile.viteHost}:${profile.vitePort}/`;
return {
viteUrl: profileViteUrl,
viteMarkerUrl: `${profileViteUrl}__agc_dev_server.json`,
};
}
function isVitePortListening() {
async function readExistingViteServer(profile = devProfile) {
return httpGetText(resolveViteProfileUrls(profile).viteUrl);
}
function isVitePortListening(profile = devProfile) {
return new Promise((resolveRequest) => {
const socket = net.connect({ host: viteHost, port: vitePort });
const socket = net.connect({
host: profile.viteHost,
port: profile.vitePort,
});
socket.once('connect', () => {
socket.destroy();
resolveRequest(true);
@@ -160,8 +210,11 @@ function isAiGameCreatorServer(response) {
);
}
async function readExistingViteMarker() {
const response = await httpGetText(viteMarkerUrl, 2000);
async function readExistingViteMarker(profile = devProfile) {
const response = await httpGetText(
resolveViteProfileUrls(profile).viteMarkerUrl,
2000,
);
if (!response || response.statusCode !== 200) {
return null;
}
@@ -173,15 +226,17 @@ async function readExistingViteMarker() {
}
async function preflightExistingVite({
readServer = readExistingViteServer,
portListening = isVitePortListening,
readMarker = readExistingViteMarker,
profile = devProfile,
readServer = () => readExistingViteServer(profile),
portListening = () => isVitePortListening(profile),
readMarker = () => readExistingViteMarker(profile),
} = {}) {
const { viteUrl: profileViteUrl } = resolveViteProfileUrls(profile);
const existing = await readServer();
if (!existing) {
if (await portListening()) {
throw new Error(
`${viteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
`${profileViteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
);
}
return { status: 'available', apiTarget: '' };
@@ -189,7 +244,7 @@ async function preflightExistingVite({
if (!isAiGameCreatorServer(existing)) {
throw new Error(
`${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
`${profileViteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
);
}
@@ -202,7 +257,7 @@ async function preflightExistingVite({
: '';
const actualTarget = markerApiTarget || 'unknown';
throw new Error(
`${viteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`,
`${profileViteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`,
);
}
@@ -507,27 +562,50 @@ async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
throw new Error('等待配套后端和数据库启动超时');
}
function buildBackendStartArguments(profile = devProfile) {
return [
'--prefix',
'../..',
'run',
'agc:backend',
'--',
'--database',
profile.backendDatabase,
'--spacetime-data-dir',
profile.backendSpacetimeDataDir,
'--api-port',
String(profile.apiPort),
'--bgfilter-worker-port',
String(profile.bgfilterWorkerPort),
'--spacetime-port',
String(profile.spacetimePort),
'--no-interactive',
];
}
function buildViteStartArguments(profile = devProfile) {
return [
'--prefix',
'../..',
'exec',
'vite',
'--',
'--config',
'vite.config.ts',
'--host',
profile.viteHost,
'--port',
String(profile.vitePort),
'--strictPort',
];
}
async function ensureBackend({
onBackendChild = () => {},
checkBackendReady = isBackendReady,
resolveTargets = readBackendTargets,
spawnBackend = () =>
spawnChild(
npm,
[
'--prefix',
'../..',
'run',
'agc:backend',
'--',
'--database',
backendDatabase,
'--spacetime-data-dir',
backendSpacetimeDataDir,
'--no-interactive',
],
{ cwd: appRoot },
),
spawnChild(npm, buildBackendStartArguments(), { cwd: appRoot }),
waitUntilReady = waitForBackendReady,
} = {}) {
if (await checkBackendReady()) {
@@ -569,11 +647,7 @@ async function startVite(apiTarget) {
);
}
return spawnChild(
npm,
['--prefix', '../..', 'exec', 'vite', '--', '--config', 'vite.config.ts'],
{ cwd: appRoot },
);
return spawnChild(npm, buildViteStartArguments(), { cwd: appRoot });
}
async function main() {
@@ -593,6 +667,9 @@ async function main() {
}
try {
console.log(
`[ai-game-creator-shell] dev profile ${devProfile.name}: vite=${viteUrl} api=${apiPort} spacetime=${spacetimePort}`,
);
await preflightExistingVite();
const backend = await ensureBackend({
onBackendChild(child) {
@@ -650,6 +727,8 @@ function isDirectModuleExecution() {
}
export {
buildBackendStartArguments,
buildViteStartArguments,
ensureBackend,
formatChildFailure,
isDirectModuleExecution,
@@ -658,6 +737,7 @@ export {
readChildFailure,
readLinuxProcessGroupAlive,
resolveBackendTargetsFromState,
resolveDevStackProfile,
runWindowsTaskkill,
spawnChild,
stopChild,
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
import {
preflightExistingVite,
resolveDevStackProfile,
spawnChild,
stopChild,
terminateChildTree,
@@ -15,24 +16,43 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
function parseLauncherArguments(argv) {
const args = [...argv];
const appRun = args[0] === '--app-run';
if (appRun) {
args.shift();
}
const gameChat = args[0] === '--game-chat';
if (gameChat) {
args.shift();
}
return { gameChat, args };
if (appRun && gameChat) {
throw new Error('app-run profile 不能与 game-chat 入口同时使用');
}
return { appRun, gameChat, args };
}
function buildTauriArguments(argv) {
const { gameChat, args } = parseLauncherArguments(argv);
const { appRun, gameChat, args } = parseLauncherArguments(argv);
if (appRun) {
return [
'dev',
'--config',
'src-tauri/tauri.app-run-dev.conf.json',
...args,
];
}
if (gameChat) {
return ['dev', '--', '--', '--game-chat', ...args];
}
return ['dev', ...args];
}
function spawnTauriCli(argv) {
function spawnTauriCli(argv, { profileName = 'default' } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
env: {
...process.env,
GENARRATIVE_AGC_DEV_PROFILE: profileName,
},
shell: false,
});
}
@@ -46,10 +66,12 @@ async function runTauriDev(
terminateTree = terminateChildTree,
} = {},
) {
await preflight();
const { appRun } = parseLauncherArguments(argv);
const profile = resolveDevStackProfile(appRun ? 'app-run' : 'default');
await preflight({ profile });
const tauriArguments = buildTauriArguments(argv);
const child = spawnCli(tauriArguments);
const child = spawnCli(tauriArguments, { profileName: profile.name });
let resolveShutdown;
let shutdownSignal = '';
let repeatedSignal = false;
File diff suppressed because it is too large Load Diff
@@ -519,7 +519,7 @@ fn game_creator_art_asset_plan_tool_plan_prompt(
);
}
format!(
"{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。当且仅当当前 Project Supervisor 的静态委派 expectedArtifacts 同时保留 assets/art-spritesheet.png 并声明另一个 UI 图集 PNG 时,可按委派语义额外生成该路径,固定使用 1:1、1K、assetKind=ui-spritesheet 和贴合任务的 assetLabel;初次生成 replaceExisting=false,只有完整继承同一 expectedArtifacts 的唯一返工委派可设 replaceExisting=true。不得自行发明额外路径。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。"
"{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。"
)
}
@@ -1234,9 +1234,6 @@ mod tests {
assert!(with_canvas.contains("warning.code=postprocess-failed-source-preserved"));
assert!(with_canvas.contains("不得登记、验收或自动重试"));
assert!(with_canvas.contains("仅 sliceWarning"));
assert!(with_canvas.contains("Project Supervisor 的静态委派 expectedArtifacts"));
assert!(with_canvas.contains("assetKind=ui-spritesheet"));
assert!(with_canvas.contains("不得自行发明额外路径"));
}
#[test]
@@ -629,11 +629,6 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
.actions
.iter()
.any(|action| action.tool.trim() == "agent.delegate");
let has_code_asset_route = agent_id == "code-director"
&& plan
.actions
.iter()
.any(|action| action.tool.trim() == "agent.route_manifest");
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
if let Some(failed_playtest_revision) = verification_gate.failed_playtest_revision {
if project_revision < failed_playtest_revision {
@@ -770,7 +765,6 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
|| mutation_revision.is_some()
|| !plan.response.trim().is_empty()
|| has_mutation
|| has_code_asset_route
{
return Ok(());
}
@@ -819,7 +813,6 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_read_only_delivery_pla
| "command.output_read"
| "command.poll"
| "image.inspect" => false,
"agent.route_manifest" => agent_id != "code-director",
"preview.validate" => agent_id != "preview-playtest",
"command.run_limited" => {
agent_id != "preview-readiness"
@@ -1778,13 +1771,6 @@ mod tests {
serde_json::json!({"commandId": "game.static_smoke"}),
);
let preview = plan_for("preview.validate", serde_json::json!({}));
let route_manifest = plan_for(
"agent.route_manifest",
serde_json::json!({
"strategy": "use-existing-art",
"missingAssetSlots": [],
}),
);
assert!(validate_agent_runtime_autonomous_read_only_delivery_plan(
"preview-readiness",
@@ -1798,41 +1784,9 @@ mod tests {
&preview,
)
.is_ok());
assert!(validate_agent_runtime_autonomous_read_only_delivery_plan(
"code-director",
true,
&route_manifest,
)
.is_ok());
let verification_gate = AgentRuntimeVerificationGate {
schema_version: "test".to_string(),
project_id: "test".to_string(),
agent_id: "code-director".to_string(),
run_id: "test".to_string(),
requires_verification: false,
mutation_revision: None,
verified_revision: None,
last_mutation_tool: None,
last_verification_tool: None,
last_verification_status: None,
failed_playtest_revision: None,
updated_at: 0,
};
assert!(validate_agent_runtime_autonomous_plan_liveness(
"code-director",
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
0,
&verification_gate,
&[],
&route_manifest,
false,
false,
)
.is_ok());
for (agent_id, plan) in [
("quality-review", &smoke),
("quality-review", &preview),
("quality-review", &route_manifest),
("preview-readiness", &preview),
("preview-playtest", &smoke),
] {
@@ -19,22 +19,12 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_content(
"taskContext": task,
}))
.map_err(|error| format!("序列化待确认工具动作失败:{error}"))?;
validate_agent_runtime_pending_sensitive_serialized_content(&content)?;
let action_content = serde_json::to_string(action)
.map_err(|error| format!("序列化待确认工具动作失败:{error}"))?;
validate_agent_runtime_pending_project_path_content(root, &action_content)
validate_agent_runtime_pending_serialized_content(root, &content)
}
pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content(
root: &Path,
content: &str,
) -> Result<(), String> {
validate_agent_runtime_pending_sensitive_serialized_content(content)?;
validate_agent_runtime_pending_project_path_content(root, content)
}
fn validate_agent_runtime_pending_sensitive_serialized_content(
content: &str,
) -> Result<(), String> {
let lower = content.to_ascii_lowercase();
let sensitive_rule = [
@@ -56,13 +46,6 @@ fn validate_agent_runtime_pending_sensitive_serialized_content(
"待确认工具输入命中敏感规则 #{rule}Runtime 已拒绝持久化"
));
}
Ok(())
}
fn validate_agent_runtime_pending_project_path_content(
root: &Path,
content: &str,
) -> Result<(), String> {
let root_display = root.to_string_lossy();
if !root_display.is_empty() && content.contains(root_display.as_ref()) {
return Err("待确认工具输入包含项目绝对路径,Runtime 已拒绝持久化".to_string());
@@ -391,12 +374,7 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record(
}
let serialized = serde_json::to_string(pending)
.map_err(|error| format!("序列化 Agent Runtime 待确认动作失败:{error}"))?;
validate_agent_runtime_pending_sensitive_serialized_content(&serialized)?;
let mut project_path_record = pending.clone();
project_path_record.task.clear();
let project_path_serialized = serde_json::to_string(&project_path_record)
.map_err(|error| format!("序列化 Agent Runtime 待确认路径校验记录失败:{error}"))?;
validate_agent_runtime_pending_project_path_content(root, &project_path_serialized)?;
validate_agent_runtime_pending_serialized_content(root, &serialized)?;
let action_fingerprint = agent_runtime_pending_tool_action_fingerprint(
&pending.action,
&pending.task,
@@ -784,115 +762,6 @@ mod tests {
}
}
#[test]
fn pending_content_allows_project_root_in_task_context_when_action_is_relative() {
let root = Path::new("/data/dsk/games/game01");
let action = AgentRuntimeToolAction {
tool: "file.list".to_string(),
reason: Some("核对当前资产".to_string()),
input: serde_json::json!({ "path": "assets" }),
};
validate_agent_runtime_pending_tool_action_content(
root,
&action,
"继续修复 /data/dsk/games/game01 中的现有项目",
)
.expect("task context may identify the current project while tool input stays relative");
}
#[test]
fn pending_content_rejects_project_root_in_tool_action() {
let root = Path::new("/data/dsk/games/game01");
let action = AgentRuntimeToolAction {
tool: "file.read".to_string(),
reason: Some("读取当前入口".to_string()),
input: serde_json::json!({
"path": "/data/dsk/games/game01/game/index.html"
}),
};
let error =
validate_agent_runtime_pending_tool_action_content(root, &action, "继续修复当前项目")
.expect_err("tool action must keep using a project-relative path");
assert!(error.contains("项目绝对路径"), "{error}");
}
#[tokio::test]
async fn provider_batch_round_trips_when_only_task_identifies_project_root() {
let temporary = crate::tests::canonical_test_tempdir("pending-task-project-root-");
let root = temporary.path();
init_local_game_project_at(root, "pending-task-project-root", "待确认路径批次测试")
.expect("init project");
let task = format!("继续修复 {} 中的现有项目", root.display());
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
"art-director",
&task,
"pending-task-project-root-run",
"agent-ready-task-scheduler",
"核对当前项目",
vec!["核对当前项目".to_string()],
)
.expect("start runtime");
runtime.loop_iteration = 1;
let plan = AgentRuntimeToolPlan {
thinking_summary: "核对相对路径项目上下文".to_string(),
plan_update: None,
plan: vec!["核对资产".to_string(), "核对入口".to_string()],
actions: vec![
AgentRuntimeToolAction {
tool: "file.list".to_string(),
reason: Some("核对当前资产".to_string()),
input: serde_json::json!({ "path": "assets" }),
},
AgentRuntimeToolAction {
tool: "file.read".to_string(),
reason: Some("核对当前入口".to_string()),
input: serde_json::json!({
"path": "game/index.html",
"startLine": 1,
"maxLines": 20
}),
},
],
response: String::new(),
};
let project_revision =
read_game_creator_agent_runtime_project_revision(root).expect("read project revision");
let repository_fingerprint = build_repository_startup_context_at(root)
.expect("build repository context")
.fingerprint;
let prepared = prepare_game_creator_agent_runtime_provider_action_batch(
root,
&runtime,
&task,
&plan,
&[],
&project_revision,
&repository_fingerprint,
)
.await
.expect("prepare provider batch");
let prepared = match prepared {
AgentRuntimeProviderActionBatchPreparation::Ready(batch) => batch,
other => panic!("expected ready provider batch, got {other:?}"),
};
let persisted = read_game_creator_agent_runtime_provider_action_batch(
root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read persisted provider batch");
assert_eq!(persisted.batch_id, prepared.batch_id);
assert_eq!(persisted.actions.len(), 2);
assert!(persisted.actions.iter().all(|pending| pending.task == task));
assert_eq!(persisted.actions[0].action, plan.actions[0]);
assert_eq!(persisted.actions[1].action, plan.actions[1]);
}
#[test]
fn pending_content_still_rejects_api_key_fields_and_secret_tokens() {
let root = Path::new("C:\\workspace");
@@ -1,12 +1,7 @@
use super::*;
pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] = &[
"game-art",
"icon-spec",
"ui-prototype",
"art-spritesheet",
"ui-spritesheet",
];
pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] =
&["game-art", "icon-spec", "ui-prototype", "art-spritesheet"];
#[cfg(test)]
mod canvas_asset_kind_contract_tests {
@@ -16,13 +11,7 @@ mod canvas_asset_kind_contract_tests {
fn canvas_asset_kind_catalog_preserves_authoritative_contract() {
assert_eq!(
AGENT_RUNTIME_CANVAS_ASSET_KINDS,
&[
"game-art",
"icon-spec",
"ui-prototype",
"art-spritesheet",
"ui-spritesheet",
]
&["game-art", "icon-spec", "ui-prototype", "art-spritesheet"]
);
}
}
@@ -204,14 +204,16 @@ fn queue_game_chat_fast_path_child(
#[test]
fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_need_repair() {
let root = std::env::temp_dir().join(format!(
"genarrative-agent-main-loop-legacy-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
let root = fs::canonicalize(std::env::temp_dir())
.expect("canonicalize temporary root")
.join(format!(
"genarrative-agent-main-loop-legacy-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
init_local_game_project_at(&root, "legacy-derived-visuals", "旧派生视觉返工门禁")
.expect("project init");
assert!(!autonomous_registered_derived_visuals_need_repair_at(&root));
@@ -308,6 +310,14 @@ fn prepare_autonomous_completion_evidence(
("memory/project.md", "# 项目记忆\n\n正式约束。\n"),
("game/game_design.md", "# 游戏设计\n\n核心循环。\n"),
("game/balance.json", r#"{"lives":3,"speed":1}"#),
(
"game/tunable-parameters.json",
r#"{"schemaVersion":"game-creator-tunable-parameters.v1","parameters":[]}"#,
),
(
"game/tunable-values.json",
r#"{"schemaVersion":"game-creator-tunable-values.v1","values":{}}"#,
),
(
"assets/manifest.art.json",
r#"{"assets":[{"path":"assets/art-spritesheet.png"}],"sliceManifest":"assets/art-spritesheet-slices/manifest.json","status":"generated"}"#,
@@ -869,6 +879,16 @@ fn game_chat_code_completion_blocker_reopens_active_repair_before_delivery() {
render_game_chat_fast_path_html("制作水晶俄罗斯方块小游戏"),
)
.expect("write code entry with all art slices");
fs::write(
root.join("game/tunable-parameters.json"),
r#"{"schemaVersion":"game-creator-tunable-parameters.v1","parameters":[]}"#,
)
.expect("write repaired tunable parameter registry");
fs::write(
root.join("game/tunable-values.json"),
r#"{"schemaVersion":"game-creator-tunable-values.v1","values":{}}"#,
)
.expect("write repaired tunable parameter values");
let mut repaired_but_failed_plan = child_state.clone();
repaired_but_failed_plan.plan_steps[0].status = AGENT_RUNTIME_PLAN_STATUS_FAILED.to_string();
let repaired_failed_error = game_chat_fast_path_plan_at(
@@ -1444,6 +1444,89 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
}),
)?;
}
if status == GameCreationAppTaskStatus::Completed && state.agent_id == "preview-playtest" {
let manifest_before_completion = read_manifest_for_project(root)?;
let required_tasks = autonomous_manifest_seed_tasks_for_source(&root_parent_binding.source);
let required_by_id = required_tasks
.iter()
.map(|task| (task.id.as_str(), task))
.collect::<std::collections::HashMap<_, _>>();
let mut prerequisite_ids = std::collections::BTreeSet::new();
let mut pending_ids = vec![state.agent_id.as_str()];
while let Some(task_id) = pending_ids.pop() {
if !prerequisite_ids.insert(task_id) {
continue;
}
let task = required_by_id
.get(task_id)
.ok_or_else(|| format!("可运行版本项目完整性合同缺少任务:{task_id}"))?;
pending_ids.extend(task.dependencies.iter().map(String::as_str));
}
let incomplete = required_tasks
.iter()
.filter(|required| prerequisite_ids.contains(required.id.as_str()))
.filter(|required| required.id != state.agent_id)
.filter(|required| {
manifest_before_completion
.tasks
.iter()
.find(|task| task.id == required.id)
.is_none_or(|task| task.status != GameCreationAppTaskStatus::Completed)
})
.map(|task| task.id.as_str())
.collect::<Vec<_>>();
if !incomplete.is_empty() {
return Err(format!(
"可运行版本项目完整性检查未通过:{}",
incomplete.join("")
));
}
let readiness_records =
latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks(
&game_creator_agent_runtime_task_path(root, "preview-readiness"),
)?);
let readiness = readiness_records
.iter()
.rev()
.find(|record| {
record.parent_agent_id.as_deref() == Some(parent_agent_id.as_str())
&& record.parent_run_id.as_deref() == Some(parent_run_id.as_str())
&& record.status == "completed"
})
.ok_or_else(|| "可运行版本缺少 preview-readiness 完成回执".to_string())?;
let current_revision = read_game_creator_agent_runtime_project_revision(root)?;
let readiness_gate = read_game_creator_agent_runtime_verification_gate(
root,
&readiness.agent_id,
&readiness.run_id,
)?;
if readiness_gate.last_verification_tool.as_deref() != Some("game.static_smoke")
|| readiness_gate.last_verification_status.as_deref()
!= Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)
|| readiness_gate.verified_revision != Some(current_revision.revision)
{
return Err("可运行版本缺少当前 revision 的 game.static_smoke 通过凭证".to_string());
}
let contract = autonomous_playtest_completion_contract_for_state_at(root, state)?
.ok_or_else(|| "可运行版本缺少 preview.validate 完成合同".to_string())?;
let receipt = read_autonomous_playtest_receipt(root, &contract)?
.ok_or_else(|| "可运行版本缺少 preview.validate 成功回执".to_string())?;
verify_autonomous_playtest_evidence_files_at(root, &receipt)?;
if receipt.revision != current_revision.revision {
return Err(format!(
"可运行版本 revision 不一致:receipt={} current={}",
receipt.revision, current_revision.revision
));
}
register_current_runnable_game_version_at(
root,
current_revision.revision,
&receipt.agent_id,
&receipt.run_id,
&receipt.report.path,
receipt.playtest_scenario,
)?;
}
update_manifest_task_status_at(root, &state.agent_id, status.clone())?;
append_agent_db_record(
root,
@@ -1529,8 +1612,13 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt(
} else {
""
};
let code_tunable_parameter_requirement = if task.id == "code-prototype" {
" 数值微调合同固定为 game/tunable-parameters.json 与 game/tunable-values.json。注册表 schemaVersion 必须为 game-creator-tunable-parameters.v1parameters 最多 128 项;每项包含稳定 parameterId、label、valueType、defaultValue、currentValue、可选 min/max/step/enumValues/unit、固定 writePath=game/tunable-values.json#/values/<parameterId>、effectMode=next-relaunch、editablePhase=paused、codeMutationAllowed=false。值文件 schemaVersion 必须为 game-creator-tunable-values.v1values 只包含已登记 parameterId。只登记当前游戏确实读取的少量数值;game/index.html 必须在每次新启动时读取值文件并应用,运行中的 iframe 不得监听或热写该文件。"
} else {
""
};
let owner_prompt = format!(
"{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSONcode-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.updateRuntime 会在子 Run 终态后幂等投影 manifest。"
"{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSONcode-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}{code_tunable_parameter_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.updateRuntime 会在子 Run 终态后幂等投影 manifest。"
);
return format!("{owner_prompt}{code_visual_asset_requirement}");
}
@@ -75,7 +75,11 @@ pub(in crate::agent) fn autonomous_manifest_owner_artifact_paths(
"balance-seed" => &["game/balance.json"],
"art-asset-plan" => &["assets/manifest.art.json"],
"audio-asset-plan" => &["assets/manifest.audio.json"],
"code-prototype" => &[AGENT_RUNTIME_GAME_INDEX_PATH],
"code-prototype" => &[
AGENT_RUNTIME_GAME_INDEX_PATH,
"game/tunable-parameters.json",
"game/tunable-values.json",
],
"publish-package" => &["exports/README.md"],
_ => &[],
}
@@ -6518,7 +6522,8 @@ pub(in crate::agent) fn autonomous_playtest_contract_prompt(
concat!(
"完成合同要求 generic-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level",
"界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"primary-action\" 与 data-playtest-id=\"restart\" 的真实可点击控件;primary-action 必须映射游戏的真实主要玩法操作,并在动作发生时推进 state sequence,不能使用空操作或仅更新装饰 UI 的按钮;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。",
"初始状态必须是 ready 且 level 为正整数;start 后状态必须推进并进入 playing,并先至少持续 2 秒保持 playing,让玩家获得可操作机会,在 primary-action 之前进入 ready、won 或 lost 都会失败;随后 primary-action 必须再次严格推进 sequenceprimary-action 后 phase 可为 playing、won 或 lost,单次 won 或 lost 都是正常游戏终态,不会仅凭一次 lost 判定试玩失败;若动作后仍为 playing,则最多继续观察 3 秒,期间 won/lost 可提前形成首轮结果,始终 playing 也可在观察完成后证明非失败推进。restart 后必须再次推进,且至少持续 3 秒的稳定观察窗口内只能保持 ready 或 playing,进入 won 或 lost 都会失败;如果首轮 primary-action 结果为 lost,重开稳定后必须自动执行第二次受控尝试,恢复为 ready 时先 start 推进到 playing,随后无论重开结果原本是 ready 还是 playing,都必须再次完成至少 2 秒的 playing 操作机会,再次点击 primary-action 且严格推进 sequence;第二次必须进入 won,或保持 playing 并完成 3 秒观察,观察期间可进入 won 但不得进入 lost。两次受控尝试都进入 lost 说明游戏存在无法正常推进的固定失败,必须判定试玩失败;全部观察期间 sequence 始终不得回退。"
"初始状态必须是 ready 且 level 为正整数;start 后状态必须推进并进入 playing,并先至少持续 2 秒保持 playing,让玩家获得可操作机会,在 primary-action 之前进入 ready、won 或 lost 都会失败;随后 primary-action 必须再次严格推进 sequenceprimary-action 后 phase 可为 playing、won 或 lost,单次 won 或 lost 都是正常游戏终态,不会仅凭一次 lost 判定试玩失败;若动作后仍为 playing,则最多继续观察 3 秒,期间 won/lost 可提前形成首轮结果,始终 playing 也可在观察完成后证明非失败推进。restart 后必须再次推进,且至少持续 3 秒的稳定观察窗口内只能保持 ready 或 playing,进入 won 或 lost 都会失败;如果首轮 primary-action 结果为 lost,重开稳定后必须自动执行第二次受控尝试,恢复为 ready 时先 start 推进到 playing,随后无论重开结果原本是 ready 还是 playing,都必须再次完成至少 2 秒的 playing 操作机会,再次点击 primary-action 且严格推进 sequence;第二次必须进入 won,或保持 playing 并完成 3 秒观察,观察期间可进入 won 但不得进入 lost。两次受控尝试都进入 lost 说明游戏存在无法正常推进的固定失败,必须判定试玩失败;全部观察期间 sequence 始终不得回退。",
"还必须监听 genarrative:host-message 中的 host.slice.start / host.slice.stopgeneric-v1 的正式 sliceId 固定为 core-gameplay,必须通过 window.genarrativeGameBridge.reportSlice 上报该 ID 的 playing、paused、completed 或 failed。游戏自身 hit-test / hover 只允许通过 reportTargetInspection 上报当前 .agent/manifest.json 已登记的 asset.id,或与该 asset.id 对应的资源槽位 slotId;离开对象调用 clearInspection,不得上报资源名称、路径、HTML 或宿主命令。"
)
}
BrowserPlaytestScenario::TetrisV1 => {
@@ -6533,7 +6538,8 @@ pub(in crate::agent) fn autonomous_playtest_contract_prompt(
"完成合同要求 lane-defense-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level、selectedDefenderId、defenders 数组、enemies 数组;每个 enemy 必须含非空 id、非负 lane、会随移动变化的 position、health 与正数 maxHealth。",
"界面必须清晰显示一个原创项目标题、至少两个原创防御单位选项、资源与波次状态,以及开始、加速、下一关和重开等可理解操作;玩法类型不授权复刻现有游戏,不得沿用、翻译或近似改写现有作品的角色、单位名、Logo、贴图、标志性布局或受保护视觉语言。界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"defender-option\"、data-playtest-id=\"lane-cell\"、data-playtest-id=\"speed-up\"、data-playtest-id=\"next-level\"、data-playtest-id=\"restart\" 的真实可点击控件;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。",
"防御单位多选项 UI 只能给一个真实控件设置 data-playtest-id=\"defender-option\" 作为自动化入口,关卡多格 UI 只能给一个真实控件设置 data-playtest-id=\"lane-cell\" 作为自动化入口,其余选项和格子不得复用这两个固定值。",
"受控试玩会依次开始、选择并放置防御单位、加速,要求敌人移动并受伤、关卡进入 won;随后 next-level 必须让 level 增加,restart 必须再次推进 sequence 并回到 ready 或 playing。"
"受控试玩会依次开始、选择并放置防御单位、加速,要求敌人移动并受伤、关卡进入 won;随后 next-level 必须让 level 增加,restart 必须再次推进 sequence 并回到 ready 或 playing。",
"还必须监听 genarrative:host-message 中的 host.slice.start / host.slice.stoplane-defense-v1 的正式 sliceId 依次固定为 deploy-defender、resolve-wave、advance-level,必须通过 window.genarrativeGameBridge.reportSlice 上报当前 ID 的 playing、paused、completed 或 failed。游戏自身 hit-test / hover 只允许通过 reportTargetInspection 上报当前 .agent/manifest.json 已登记的 asset.id,或与该 asset.id 对应的资源槽位 slotId;离开对象调用 clearInspection,不得上报资源名称、路径、HTML 或宿主命令。"
)
}
}
@@ -333,6 +333,16 @@ fn prepare_completed_autonomous_manifest_fixture(root: &Path) {
.expect("write game design fixture");
fs::write(root.join("game/balance.json"), br#"{"lives":3,"speed":1}"#)
.expect("write balance fixture");
fs::write(
root.join("game/tunable-parameters.json"),
br#"{"schemaVersion":"game-creator-tunable-parameters.v1","parameters":[]}"#,
)
.expect("write tunable parameter registry fixture");
fs::write(
root.join("game/tunable-values.json"),
br#"{"schemaVersion":"game-creator-tunable-values.v1","values":{}}"#,
)
.expect("write tunable parameter values fixture");
fs::write(
root.join("assets/manifest.art.json"),
br#"{"assets":["art-spritesheet.png"]}"#,
@@ -1030,6 +1040,16 @@ fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_progress(
.expect("continued root must pass the scheduler contract gate");
assert_eq!(scheduled.len(), 1);
assert_eq!(scheduled[0].state.agent_id, "design-director");
let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !game_creator_agent_runtime_task_lock_is_available(&root, "design-director")
.expect("probe scheduled design child lane")
{
assert!(
std::time::Instant::now() < release_deadline,
"scheduled design child lane did not settle before continuation validation"
);
std::thread::yield_now();
}
update_manifest_task_status_at(
&root,
"design-director",
@@ -5350,6 +5370,143 @@ fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() {
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &playtest_state).is_none());
}
#[test]
fn preview_playtest_terminal_registers_a_runnable_snapshot_before_downstream_publish_tasks_complete(
) {
let (_temporary, root, parent_state, contract) =
autonomous_fixture("做一个完整小游戏", "autonomous-runnable-version-parent");
for task_id in ["publish-strategy", "publish-package"] {
update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending)
.unwrap_or_else(|error| panic!("leave downstream {task_id} pending: {error}"));
}
let task_ids = read_manifest_for_project(&root)
.expect("read autonomous manifest")
.tasks
.into_iter()
.map(|task| task.id)
.collect::<Vec<_>>();
for task_id in task_ids {
if !matches!(
task_id.as_str(),
"preview-readiness" | "preview-playtest" | "publish-strategy" | "publish-package"
) {
update_manifest_task_status_at(&root, &task_id, GameCreationAppTaskStatus::Completed)
.unwrap_or_else(|error| panic!("complete prerequisite {task_id}: {error}"));
}
}
update_manifest_task_status_at(
&root,
"preview-readiness",
GameCreationAppTaskStatus::Running,
)
.expect("mark preview readiness running");
let readiness_child =
queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness");
let revision = advance_game_index_revision(
&root,
&parent_state,
"<!doctype html><title>可运行版本</title><canvas></canvas>",
);
let readiness_state = agent_runtime_state_from_task_record(&readiness_child);
mark_verification_passed(&root, &readiness_state, "game.static_smoke");
let readiness_terminal = AgentRuntimeTaskRecord {
status: "completed".to_string(),
phase: "completed".to_string(),
updated_at: unix_timestamp(),
..readiness_child
};
append_game_creator_agent_runtime_task_record(&root, &readiness_terminal)
.expect("persist completed preview readiness record");
// This test isolates runnable-version registration. Calling the production
// terminal projector here would asynchronously schedule preview-playtest;
// the explicit child fixture below could then race it and create a second
// logical run with a generated `-dup-*` run id.
update_manifest_task_status_at(
&root,
"preview-readiness",
GameCreationAppTaskStatus::Completed,
)
.expect("project preview readiness completion without scheduling the next wave");
update_manifest_task_status_at(
&root,
"preview-playtest",
GameCreationAppTaskStatus::Running,
)
.expect("mark preview playtest running");
let playtest_child =
queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-playtest");
let playtest_state = agent_runtime_state_from_task_record(&playtest_child);
let result = browser_result_fixture(
&root,
&parent_state,
revision,
BrowserPlaytestScenario::GenericV1,
);
let action = AgentRuntimeToolAction {
tool: "preview.validate".to_string(),
reason: Some("验证可运行版本".to_string()),
input: serde_json::json!({}),
};
let action_fingerprint =
agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task);
let action_id =
agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint);
write_autonomous_playtest_receipt_at(
&root,
&contract,
&action_id,
&action_fingerprint,
revision,
&result,
)
.expect("persist runnable playtest receipt");
let playtest_terminal = AgentRuntimeTaskRecord {
status: "completed".to_string(),
phase: "completed".to_string(),
updated_at: unix_timestamp(),
..playtest_child
};
append_game_creator_agent_runtime_task_record(&root, &playtest_terminal)
.expect("persist completed preview playtest record");
project_autonomous_manifest_ready_task_terminal_at(
&root,
&agent_runtime_state_from_task_record(&playtest_terminal),
)
.expect("project preview playtest and register runnable version");
let manifest = read_manifest_for_project(&root).expect("read runnable manifest");
assert_eq!(manifest.runnable_versions.len(), 1);
let version = &manifest.runnable_versions[0];
assert_eq!(version.project_revision, revision);
assert_eq!(
version.created_reason,
RunnableGameVersionCreatedReason::Initial
);
assert_eq!(
manifest.current_runnable_version_id.as_deref(),
Some(version.version_id.as_str())
);
assert!(root
.join(&version.artifact_path)
.join("game/index.html")
.is_file());
for task_id in ["publish-strategy", "publish-package"] {
let status = manifest
.tasks
.iter()
.find(|task| task.id == task_id)
.map(|task| &task.status)
.unwrap_or_else(|| panic!("missing downstream task {task_id}"));
assert_ne!(
status,
&GameCreationAppTaskStatus::Completed,
"runnable registration must not wait for downstream {task_id} completion"
);
}
}
#[test]
fn autonomous_completion_rejects_formal_artifact_unchanged_from_run_baseline() {
let baseline_bytes =
@@ -5449,6 +5606,32 @@ fn autonomous_playtest_contract_requires_unique_visible_enabled_automation_contr
}
}
#[test]
fn autonomous_playtest_contract_freezes_formal_slice_and_hover_identifiers() {
let generic = autonomous_playtest_contract_prompt(BrowserPlaytestScenario::GenericV1);
for requirement in [
"host.slice.start / host.slice.stop",
"sliceId 固定为 core-gameplay",
"window.genarrativeGameBridge.reportSlice",
".agent/manifest.json 已登记的 asset.id",
"资源槽位 slotId",
"clearInspection",
] {
assert!(
generic.contains(requirement),
"missing generic P4/P5 requirement: {requirement}"
);
}
let lane = autonomous_playtest_contract_prompt(BrowserPlaytestScenario::LaneDefenseV1);
for slice_id in ["deploy-defender", "resolve-wave", "advance-level"] {
assert!(
lane.contains(slice_id),
"missing lane-defense formal slice ID: {slice_id}"
);
}
}
#[test]
fn generic_playtest_contract_requires_play_opportunity_and_post_action_outcome() {
let prompt = autonomous_playtest_contract_prompt(BrowserPlaytestScenario::GenericV1);
@@ -7495,47 +7678,6 @@ fn autonomous_ready_terminal_failures_are_projected_without_retry() {
}
}
#[test]
fn autonomous_scheduler_reprojects_a_recovered_terminal_child_before_returning() {
let (_temporary, root, parent_state, _contract) =
autonomous_fixture("做一个完整小游戏", "autonomous-recovered-terminal-parent");
update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running)
.expect("mark recovered autonomous child running");
let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "design-director");
append_game_creator_agent_runtime_task_record(
&root,
&AgentRuntimeTaskRecord {
status: "completed".to_string(),
phase: "completed".to_string(),
current_action: "recovered child already completed".to_string(),
terminal_detail: Some("completed before scheduler recovery".to_string()),
error: None,
updated_at: unix_timestamp(),
..record
},
)
.expect("append recovered autonomous child terminal");
let scheduled = schedule_autonomous_game_build_ready_tasks_at(
&root,
&parent_state.agent_id,
&parent_state.run_id,
3,
)
.expect("recover terminal autonomous child");
assert_eq!(scheduled.len(), 1);
assert_eq!(
read_manifest_for_project(&root)
.expect("read reprojected manifest")
.tasks
.iter()
.find(|task| task.id == "design-director")
.map(|task| &task.status),
Some(&GameCreationAppTaskStatus::Completed),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_autonomous_child_terminal_projection_preserves_all_manifest_updates() {
let (_temporary, root, parent_state, _contract) =
@@ -705,19 +705,6 @@ pub(in crate::agent) fn continuation_for_game_creator_agent_runtime_steer(
..AgentRuntimeContinuationContext::default()
};
context_tracker.apply_to_continuation(&mut continuation);
if next_loop_index > 0
&& next_loop_index % AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT == 0
&& continuation.window_completed_loops > 0
{
continuation.window_completed_loops = 0;
continuation.window_observation_fingerprints.clear();
continuation.last_window_fingerprint =
super::context_window::agent_runtime_context_window_fingerprint(
&context_tracker.observation_signatures,
)
.or_else(|| context_tracker.last_window_fingerprint.clone());
continuation.context_stalled = false;
}
continuation
}
@@ -889,41 +876,4 @@ mod tests {
fs::remove_dir_all(root).ok();
}
#[test]
fn steer_at_a_skipped_checkpoint_boundary_starts_a_fresh_window() {
let temporary = crate::tests::canonical_test_tempdir("steer-window-boundary-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "project-steer-window", "追加指令窗口边界")
.expect("project init");
let runtime = start_game_creator_agent_runtime_task_at(
&root,
"code-prototype",
"验证追加指令窗口边界",
"steer-context-window-boundary-run",
"agent-background-task",
"追加指令窗口边界测试",
vec!["恢复时保持 context bundle 有效".to_string()],
)
.expect("start steer window boundary runtime state");
let mut tracker = AgentRuntimeContextWindowTracker {
completed_loops: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT - 1,
..AgentRuntimeContextWindowTracker::default()
};
tracker
.observation_signatures
.insert("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string());
let continuation = continuation_for_game_creator_agent_runtime_steer(
&runtime,
&AgentRuntimeToolPlan::default(),
&[],
AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT,
&tracker,
);
assert_eq!(continuation.window_completed_loops, 0);
assert!(continuation.window_observation_fingerprints.is_empty());
assert!(!continuation.context_stalled);
}
}
@@ -33,11 +33,7 @@ pub(in crate::agent) use run_status::*;
pub(in crate::agent) use task_ops::*;
#[cfg(test)]
pub(crate) use media::{
resolve_agent_runtime_platform_art_generation_options_at,
validate_agent_runtime_canvas_delegated_ui_spritesheet_authorization_at,
validate_agent_runtime_canvas_replacement_authorization_at, AGENT_RUNTIME_ART_SPRITESHEET_PATH,
};
pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at;
pub(crate) use action_history::{
is_valid_agent_runtime_action_id, observe_agent_runtime_action_history,
File diff suppressed because it is too large Load Diff
@@ -1257,6 +1257,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
"missingAssetSlots": {
"type": "array",
"maxItems": 2,
"uniqueItems": true,
"items": { "type": "string", "enum": ["art-spec", "core-spritesheet"] }
}
}
@@ -1380,7 +1381,7 @@ mod tests {
let Some(object) = schema.as_object() else {
return;
};
for keyword in ["oneOf", "anyOf", "allOf", "not", "uniqueItems"] {
for keyword in ["oneOf", "anyOf", "allOf", "not"] {
if object.contains_key(keyword) {
issues.push(format!(
"strict schema contains unsupported {keyword} at {path}"
@@ -1026,131 +1026,9 @@ pub(crate) fn register_local_asset_entry(
id_prefix: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
register_local_asset_entry_internal(root, local_path, kind, media_type, id_prefix, source, None)
.map(|(registered, _)| registered)
.map_err(LocalAssetRegistrationError::into_message)
}
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct CanvasAssetRegistrationAuditIdentity {
pub(crate) asset_id: String,
pub(crate) local_path: String,
pub(crate) record_type: String,
pub(crate) transaction_id: String,
}
#[derive(Debug, Eq, PartialEq)]
pub(crate) enum CanvasAssetRegistrationError {
BeforeMutation(String),
AuditAppendOutcomeUnknown {
registered: UploadLocalAssetResult,
audit: CanvasAssetRegistrationAuditIdentity,
message: String,
},
}
impl CanvasAssetRegistrationError {
pub(crate) fn audit_identity(&self) -> Option<&CanvasAssetRegistrationAuditIdentity> {
match self {
Self::BeforeMutation(_) => None,
Self::AuditAppendOutcomeUnknown { audit, .. } => Some(audit),
}
}
}
impl std::fmt::Display for CanvasAssetRegistrationError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BeforeMutation(message) => formatter.write_str(message),
Self::AuditAppendOutcomeUnknown {
audit, message, ..
} => write!(
formatter,
"{message};素材登记审计追加结果未知:assetId={}, localPath={}, recordType={}, transactionId={}",
audit.asset_id, audit.local_path, audit.record_type, audit.transaction_id
),
}
}
}
pub(crate) fn register_local_asset_entry_for_canvas_transaction(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
transaction_id: &str,
) -> Result<(UploadLocalAssetResult, String), CanvasAssetRegistrationError> {
let transaction_id = transaction_id.trim();
validate_agent_db_canvas_asset_transaction_id(transaction_id)
.map_err(CanvasAssetRegistrationError::BeforeMutation)?;
register_local_asset_entry_internal(
root,
local_path,
kind,
media_type,
id_prefix,
source,
Some(transaction_id),
)
.map_err(|error| error.into_canvas_error(transaction_id))
}
enum LocalAssetRegistrationError {
BeforeMutation(String),
AuditAppendOutcomeUnknown {
registered: UploadLocalAssetResult,
record_type: String,
message: String,
},
}
impl LocalAssetRegistrationError {
fn into_message(self) -> String {
match self {
Self::BeforeMutation(message) | Self::AuditAppendOutcomeUnknown { message, .. } => {
message
}
}
}
fn into_canvas_error(self, transaction_id: &str) -> CanvasAssetRegistrationError {
match self {
Self::BeforeMutation(message) => CanvasAssetRegistrationError::BeforeMutation(message),
Self::AuditAppendOutcomeUnknown {
registered,
record_type,
message,
} => CanvasAssetRegistrationError::AuditAppendOutcomeUnknown {
audit: CanvasAssetRegistrationAuditIdentity {
asset_id: registered.id.clone(),
local_path: registered.local_path.clone(),
record_type,
transaction_id: transaction_id.to_string(),
},
registered,
message,
},
}
}
}
fn register_local_asset_entry_internal(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
transaction_id: Option<&str>,
) -> Result<(UploadLocalAssetResult, String), LocalAssetRegistrationError> {
let normalized_path =
normalize_relative_path(local_path).map_err(LocalAssetRegistrationError::BeforeMutation)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)
.map_err(LocalAssetRegistrationError::BeforeMutation)?;
let (manifest_path, mut manifest) =
read_or_create_manifest(root).map_err(LocalAssetRegistrationError::BeforeMutation)?;
let normalized_path = normalize_relative_path(local_path)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
let kind = if kind.is_empty() { "asset" } else { kind };
let media_type = if media_type.is_empty() {
"application/octet-stream"
@@ -1184,49 +1062,25 @@ fn register_local_asset_entry_internal(
});
(id, "asset.register")
};
if let Some(transaction_id) = transaction_id {
validate_agent_db_canvas_asset_audit_identity(transaction_id, &id, &normalized_path)
.map_err(LocalAssetRegistrationError::BeforeMutation)?;
}
write_manifest(&manifest_path, &manifest)
.map_err(LocalAssetRegistrationError::BeforeMutation)?;
let mut audit = serde_json::json!({
"recordType": record_type,
"assetId": id.clone(),
"localPath": normalized_path.clone(),
"kind": kind,
"mediaType": media_type,
"source": source_for_record,
});
if let Some(transaction_id) = transaction_id {
audit
.as_object_mut()
.expect("asset registration audit is an object")
.insert(
"transactionId".to_string(),
serde_json::Value::String(transaction_id.to_string()),
);
}
let registered = UploadLocalAssetResult {
write_manifest(&manifest_path, &manifest)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": record_type,
"assetId": id.clone(),
"localPath": normalized_path.clone(),
"kind": kind,
"mediaType": media_type,
"source": source_for_record,
}),
)?;
Ok(UploadLocalAssetResult {
id,
local_path: normalized_path.clone(),
absolute_path: absolute_path.to_string_lossy().into_owned(),
manifest_path: manifest_path.to_string_lossy().into_owned(),
};
let append_result = if transaction_id.is_some() {
append_agent_db_canvas_asset_transaction_audit_idempotent(root, audit).map(|_| ())
} else {
append_agent_db_record(root, audit)
};
if let Err(message) = append_result {
return Err(LocalAssetRegistrationError::AuditAppendOutcomeUnknown {
registered,
record_type: record_type.to_string(),
message,
});
}
Ok((registered, record_type.to_string()))
})
}
#[cfg(test)]
@@ -1234,152 +1088,6 @@ mod tests {
use super::*;
use std::io::{Read, Write};
fn unique_asset_registration_test_root(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-asset-registration-{label}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
fn canvas_registration_source() -> GameCreationAppAssetSource {
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-project-1".to_string()),
resource_id: Some("resource-1".to_string()),
asset_object_id: Some("asset-object-1".to_string()),
task_id: Some("task-1".to_string()),
prompt: Some("test prompt".to_string()),
model: Some("test-model".to_string()),
generation_route: Some("test-route".to_string()),
generation_kind: Some("test-kind".to_string()),
reference_resource_ids: Vec::new(),
}
}
#[test]
fn canvas_registration_sync_unknown_error_retains_complete_audit_identity() {
let root = unique_asset_registration_test_root("post-sync-identity");
init_local_game_project_at(&root, "asset-registration-test", "素材登记测试")
.expect("initialize asset registration fixture");
let local_path = "assets/ui-spritesheet.png";
fs::write(root.join(local_path), b"test-image").expect("write asset registration fixture");
fs::write(
root.join(".agent/runtime/test-fail-after-agent-db-record-sync"),
"asset.register",
)
.expect("inject post-sync Agent DB failure");
let error = register_local_asset_entry_for_canvas_transaction(
&root,
local_path,
"ui-spritesheet",
"image/png",
"platform-art",
canvas_registration_source(),
"canvas-transaction-1",
)
.expect_err("post-sync append outcome must be surfaced as unknown");
let audit = error
.audit_identity()
.expect("unknown append error carries audit identity");
let registered = match &error {
CanvasAssetRegistrationError::AuditAppendOutcomeUnknown { registered, .. } => {
registered
}
CanvasAssetRegistrationError::BeforeMutation(_) => {
panic!("post-sync error must carry registered asset")
}
};
assert_eq!(audit.asset_id, registered.id);
assert_eq!(audit.local_path, local_path);
assert_eq!(audit.record_type, "asset.register");
assert_eq!(audit.transaction_id, "canvas-transaction-1");
assert!(error.to_string().contains("追加结果未知"));
let records = fs::read_to_string(root.join(".agent/agent.db"))
.expect("read durably appended asset audit");
let matching = records
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.find(|record| {
record.get("recordType").and_then(serde_json::Value::as_str)
== Some("asset.register")
&& record
.get("transactionId")
.and_then(serde_json::Value::as_str)
== Some("canvas-transaction-1")
})
.expect("post-sync failure occurs after the audit is durable");
assert_eq!(matching["assetId"], audit.asset_id);
assert_eq!(matching["localPath"], audit.local_path);
fs::remove_dir_all(root).ok();
}
#[test]
fn canvas_registration_rejects_non_compensatable_identity_before_manifest_mutation() {
let cases = [
(
"unsafe-transaction",
"assets/ui-spritesheet.png",
"platform-art",
"../unsafe",
),
(
"outside-assets",
"ui/ui-spritesheet.png",
"platform-art",
"canvas-transaction-2",
),
(
"unsafe-asset-id",
"assets/ui-spritesheet.png",
"../unsafe",
"canvas-transaction-3",
),
];
for (label, local_path, id_prefix, transaction_id) in cases {
let root = unique_asset_registration_test_root(label);
init_local_game_project_at(&root, "asset-registration-test", "素材登记测试")
.expect("initialize asset registration fixture");
let absolute_path = root.join(local_path);
fs::create_dir_all(
absolute_path
.parent()
.expect("asset identity fixture has parent"),
)
.expect("create asset identity fixture parent");
fs::write(&absolute_path, b"test-image").expect("write asset identity fixture");
let (manifest_path, manifest_before) =
read_or_create_manifest(&root).expect("read manifest before rejected registration");
let error = register_local_asset_entry_for_canvas_transaction(
&root,
local_path,
"ui-spritesheet",
"image/png",
id_prefix,
canvas_registration_source(),
transaction_id,
)
.expect_err("non-compensatable Canvas identity must be rejected");
assert!(
matches!(error, CanvasAssetRegistrationError::BeforeMutation(_)),
"{error}"
);
assert_eq!(
read_manifest(&manifest_path).expect("read manifest after rejected registration"),
manifest_before
);
fs::remove_dir_all(root).ok();
}
}
fn read_asset_test_request(stream: &mut std::net::TcpStream) {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
@@ -193,7 +193,7 @@ pub(crate) fn get_local_game_manifest(
return Err(format!("不支持通过 manifest 执行命令:{command_id}"));
}
enforce_project_permission_policy(root, command_id)?;
read_existing_manifest_for_project(root)
read_manifest_for_project(root)
}
#[tauri::command]
@@ -996,6 +996,61 @@ pub(crate) fn register_local_asset(
)
}
#[tauri::command]
pub(crate) fn replace_local_game_runnable_resource(
project_path: String,
expected_project_id: String,
parent_version_id: String,
slot_id: String,
replacement_resource_id: String,
expected_project_revision: u64,
) -> Result<ReplaceRunnableGameResourceResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "file.write")?;
let _lock = acquire_project_write_lock(root, "runnable.resource_replace")?;
replace_runnable_game_resource_at(
root,
expected_project_id.trim(),
parent_version_id.trim(),
slot_id.trim(),
replacement_resource_id.trim(),
expected_project_revision,
)
}
#[tauri::command]
pub(crate) fn read_local_game_tunable_parameters(
project_path: String,
expected_project_id: String,
version_id: String,
) -> Result<GameTunableParametersReadModel, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "file.read")?;
read_game_tunable_parameters_at(root, expected_project_id.trim(), version_id.trim())
}
#[tauri::command]
pub(crate) fn update_local_game_tunable_parameter(
project_path: String,
expected_project_id: String,
version_id: String,
parameter_id: String,
value: serde_json::Value,
expected_project_revision: u64,
) -> Result<UpdateGameTunableParameterResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "file.write")?;
let _lock = acquire_project_write_lock(root, "tunable.update")?;
update_game_tunable_parameter_at(
root,
expected_project_id.trim(),
version_id.trim(),
parameter_id.trim(),
value,
expected_project_revision,
)
}
#[tauri::command]
pub(crate) fn import_canvas_asset(
project_path: String,
@@ -23,20 +23,25 @@ use reqwest::header;
use serde::{Deserialize, Serialize};
use shared_contracts::game_creation_app::{
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
validate_game_iteration_versions, validate_runnable_game_versions,
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition,
GameIterationVersionResourceBinding, GameResourceCategory, GameResourceDescriptor,
GameTestSlice, GameTestSliceStatus, GameTunableParameterDefinition,
GameTunableParameterValueType, ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasPosition, RunnableGameResourceReplacement, RunnableGameVersion,
RunnableGameVersionCreatedReason, RunnableGameVersionValidation,
UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus,
GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
RUNNABLE_GAME_VERSION_SCHEMA_VERSION,
};
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::DialogExt;
@@ -138,6 +143,13 @@ struct LocalPreviewResult {
root: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalPreviewIdentity {
preview_id: String,
origin: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalPreviewStatus {
@@ -147,6 +159,15 @@ struct LocalPreviewStatus {
root: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct RunnableGameVersionLaunchResult {
manifest: GameCreationAppManifest,
version: RunnableGameVersion,
preview: LocalPreviewResult,
preview_identity: LocalPreviewIdentity,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalGameProjectRevisionStatus {
@@ -2187,6 +2208,9 @@ fn main() {
read_game_creator_mcp_catalog,
upload_local_asset,
register_local_asset,
replace_local_game_runnable_resource,
read_local_game_tunable_parameters,
update_local_game_tunable_parameter,
import_canvas_asset,
import_canvas_export,
sync_canvas_project_assets,
@@ -2227,6 +2251,7 @@ fn main() {
open_game_creator_launcher_window,
open_project_supervisor_chat_window,
start_local_game_preview,
launch_local_game_runnable_version,
activate_local_game_preview,
stop_local_game_preview,
stop_local_game_preview_if_matches,
@@ -7,6 +7,7 @@ pub(crate) struct PreviewRegistry {
struct PreviewServer {
preview: LocalPreviewResult,
identity: LocalPreviewIdentity,
stop: mpsc::Sender<()>,
}
@@ -16,6 +17,19 @@ impl PreviewRegistry {
preview: LocalPreviewResult,
stop: mpsc::Sender<()>,
) -> (LocalPreviewResult, Option<LocalPreviewResult>) {
let (preview, _, previous_preview) = self.set_running_with_identity(preview, stop);
(preview, previous_preview)
}
pub(crate) fn set_running_with_identity(
&self,
preview: LocalPreviewResult,
stop: mpsc::Sender<()>,
) -> (
LocalPreviewResult,
LocalPreviewIdentity,
Option<LocalPreviewResult>,
) {
let mut current = self.current.lock().expect("preview registry lock");
let previous_preview = if let Some(previous) = current.take() {
let preview = previous.preview;
@@ -24,11 +38,21 @@ impl PreviewRegistry {
} else {
None
};
let identity = LocalPreviewIdentity {
preview_id: format!("preview-{}", uuid::Uuid::new_v4().simple()),
origin: format!("http://127.0.0.1:{}", preview.port),
};
*current = Some(PreviewServer {
preview: preview.clone(),
identity: identity.clone(),
stop,
});
(preview, previous_preview)
let registry_identity = current
.as_ref()
.expect("preview registry current server")
.identity
.clone();
(preview, registry_identity, previous_preview)
}
pub(crate) fn status(&self) -> LocalPreviewStatus {
@@ -89,6 +113,150 @@ const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024;
const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100;
const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024;
const GAME_RUN_BRIDGE_SCRIPT_PATH: &str = "/.genarrative/game-bridge.v1.js";
const GAME_RUN_BRIDGE_SCRIPT_TAG: &str =
r#"<script src="/.genarrative/game-bridge.v1.js" data-genarrative-game-bridge="v1"></script>"#;
const GAME_RUN_BRIDGE_BOOTSTRAP: &str = r#"(() => {
'use strict';
const protocolVersion = 'game-creator-run-bridge.v1';
const hostSchema = 'game-creator-host-message.v1';
const runtimeSchema = 'game-creator-runtime-message.v1';
const hostTypes = new Set(['host.start', 'host.pause', 'host.resume', 'host.stop', 'host.state.request', 'host.inspection.request', 'host.slice.start', 'host.slice.stop']);
let session = null;
let hostOrigin = null;
let hostSequence = 0;
let runtimeSequence = 0;
let state = 'starting';
let currentSlice = null;
const hostRequestIds = new Set();
const randomId = () => {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return `runtime-request-${Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('')}`;
};
const sameIdentity = message => session &&
message.sessionId === session.sessionId &&
message.previewId === session.previewId &&
message.projectId === session.projectId &&
message.versionId === session.versionId &&
message.projectRevision === session.projectRevision;
const send = (type, payload, responseTo) => {
if (!session || !hostOrigin) return false;
const message = {
schemaVersion: runtimeSchema,
protocolVersion,
requestId: randomId(),
sessionId: session.sessionId,
previewId: session.previewId,
projectId: session.projectId,
sequence: ++runtimeSequence,
type,
versionId: session.versionId,
projectRevision: session.projectRevision,
payload,
};
if (responseTo) message.responseTo = responseTo;
parent.postMessage(message, hostOrigin);
return true;
};
const reportState = (nextState, summary) => {
state = nextState;
return send('runtime.state', summary === undefined ? { status: nextState } : { status: nextState, summary });
};
const reportError = (code, message, recoverable = false) =>
send('runtime.error', { code, message, recoverable });
const reportSlice = (sliceId, title, status, summary) => {
if (currentSlice && currentSlice.sliceId === sliceId) currentSlice.status = status;
return send('runtime.slice', summary === undefined ? { sliceId, title, status } : { sliceId, title, status, summary });
};
const reportInspection = (inspectionId, label, metrics, summary) =>
send('runtime.inspection', summary === undefined ? { inspectionId, label, metrics } : { inspectionId, label, metrics, summary });
const reportTargetInspection = (targetKind, targetId) =>
send('runtime.inspection', { inspectionId: `hover-${targetKind}`, label: '', metrics: {}, targetKind, targetId });
const clearInspection = () =>
send('runtime.inspection', { inspectionId: 'hover-clear', label: '', metrics: {}, targetKind: 'none' });
Object.defineProperty(window, 'genarrativeGameBridge', {
configurable: false,
enumerable: false,
writable: false,
value: Object.freeze({ protocolVersion, reportState, reportError, reportSlice, reportInspection, reportTargetInspection, clearInspection }),
});
addEventListener('message', event => {
const message = event.data;
if (event.source !== parent || !message || typeof message !== 'object' ||
message.schemaVersion !== hostSchema || message.protocolVersion !== protocolVersion ||
!hostTypes.has(message.type) || !Number.isSafeInteger(message.sequence) ||
typeof message.requestId !== 'string' || hostRequestIds.has(message.requestId)) return;
if (message.type === 'host.start') {
if (session || message.sequence !== 1 || typeof message.sessionId !== 'string' ||
typeof message.previewId !== 'string' || typeof message.projectId !== 'string' ||
typeof message.versionId !== 'string' || !Number.isSafeInteger(message.projectRevision)) return;
hostOrigin = event.origin;
hostSequence = 1;
hostRequestIds.add(message.requestId);
session = {
sessionId: message.sessionId,
previewId: message.previewId,
projectId: message.projectId,
versionId: message.versionId,
projectRevision: message.projectRevision,
};
send('runtime.ready', { capabilities: ['state', 'slice', 'inspection'] }, message.requestId);
state = 'playing';
send('runtime.state', { status: state });
dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message }));
return;
}
if (event.origin !== hostOrigin || !sameIdentity(message) || message.sequence !== hostSequence + 1) return;
if ((message.type === 'host.slice.start' || message.type === 'host.slice.stop') &&
(!message.payload || typeof message.payload !== 'object' || Object.keys(message.payload).length !== 1 ||
typeof message.payload.sliceId !== 'string' || !message.payload.sliceId)) return;
hostSequence = message.sequence;
hostRequestIds.add(message.requestId);
if (message.type === 'host.pause') {
state = 'paused';
send('runtime.state', { status: state }, message.requestId);
} else if (message.type === 'host.resume') {
state = 'playing';
send('runtime.state', { status: state }, message.requestId);
} else if (message.type === 'host.state.request') {
send('runtime.state', { status: state }, message.requestId);
} else if (message.type === 'host.inspection.request') {
send('runtime.inspection', {
inspectionId: 'host-baseline',
label: '运行画面',
metrics: { state, viewportWidth: innerWidth, viewportHeight: innerHeight },
}, message.requestId);
} else if (message.type === 'host.slice.start') {
currentSlice = { sliceId: message.payload.sliceId, status: 'starting' };
send('runtime.slice', { sliceId: currentSlice.sliceId, title: '', status: 'starting' }, message.requestId);
dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message }));
if (currentSlice && currentSlice.sliceId === message.payload.sliceId && currentSlice.status === 'starting') {
currentSlice.status = 'playing';
send('runtime.slice', { sliceId: currentSlice.sliceId, title: '', status: 'playing' });
}
return;
} else if (message.type === 'host.slice.stop') {
const sliceId = message.payload.sliceId;
if (currentSlice && currentSlice.sliceId === sliceId) currentSlice.status = 'paused';
send('runtime.slice', { sliceId, title: '', status: 'paused' }, message.requestId);
currentSlice = null;
clearInspection();
} else if (message.type === 'host.stop') {
state = 'stopped';
send('runtime.state', { status: state }, message.requestId);
}
dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message }));
if (message.type === 'host.stop') {
session = null;
hostOrigin = null;
}
});
})();
"#;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PreviewListenerAcceptDisposition {
@@ -382,6 +550,14 @@ pub(crate) fn activate_local_game_preview(
pub(crate) fn start_local_game_preview_for_project(
root: &Path,
) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> {
start_local_game_preview_for_served_root(root, root)
}
pub(crate) fn start_local_game_preview_for_served_root(
project_root: &Path,
served_root: &Path,
) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> {
let root = project_root;
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
@@ -389,7 +565,10 @@ pub(crate) fn start_local_game_preview_for_project(
return Err("项目目录必须是绝对路径".to_string());
}
let game_root = root.join("game");
if served_root.as_os_str().is_empty() || !served_root.is_absolute() {
return Err("预览产物目录无效".to_string());
}
let game_root = served_root.join("game");
if !game_root.is_dir() {
return Err(format!("游戏目录不存在:{}", game_root.display()));
}
@@ -409,7 +588,7 @@ pub(crate) fn start_local_game_preview_for_project(
listener
.set_nonblocking(true)
.map_err(|error| format!("设置预览监听失败:{error}"))?;
let served_root = root.to_path_buf();
let served_root = served_root.to_path_buf();
let (stop_sender, stop_receiver) = mpsc::channel();
thread::spawn(move || loop {
@@ -443,6 +622,82 @@ pub(crate) fn start_local_game_preview_for_project(
))
}
#[tauri::command]
pub(crate) fn launch_local_game_runnable_version(
project_path: String,
expected_project_id: String,
version_id: Option<String>,
registry: tauri::State<'_, PreviewRegistry>,
) -> Result<RunnableGameVersionLaunchResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "preview.start")?;
let _lock = acquire_project_write_lock(root, "preview.start")?;
let current_manifest = read_existing_manifest_for_project(root)?;
if current_manifest.project_id != expected_project_id.trim() {
return Err("可运行版本项目身份不一致".to_string());
}
let version_id = version_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or(current_manifest.current_runnable_version_id.clone())
.ok_or_else(|| "当前无可运行版本".to_string())?;
let _ = registry.stop_for_project(Some(root));
let (_, version, artifact_root) =
resolve_runnable_game_version_at(root, expected_project_id.trim(), &version_id)?;
let (preview, stop) = match start_local_game_preview_for_served_root(root, &artifact_root) {
Ok(result) => result,
Err(error) => {
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
return Err(format!("可运行版本预览启动失败:{error}"));
}
};
if let Err(error) = record_preview_state(
root,
GameCreationAppPreviewStatus::Running,
Some(preview.url.clone()),
Some(preview.port),
) {
let _ = stop.send(());
return Err(error);
}
if let Err(error) = append_preview_log(root, "running", Some(&preview.url)) {
let _ = stop.send(());
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
return Err(error);
}
let (preview, preview_identity, previous_preview) =
registry.set_running_with_identity(preview, stop);
if let Some(previous_preview) = previous_preview.as_ref() {
record_replaced_preview_stop(previous_preview);
}
if let Err(error) = append_preview_start_trace_step(root, &preview) {
let _ = registry.stop();
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
return Err(error);
}
let (manifest, selected_version, _) = match select_current_runnable_game_version_at(
root,
expected_project_id.trim(),
&version.version_id,
) {
Ok(selected) => selected,
Err(error) => {
let _ = registry.stop();
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
return Err(error);
}
};
Ok(RunnableGameVersionLaunchResult {
manifest,
version: selected_version,
preview,
preview_identity,
})
}
fn handle_preview_stream(mut stream: TcpStream, root: &Path) {
// The listener is nonblocking so its accept loop can observe the stop channel. Windows may
// inherit that mode on accepted sockets; switch each connection back to blocking mode before
@@ -560,6 +815,21 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str)
);
}
if url_path.split('?').next() == Some(GAME_RUN_BRIDGE_SCRIPT_PATH) {
let content_length = GAME_RUN_BRIDGE_BOOTSTRAP.len();
let body = if is_head {
Vec::new()
} else {
GAME_RUN_BRIDGE_BOOTSTRAP.as_bytes().to_vec()
};
return http_response(
"200 OK",
"text/javascript; charset=utf-8",
&body,
content_length,
);
}
let file_path = match resolve_preview_path(root, url_path) {
Ok(path) => path,
Err(_) => {
@@ -574,11 +844,41 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str)
return http_response("404 Not Found", "text/plain", body, b"not found".len());
}
};
let body = if file_path.extension().and_then(|value| value.to_str()) == Some("html") {
inject_game_run_bridge_tag(body)
} else {
body
};
let content_length = body.len();
let body = if is_head { Vec::new() } else { body };
http_response("200 OK", content_type(&file_path), &body, content_length)
}
fn inject_game_run_bridge_tag(body: Vec<u8>) -> Vec<u8> {
let html = match String::from_utf8(body) {
Ok(html) => html,
Err(error) => return error.into_bytes(),
};
if html.contains("data-genarrative-game-bridge=") {
return html.into_bytes();
}
let lowercase = html.to_ascii_lowercase();
let insertion_index = lowercase
.find("<head")
.and_then(|start| {
lowercase[start..]
.find('>')
.map(|offset| start + offset + 1)
})
.or_else(|| lowercase.find("</body>"))
.unwrap_or(html.len());
let mut injected = String::with_capacity(html.len() + GAME_RUN_BRIDGE_SCRIPT_TAG.len());
injected.push_str(&html[..insertion_index]);
injected.push_str(GAME_RUN_BRIDGE_SCRIPT_TAG);
injected.push_str(&html[insertion_index..]);
injected.into_bytes()
}
pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result<PathBuf, String> {
let path = url_path.split('?').next().unwrap_or("/");
let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?;
@@ -12,6 +12,7 @@ mod manifest;
mod memory;
mod resource_dependency_graph;
mod resource_layout;
mod runnable_versions;
mod verification;
pub(crate) use agent_db::*;
@@ -23,4 +24,5 @@ pub(crate) use manifest::*;
pub(crate) use memory::*;
pub(crate) use resource_dependency_graph::*;
pub(crate) use resource_layout::*;
pub(crate) use runnable_versions::*;
pub(crate) use verification::*;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More