Compare commits

...

40 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
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
48 changed files with 8606 additions and 1034 deletions
+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;
@@ -5710,10 +5710,22 @@ mod canvas_generation_tests {
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set request read timeout");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let mut bytes = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let read = stream.read(&mut buffer).expect("read request bytes");
let read = match stream.read(&mut buffer) {
Ok(read) => read,
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) && std::time::Instant::now() < deadline =>
{
continue;
}
Err(error) => panic!("read request bytes: {error}"),
};
if read == 0 {
break;
}
@@ -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);
@@ -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::*;
@@ -799,6 +799,12 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))?;
validate_game_iteration_versions(&manifest.versions)
.map_err(|error| format!("校验 {label} 项目版本失败:{error}"))?;
validate_runnable_game_versions(
&manifest.project_id,
&manifest.runnable_versions,
manifest.current_runnable_version_id.as_deref(),
)
.map_err(|error| format!("校验 {label} 可运行版本失败:{error}"))?;
Ok(manifest)
}
@@ -889,6 +895,12 @@ where
{
validate_game_iteration_versions(&manifest.versions)
.map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?;
validate_runnable_game_versions(
&manifest.project_id,
&manifest.runnable_versions,
manifest.current_runnable_version_id.as_deref(),
)
.map_err(|error| format!("校验 manifest 可运行版本失败:{error}"))?;
let payload = serde_json::to_string_pretty(manifest)
.map_err(|error| format!("序列化 manifest 失败:{error}"))?;
if let Some(parent) = path.parent() {
@@ -908,6 +920,15 @@ where
{
return Err("项目版本记录写入后不可修改、删除或重排".to_string());
}
if existing.runnable_versions.len() > manifest.runnable_versions.len()
|| existing
.runnable_versions
.iter()
.zip(&manifest.runnable_versions)
.any(|(existing, candidate)| existing != candidate)
{
return Err("可运行版本记录写入后不可修改、删除或重排".to_string());
}
}
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
@@ -1,6 +1,8 @@
use super::*;
use shared_contracts::game_creation_app::{
GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding,
RunnableGameVersion, RunnableGameVersionCreatedReason, RunnableGameVersionValidation,
RUNNABLE_GAME_VERSION_SCHEMA_VERSION,
};
fn unique_manifest_test_root(test_name: &str) -> PathBuf {
@@ -62,6 +64,41 @@ fn version_fixture(
}
}
fn runnable_version_fixture(
project_id: &str,
version_id: &str,
parent_version_id: Option<&str>,
project_revision: u64,
created_reason: RunnableGameVersionCreatedReason,
) -> RunnableGameVersion {
RunnableGameVersion {
schema_version: RUNNABLE_GAME_VERSION_SCHEMA_VERSION.to_string(),
version_id: version_id.to_string(),
project_id: project_id.to_string(),
parent_version_id: parent_version_id.map(str::to_string),
project_revision,
artifact_path: format!(".agent/runnable-versions/{version_id}/artifact"),
artifact_sha256: "a".repeat(64),
entry_path: "game/index.html".to_string(),
resource_bindings: Vec::new(),
test_slices: Vec::new(),
resource_descriptors: Vec::new(),
resource_replacement: None,
created_reason,
validation: RunnableGameVersionValidation {
static_smoke_passed: true,
preview_validate_passed: true,
playtest_passed: true,
agent_id: "program-agent".to_string(),
run_id: format!("run-{project_revision}"),
report_path: format!(
".agent/runtime/browser-validations/program-agent/run-{project_revision}/1/validation.json"
),
},
created_at: project_revision,
}
}
#[test]
fn manifest_versions_are_append_only_at_the_storage_boundary() {
let root = unique_manifest_test_root("versions-append-only");
@@ -163,6 +200,73 @@ fn concurrent_manifest_write_cannot_overwrite_an_installed_version_with_a_stale_
fs::remove_dir_all(root).ok();
}
#[test]
fn concurrent_manifest_write_cannot_overwrite_an_installed_runnable_version() {
let root = unique_manifest_test_root("runnable-versions-concurrent-append-only");
let manifest_path = root.join(".agent/manifest.json");
let project_id = "project-runnable-versioned";
let mut stale_manifest = new_game_creation_app_manifest(project_id, "并发可运行版本项目");
stale_manifest
.runnable_versions
.push(runnable_version_fixture(
project_id,
"runnable-root",
None,
1,
RunnableGameVersionCreatedReason::Initial,
));
stale_manifest.current_runnable_version_id = Some("runnable-root".to_string());
write_manifest(&manifest_path, &stale_manifest).expect("write initial runnable version");
let mut newer_manifest = stale_manifest.clone();
newer_manifest
.runnable_versions
.push(runnable_version_fixture(
project_id,
"runnable-child",
Some("runnable-root"),
2,
RunnableGameVersionCreatedReason::AgentRevision,
));
newer_manifest.current_runnable_version_id = Some("runnable-child".to_string());
let (newer_locked_tx, newer_locked_rx) = mpsc::channel();
let (release_newer_tx, release_newer_rx) = mpsc::channel();
let newer_path = manifest_path.clone();
let newer_writer = std::thread::spawn(move || {
write_manifest_with_lock_hook(&newer_path, &newer_manifest, || {
newer_locked_tx
.send(())
.expect("signal newer lock acquired");
release_newer_rx.recv().expect("release newer writer");
})
});
newer_locked_rx
.recv_timeout(Duration::from_secs(2))
.expect("newer writer acquires manifest lock");
let stale_path = manifest_path.clone();
let stale_writer = std::thread::spawn(move || write_manifest(&stale_path, &stale_manifest));
release_newer_tx.send(()).expect("release newer writer");
newer_writer
.join()
.expect("join newer writer")
.expect("install newer runnable manifest");
let stale_error = stale_writer
.join()
.expect("join stale writer")
.expect_err("reject stale runnable manifest");
assert!(
stale_error.contains("可运行版本记录写入后不可修改、删除或重排"),
"{stale_error}"
);
let installed = read_manifest(&manifest_path).expect("read final manifest");
assert_eq!(installed.runnable_versions.len(), 2);
assert_eq!(installed.runnable_versions[1].version_id, "runnable-child");
fs::remove_dir_all(root).ok();
}
#[test]
fn manifest_install_uses_previous_when_direct_replace_fails() {
let root = unique_manifest_test_root("replace-fallback");
File diff suppressed because it is too large Load Diff
@@ -2433,6 +2433,41 @@ fn preview_registry_reports_status_and_stops_previous_server() {
assert_eq!(registry.status().status, "stopped");
}
#[test]
fn preview_registry_rotates_p3_identity_with_the_owned_server() {
let registry = PreviewRegistry::default();
let (first_stop, first_receiver) = mpsc::channel();
let (_, first_identity, previous) = registry.set_running_with_identity(
LocalPreviewResult {
url: "http://127.0.0.1:4101/".to_string(),
port: 4101,
root: "/tmp/game-one".to_string(),
},
first_stop,
);
assert!(previous.is_none());
assert_eq!(first_identity.origin, "http://127.0.0.1:4101");
assert!(first_identity.preview_id.starts_with("preview-"));
let (second_stop, _) = mpsc::channel();
let (_, second_identity, previous) = registry.set_running_with_identity(
LocalPreviewResult {
url: "http://127.0.0.1:4102/".to_string(),
port: 4102,
root: "/tmp/game-two".to_string(),
},
second_stop,
);
assert!(first_receiver.try_recv().is_ok());
assert_eq!(
previous.expect("replaced preview").url,
"http://127.0.0.1:4101/"
);
assert_ne!(first_identity.preview_id, second_identity.preview_id);
assert_eq!(second_identity.origin, "http://127.0.0.1:4102");
registry.stop();
}
#[test]
fn replaced_preview_records_stopped_state() {
let root = unique_project_path();
@@ -3240,6 +3275,74 @@ fn local_preview_server_serves_game_index() {
fs::remove_dir_all(root).ok();
}
#[test]
fn local_preview_can_serve_an_immutable_version_snapshot_instead_of_the_working_tree() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-snapshot", "快照预览测试").expect("project init");
fs::write(root.join("game/index.html"), "<main>working tree</main>")
.expect("write working tree game");
let snapshot_root = root.join(".agent/runnable-versions/runnable-r1/artifact");
fs::create_dir_all(snapshot_root.join("game")).expect("create snapshot game directory");
fs::create_dir_all(snapshot_root.join("assets")).expect("create snapshot assets directory");
fs::write(
snapshot_root.join("game/index.html"),
"<main>immutable snapshot</main>",
)
.expect("write snapshot game");
let (preview, stop) = start_local_game_preview_for_served_root(&root, &snapshot_root)
.expect("snapshot preview start");
let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect");
stream
.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
.expect("request");
let mut response = String::new();
stream.read_to_string(&mut response).expect("response");
assert!(response.contains("200 OK"), "{response}");
assert!(response.contains("immutable snapshot"), "{response}");
assert!(!response.contains("working tree"), "{response}");
assert_eq!(preview.root, root.to_string_lossy());
let _ = stop.send(());
fs::remove_dir_all(root).ok();
}
#[test]
fn local_preview_injects_the_host_owned_runtime_bridge_without_mutating_html() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-bridge", "运行桥预览测试").expect("project init");
let original = "<!doctype html><html><head><title>Bridge</title></head><body></body></html>";
fs::write(root.join("game/index.html"), original).expect("write bridge fixture");
let html_response =
String::from_utf8(build_preview_response(&root, "GET", "/")).expect("html response");
assert!(html_response.contains("data-genarrative-game-bridge=\"v1\""));
assert!(html_response.contains("/.genarrative/game-bridge.v1.js"));
assert_eq!(
fs::read_to_string(root.join("game/index.html")).expect("read original html"),
original
);
let script_response = String::from_utf8(build_preview_response(
&root,
"GET",
"/.genarrative/game-bridge.v1.js",
))
.expect("bridge response");
assert!(script_response.contains("Content-Type: text/javascript; charset=utf-8"));
assert!(script_response.contains("game-creator-run-bridge.v1"));
assert!(script_response.contains("runtime.ready"));
assert!(script_response.contains("event.source !== parent"));
assert!(script_response.contains("host.slice.start"));
assert!(script_response.contains("host.slice.stop"));
assert!(script_response.contains("reportTargetInspection"));
assert!(script_response.contains("clearInspection"));
assert!(script_response.contains("Object.keys(message.payload).length !== 1"));
fs::remove_dir_all(root).ok();
}
#[test]
fn local_preview_server_drains_split_browser_headers_before_response() {
let root = unique_project_path();
@@ -0,0 +1,21 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Genarrative AI Game Creator App Run",
"identifier": "world.genarrative.ai-game-creator.app-run",
"build": {
"devUrl": "http://127.0.0.1:3081/"
},
"app": {
"windows": [
{
"label": "client",
"title": "AI 游戏创作 · App Run",
"url": "index.html",
"width": 1280,
"height": 800,
"minWidth": 1280,
"minHeight": 800
}
]
}
}
+1 -3
View File
@@ -831,9 +831,7 @@ export function App({
projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream;
const refreshManifest = useCallback(
(
nextProjectPath = localProjectPathRef.current ?? '',
): Promise<void> => {
(nextProjectPath = localProjectPathRef.current ?? ''): Promise<void> => {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath) {
return Promise.resolve();
@@ -52,6 +52,7 @@ export function WorkspaceLauncherShell({
setAgentRuntimeSummaries: setActiveProjectAgentRuntimeSummaries,
activeProjectAgentResults,
setAgentResults: setActiveProjectAgentResults,
updateCurrentProjectManifest,
resetLauncherHomeDraft,
createHomeDraft,
openProject,
@@ -176,6 +177,8 @@ export function WorkspaceLauncherShell({
agentResults={activeProjectAgentResults}
onHomeOpen={() => setLauncherView('home')}
onProjectsOpen={() => setLauncherView('projects')}
onManifestChange={updateCurrentProjectManifest}
onPreviewChange={setActiveProjectPreview}
supervisor={
<ProjectSupervisor
key={currentProjectContext.projectPath}
@@ -2,6 +2,7 @@ import {
type Dispatch,
type FormEvent,
type SetStateAction,
useCallback,
useState,
} from 'react';
@@ -96,6 +97,16 @@ export function useHomeProjectCreation({
rememberRecentWorkspace(context.projectPath);
}
const updateCurrentProjectManifest = useCallback((manifest: GameCreationAppManifest) => {
setCurrentProjectContext((current) =>
current && current.manifest.projectId === manifest.projectId
? current.manifest === manifest
? current
: { ...current, manifest }
: current,
);
}, []);
async function importHomeAttachments(
invoke: TauriInvoke,
nextProjectPath: string,
@@ -437,6 +448,7 @@ export function useHomeProjectCreation({
setAgentRuntimeSummaries,
activeProjectAgentResults,
setAgentResults,
updateCurrentProjectManifest,
pendingNonEmptyProject,
resetLauncherHomeDraft,
createHomeDraft,
@@ -1,10 +1,54 @@
/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its small rendering adapter for focused tests. */
/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its rendering adapter for focused tests. */
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import type {
GameHostMessageType,
GameRunSession,
GameRuntimeMessage,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
createGameHostMessage,
createGameRunSession,
type GameRunBridgeIdentity,
GameRunBridgeRuntimeGuard,
gameRunSessionFromRuntimeMessage,
newGameRunBridgeRequestId,
newGameRunSessionId,
validateGameRunBridgeIdentity,
} from './gameRunBridge';
export type LocalGamePreviewLike = {
status?: string | null;
url?: string | null;
};
export type LocalGamePreviewFrameHandle = {
pause: () => boolean;
resume: () => boolean;
stop: () => boolean;
requestState: () => boolean;
requestInspection: () => boolean;
startSlice: (sliceId: string) => boolean;
stopSlice: (sliceId: string) => boolean;
markStale: (
reason: 'project-revision-changed' | 'version-changed',
) => boolean;
};
type ActiveBridgeSession = {
guard: GameRunBridgeRuntimeGuard;
identity: GameRunBridgeIdentity;
nextHostSequence: number;
session: GameRunSession;
};
export function resolveEmbeddedPreviewUrl(
preview: LocalGamePreviewLike | null | undefined,
) {
@@ -25,26 +69,189 @@ export function resolveEmbeddedPreviewUrl(
}
}
export function LocalGamePreviewFrame({
preview,
title,
className,
}: {
preview: LocalGamePreviewLike | null | undefined;
title: string;
className?: string;
}) {
export const LocalGamePreviewFrame = forwardRef<
LocalGamePreviewFrameHandle,
{
preview: LocalGamePreviewLike | null | undefined;
title: string;
className?: string;
runIdentity?: GameRunBridgeIdentity | null;
onSessionChange?: (session: GameRunSession) => void;
onRuntimeMessage?: (message: GameRuntimeMessage) => void;
onBridgeError?: (message: string) => void;
}
>(function LocalGamePreviewFrame(
{
preview,
title,
className,
runIdentity,
onSessionChange,
onRuntimeMessage,
onBridgeError,
},
forwardedRef,
) {
const embeddedUrl = resolveEmbeddedPreviewUrl(preview);
const iframeRef = useRef<HTMLIFrameElement>(null);
const activeRef = useRef<ActiveBridgeSession | null>(null);
const callbacksRef = useRef({
onBridgeError,
onRuntimeMessage,
onSessionChange,
});
callbacksRef.current = {
onBridgeError,
onRuntimeMessage,
onSessionChange,
};
const sendHostMessage = useCallback(
(type: GameHostMessageType, sliceId?: string) => {
const active = activeRef.current;
const targetWindow = iframeRef.current?.contentWindow;
if (!active || !targetWindow) {
return false;
}
const requestId = newGameRunBridgeRequestId('host');
if (!requestId || !active.guard.registerHostRequest(requestId, type)) {
callbacksRef.current.onBridgeError?.('运行会话无法创建安全请求身份');
return false;
}
const message = createGameHostMessage({
identity: active.identity,
sessionId: active.session.sessionId,
requestId,
sequence: active.nextHostSequence,
type,
payload: sliceId ? { sliceId } : undefined,
});
active.nextHostSequence += 1;
targetWindow.postMessage(message, active.identity.previewOrigin);
return true;
},
[],
);
const stopActiveSession = useCallback(
(notify: boolean) => {
const active = activeRef.current;
if (!active) {
return false;
}
sendHostMessage('host.stop');
activeRef.current = null;
if (notify) {
callbacksRef.current.onSessionChange?.({
...active.session,
status: 'stopped',
updatedAt: Date.now(),
});
}
return true;
},
[sendHostMessage],
);
useImperativeHandle(
forwardedRef,
() => ({
pause: () => sendHostMessage('host.pause'),
resume: () => sendHostMessage('host.resume'),
stop: () => stopActiveSession(true),
requestState: () => sendHostMessage('host.state.request'),
requestInspection: () => sendHostMessage('host.inspection.request'),
startSlice: (sliceId) => sendHostMessage('host.slice.start', sliceId),
stopSlice: (sliceId) => sendHostMessage('host.slice.stop', sliceId),
markStale: (reason) => {
const active = activeRef.current;
if (!active) {
return false;
}
active.session = {
...active.session,
stale: true,
staleReason: reason,
updatedAt: Date.now(),
};
callbacksRef.current.onSessionChange?.(active.session);
return true;
},
}),
[sendHostMessage, stopActiveSession],
);
useEffect(() => {
function receiveRuntimeMessage(event: MessageEvent<unknown>) {
const active = activeRef.current;
if (
!active ||
event.source !== iframeRef.current?.contentWindow ||
event.origin !== active.identity.previewOrigin
) {
return;
}
const message = active.guard.accept(event.data);
if (!message) {
return;
}
active.session = gameRunSessionFromRuntimeMessage(
active.session,
message,
Date.now(),
);
callbacksRef.current.onSessionChange?.(active.session);
callbacksRef.current.onRuntimeMessage?.(message);
}
window.addEventListener('message', receiveRuntimeMessage);
return () => {
window.removeEventListener('message', receiveRuntimeMessage);
stopActiveSession(false);
};
}, [stopActiveSession]);
function handleLoad() {
stopActiveSession(true);
if (!runIdentity || !embeddedUrl) {
return;
}
const identity = validateGameRunBridgeIdentity(runIdentity);
if (!identity || new URL(embeddedUrl).origin !== identity.previewOrigin) {
callbacksRef.current.onBridgeError?.(
'运行预览身份与 loopback origin 不一致',
);
return;
}
const sessionId = newGameRunSessionId();
if (!sessionId) {
callbacksRef.current.onBridgeError?.('当前 WebView 不支持安全运行会话');
return;
}
const session = createGameRunSession(identity, sessionId, Date.now());
activeRef.current = {
guard: new GameRunBridgeRuntimeGuard(identity, sessionId),
identity,
nextHostSequence: 1,
session,
};
callbacksRef.current.onSessionChange?.(session);
if (!sendHostMessage('host.start')) {
activeRef.current = null;
}
}
if (!embeddedUrl) {
return null;
}
return (
<iframe
ref={iframeRef}
className={className}
title={title}
src={embeddedUrl}
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"
allow="autoplay; fullscreen; gamepad"
onLoad={handleLoad}
/>
);
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
import type {
GameRuntimeMessage,
GameTestSlice,
GameTestSliceStatus,
RunnableGameVersion,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ActiveGameTestSlice = GameTestSlice & {
stale: boolean;
};
export type TrustedGameResourceInspection = {
targetKind: 'resource' | 'entity';
targetId: string;
resourceId: string;
slotId: string;
name: string;
category: 'document' | 'version' | 'art' | 'audio';
subtype: string;
format: string;
width?: number;
height?: number;
durationMs?: number;
versionId: string;
};
const transitions: Record<
GameTestSliceStatus,
ReadonlySet<GameTestSliceStatus>
> = {
idle: new Set(['starting']),
starting: new Set(['playing', 'failed']),
playing: new Set(['paused', 'completed', 'failed']),
paused: new Set(['playing', 'completed', 'failed']),
completed: new Set(['starting']),
failed: new Set(['starting']),
};
function validSliceText(value: string, maxChars: number) {
return value.length > 0 && value.length <= maxChars && value.trim() === value;
}
export function formalGameTestSlices(version: RunnableGameVersion | null) {
if (!version?.testSlices || version.testSlices.length > 64) {
return [];
}
const ids = new Set<string>();
const orders = new Set<number>();
for (const slice of version.testSlices) {
if (
slice.versionId !== version.versionId ||
slice.status !== 'idle' ||
!validSliceText(slice.sliceId, 128) ||
!validSliceText(slice.title, 128) ||
!Number.isSafeInteger(slice.order) ||
slice.order < 0 ||
ids.has(slice.sliceId) ||
orders.has(slice.order)
) {
return [];
}
ids.add(slice.sliceId);
orders.add(slice.order);
}
return [...version.testSlices].sort(
(left, right) =>
left.order - right.order || left.sliceId.localeCompare(right.sliceId),
);
}
export function beginGameTestSlice(slice: GameTestSlice): ActiveGameTestSlice {
return { ...slice, status: 'starting', stale: false };
}
export function projectGameTestSliceMessage(
active: ActiveGameTestSlice | null,
message: GameRuntimeMessage,
): ActiveGameTestSlice | null {
if (
!active ||
active.stale ||
message.type !== 'runtime.slice' ||
message.payload.sliceId !== active.sliceId
) {
return active;
}
const nextStatus = message.payload.status;
if (
nextStatus === active.status ||
transitions[active.status].has(nextStatus)
) {
return { ...active, status: nextStatus };
}
return active;
}
export function projectGameTestSliceSessionStatus(
active: ActiveGameTestSlice | null,
message: GameRuntimeMessage,
): ActiveGameTestSlice | null {
if (!active || active.stale || message.type !== 'runtime.state') {
return active;
}
const nextStatus =
message.payload.status === 'paused'
? 'paused'
: message.payload.status === 'playing'
? 'playing'
: null;
if (!nextStatus || !transitions[active.status].has(nextStatus)) {
return active;
}
return { ...active, status: nextStatus };
}
export function trustedInspectionFromRuntimeMessage(
version: RunnableGameVersion | null,
message: GameRuntimeMessage,
): TrustedGameResourceInspection | null | undefined {
if (!version || message.type !== 'runtime.inspection') {
return undefined;
}
const { targetKind, targetId } = message.payload;
if (targetKind === 'none') {
return null;
}
if ((targetKind !== 'resource' && targetKind !== 'entity') || !targetId) {
return undefined;
}
const binding = version.resourceBindings.find((candidate) =>
targetKind === 'resource'
? candidate.resourceId === targetId
: candidate.slotId === targetId,
);
if (!binding) {
return null;
}
const descriptor = version.resourceDescriptors?.find(
(candidate) => candidate.resourceId === binding.resourceId,
);
if (!descriptor) {
return null;
}
return {
targetKind,
targetId,
resourceId: descriptor.resourceId,
slotId: binding.slotId,
name: descriptor.name,
category: descriptor.category,
subtype: descriptor.subtype,
format: descriptor.format,
width: descriptor.width,
height: descriptor.height,
durationMs: descriptor.durationMs,
versionId: version.versionId,
};
}
+262 -33
View File
@@ -750,7 +750,9 @@ textarea {
@media (prefers-reduced-motion: reduce) {
.launcher-agent-chat-waiting > span,
.game-chat-runtime-status[data-tone='active'] .game-chat-runtime-state > span {
.game-chat-runtime-status[data-tone='active']
.game-chat-runtime-state
> span {
animation: none;
}
}
@@ -1538,7 +1540,9 @@ textarea {
box-shadow: 0 0 0 4px rgb(240 68 56 / 15%);
}
.game-chat-runtime-status[data-tone='complete'] .game-chat-runtime-state > span {
.game-chat-runtime-status[data-tone='complete']
.game-chat-runtime-state
> span {
background: #2e90fa;
box-shadow: 0 0 0 4px rgb(46 144 250 / 14%);
}
@@ -3963,13 +3967,14 @@ iframe.preview-frame {
.game-workbench-stage {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
}
.game-workbench-toolbar {
display: flex;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
padding: 8px 12px;
@@ -3978,19 +3983,33 @@ iframe.preview-frame {
}
.game-workbench-tabs,
.game-workbench-view-actions {
.game-workbench-view-actions,
.game-resource-layout-switch {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
}
.game-workbench-tabs {
flex: 0 0 auto;
flex-wrap: wrap;
padding: 3px;
border-radius: 999px;
background: #f3ded3;
}
.game-workbench-view-actions {
min-width: 0;
width: 100%;
flex-wrap: nowrap;
justify-content: flex-end;
}
.game-resource-layout-switch {
flex: 0 0 auto;
flex-wrap: nowrap;
}
.game-workbench-tabs button,
.game-workbench-view-actions button {
display: inline-flex;
@@ -4028,11 +4047,19 @@ iframe.preview-frame {
.game-resource-reorder-status {
min-width: 0;
flex: 1 1 auto;
max-width: min(240px, 28vw);
overflow: hidden;
color: #9a725f;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-reorder-status:empty {
display: none;
}
.game-run-unavailable {
margin: 0;
padding: 7px 14px;
@@ -4045,7 +4072,9 @@ iframe.preview-frame {
.game-resource-manager {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.game-resource-search {
@@ -4099,14 +4128,42 @@ iframe.preview-frame {
.game-resource-canvas {
position: relative;
flex: 1;
width: 100%;
min-width: 0;
min-height: 0;
padding: 12px;
overflow: auto;
overflow-x: auto;
overflow-y: auto;
scrollbar-color: #d5a089 #f7e7df;
scrollbar-width: thin;
background-color: #fffdfa;
background-image: radial-gradient(#eaded8 0.8px, transparent 0.8px);
background-size: 18px 18px;
}
.game-resource-canvas::-webkit-scrollbar {
width: 10px;
height: 12px;
}
.game-resource-canvas::-webkit-scrollbar-track {
border-radius: 999px;
background: #f7e7df;
}
.game-resource-canvas::-webkit-scrollbar-thumb {
border: 2px solid #f7e7df;
border-radius: 999px;
background: #d5a089;
}
.game-resource-canvas::-webkit-scrollbar-thumb:hover {
background: #c98566;
}
.game-resource-canvas::-webkit-scrollbar-corner {
background: #f7e7df;
}
.game-resource-canvas-content {
position: relative;
display: grid;
@@ -4137,7 +4194,6 @@ iframe.preview-frame {
white-space: nowrap;
border: 0;
}
.game-resource-dependency-edge,
.game-resource-dependency-edge path {
fill: none;
@@ -4152,21 +4208,6 @@ iframe.preview-frame {
opacity: 1;
}
.game-resource-dependency-edge--task path {
stroke: #918b87;
stroke-width: 1.4px;
stroke-dasharray: 4 7;
}
.game-resource-dependency-edge--task .game-resource-dependency-trunk {
stroke-width: 1.7px;
opacity: 0.88;
}
.game-resource-dependency-edge--task .game-resource-dependency-branch {
opacity: 0.72;
}
.game-resource-dependency-edge.is-cyclic,
.game-resource-dependency-edge.is-cyclic path {
stroke-dashoffset: 4;
@@ -4177,11 +4218,6 @@ iframe.preview-frame {
stroke-linejoin: round;
}
.game-resource-dependency-marker--task path {
fill: #918b87;
stroke-linejoin: round;
}
.game-resource-section {
position: relative;
z-index: 1;
@@ -4277,6 +4313,14 @@ iframe.preview-frame {
0 0 0 2px rgb(216 115 66 / 14%);
}
.game-resource-card.is-current-version {
border-color: #c85f31;
background: #fff7f1;
box-shadow:
0 8px 22px rgb(195 105 62 / 18%),
inset 0 0 0 2px rgb(216 115 66 / 16%);
}
.game-resource-card-icon {
display: grid;
grid-row: 1 / 4;
@@ -4426,6 +4470,18 @@ iframe.preview-frame {
font-size: 10px;
}
.game-resource-replace-trigger {
justify-self: start;
min-height: 32px;
padding: 0 14px;
border: 1px solid #d87a4d;
border-radius: 999px;
background: #fff6f0;
color: #a8502c;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.game-resource-focus-metadata dt {
color: #a08377;
}
@@ -4655,13 +4711,32 @@ iframe.preview-frame {
.game-run-surface {
display: grid;
grid-template-rows: minmax(300px, 1fr) auto auto;
grid-template-rows: minmax(300px, 1fr) auto auto auto;
gap: 12px;
min-height: 0;
padding: 12px;
background: #fffdfa;
}
.game-run-version-picker {
display: inline-flex;
align-items: center;
gap: 6px;
color: #76574a;
font-size: 10px;
font-weight: 700;
}
.game-run-version-picker select {
min-width: 190px;
height: 30px;
border: 1px solid #e4cfc4;
border-radius: 9px;
background: #fff;
color: #65483d;
font-size: 10px;
}
.game-run-preview {
position: relative;
display: grid;
@@ -4728,6 +4803,11 @@ iframe.preview-frame {
cursor: pointer;
}
.game-run-slice-controls button:disabled {
cursor: default;
opacity: 0.45;
}
.game-run-slice-controls span {
color: #9a7d70;
font-size: 11px;
@@ -4740,6 +4820,13 @@ iframe.preview-frame {
gap: 10px;
}
.game-run-status {
margin: 0;
color: #8b634f;
font-size: 10px;
text-align: center;
}
.game-run-panels > section {
display: grid;
align-content: start;
@@ -4807,6 +4894,144 @@ iframe.preview-frame {
font-size: 10px;
}
.game-run-panels button {
justify-self: start;
min-height: 28px;
padding: 0 12px;
border: 1px solid #dfb59f;
border-radius: 999px;
background: #fff8f4;
color: #855c4c;
font-size: 10px;
cursor: pointer;
}
.game-run-panels button:disabled {
cursor: default;
opacity: 0.5;
}
.game-tunable-panel {
grid-column: 1 / -1;
}
.game-tunable-natural-language {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.game-tunable-natural-language input {
width: 100%;
}
.game-tunable-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.game-tunable-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 8px;
padding: 8px;
border: 1px solid #eee0d9;
border-radius: 9px;
background: #fffdfa;
}
.game-tunable-item.is-matched {
border-color: #d87342;
box-shadow: 0 0 0 2px rgb(216 115 66 / 12%);
}
.game-tunable-item label {
grid-template-columns: minmax(0, 1fr) 96px;
}
.game-tunable-item select {
min-width: 0;
height: 30px;
border: 1px solid #eadbd4;
border-radius: 8px;
background: #faf7f5;
color: #62483d;
font-size: 10px;
}
.game-resource-replacement-dialog {
display: grid;
gap: 14px;
width: min(620px, calc(100vw - 32px));
max-height: min(680px, calc(100vh - 32px));
padding: 18px;
overflow: auto;
border: 1px solid #e3c9bb;
border-radius: 18px;
background: #fffdfb;
box-shadow: 0 24px 80px rgb(80 43 28 / 25%);
}
.game-resource-replacement-dialog > header,
.game-resource-replacement-dialog > footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.game-resource-replacement-dialog h2,
.game-resource-replacement-dialog p {
margin: 0;
}
.game-resource-replacement-dialog h2 {
color: #5b4035;
font-size: 16px;
}
.game-resource-replacement-dialog p,
.game-resource-replacement-dialog small {
color: #987a6d;
font-size: 10px;
}
.game-resource-replacement-dialog button {
min-height: 32px;
padding: 0 12px;
border: 1px solid #e2c5b6;
border-radius: 10px;
background: #fff7f2;
color: #805747;
cursor: pointer;
}
.game-resource-replacement-dialog button:disabled {
cursor: default;
opacity: 0.5;
}
.game-resource-replacement-candidates {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.game-resource-replacement-candidates button {
display: grid;
justify-items: start;
gap: 4px;
min-height: 72px;
text-align: left;
}
.game-resource-replacement-candidates button.is-selected {
border-color: #d87342;
background: #fff0e7;
}
.game-workbench-chat {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
@@ -5276,7 +5501,7 @@ iframe.preview-frame {
.game-workbench-toolbar {
align-items: stretch;
flex-direction: column;
grid-template-columns: minmax(0, 1fr);
}
.game-workbench-view-actions {
@@ -5301,10 +5526,9 @@ iframe.preview-frame {
min-height: 560px;
}
.game-workbench-toolbar,
.game-workbench-view-actions {
.game-workbench-toolbar {
align-items: stretch;
flex-direction: column;
grid-template-columns: minmax(0, 1fr);
}
.game-workbench-tabs {
@@ -5329,6 +5553,11 @@ iframe.preview-frame {
grid-template-columns: 1fr;
}
.game-tunable-list,
.game-resource-replacement-candidates {
grid-template-columns: 1fr;
}
.game-workbench-chat .project-supervisor-message-list {
max-height: 52vh;
}

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