Compare commits

..

26 Commits

Author SHA1 Message Date
k88936 0c4b1c34d9 标注模型下载的重复 task 查询
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m17s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m19s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m31s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m39s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m3s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m36s
Project CI / Native shell tests (pull_request) Failing after 11m43s
Project CI / AI game creator shell web tests (pull_request) Failing after 10m21s
Project CI / Frontend tests (pull_request) Successful in 12m23s
在 download_model 增加 TODO,说明 SDK 只提供 download_model(&Task) 因而必须再取一次 task

若将来 SDK 支持按 URL 下载,可直接使用 snapshot 中的模型 URL 省掉这次请求
2026-09-19 14:17:31 +08:00
k88936 3c1059aa39 移除 TripoUrl 的 Display 实现
删除 TripoUrl 的 Display,避免 `{}` 这类通用格式化输出带签名的完整 URL

完整 URL 仍可通过 as_str() 显式读取,日志与调试统一走 redacted() 或脱敏后的 Debug

技术方案文档补充该收敛说明
2026-09-19 14:17:00 +08:00
k88936 1392303f1c 请求契约把 model 改为必填字段
三个生成请求的 model 由 Option<Model3dModelVersion> 改为必填 Model3dModelVersion,并同步重新生成 TypeScript 绑定

validate_generation_options 不再重复校验 model 缺失,直接由必填的 model 推导 family

三个 client 的 to_sdk_params 改为直接提交模型版本字符串

冒烟示例与技术方案文档同步为必填写法
2026-09-19 14:16:39 +08:00
k88936 12512f26b2 multiview 输入拒绝未知字段
Model3dMultiviewInputs 增加 deny_unknown_fields,与 Model3dMultiviewToModelRequest 的严格反序列化保持一致

未知视图键(例如拼写错误的 righ)不再被静默丢弃,避免用少于调用方预期的视图提交任务

ts-rs 12 不支持 enum 级该属性,构建时会打印一条可忽略的告警,已在代码注释说明
2026-09-19 14:14:55 +08:00
k88936 800da43a8d 说明生成结果 DTO 的 number 精度边界
为 poll_after_ms、updated_at_micros、size_bytes 补充注释,说明其量级远低于 2^53,按 TS number 导出即可精确表示

重新生成 TypeScript 绑定,把上述说明同步为 JSDoc

技术方案补充同一说明,明确不做 bigint/string 特殊处理
2026-09-19 14:14:14 +08:00
k88936 b703f0c374 同步Tripo集成文档的契约导出与脱敏说明
补充 TripoUrl 脱敏输出与 as_str 读取完整 URL 的说明

补充请求可选字段在 TypeScript 绑定中为 field?: T | null 及 model3d barrel 导出入口

补充 ts-rs 生成后需 prettier 格式化再提交的流程说明
2026-09-19 13:17:58 +08:00
k88936 b87d9358e5 对齐 Api 与 Request 的可重试状态码
TripoError::is_retryable 的 Api 分支补充 408 与 425,与 Request 分支的可重试集合保持一致
2026-09-19 13:15:31 +08:00
k88936 f4996d7ac2 auto_size 仅在显式开启时按模型能力拦截
validate_generation_options 中 auto_size 的 H25 校验由 is_some() 改为 == Some(true)

auto_size: false 不再被当作启用,与 quad、smart_low_poly、generate_parts 的取值语义保持一致
2026-09-19 13:15:00 +08:00
k88936 09ef92420f multiview 复用统一的 task_id 校验
删除 submit_multiview_to_model 中重复的空白判断,改为调用 common::validate_task_id

task_id 非法时的错误字段由 Inputs 改为更精确的 TaskId,与 get_task、download_model 保持一致
2026-09-19 13:14:32 +08:00
k88936 091938ed59 multiview 提交前裁剪 task_id
to_sdk_params 改为提交 task_id.trim(),与 task_id.trim().is_empty() 的校验使用同一份标准化输入

带前后空白的 task_id 不再原样转发给 Provider
2026-09-19 13:13:54 +08:00
k88936 ddb8b9e8ba multiview 空白视图按未提供处理
to_sdk_params 新增 optional_view,将仅含空白的 left、back、right 归一化为未提供

空白视图不再以 Some(FileInput) 形式提交,与提交前“至少两个视图”的校验保持一致
2026-09-19 13:13:35 +08:00
k88936 230e4d49f2 image_to_model 提交前统一使用裁剪后的输入
to_sdk_params 改为传入 request.input.trim(),与提交前的空值校验使用同一份标准化输入

带前后空白的输入不再原样转发给 Provider
2026-09-19 13:12:50 +08:00
k88936 6c209b115a 冒烟示例在任务未完成时跳过下载并继续后续入口
轮询改用 throw_on_failure=false,失败终态交回示例按 status 判定而不是直接返回错误

非 Completed 的任务打印 status 与 failure 后 continue,避免一个入口失败中断其余入口的冒烟
2026-09-19 13:12:23 +08:00
k88936 b631125a16 冒烟示例不再输出完整下载地址
产物行改用 TripoUrl::redacted(),避免把带签名的下载 URL 写入标准输出
2026-09-19 13:07:41 +08:00
k88936 771ff88440 脱敏 TripoUrl 的 Debug 输出
为 TripoUrl 手写 Debug 实现,改用隐藏 query 与 fragment 的脱敏形式

新增 TripoUrl::redacted 提供日志用脱敏 URL,完整 URL 仍由 as_str 显式读取

TripoTaskSnapshot、TripoTaskOutput、TripoDownloadedModel 的调试输出随之不再暴露带签名的下载地址
2026-09-19 13:07:41 +08:00
k88936 950209f038 为 model3d 请求契约的可选字段补充 ts(optional)
为 image_to_model、multiview_to_model、text_to_model 三个请求的可选字段添加 ts(optional = nullable)

multiview 视图分支的 left、back、right 同步标记为可选

重新生成 packages/shared/src/contracts/model3d 下的 TypeScript 绑定,使 TS 侧可按 serde 语义省略字段
2026-09-19 13:07:41 +08:00
k88936 42a5929a73 清理 model3d 请求契约中冗余的 serde(default)
移除 image_to_model、multiview_to_model、text_to_model 三个请求结构体 Option 字段上的 serde(default)

同步移除 multiview 视图分支中三个可选视图的 serde(default)

serde 对 Option 字段本身即按 None 处理缺失字段,生成的 TypeScript 绑定保持不变
2026-09-19 13:07:41 +08:00
k88936 eda9da4d8d 单点维护 model3d 的 ts-rs 导出根目录
在 model3d/mod.rs 新增 model3d_ts_export_dir 宏,集中定义仓库相对导出根路径

common、image_to_model、multiview_to_model、text_to_model 改为调用宏并只声明各自子目录名
2026-09-19 13:07:41 +08:00
k88936 35be25dbd6 补齐 model3d 契约在 packages/shared 的导出入口
新增 packages/shared/src/contracts/model3d/index.ts 聚合 ts-rs 生成的 3D 生成契约类型

contracts/index.ts 增加 export type * from './model3d'

src/index.ts 增加 export type * from './contracts/model3d'
2026-09-19 13:02:01 +08:00
k88936 da9ee65119 记录Tripo真实冒烟结果结构
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m41s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m48s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m50s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 8m0s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m17s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m29s
Project CI / Frontend tests (pull_request) Successful in 14m49s
Project CI / AI game creator shell web tests (pull_request) Failing after 18m48s
Project CI / Native shell tests (pull_request) Failing after 27m31s
保留三个生成入口的脱敏真实结果形状与下载产物信息

使用实际可访问的 Rustacean 图片完成 image 与 multiview 示例

同步严格 endpoint 结果契约和真实 Provider 验收文档
2026-09-18 21:42:17 +08:00
k88936 527706b84e 按接口收紧Tripo任务结果类型
为 text、image、multiview 三类生成定义独立必填结果结构

以枚举关联任务类型与具体结果并对缺失字段失败关闭

将通用 URL 类型由 TripoModelUrl 更名为 TripoUrl
2026-09-18 21:37:38 +08:00
k88936 a4beb53dc6 完善Tripo冒烟CLI产物流程
等待三类生成任务完成并打印映射后的输出

完成后下载模型产物到当前目录并输出文件信息

仅在示例内部使用SDK轮询器,不扩展平台公共接口
2026-09-18 18:51:49 +08:00
k88936 25314d9a4a 新增Tripo三类生成冒烟CLI
增加仅通过环境变量配置的 Tripo generation example

顺序提交 text、image、multiview 三类任务并执行单次状态查询

保持无命令行参数且不调用 SDK 轮询接口
2026-09-18 18:42:58 +08:00
k88936 5be4a51ac1 完善Tripo枚举与参数组合校验
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m19s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m22s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m26s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m38s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m54s
Project CI / Native shell tests (pull_request) Failing after 3m5s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m13s
Project CI / Frontend tests (pull_request) Successful in 5m44s
按提供的 Tripo 文档补齐 compression、texture quality 与 texture version 枚举

移除未在三个生成接口出现的 style 占位字段并同步 Rust/TypeScript contracts

增加模型版本、纹理版本、生成部件、quad、smart-low-poly 与 face limit 的副作用前校验

同步 Provider 技术方案和 ADR
2026-09-18 17:23:55 +08:00
k88936 37b355a814 接入Tripo三类3D生成Provider
加入固定 revision 的 tripo-rust-sdk 与 platform-tripo workspace crate

实现 text-to-model、image-to-model、multiview-to-model 提交、通用任务查询和显式模型下载

抽取通用任务类型、状态映射、输出 URL 校验与 SDK 错误归一,保持 wait_for_task 不对外暴露

同步 Provider 边界文档并暂不接入 api-server
2026-09-18 16:02:13 +08:00
k88936 1c4009ea3b 建立3D生成目录化契约
新增 model3d common、text-to-model、image-to-model、multiview-to-model Rust contracts

通过 ts-rs 按目录生成 TypeScript bindings,移除依赖 barrel 的组织方式

增加 model3d contracts 定向生成脚本
2026-09-18 15:59:19 +08:00
428 changed files with 10921 additions and 42382 deletions
-3
View File
@@ -23,6 +23,3 @@
*.meta text *.meta text
*.anim text *.anim text
*.controller text *.controller text
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
packages/shared/src/contracts/generated/** linguist-generated=true
+4 -22
View File
@@ -221,7 +221,8 @@ jobs:
- name: Run AI game creator shell agent-run smoke - name: Run AI game creator shell agent-run smoke
run: npm run check:native-shells:agc-rust-smoke run: npm run check:native-shells:agc-rust-smoke
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。 # AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate
# 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。
ai-game-creator-shell-rust-crates: ai-game-creator-shell-rust-crates:
name: AI game creator shell Rust crates name: AI game creator shell Rust crates
runs-on: genarrative-ci runs-on: genarrative-ci
@@ -262,15 +263,13 @@ jobs:
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试 # `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
# 会在测试阶段自己 `Updating crates.io index`crates.io 一抖动整条 job 就红 # 会在测试阶段自己 `Updating crates.io index`crates.io 一抖动整条 job 就红
# (见 #327 / PR #316 run 1950)。 # (见 #327 / PR #316 run 1950)。
# Cocos 插件也使用独立且未提交的锁文件,一并预热。 # 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
# 这些 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内, # 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本 # 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
# 解析,不再触碰 registry index。 # 解析,不再触碰 registry index。
for manifest_path in \ for manifest_path in \
server-rs/crates/agent-runtime-core/Cargo.toml \ server-rs/crates/agent-runtime-core/Cargo.toml \
server-rs/crates/agent-runtime-orchestration/Cargo.toml \ server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml; do
for attempt in $(seq 1 5); do for attempt in $(seq 1 5); do
if cargo fetch \ if cargo fetch \
--target x86_64-unknown-linux-gnu \ --target x86_64-unknown-linux-gnu \
@@ -285,23 +284,6 @@ jobs:
done done
done done
- name: Prepare Unity plugin Rust dependencies
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo 'Unity plugin Cargo dependency fetch failed after 5 attempts.' >&2
exit 1
fi
sleep $((attempt * 2))
done
- name: Run AI game creator shell shared crate gates - name: Run AI game creator shell shared crate gates
run: npm run check:native-shells:agc-rust-crates run: npm run check:native-shells:agc-rust-crates
-10
View File
@@ -41,17 +41,7 @@ temp*build*/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/ /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json
/apps/ai-game-creator-shell/src-tauri/resources/plugins/ /apps/ai-game-creator-shell/src-tauri/resources/plugins/
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/bin/
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-path/
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-resources/
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
/plugins/agc-cocos-editor/native/payload/ /plugins/agc-cocos-editor/native/payload/
/plugins/agc-unity-editor/dotnet/**/bin/
/plugins/agc-unity-editor/dotnet/**/obj/
/plugins/agc-unity-editor/dotnet/publish/
/plugins/agc-unity-editor/dotnet/native-build/
/apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json /apps/ai-game-creator-shell/game-creator.config.local.json
+1 -10
View File
@@ -1,14 +1,5 @@
{ {
"singleQuote": true, "singleQuote": true,
"semi": true, "semi": true,
"trailingComma": "all", "trailingComma": "all"
"overrides": [
{
"files": "packages/shared/src/contracts/generated/**/*.ts",
"options": {
"printWidth": 1000,
"singleQuote": false
}
}
]
} }
+18 -16
View File
@@ -50,6 +50,24 @@ _Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成
## Language ## Language
### 3D Model Generation
**3D 模型生成操作**:
由文本提示驱动、可异步查询并最终产生模型文件的 provider-neutral 操作;操作状态和产品资源结果分开建模。
_Avoid_: 直接把第三方 provider task 当作产品资源、把模型 URL 当作永久资源地址
**Provider task**:
第三方 3D 生成平台返回的任务引用与任务快照,只存在于 provider adapter 的 Rust 边界内;它不是 Genarrative 的内部操作 ID。
_Avoid_: 把 Tripo task ID 当作稳定产品 operationId、让 SDK 类型穿透 api-server
**临时 provider URL**:
第三方任务完成后返回的短时模型下载地址,必须通过显式下载流程转换为本地或持久化资源,不能直接当作长期资产地址。
_Avoid_: 永久 OSS URL、资源 ID
**模型 artifact**:
由 text-to-model、image-to-model 或 multiview-to-model 任务产生、待应用层登记和持久化的模型文件结果;provider adapter 只提供可下载结果,不负责生成 resourceId 或 assetId。
_Avoid_: 仅 UI 中展示的模型链接、没有来源任务的静态文件
### Puzzle Clear ### Puzzle Clear
**拼消消**: **拼消消**:
@@ -172,20 +190,6 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。 Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
**运行态事件**:
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
_Avoid_: 进度通知、快照轮询、第二套历史
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
## Relationships ## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发** - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -220,5 +224,3 @@ _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。 - “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。 - “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。 - “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@genarrative/ai-game-creator-shell", "name": "@genarrative/ai-game-creator-shell",
"private": true, "private": true,
"version": "0.1.67", "version": "0.1.47",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node scripts/start-tauri-dev.mjs", "dev": "node scripts/start-tauri-dev.mjs",
@@ -676,11 +676,11 @@ async function runSelfTest() {
designFoundationAssetCall?.arguments?.input?.outputPath === designFoundationAssetCall?.arguments?.input?.outputPath ===
'assets/ui-prototype.png' && 'assets/ui-prototype.png' &&
designFoundationAssetCall.arguments.input.aspectRatio === '16:9' && designFoundationAssetCall.arguments.input.aspectRatio === '16:9' &&
designFoundationAssetCall.arguments.input.assetKind === 'ui-design' && designFoundationAssetCall.arguments.input.assetKind === 'ui-prototype' &&
artAssetPlanAssetCall?.arguments?.input?.outputPath === artAssetPlanAssetCall?.arguments?.input?.outputPath ===
'assets/art-spritesheet.png' && 'assets/art-spritesheet.png' &&
artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' && artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' &&
artAssetPlanAssetCall.arguments.input.assetKind === 'icon-spritesheet', artAssetPlanAssetCall.arguments.input.assetKind === 'art-spritesheet',
'self-test-visual-assets-invalid', 'self-test-visual-assets-invalid',
); );
@@ -70,7 +70,7 @@ const requiredFormalArtifactSpecs = [
{ path: 'game/balance.json', kind: 'json' }, { path: 'game/balance.json', kind: 'json' },
{ path: 'assets/manifest.art.json', kind: 'json' }, { path: 'assets/manifest.art.json', kind: 'json' },
{ path: 'assets/manifest.audio.json', kind: 'json' }, { path: 'assets/manifest.audio.json', kind: 'json' },
{ path: 'game/index.html', kind: 'file' }, { path: 'game/index.html', kind: 'game-entry' },
{ path: 'exports/README.md', kind: 'file' }, { path: 'exports/README.md', kind: 'file' },
]; ];
const editorImageArtifactSpecs = [ const editorImageArtifactSpecs = [
@@ -14,71 +14,16 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。 // 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
const repoRoot = path.resolve(appRoot, '..', '..'); const repoRoot = path.resolve(appRoot, '..', '..');
const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
function defaultTarget() { const releaseTarget =
return process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
} const bundleRoot = path.join(
appRoot,
function explicitBuildTarget(args) { 'src-tauri',
let target; 'target',
const separator = args.indexOf('--'); releaseTarget,
const options = separator < 0 ? args : args.slice(0, separator); 'release',
for (let index = 0; index < options.length; index += 1) { 'bundle',
const argument = options[index]; );
let value;
if (argument === '--target' || argument === '-t') {
value = options[++index];
} else if (argument.startsWith('--target=')) {
value = argument.slice('--target='.length);
} else {
continue;
}
if (!value?.trim() || value.startsWith('-')) {
throw new Error('--target 缺少有效目标');
}
if (target !== undefined) throw new Error('不能重复指定 --target');
target = value.trim();
}
return target;
}
function validateReleaseTarget(target) {
if (target === 'universal-apple-darwin') {
throw new Error(
'内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin',
);
}
if (
![
'x86_64-pc-windows-msvc',
'aarch64-apple-darwin',
'x86_64-apple-darwin',
].includes(target)
) {
throw new Error(`不支持的发布目标:${target}`);
}
return target;
}
/** 在入口冻结目标;所有发布步骤共享同一上下文,不再各自读取默认目标。 */
export function resolveReleaseContext(args = [], env = process.env) {
const target = validateReleaseTarget(
explicitBuildTarget(args) ||
env.AGC_BUILD_TARGET?.trim() ||
defaultReleaseTarget,
);
return Object.freeze({
target,
channel: resolveReleaseChannel(env, target),
bundleRoot: path.join(
appRoot,
'src-tauri',
'target',
target,
'release',
'bundle',
),
});
}
const packageJsonPath = path.join(appRoot, 'package.json'); const packageJsonPath = path.join(appRoot, 'package.json');
const rootPackageLockPath = path.resolve(appRoot, '../..', 'package-lock.json'); const rootPackageLockPath = path.resolve(appRoot, '../..', 'package-lock.json');
const tauriConfigPath = path.join(appRoot, 'src-tauri', 'tauri.conf.json'); const tauriConfigPath = path.join(appRoot, 'src-tauri', 'tauri.conf.json');
@@ -105,7 +50,6 @@ export const agcReleasePathPatterns = [
'packages/', 'packages/',
'server-rs/crates/', 'server-rs/crates/',
'plugins/agc-cocos-editor/', 'plugins/agc-cocos-editor/',
'plugins/agc-unity-editor/',
'apps/desktop-shell/src-tauri/icons/', 'apps/desktop-shell/src-tauri/icons/',
'package.json', 'package.json',
'package-lock.json', 'package-lock.json',
@@ -155,7 +99,7 @@ export function nextPatchVersion(localVersion, remoteVersion) {
return `${major}.${minor}.${patch + 1}`; return `${major}.${minor}.${patch + 1}`;
} }
export function resolveReleasePlatform(target = defaultTarget()) { export function resolveReleasePlatform(target = releaseTarget) {
if (target.includes('windows')) return 'windows'; if (target.includes('windows')) return 'windows';
if (target.includes('apple-darwin')) return 'darwin'; if (target.includes('apple-darwin')) return 'darwin';
if (target.includes('linux')) return 'linux'; if (target.includes('linux')) return 'linux';
@@ -164,7 +108,7 @@ export function resolveReleasePlatform(target = defaultTarget()) {
export function resolveReleaseChannel( export function resolveReleaseChannel(
env = process.env, env = process.env,
target = defaultTarget(), target = releaseTarget,
) { ) {
const platform = resolveReleasePlatform(target); const platform = resolveReleasePlatform(target);
const requested = env.AGC_UPDATE_CHANNEL?.trim(); const requested = env.AGC_UPDATE_CHANNEL?.trim();
@@ -198,10 +142,13 @@ export function updateManifestUrl(channel = resolveReleaseChannel()) {
} }
/** /**
* 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。 * 更新插件按运行时平台键查找清单条目:universal macOS 包同时挂
* `darwin-aarch64` 与 `darwin-x86_64`,单架构目标只挂对应键。
*/ */
export function resolveManifestPlatformKeys(target = defaultTarget()) { export function resolveManifestPlatformKeys(target = releaseTarget) {
validateReleaseTarget(target); if (target === 'universal-apple-darwin') {
return ['darwin-aarch64', 'darwin-x86_64'];
}
if (target === 'aarch64-apple-darwin') return ['darwin-aarch64']; if (target === 'aarch64-apple-darwin') return ['darwin-aarch64'];
if (target === 'x86_64-apple-darwin') return ['darwin-x86_64']; if (target === 'x86_64-apple-darwin') return ['darwin-x86_64'];
if (target.includes('windows')) { if (target.includes('windows')) {
@@ -309,8 +256,8 @@ function replaceVersionLine(source, version, pattern, label) {
return source.replace(pattern, `$1${version}$3`); return source.replace(pattern, `$1${version}$3`);
} }
export async function prepareReleaseVersion(context = resolveReleaseContext()) { export async function prepareReleaseVersion() {
const { channel } = context; const channel = resolveReleaseChannel();
const localVersion = parseVersion(readPackageJson().version, '本地版本'); const localVersion = parseVersion(readPackageJson().version, '本地版本');
const remoteVersion = await resolveRemoteHighWaterVersion(channel); const remoteVersion = await resolveRemoteHighWaterVersion(channel);
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
@@ -383,14 +330,18 @@ export async function prepareReleaseVersion(context = resolveReleaseContext()) {
export function buildTauriBuildArguments( export function buildTauriBuildArguments(
args = [], args = [],
target = defaultTarget(), target = releaseTarget,
platform = process.platform, platform = process.platform,
) { ) {
const noBundle = args.includes('--no-bundle'); const noBundle = args.includes('--no-bundle');
const explicitTarget = explicitBuildTarget(args); const targetIndex = args.indexOf('--target');
const explicitTarget =
targetIndex >= 0
? args[targetIndex + 1]
: args
.find((value) => value.startsWith('--target='))
?.slice('--target='.length);
const targetArgs = noBundle || explicitTarget ? [] : ['--target', target]; const targetArgs = noBundle || explicitTarget ? [] : ['--target', target];
if (!noBundle || explicitTarget)
validateReleaseTarget(explicitTarget || target);
const features = defaultEditorFeatures( const features = defaultEditorFeatures(
explicitTarget || (noBundle ? platform : target), explicitTarget || (noBundle ? platform : target),
); );
@@ -423,33 +374,18 @@ function writeChannelConfigFile(channel) {
return configPath; return configPath;
} }
export function runTauriBuild( export function runTauriBuild(args = []) {
args = [], const tauriArguments = buildTauriBuildArguments(args);
context = resolveReleaseContext(args), if (!tauriArguments.includes('--config') && !tauriArguments.includes('-c')) {
{ spawn = spawnSync } = {}, const channel = resolveReleaseChannel();
) { const configPath = writeChannelConfigFile(channel);
if ( console.log(
explicitBuildTarget(args) && `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
explicitBuildTarget(args) !== context.target );
) { tauriArguments.push('--config', configPath);
throw new Error('构建参数与发布上下文目标不一致');
} }
const tauriArguments = buildTauriBuildArguments(args, context.target);
const { channel } = context;
const configPath = writeChannelConfigFile(channel);
console.log(
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
);
// 最后合并渠道配置,防止用户配置中的端点与实际发布目标分叉。
const separator = tauriArguments.indexOf('--');
tauriArguments.splice(
separator < 0 ? tauriArguments.length : separator,
0,
'--config',
configPath,
);
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = spawn( const result = spawnSync(
npmCommand, npmCommand,
['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments], ['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' }, { cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
@@ -466,11 +402,11 @@ function listFiles(root) {
}); });
} }
function artifactPriority(filePath, target) { function artifactPriority(filePath) {
const name = path.basename(filePath).toLowerCase(); const name = path.basename(filePath).toLowerCase();
if (target.includes('windows')) return name.endsWith('.exe') ? 0 : 99; if (releaseTarget.includes('windows')) return name.endsWith('.exe') ? 0 : 99;
// 更新链路要的是 updater 产物(macOS 为 .app.tar.gz),dmg 只作人工分发。 // 更新链路要的是 updater 产物(macOS 为 .app.tar.gz),dmg 只作人工分发。
if (target.includes('apple-darwin')) { if (releaseTarget.includes('apple-darwin')) {
return name.endsWith('.app.tar.gz') ? 0 : 99; return name.endsWith('.app.tar.gz') ? 0 : 99;
} }
if (name.endsWith('.appimage.tar.gz')) return 0; if (name.endsWith('.appimage.tar.gz')) return 0;
@@ -480,8 +416,7 @@ function artifactPriority(filePath, target) {
return 99; return 99;
} }
export function selectReleaseArtifact(files, target = defaultTarget()) { export function selectReleaseArtifact(files) {
validateReleaseTarget(target);
const explicit = process.env.AGC_UPDATE_ARTIFACT?.trim(); const explicit = process.env.AGC_UPDATE_ARTIFACT?.trim();
if (explicit) { if (explicit) {
const resolved = path.resolve(explicit); const resolved = path.resolve(explicit);
@@ -492,10 +427,9 @@ export function selectReleaseArtifact(files, target = defaultTarget()) {
} }
return ( return (
[...files] [...files]
.filter((filePath) => artifactPriority(filePath, target) < 99) .filter((filePath) => artifactPriority(filePath) < 99)
.sort((left, right) => { .sort((left, right) => {
const priority = const priority = artifactPriority(left) - artifactPriority(right);
artifactPriority(left, target) - artifactPriority(right, target);
return priority || left.localeCompare(right); return priority || left.localeCompare(right);
})[0] ?? null })[0] ?? null
); );
@@ -516,15 +450,13 @@ function readUpdaterSignature(artifactPath) {
export function createUpdateManifest( export function createUpdateManifest(
artifactPath, artifactPath,
{ {
target = defaultTarget(), channel = resolveReleaseChannel(),
channel = resolveReleaseChannel(process.env, target), target = releaseTarget,
publishedAt = new Date().toISOString(), publishedAt = new Date().toISOString(),
notes = readReleaseNotes(), notes = readReleaseNotes(),
commit = readHeadCommit(), commit = readHeadCommit(),
} = {}, } = {},
) { ) {
validateReleaseTarget(target);
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target);
const signature = readUpdaterSignature(artifactPath); const signature = readUpdaterSignature(artifactPath);
const version = readPackageJson().version; const version = readPackageJson().version;
const fileName = path.basename(artifactPath); const fileName = path.basename(artifactPath);
@@ -672,11 +604,9 @@ export function createLegacyUpdateManifest(
}; };
} }
export async function generateUpdateManifest( export async function generateUpdateManifest() {
context = resolveReleaseContext(), const channel = resolveReleaseChannel();
) { const artifact = selectReleaseArtifact(listFiles(bundleRoot));
const { channel, target, bundleRoot } = context;
const artifact = selectReleaseArtifact(listFiles(bundleRoot), target);
if (!artifact) { if (!artifact) {
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
} }
@@ -693,7 +623,7 @@ export async function generateUpdateManifest(
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'}`, `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'}`,
); );
} }
const manifest = createUpdateManifest(artifact, { channel, target, notes }); const manifest = createUpdateManifest(artifact, { channel, notes });
const manifestPath = path.join(bundleRoot, 'latest.json'); const manifestPath = path.join(bundleRoot, 'latest.json');
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const notesPath = path.join(bundleRoot, 'release-notes.txt'); const notesPath = path.join(bundleRoot, 'release-notes.txt');
@@ -745,24 +675,12 @@ export async function generateUpdateManifest(
}; };
} }
export async function buildRelease(
args = [],
{
prepareVersion = prepareReleaseVersion,
build = runTauriBuild,
generateManifest = generateUpdateManifest,
} = {},
) {
const context = resolveReleaseContext(args);
if (!args.includes('--no-bundle')) await prepareVersion(context);
build(args, context);
if (!args.includes('--no-bundle')) return generateManifest(context);
}
if ( if (
process.argv[1] && process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) { ) {
const args = process.argv.slice(2); const args = process.argv.slice(2);
await buildRelease(args); if (!args.includes('--no-bundle')) await prepareReleaseVersion();
runTauriBuild(args);
if (!args.includes('--no-bundle')) await generateUpdateManifest();
} }
@@ -14,8 +14,6 @@ import { fileURLToPath } from 'node:url';
import { import {
agcReleasePathPatterns, agcReleasePathPatterns,
buildRelease,
buildTauriBuildArguments,
collectRecentReleaseCommits, collectRecentReleaseCommits,
collectReleaseCommits, collectReleaseCommits,
compareVersions, compareVersions,
@@ -24,14 +22,11 @@ import {
createUpdateManifest, createUpdateManifest,
formatRecentReleaseNotes, formatRecentReleaseNotes,
formatReleaseNotes, formatReleaseNotes,
generateUpdateManifest,
nextPatchVersion, nextPatchVersion,
resolveManifestPlatformKeys, resolveManifestPlatformKeys,
resolvePreviousReleaseCommit, resolvePreviousReleaseCommit,
resolveReleaseChannel, resolveReleaseChannel,
resolveReleaseContext,
resolveRemoteHighWaterVersion, resolveRemoteHighWaterVersion,
runTauriBuild,
selectReleaseArtifact, selectReleaseArtifact,
updateManifestUrl, updateManifestUrl,
} from './build-release.mjs'; } from './build-release.mjs';
@@ -39,21 +34,6 @@ import {
const windowsTarget = 'x86_64-pc-windows-msvc'; const windowsTarget = 'x86_64-pc-windows-msvc';
const universalTarget = 'universal-apple-darwin'; const universalTarget = 'universal-apple-darwin';
test('native sidecar builds reject universal targets and accept each macOS architecture', () => {
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
assert.throws(
() => buildTauriBuildArguments(['--target=universal-apple-darwin']),
/单架构/,
);
for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
assert.deepEqual(buildTauriBuildArguments([], target), [
'build',
'--target',
target,
]);
}
});
function withEnv(overrides, run) { function withEnv(overrides, run) {
const previous = new Map(); const previous = new Map();
for (const [key, value] of Object.entries(overrides)) { for (const [key, value] of Object.entries(overrides)) {
@@ -152,12 +132,9 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
}); });
}); });
test('macOS manifests only advertise the architecture actually built', () => { test('universal macOS builds publish one artifact under both platform keys', () => {
assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/); assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [
assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [
'darwin-aarch64', 'darwin-aarch64',
]);
assert.deepEqual(resolveManifestPlatformKeys('x86_64-apple-darwin'), [
'darwin-x86_64', 'darwin-x86_64',
]); ]);
assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [ assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [
@@ -165,222 +142,6 @@ test('macOS manifests only advertise the architecture actually built', () => {
]); ]);
}); });
test('release context resolves explicit targets before environment/default and fails closed', () => {
for (const args of [
['--target', 'aarch64-apple-darwin'],
['--target=aarch64-apple-darwin'],
['-t', 'aarch64-apple-darwin'],
]) {
for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) {
const context = resolveReleaseContext(args, env);
assert.equal(context.target, 'aarch64-apple-darwin');
assert.equal(context.channel, 'dev-mac');
assert.match(
context.bundleRoot.replaceAll('\\', '/'),
/target\/aarch64-apple-darwin\/release\/bundle$/,
);
assert.ok(Object.isFrozen(context));
}
assert.throws(
() => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }),
/只能用于 windows/,
);
}
assert.equal(resolveReleaseContext([], {}).target, windowsTarget);
assert.equal(
resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' })
.channel,
'dev-mac',
);
for (const args of [
['--target'],
['--target='],
['--target', '--no-bundle'],
['--target', windowsTarget, '--target=aarch64-apple-darwin'],
['--target', universalTarget],
['--target', 'unknown'],
])
assert.throws(() => resolveReleaseContext(args, {}));
});
test('explicit macOS target drives version lookup, Tauri endpoint, artifact and manifest together', async () => {
const calls = [];
const seenContexts = [];
await withStubbedFetch(
(url) => {
calls.push(url);
assert.match(url, /\/dev-mac\/latest\.json$/);
return jsonResponse({ version: '0.1.67' });
},
() =>
withEnv(
{ AGC_BUILD_TARGET: undefined, AGC_UPDATE_CHANNEL: undefined },
() =>
buildRelease(['--target', 'aarch64-apple-darwin'], {
prepareVersion: async (context) => {
seenContexts.push(context);
assert.equal(
await resolveRemoteHighWaterVersion(context.channel),
'0.1.67',
);
},
build: (args, context) => {
seenContexts.push(context);
runTauriBuild(args, context, {
spawn: (_binary, command) => {
const configIndex = command.lastIndexOf('--config');
const config = JSON.parse(
readFileSync(command[configIndex + 1], 'utf8'),
);
assert.match(
config.plugins.updater.endpoints[0],
/\/dev-mac\/latest\.json$/,
);
assert.ok(command.includes('aarch64-apple-darwin'));
assert.ok(
!command.includes('--features=cocos-editor-execute'),
);
return { status: 0 };
},
});
},
generateManifest: (context) => {
seenContexts.push(context);
withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => {
assert.equal(
selectReleaseArtifact(
['/tmp/win.exe', artifact, '/tmp/mac.dmg'],
context.target,
),
artifact,
);
const manifest = createUpdateManifest(artifact, context);
assert.deepEqual(Object.keys(manifest.platforms), [
'darwin-aarch64',
]);
assert.match(
manifest.platforms['darwin-aarch64'].url,
/\/dev-mac\//,
);
});
},
}),
),
);
assert.equal(calls.length, 1, 'Mac 不应读取 Windows 迁移指针');
assert.equal(seenContexts.length, 3);
assert.ok(seenContexts.every((context) => context === seenContexts[0]));
});
test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
try {
const artifact = path.join(root, '陶泥儿.app.tar.gz');
writeFileSync(artifact, 'mac package');
writeFileSync(`${artifact}.sig`, 'mac signature');
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
const context = {
...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}),
bundleRoot: root,
};
const result = await withStubbedFetch(
(url) => {
assert.match(url, /\/dev-mac\/latest\.json$/);
return jsonResponse({}, 404);
},
() => generateUpdateManifest(context),
);
assert.equal(result.artifact, artifact);
assert.equal(result.manifestPath, path.join(root, 'latest.json'));
assert.equal(result.legacyManifestPath, null);
assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']);
assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('invalid target or mismatched channel fails before any release side effect', async () => {
let touched = false;
const sideEffects = {
prepareVersion: () => {
touched = true;
},
build: () => {
touched = true;
},
generateManifest: () => {
touched = true;
},
};
await assert.rejects(
() => buildRelease(['--target', universalTarget], sideEffects),
/单架构/,
);
await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
assert.rejects(
() => buildRelease(['--target=aarch64-apple-darwin'], sideEffects),
/只能用于 windows/,
),
);
assert.equal(touched, false);
});
test('Windows remains the default and explicit Windows overrides macOS environment', () => {
const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg'];
for (const context of [
resolveReleaseContext([], {}),
resolveReleaseContext(['--target', windowsTarget], {
AGC_BUILD_TARGET: 'aarch64-apple-darwin',
}),
]) {
assert.equal(context.channel, 'dev-win');
assert.equal(
selectReleaseArtifact(files, context.target),
'/tmp/windows.exe',
);
runTauriBuild(
['--target', windowsTarget, '--config', 'user-config.json'],
context,
{
spawn: (_binary, command) => {
assert.ok(
command.includes(
'--features=cocos-editor-execute,unity-editor-execute',
),
);
assert.ok(command.includes('user-config.json'));
const configIndex = command.lastIndexOf('--config');
const config = JSON.parse(
readFileSync(command[configIndex + 1], 'utf8'),
);
assert.match(
config.plugins.updater.endpoints[0],
/\/dev-win\/latest\.json$/,
);
return { status: 0 };
},
},
);
}
});
test('no-bundle smoke skips version writes and manifest generation', async () => {
const steps = [];
await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], {
prepareVersion: () => {
steps.push('version');
},
build: (_args, context) => {
steps.push(context.channel);
},
generateManifest: () => {
steps.push('manifest');
},
});
assert.deepEqual(steps, ['dev-mac']);
});
test('channel manifest carries version, platform keys and signature', () => { test('channel manifest carries version, platform keys and signature', () => {
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
@@ -584,7 +345,6 @@ test('release upload forces overwrite for artifact, signature and channel pointe
); );
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u); assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
assert.match(source, /agc\/latest\.json/u); assert.match(source, /agc\/latest\.json/u);
assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u);
}); });
test('release notes list client commits with short sha and bound their size', () => { test('release notes list client commits with short sha and bound their size', () => {
@@ -19,6 +19,6 @@ export function withDefaultCargoFeatures(argv, features) {
export function defaultEditorFeatures(target) { export function defaultEditorFeatures(target) {
return target === 'win32' || target.includes('windows') return target === 'win32' || target.includes('windows')
? ['cocos-editor-execute', 'unity-editor-execute'] ? ['cocos-editor-execute']
: []; : [];
} }
@@ -9,7 +9,7 @@ test('Windows release includes the same editor feature as development', () => {
buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'), buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'),
[ [
'build', 'build',
'--features=cocos-editor-execute,unity-editor-execute', '--features=cocos-editor-execute',
'--target', '--target',
'x86_64-pc-windows-msvc', 'x86_64-pc-windows-msvc',
], ],
@@ -35,12 +35,6 @@ const windowsTauriConfig = JSON.parse(
'utf8', 'utf8',
), ),
); );
const macosTauriConfig = JSON.parse(
fs.readFileSync(
new URL('../src-tauri/tauri.macos.conf.json', import.meta.url),
'utf8',
),
);
const cargoManifestSource = fs.readFileSync( const cargoManifestSource = fs.readFileSync(
new URL('../src-tauri/Cargo.toml', import.meta.url), new URL('../src-tauri/Cargo.toml', import.meta.url),
'utf8', 'utf8',
@@ -143,6 +137,7 @@ const allowedUncalledTauriCommands = [
'reload_agc_plugin', 'reload_agc_plugin',
'call_agc_plugin', 'call_agc_plugin',
'read_agc_plugin_panel', 'read_agc_plugin_panel',
'set_agc_plugin_project_path',
'set_agc_plugin_enabled', 'set_agc_plugin_enabled',
]; ];
const sourceExtensions = new Set([ const sourceExtensions = new Set([
@@ -1363,37 +1358,6 @@ if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory', 'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory',
); );
} }
assert.deepEqual(
macosTauriConfig.bundle?.resources,
Object.fromEntries([
...[
'bin/codex',
'bin/codex-code-mode-host',
'codex-path/rg',
'codex-resources/zsh/bin/zsh',
'codex-package.json',
'NOTICE.md',
'manifest.json',
].map((file) => [
`resources/codex/mac-native/${file}`,
`coding-agent/mac-native/${file}`,
]),
['resources/plugins', 'plugins'],
]),
'macOS must bundle the complete native Codex layout and plugin workspace',
);
assert.deepEqual(
macosTauriConfig.plugins?.updater?.endpoints,
[
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json',
],
'macOS local builds must not use the Windows update channel',
);
assert.equal(
macosTauriConfig.bundle?.macOS?.minimumSystemVersion,
'15.0',
'macOS deployment baseline must cover the bundled native zsh requirement',
);
if (tauriConfig.app?.withGlobalTauri !== true) { if (tauriConfig.app?.withGlobalTauri !== true) {
throw new Error( throw new Error(
@@ -1758,7 +1722,7 @@ for (const snippet of [
'fn append_local_permission_log_at(', 'fn append_local_permission_log_at(',
'"command.auto"', '"command.auto"',
'GameCreationAppPermission::Auto', 'GameCreationAppPermission::Auto',
'fn game_creator_bundled_codex_cli_path', 'GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH',
'validate_game_creator_bundled_codex_cli', 'validate_game_creator_bundled_codex_cli',
'内置 Codex CLI 完整性校验失败', '内置 Codex CLI 完整性校验失败',
]) { ]) {
@@ -1,222 +0,0 @@
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。
assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行');
const source = path.resolve(process.argv[2] || '');
assert.ok(
source.endsWith('.app') && fs.statSync(source).isDirectory(),
'请传入 .app 绝对路径',
);
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')),
);
const app = path.join(root, '陶泥儿 隔离测试.app');
const home = path.join(root, 'home');
const config = path.join(root, 'config');
const tmp = path.join(root, 'tmp');
const codexHome = path.join(root, 'codex-home');
for (const directory of [home, config, tmp, codexHome]) {
fs.mkdirSync(directory, { mode: 0o700 });
}
const env = {
HOME: home,
PATH: '/usr/bin:/bin',
TMPDIR: tmp,
CODEX_HOME: codexHome,
};
function run(command, args) {
const result = spawnSync(command, args, {
cwd: root,
env,
encoding: 'utf8',
timeout: 30_000,
maxBuffer: 1024 * 1024,
});
assert.ifError(result.error);
return result;
}
async function hashFile(file) {
const hash = createHash('sha256');
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
return hash.digest('hex');
}
async function handshake(executable) {
const child = spawn(executable, ['app-server'], {
cwd: root,
env,
stdio: ['pipe', 'pipe', 'pipe'],
});
let buffered = '';
let stderrBytes = 0;
try {
await new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('app-server 初始化超时')),
15_000,
);
const finish = (error) => {
clearTimeout(timer);
if (error) reject(error);
else resolve();
};
child.on('error', finish);
child.on('exit', (code) =>
finish(new Error(`app-server 提前退出 ${code}`)),
);
child.stderr.on('data', (chunk) => {
stderrBytes += chunk.length;
if (stderrBytes > 1024 * 1024)
finish(new Error('app-server stderr 超限'));
});
child.stdout.on('data', (chunk) => {
buffered += chunk.toString('utf8');
if (buffered.length > 1024 * 1024)
return finish(new Error('app-server stdout 超限'));
let end;
while ((end = buffered.indexOf('\n')) >= 0) {
const line = buffered.slice(0, end);
buffered = buffered.slice(end + 1);
try {
const message = JSON.parse(line);
if (message.id !== 1) continue;
assert.ok(message.result?.userAgent, '初始化必须返回真实服务身份');
assert.equal(message.error, undefined);
child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
finish();
} catch (error) {
finish(error);
}
}
});
child.stdin.on('error', finish);
child.stdin.write(
`${JSON.stringify({
id: 1,
method: 'initialize',
params: {
clientInfo: {
name: 'agc_bundle_smoke',
title: 'AGC bundle smoke',
version: '1',
},
capabilities: { experimentalApi: true },
},
})}\n`,
);
});
} finally {
if (child.exitCode === null && child.signalCode === null) {
await new Promise((resolve) => {
const timer = setTimeout(() => child.kill('SIGKILL'), 3000);
child.once('exit', () => {
clearTimeout(timer);
resolve();
});
child.kill('SIGTERM');
});
}
}
}
try {
fs.cpSync(source, app, { recursive: true });
const resources = path.join(app, 'Contents/Resources');
const bundle = path.join(resources, 'coding-agent/mac-native');
const executable = path.join(bundle, 'bin/codex');
const main = path.join(
app,
'Contents/MacOS/genarrative-ai-game-creator-shell',
);
const manifest = JSON.parse(
fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'),
);
assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2');
assert.equal(
manifest.platform,
process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64',
);
assert.equal(manifest.version, 'codex-cli 0.147.0');
const components = [
'bin/codex',
'bin/codex-code-mode-host',
'codex-path/rg',
'codex-resources/zsh/bin/zsh',
'codex-package.json',
];
assert.deepEqual(Object.keys(manifest.files).sort(), [...components].sort());
for (const component of components) {
const file = path.join(bundle, component);
assert.equal(await hashFile(file), manifest.files[component], component);
if (component !== 'codex-package.json') {
fs.accessSync(file, fs.constants.X_OK);
const arch = run('/usr/bin/lipo', ['-archs', file]);
assert.equal(arch.status, 0, component);
assert.equal(
arch.stdout.trim(),
process.arch === 'arm64' ? 'arm64' : 'x86_64',
component,
);
}
}
assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md')));
const plugin = path.join(resources, 'plugins/agc-cocos-editor');
for (const file of [
'plugin.json',
'src/entry.mjs',
'panels/cocos-editor.html',
]) {
assert.ok(fs.existsSync(path.join(plugin, file)), file);
}
const packageFiles = fs.readdirSync(resources, { recursive: true });
assert.ok(
!packageFiles.some((file) =>
/(^|\/)(\.env[^/]*|auth\.json|node_modules|target|\.git)(\/|$)|\.(exe|dll)$/.test(
file,
),
),
);
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
assert.equal(
run(path.join(bundle, 'codex-path/rg'), ['--version']).status,
0,
);
assert.equal(
run(path.join(bundle, 'codex-resources/zsh/bin/zsh'), ['--version']).status,
0,
);
// 使用正式 AGC 查找/校验入口,而非只证明 sidecar 可以独立执行。
const status = run(main, ['--config-dir', config, '--llm-status']);
const statusText = `${status.stdout}\n${status.stderr}`;
assert.ok(!statusText.includes('Codex CLI 未安装'), statusText);
assert.ok(
statusText.includes('authentication-required'),
'隔离账号应仅被登录门禁拒绝',
);
await handshake(executable);
// 临时复制品缺少辅助程序时,正式入口必须拒绝内置程序;PATH 无全局 Codex 可兜底。
fs.renameSync(
path.join(bundle, 'bin/codex-code-mode-host'),
path.join(root, 'saved-code-mode-host'),
);
const broken = run(main, ['--config-dir', config, '--llm-status']);
assert.notEqual(broken.status, 0);
assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/);
console.log(
'PASS: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝',
);
console.log(
'未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提',
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
@@ -290,7 +290,7 @@ function deterministicArtManifest(
{ {
id: 'garden-guardians-spritesheet', id: 'garden-guardians-spritesheet',
path: 'assets/art-spritesheet.png', path: 'assets/art-spritesheet.png',
kind: 'icon-spritesheet', kind: 'art-spritesheet',
usage: ['defenders', 'enemies', 'battlefield-ui'], usage: ['defenders', 'enemies', 'battlefield-ui'],
source: 'canvas', source: 'canvas',
status: 'ready', status: 'ready',
@@ -710,7 +710,7 @@ function canvasAssetCall(agentId) {
outputPath: 'assets/ui-prototype.png', outputPath: 'assets/ui-prototype.png',
aspectRatio: '16:9', aspectRatio: '16:9',
imageSize: '2K', imageSize: '2K',
assetKind: 'ui-design', assetKind: 'ui-prototype',
assetLabel: '游戏横屏界面原型图', assetLabel: '游戏横屏界面原型图',
replaceExisting: false, replaceExisting: false,
}); });
@@ -721,7 +721,7 @@ function canvasAssetCall(agentId) {
outputPath: 'assets/art-spritesheet.png', outputPath: 'assets/art-spritesheet.png',
aspectRatio: '1:1', aspectRatio: '1:1',
imageSize: '1K', imageSize: '1K',
assetKind: 'icon-spritesheet', assetKind: 'art-spritesheet',
assetLabel: '游戏首版核心美术素材', assetLabel: '游戏首版核心美术素材',
replaceExisting: false, replaceExisting: false,
sliceMode: 'connected-components', sliceMode: 'connected-components',
@@ -830,12 +830,12 @@ function missingGeneratedVisualAssetObservation(context, agentId) {
}, },
'design-foundation': { 'design-foundation': {
path: 'assets/ui-prototype.png', path: 'assets/ui-prototype.png',
kind: 'ui-design', kind: 'ui-prototype',
summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务', summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务',
}, },
'art-asset-plan': { 'art-asset-plan': {
path: 'assets/art-spritesheet.png', path: 'assets/art-spritesheet.png',
kind: 'icon-spritesheet', kind: 'art-spritesheet',
summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务', summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务',
}, },
}[agentId]; }[agentId];
@@ -2667,7 +2667,7 @@ function createDeterministicCanvasFixture(apiKey) {
asset: { asset: {
assetId: `asset-${sliceImageId}`, assetId: `asset-${sliceImageId}`,
assetObjectId: sliceAssetObjectId, assetObjectId: sliceAssetObjectId,
assetKind: 'icon', assetKind: 'art-spritesheet-slice',
projectId, projectId,
taskId, taskId,
}, },
@@ -2707,7 +2707,7 @@ function createDeterministicCanvasFixture(apiKey) {
spritesheetAsset: { spritesheetAsset: {
assetId: `asset-${imageId}`, assetId: `asset-${imageId}`,
assetObjectId, assetObjectId,
assetKind: 'icon-spritesheet', assetKind: 'art-spritesheet',
projectId, projectId,
taskId, taskId,
}, },
@@ -2749,7 +2749,7 @@ function createDeterministicCanvasFixture(apiKey) {
const assetObjectId = `asset-object-${imageId}`; const assetObjectId = `asset-object-${imageId}`;
const resourceId = `resource-${imageId}`; const resourceId = `resource-${imageId}`;
const assetKind = const assetKind =
typeof body?.assetKind === 'string' ? body.assetKind : 'image'; typeof body?.assetKind === 'string' ? body.assetKind : 'game-art';
images.set(imageId, { images.set(imageId, {
...image, ...image,
objectKey, objectKey,
@@ -12,7 +12,8 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) {
process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`; process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
const dryRun = readReleaseDryRun(); const dryRun = readReleaseDryRun();
const { buildRelease } = await import('./build-release.mjs'); const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } =
await import('./build-release.mjs');
function runOssutil(args) { function runOssutil(args) {
const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil'; const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil';
@@ -50,8 +51,10 @@ function runOssutil(args) {
if (result.status !== 0) process.exit(result.status ?? 1); if (result.status !== 0) process.exit(result.status ?? 1);
} }
await prepareReleaseVersion();
runTauriBuild([]);
const { artifact, channel, legacyManifestPath, manifest, manifestPath } = const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
await buildRelease(process.argv.slice(2)); await generateUpdateManifest();
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`; const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过; // Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。 // 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
@@ -1,10 +1,7 @@
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { import { withDefaultCargoFeatures } from './cargo-features.mjs';
defaultEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
import { import {
readAgcDevEndpoint, readAgcDevEndpoint,
resolveAgcDevEndpoint, resolveAgcDevEndpoint,
@@ -50,8 +47,9 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
]; ];
} }
// 开发和发行构建使用同一平台编辑器 feature 集合。 // `agc_cocos_execute` 与 Cocos 编辑器适配器只在 `cocos-editor-execute` feature
// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 // 注册。开发构建默认在 Windows 打开它,否则 Agent 的工具清单里根本没有该工具,
// 只能退化成改写脚本。可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。
function readDevCargoFeatures(env = process.env) { function readDevCargoFeatures(env = process.env) {
const override = env.AGC_DEV_CARGO_FEATURES; const override = env.AGC_DEV_CARGO_FEATURES;
if (override !== undefined) { if (override !== undefined) {
@@ -60,7 +58,7 @@ function readDevCargoFeatures(env = process.env) {
.map((value) => value.trim()) .map((value) => value.trim())
.filter(Boolean); .filter(Boolean);
} }
return defaultEditorFeatures(process.platform); return process.platform === 'win32' ? ['cocos-editor-execute'] : [];
} }
function withDevCargoFeatures(argv, features = readDevCargoFeatures()) { function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
+1 -13
View File
@@ -1745,7 +1745,7 @@ dependencies = [
[[package]] [[package]]
name = "genarrative-ai-game-creator-shell" name = "genarrative-ai-game-creator-shell"
version = "0.1.67" version = "0.1.47"
dependencies = [ dependencies = [
"agent-runtime-core", "agent-runtime-core",
"axum", "axum",
@@ -1793,7 +1793,6 @@ dependencies = [
"ttf-parser", "ttf-parser",
"typed_floats", "typed_floats",
"unicode-normalization", "unicode-normalization",
"unity-editor-bridge",
"url", "url",
"uuid", "uuid",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -3932,7 +3931,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
@@ -6410,16 +6408,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unity-editor-bridge"
version = "0.1.0"
dependencies = [
"editor-adapter-api",
"serde",
"serde_json",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.11" version = "0.2.11"
@@ -1,6 +1,6 @@
[package] [package]
name = "genarrative-ai-game-creator-shell" name = "genarrative-ai-game-creator-shell"
version = "0.1.67" version = "0.1.47"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -12,7 +12,6 @@ template-library-fixtures = []
cocos-editor = ["cocos-editor-bridge/process-discovery"] cocos-editor = ["cocos-editor-bridge/process-discovery"]
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"] cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"] cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
unity-editor-execute = []
[build-dependencies] [build-dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@@ -28,7 +27,6 @@ nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" } agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false } cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false }
editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" } editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" }
unity-editor-bridge = { path = "../../../plugins/agc-unity-editor/native/unity-editor-bridge" }
base64 = "0.22" base64 = "0.22"
axum = "0.8" axum = "0.8"
chromiumoxide = "0.9.1" chromiumoxide = "0.9.1"
@@ -58,7 +56,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" }
portable-pty = "0.9" portable-pty = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
regex = "1" regex = "1"
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] } shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
tauri = { version = "2.11.2", features = [] } tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1" tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] } tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
+48 -190
View File
@@ -1,18 +1,31 @@
#[path = "build_support/codex_bundle.rs"]
mod codex_bundle;
#[path = "build_support/frontend_dist_guard.rs"] #[path = "build_support/frontend_dist_guard.rs"]
mod frontend_dist_guard; mod frontend_dist_guard;
#[path = "build_support/runtime_prompt_bundle.rs"] #[path = "build_support/runtime_prompt_bundle.rs"]
mod runtime_prompt_bundle; mod runtime_prompt_bundle;
#[cfg(windows)]
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::env; use std::env;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
#[cfg(windows)]
use std::io::{BufReader, Read}; use std::io::{BufReader, Read};
const BUNDLED_CODEX_CLI_VERSION: &str = "codex-cli 0.147.0";
#[cfg(windows)]
const BUNDLED_CODEX_FILES: [&str; 6] = [
"bin/codex.exe",
"bin/codex-code-mode-host.exe",
"codex-path/rg.exe",
"codex-resources/codex-command-runner.exe",
"codex-resources/codex-windows-sandbox-setup.exe",
"codex-package.json",
];
#[cfg(windows)]
fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> { fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
let file = fs::File::open(path)?; let file = fs::File::open(path)?;
let mut reader = BufReader::new(file); let mut reader = BufReader::new(file);
@@ -29,15 +42,7 @@ fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
} }
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET"); #[cfg(windows)]
println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
let Some(layout) = codex_bundle::for_target(&target) else {
assert!(
!target.contains("windows") && !target.contains("apple-darwin"),
"不支持的 Codex 随包目标:{target}"
);
return;
};
{ {
let app_root = manifest_dir let app_root = manifest_dir
.parent() .parent()
@@ -46,23 +51,24 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
.parent() .parent()
.and_then(|apps_dir| apps_dir.parent()) .and_then(|apps_dir| apps_dir.parent())
.expect("AI 游戏创作应用必须位于仓库 apps 目录下"); .expect("AI 游戏创作应用必须位于仓库 apps 目录下");
let package = layout.npm_package; let source_candidates = [
let source_candidates = [app_root, repo_root] app_root.join(
.into_iter() "node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc",
.flat_map(|root| { ),
[ app_root.join(
root.join(format!("node_modules/@openai/{package}/vendor/{target}")), "node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc",
root.join(format!( ),
"node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}" repo_root.join(
)), "node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc",
] ),
}) repo_root.join(
.collect::<Vec<_>>(); "node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc",
),
];
let source = source_candidates let source = source_candidates
.iter() .iter()
.find(|path| { .find(|path| {
layout BUNDLED_CODEX_FILES
.files
.iter() .iter()
.all(|relative| path.join(relative).is_file()) .all(|relative| path.join(relative).is_file())
}) })
@@ -77,26 +83,14 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
.join("") .join("")
) )
}); });
let metadata: serde_json::Value = serde_json::from_slice( let target_dir = manifest_dir.join("resources/codex/win-x64");
&fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
)
.expect("Codex 原生包元数据无效");
codex_bundle::validate_package_metadata(&metadata, &target, layout)
.unwrap_or_else(|error| panic!("{error}"));
let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
let notice = target_dir.join("NOTICE.md"); let notice = target_dir.join("NOTICE.md");
if target.contains("apple-darwin") {
let source_notice =
manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md");
stage_plugin_file(&source_notice, &notice);
println!("cargo:rerun-if-changed={}", source_notice.display());
}
if !notice.is_file() { if !notice.is_file() {
panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display());
} }
fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败"); fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败");
let mut file_hashes = serde_json::Map::new(); let mut file_hashes = serde_json::Map::new();
for relative in layout.files { for relative in BUNDLED_CODEX_FILES {
let source_path = source.join(relative); let source_path = source.join(relative);
let target_path = target_dir.join(relative); let target_path = target_dir.join(relative);
if let Some(parent) = target_path.parent() { if let Some(parent) = target_path.parent() {
@@ -110,23 +104,15 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
if !target_matches_source { if !target_matches_source {
fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败"); fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败");
} }
// 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
fs::set_permissions(
&target_path,
fs::metadata(&source_path)
.expect("读取组件权限失败")
.permissions(),
)
.expect("保留内置 Codex CLI 组件权限失败");
file_hashes.insert( file_hashes.insert(
relative.to_string(), relative.to_string(),
serde_json::Value::String(source_sha256), serde_json::Value::String(source_sha256),
); );
} }
let manifest = serde_json::json!({ let manifest = serde_json::json!({
"schemaVersion": codex_bundle::SCHEMA, "schemaVersion": "genarrative-codex-sidecar.v2",
"platform": layout.platform, "platform": "win32-x64",
"version": codex_bundle::CLI_VERSION, "version": BUNDLED_CODEX_CLI_VERSION,
"files": file_hashes, "files": file_hashes,
}); });
let manifest_path = target_dir.join("manifest.json"); let manifest_path = target_dir.join("manifest.json");
@@ -140,7 +126,7 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
{ {
fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败"); fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败");
} }
for relative in layout.files { for relative in BUNDLED_CODEX_FILES {
println!("cargo:rerun-if-changed={}", source.join(relative).display()); println!("cargo:rerun-if-changed={}", source.join(relative).display());
} }
println!("cargo:rerun-if-changed={}", notice.display()); println!("cargo:rerun-if-changed={}", notice.display());
@@ -196,7 +182,6 @@ fn main() {
); );
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
stage_bundled_codex_cli(&manifest_dir); stage_bundled_codex_cli(&manifest_dir);
prepare_unity_editor_helper(&manifest_dir);
stage_plugin_workspace(&manifest_dir); stage_plugin_workspace(&manifest_dir);
stage_cocos_editor_payload(&manifest_dir); stage_cocos_editor_payload(&manifest_dir);
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
@@ -267,116 +252,12 @@ fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) {
#[cfg(not(windows))] #[cfg(not(windows))]
fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {} fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {}
/// Unity helper 是插件的随包运行文件。内容指纹避免每次 Cargo 检查都重新发布 .NET。
fn prepare_unity_editor_helper(manifest_dir: &std::path::Path) {
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_UNITY_EDITOR_EXECUTE");
let target = env::var("TARGET").expect("Cargo TARGET");
if env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()
|| target != "x86_64-pc-windows-msvc"
{
return;
}
let root = manifest_dir.join("../../../plugins/agc-unity-editor/dotnet");
let mut sources = Vec::new();
collect_unity_helper_sources(&root, &mut sources);
sources.sort();
let mut fingerprint = Sha256::new();
for source in &sources {
println!("cargo:rerun-if-changed={}", source.display());
fingerprint.update(
source
.strip_prefix(&root)
.expect("helper source")
.to_string_lossy()
.as_bytes(),
);
fingerprint.update([0]);
fingerprint.update(fs::read(source).expect("读取 Unity helper 源文件失败"));
}
let fingerprint = format!("{:x}", fingerprint.finalize());
let publish = root.join("publish/win-x64");
let executable = publish.join("Agc.Unity.Attach.exe");
let stamp = publish.join(".agc-source.sha256");
println!("cargo:rerun-if-changed={}", executable.display());
if unity_helper_publish_complete(&publish)
&& fs::read_to_string(&stamp).ok().as_deref() == Some(&fingerprint)
{
return;
}
assert!(
cfg!(windows),
"构建 Unity 插件 helper 需要 Windows .NET 10 与 x64 C++ 工具链"
);
let status = std::process::Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(root.join("build.ps1"))
.current_dir(&root)
.status()
.expect("无法启动 Unity helper 构建脚本");
assert!(
status.success() && unity_helper_publish_complete(&publish),
"Unity helper 构建失败或缺少运行文件/许可"
);
fs::write(stamp, fingerprint).expect("写入 Unity helper 构建指纹失败");
}
fn unity_helper_publish_complete(publish: &std::path::Path) -> bool {
[
"Agc.Unity.Attach.exe",
"NOTICE",
"THIRD-PARTY-NOTICES.txt",
"licenses/DotCraft-Apache-2.0.txt",
"licenses/Roslyn-MIT.txt",
"licenses/upstream.json",
"licenses/dotnet-LICENSE.TXT",
"licenses/dotnet-THIRD-PARTY-NOTICES.TXT",
"licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf",
"licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf",
]
.iter()
.all(|name| {
fs::symlink_metadata(publish.join(name)).is_ok_and(|metadata| {
metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0
})
})
}
fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec<PathBuf>) {
for entry in fs::read_dir(root)
.expect("Unity helper 源码目录缺失")
.flatten()
{
let kind = entry.file_type().expect("读取 Unity helper 源文件类型失败");
assert!(!kind.is_symlink(), "Unity helper 源码不允许符号链接");
let name = entry.file_name();
if kind.is_dir() {
if !matches!(
name.to_str(),
Some("bin" | "obj" | "publish" | "native-build")
) {
collect_unity_helper_sources(&entry.path(), sources);
}
} else if kind.is_file() {
sources.push(entry.path());
}
}
}
/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。 /// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
/// ///
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、 /// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
/// Cargo target 目录或 node_modules。 /// Cargo target 目录或 node_modules。
#[cfg(windows)]
fn stage_plugin_workspace(manifest_dir: &std::path::Path) { fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET");
if !target.contains("windows") && !target.contains("apple-darwin") {
return;
}
let repo_root = manifest_dir let repo_root = manifest_dir
.parent() .parent()
.and_then(|app_root| app_root.parent()) .and_then(|app_root| app_root.parent())
@@ -385,10 +266,6 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
.to_path_buf(); .to_path_buf();
let workspace = repo_root.join("plugins"); let workspace = repo_root.join("plugins");
let destination_root = manifest_dir.join("resources/plugins"); let destination_root = manifest_dir.join("resources/plugins");
// staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。
if destination_root.exists() {
std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败");
}
std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败"); std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败");
let entries = match std::fs::read_dir(&workspace) { let entries = match std::fs::read_dir(&workspace) {
Ok(entries) => entries, Ok(entries) => entries,
@@ -396,13 +273,6 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
}; };
for entry in entries.flatten() { for entry in entries.flatten() {
let plugin_root = entry.path(); let plugin_root = entry.path();
assert!(
!entry
.file_type()
.expect("读取插件目录类型失败")
.is_symlink(),
"插件工作区不允许符号链接"
);
if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() { if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() {
continue; continue;
} }
@@ -415,26 +285,19 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
for relative in [ for relative in [
std::path::PathBuf::from("src"), std::path::PathBuf::from("src"),
std::path::PathBuf::from("panels"), std::path::PathBuf::from("panels"),
std::path::PathBuf::from("skills"),
std::path::PathBuf::from("native/payload"), std::path::PathBuf::from("native/payload"),
std::path::PathBuf::from("dotnet/publish/win-x64"),
] { ] {
if (relative == std::path::Path::new("native/payload") && !target.contains("windows"))
|| (relative == std::path::Path::new("dotnet/publish/win-x64")
&& (target != "x86_64-pc-windows-msvc"
|| env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()))
{
continue;
}
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
} }
println!("cargo:rerun-if-changed={}", plugin_root.display()); println!("cargo:rerun-if-changed={}", plugin_root.display());
} }
} }
#[cfg(windows)]
fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) { fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
let bytes = std::fs::read(source) let Ok(bytes) = std::fs::read(source) else {
.unwrap_or_else(|error| panic!("读取随包资源失败 {}{error}", source.display())); return;
};
if std::fs::read(destination).is_ok_and(|existing| existing == bytes) { if std::fs::read(destination).is_ok_and(|existing| existing == bytes) {
return; return;
} }
@@ -444,6 +307,7 @@ fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
std::fs::write(destination, bytes).expect("复制插件资源失败"); std::fs::write(destination, bytes).expect("复制插件资源失败");
} }
#[cfg(windows)]
fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
let entries = match std::fs::read_dir(source) { let entries = match std::fs::read_dir(source) {
Ok(entries) => entries, Ok(entries) => entries,
@@ -452,17 +316,10 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
for entry in entries.flatten() { for entry in entries.flatten() {
let target = destination.join(entry.file_name()); let target = destination.join(entry.file_name());
let path = entry.path(); let path = entry.path();
assert!(
!entry
.file_type()
.expect("读取插件文件类型失败")
.is_symlink(),
"插件资源不允许符号链接"
);
if path.is_dir() { if path.is_dir() {
let name = entry.file_name(); let name = entry.file_name();
let name = name.to_string_lossy(); let name = name.to_string_lossy();
if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") { if matches!(name.as_ref(), "target" | "node_modules" | ".git") {
continue; continue;
} }
std::fs::create_dir_all(&target).expect("创建插件资源目录失败"); std::fs::create_dir_all(&target).expect("创建插件资源目录失败");
@@ -474,14 +331,12 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
if name.contains(".test.") { if name.contains(".test.") {
continue; continue;
} }
if name.starts_with('.') {
continue;
}
stage_plugin_file(&path, &target); stage_plugin_file(&path, &target);
} }
} }
} }
#[cfg(windows)]
fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) { fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
if !source.is_file() { if !source.is_file() {
return; return;
@@ -490,3 +345,6 @@ fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
.expect("创建插件资源目录失败"); .expect("创建插件资源目录失败");
std::fs::copy(source, destination).expect("复制插件资源失败"); std::fs::copy(source, destination).expect("复制插件资源失败");
} }
#[cfg(not(windows))]
fn stage_plugin_workspace(_manifest_dir: &std::path::Path) {}
@@ -1,133 +0,0 @@
//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。
pub const VERSION: &str = "0.147.0";
pub const CLI_VERSION: &str = "codex-cli 0.147.0";
pub const SCHEMA: &str = "genarrative-codex-sidecar.v2";
#[derive(Clone, Copy, Debug)]
pub struct Layout {
pub platform: &'static str,
pub npm_package: &'static str,
pub directory: &'static str,
pub executable: &'static str,
pub files: &'static [&'static str],
}
const WINDOWS_FILES: &[&str] = &[
"bin/codex.exe",
"bin/codex-code-mode-host.exe",
"codex-path/rg.exe",
"codex-resources/codex-command-runner.exe",
"codex-resources/codex-windows-sandbox-setup.exe",
"codex-package.json",
];
const MAC_FILES: &[&str] = &[
"bin/codex",
"bin/codex-code-mode-host",
"codex-path/rg",
"codex-resources/zsh/bin/zsh",
"codex-package.json",
];
pub fn for_target(target: &str) -> Option<Layout> {
match target {
"x86_64-pc-windows-msvc" => Some(Layout {
platform: "win32-x64",
npm_package: "codex-win32-x64",
directory: "win-x64",
executable: "bin/codex.exe",
files: WINDOWS_FILES,
}),
"aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout {
platform: if target.starts_with("aarch64") {
"darwin-arm64"
} else {
"darwin-x64"
},
npm_package: if target.starts_with("aarch64") {
"codex-darwin-arm64"
} else {
"codex-darwin-x64"
},
directory: "mac-native",
executable: "bin/codex",
files: MAC_FILES,
}),
_ => None,
}
}
pub fn validate_package_metadata(
metadata: &serde_json::Value,
target: &str,
layout: Layout,
) -> Result<(), String> {
if metadata["layoutVersion"] == 1
&& metadata["version"] == VERSION
&& metadata["target"] == target
&& metadata["entrypoint"] == layout.executable
&& metadata["resourcesDir"] == "codex-resources"
&& metadata["pathDir"] == "codex-path"
{
Ok(())
} else {
Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn platform_layouts_are_explicit_and_preserve_upstream_components() {
let mac = for_target("aarch64-apple-darwin").unwrap();
assert_eq!(mac.platform, "darwin-arm64");
assert_eq!(mac.npm_package, "codex-darwin-arm64");
assert!(mac.files.contains(&"codex-resources/zsh/bin/zsh"));
assert!(mac.files.contains(&"bin/codex-code-mode-host"));
assert!(!mac.files.iter().any(|file| file.ends_with(".exe")));
let intel = for_target("x86_64-apple-darwin").unwrap();
assert_eq!(intel.platform, "darwin-x64");
assert_eq!(intel.npm_package, "codex-darwin-x64");
let windows = for_target("x86_64-pc-windows-msvc").unwrap();
assert_eq!(windows.directory, "win-x64");
assert_eq!(windows.files.len(), 6);
assert!(windows
.files
.contains(&"codex-resources/codex-windows-sandbox-setup.exe"));
assert!(for_target("universal-apple-darwin").is_none());
assert!(for_target("aarch64-pc-windows-msvc").is_none());
assert!(for_target("x86_64-unknown-linux-gnu").is_none());
}
#[test]
fn metadata_rejects_version_architecture_and_layout_drift() {
let target = "aarch64-apple-darwin";
let layout = for_target(target).unwrap();
let valid = serde_json::json!({
"layoutVersion": 1,
"version": VERSION,
"target": target,
"entrypoint": "bin/codex",
"resourcesDir": "codex-resources",
"pathDir": "codex-path",
});
assert!(validate_package_metadata(&valid, target, layout).is_ok());
for (key, value) in [
("layoutVersion", serde_json::json!(2)),
("version", serde_json::json!("0.0.0")),
("target", serde_json::json!("x86_64-apple-darwin")),
("entrypoint", serde_json::json!("bin/codex.exe")),
("resourcesDir", serde_json::json!("../private")),
("pathDir", serde_json::json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(
validate_package_metadata(&invalid, target, layout).is_err(),
"{key}"
);
}
}
}
@@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text. 5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text.
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, waits for the accepted operation, downloads and registers the completed local asset, and preserves the operation for recovery when the remote result is not yet known. 6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable. 7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand. 8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change. 9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
@@ -16,6 +16,4 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
`prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images. `prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images.
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated request. Ordinary account mode maps the External v1 shaped route to `/api/editor/images/background-removals`; ExternalDeveloper mode uses `/api/external/v1/editor/images/background-removals`. Mode and colour are part of request identity. After acceptance, the client polls the authenticated generation status route, downloads the completed media, and commits it to the local manifest. If completion is unknown, it retains the same local operation for recovery; it never retries with a new identity or exposes internal worker details. `agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
After an interrupted call, inspect `agc_list_registered_assets.pendingOperations`. Calling `agc_remove_background` again with the same source, name, mode, and colour resumes the matching pending operation. A submission marked `reconciliation-required` needs client-side reconciliation and cannot be automatically resumed. Do not change parameters to bypass a pending task. A queued receipt, fixed progress value, or absent local file does not establish that the background-removal provider is waiting in a queue; report only the observed state.
@@ -14,9 +14,6 @@ Implement the user's actual game request in the current project as an npm-manage
3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it. 3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it.
4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one. 4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content. 5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
- **画布居中只能由一处负责。** 使用 `Phaser.Scale.FIT``autoCenter: Phaser.Scale.CENTER_BOTH` 时,canvas 的直接父容器应使用尺寸明确的普通块布局,不再对同一 canvas 叠加 Grid/Flex 居中、`place-items: center`、自动外边距或居中 transform。Phaser 自动计算的 margin 与 CSS 居中叠加会使竖屏画面向右偏移。
- 若决定由 CSS 居中,则显式使用 `autoCenter: Phaser.Scale.NO_CENTER`,由 CSS 独立完成定位;外围页面可以继续使用 Grid/Flex,限制只针对同一 canvas 的重复定位。
- 出现偏移先检查游戏自身的 CSS 与 Phaser scale 配置,不添加 AGC 预览容器固定偏移补偿。修改布局后重新构建 dist,在桌面、移动及窗口 resize 后检查 canvas 相对游戏父容器居中(误差不超过 1 CSS px)、画面完整且无意外滚动条;不能仅凭 build 成功宣称布局通过。
6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and never relabel a local placeholder as platform art. 6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and never relabel a local placeholder as platform art.
7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally. 7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally.
8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion. 8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
@@ -7,7 +7,5 @@
- Score, steps, health, timer, or other core state updates consistently. - Score, steps, health, timer, or other core state updates consistently.
- Restart restores all state and does not duplicate timers, animation loops, or event listeners. - Restart restores all state and does not duplicate timers, animation loops, or event listeners.
- Desktop and mobile layouts keep the core scene visible without accidental document scrolling. - Desktop and mobile layouts keep the core scene visible without accidental document scrolling.
- 画布的缩放与居中由 Phaser 或 CSS 中的一方独立负责。`FIT + CENTER_BOTH` 不与同一 canvas 父容器的 Grid/Flex 居中、自动外边距或居中 transform 叠加;使用 CSS 居中时关闭 Phaser 自动居中(`NO_CENTER`)。
- 在构建后的实际页面检查桌面、移动和 resize:比较 canvas 与游戏父容器的中心,预期居中时水平/垂直误差不超过 1 CSS px,并检查画面没有溢出或意外滚动条。偏移先修游戏 CSS/scale 配置,不用修改 AGC 预览位置掩盖。
- HUD and overlays reserve space and do not cover essential interactive content. - HUD and overlays reserve space and do not cover essential interactive content.
- Requested Taonier art is visibly integrated into the core experience when available. - Requested Taonier art is visibly integrated into the core experience when available.

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