Compare commits

...

40 Commits

Author SHA1 Message Date
k88936 a4296a4bdd 合并 origin/master 并收敛 DirectProject 引用渲染到 canonical user item
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m17s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m15s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m45s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 7m59s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m8s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m48s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m24s
Project CI / Frontend tests (pull_request) Successful in 15m29s
Project CI / Native shell tests (pull_request) Successful in 19m38s
- .eslintrc.cjs 冲突取双方 ignore 并集,保留 contracts/generated/** 与 project-workspace/generated/**。
- 实施计划文档冲突保留双方内容:2026-09-15 资源 kind 枚举化权威口径与 master 新增计划段并存。
- resourceReferences.ts 冲突合并双方 import:kind 解析入口与 canonical DirectCodexUserItem 类型同时保留。
- project-development.suite.ts 冲突合并双方 import:UI 设计文档 kind/mediaType 常量与 ProjectSupervisorView 同时保留。
- decision-log.md 冲突取双方条目,并补记 DirectProject 引用渲染收敛到 canonical user item 的决策。
- 接受 master 退役 agent/direct_codex_references.rs,把 UI 设计文档引用生成代码上下文的 prompt 注入迁入 agent/direct_codex_user_item/wire.rs。
- assets.rs 资源登记 kind 参数收紧为 GameCreationAppAssetKind,相应测试 fixture 改用正式枚举成员。
- 同步 project-memory 实施计划、里程碑与扫描清单中的模块路径。
2026-09-16 21:01:20 +08:00
k88936 7eee89ded3 同步 AGC 资源 kind 枚举化的实施计划、里程碑与扫描清单
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m22s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m16s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m4s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m39s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m40s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m1s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m3s
Project CI / Frontend tests (pull_request) Successful in 4m35s
Project CI / Native shell tests (pull_request) Successful in 6m44s
- 实施计划:记录已落地内容(kind 由 `String` 收紧为枚举的破坏性变更、写入与读取统一正式成员、资源编辑源身份词汇表、`cargo test export_bindings` 生成路径、TS 穷举派生)
- 实施计划:修正「明确不修改」与验证命令,去掉与当前代码不符的表述
- 里程碑:验收标准除旧 alias 读边界一项外全部勾选,并补记 ts-rs 生成与退役类型导出两项证据
- 扫描清单:把「当前未决项」逐条落成结论(`asset`、`art-spritesheet*`、`game-*`、`ui*`、`Unknown` trace、生成路径),只保留 CI verify 接入一项待办
2026-09-16 19:08:28 +08:00
k88936 f9bc2d917c AGC 资源 kind 集合改为从生成 union 穷举派生
- assetKind.ts:新增 `GAME_CREATION_APP_ASSET_KIND_MEMBERS: Record<GameCreationAppAssetKind, true>`,生成 union 增删成员时会直接编译报错
- assetKind.ts:`GAME_CREATION_APP_ASSET_KINDS` 由该表派生,不再手写第二份字面量列表
- assetKind.ts:新增 `isGameCreationAppAssetKind` 类型守卫,`parseGameCreationAppAssetKind` 复用它
- view/project-development/index.tsx:资源引用的 `sourceSubtype` 只传正式 kind 成员,任务产物等非 manifest 来源传 `null`
- tests/assetKindCanonicalMapping.test.ts:两侧 kind 目录比对前先展开 Rust 常量引用(`ui-design-doc` 是常量),修正此前恒失败的两条;删除测试体里游离的 `'font',` 表达式
2026-09-16 19:07:58 +08:00
k88936 b96cd4b7c2 ts-rs 绑定统一走 cargo test export_bindings,删除退役类型导出
- asset_kind.rs:`GameCreationAppAssetKind` 自带 `#[ts(export, export_to = ../apps/ai-game-creator-shell/src/contracts/generated/)]`,`cargo test export_bindings` 即可复现生成文件
- binding.rs:删除手写的 `exports_ui_editor_types` 测试,不再由测试就地重写生成文件或重建退役类型
- binding.rs:`BindingChange` / `BindingDTO` 去掉 `TS` derive 与 `ts(export_to)`,只作为内部结构使用,不再导出退役的前端绑定
- binding.rs:清理仅为该测试存在的 `ts_rs` / `GameCreationAppAssetKind` 导入
- .prettierignore、.eslintrc.cjs:忽略 `src/contracts/generated/`,保留 ts-rs 原始格式使生成幂等且不污染工作树
- GameCreationAppAssetKind.ts:按生成器原始格式更新(与上述路径一致,无内容漂移)
2026-09-16 19:07:33 +08:00
k88936 f185d21f55 资源编辑源身份改用独立词汇表,只传正式 kind 成员
- resource_editor.rs:新增 `ResourceEditSourceKind`,把源身份拆成 `Manifest(GameCreationAppAssetKind)` / `ProjectVersion` / `AgentResultText`
- resource_editor.rs:`ResourceEditSourceSnapshot.asset_kind` 由 `String` 收紧为该枚举,项目版本与 Agent 回执不再冒充 manifest kind
- resource_editor.rs:`infer_resource_edit_source_asset_kind` 直接返回枚举成员,`art-image` / `svg` / `text` 收口为 `Image` / `Image` / `Document`
- resource_editor.rs:栅格源归一化只接受正式枚举成员,`task-artifact` 特判删除,未知值按 `Image` 收口
- resource_editor.rs:账本、HTTP 载荷与提示词边界改用 `as_str()`,manifest 写回改用 `manifest_kind()`
- resource_editor.rs:`source_subtype` 字段补充「只接受 manifest 正式 kind」的文档说明,测试 fixture 同步改用枚举成员
2026-09-16 19:07:23 +08:00
k88936 a61b0cce81 收敛 AGC 资源 kind 写入与读取到正式枚举成员
- assets.rs:`register_local_asset_at` / `register_local_asset_entry` 改为接收 `GameCreationAppAssetKind`,画板导入与画板同步在边界解析 kind
- assets.rs:`infer_canvas_export_asset_kind` 直接返回枚举成员,去掉仅供兜底的 canonical 归一
- commands.rs:资源登记按 `asset.register` 上下文解析 kind,字体导入写 `Font`,本地文件类型表与平台导入归一改用枚举
- direct_runtime.rs:直连游戏工程产物统一写 `Code`,图集切片读取按 `Icon` 叠加切片路径判定,陶泥儿美术身份改收枚举
- canvas_generation.rs:新增 `platform_art_asset_manifest_kind` 收口平台美术请求词汇,切片登记 category 改用 `Icon.as_str()`
- manifest.rs:必填视觉资产校验改用 `IconSpec` / `UiDesign` / `IconSpritesheet`,修正此前与写入侧对不上的比对
- project_gates.rs、autonomous_policy.rs、recovery_scan.rs、direct_tool_bridge.rs、ui_editor workflow/persistence/resource_bridge:读取侧与 fixture 改用正式枚举成员
- tests:资源相关 fixture 与断言改用正式枚举成员
2026-09-16 19:07:12 +08:00
k88936 2a75a2d2c8 回退资源 kind 绑定断言的误改
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
ts-rs 导出内容是双引号,测试重新生成后再校验,恢复双引号断言。
2026-09-16 17:05:03 +08:00
k88936 512f669f57 修正资源引用入口的源码断言
正则改为匹配 parseGameCreationAppAssetKind(selectedResource.subtype) 的新调用形状。
2026-09-16 16:59:51 +08:00
k88936 21dbbb4c3c 清理资源桥接测试的旧 kind fixture
将有效分类投影用例的资源 kind 改为 UiDesign 枚举与 ui-design 断言。

移除遗留 ui 字符串 fixture,使 AGC 壳测试恢复编译。
2026-09-16 16:53:27 +08:00
k88936 55d0c99e2b 修正资源 kind 转义测试期望
转义后的 ui-design 应解析为 UiDesign 枚举。
2026-09-15 20:57:14 +08:00
k88936 3907a8bf74 补齐资源 kind 字符串字段转换
资源编辑请求中的 sourceSubtype 统一从 AssetKind 显式转换字符串。
2026-09-15 20:52:53 +08:00
k88936 46dfa2cffb 修复资源枚举迁移后的测试类型错误
将字体、角色和资源图 fixture 改为 AssetKind 枚举。

将资源 kind 回传到字符串字段时显式使用 to_string。
2026-09-15 20:45:55 +08:00
k88936 97e2b26900 简化资源引用 kind 来源
引用资源时直接解析已存在的 subtype,移除无效 category 回退。
2026-09-15 20:14:13 +08:00
k88936 39e33f7a5b 更新上传资源 kind 枚举测试
按 Font 与 Unknown 返回值修正上传类型断言。

使用枚举字符串视图校验 canonical kind 集合。
2026-09-15 20:01:20 +08:00
k88936 040f646480 统一 ts-rs workspace 依赖版本
在 server-rs workspace 集中声明 ts-rs 版本。

shared-contracts 通过 workspace 依赖引用可选 ts-rs。
2026-09-15 19:53:06 +08:00
k88936 8b007c637d 修正图集切片读取侧枚举判断
恢复校验和测试筛选统一识别 Icon 资源 kind。

部分登记探测限定在图集切片路径,避免误判普通图标。
2026-09-15 19:48:48 +08:00
k88936 f0a62c6306 迁移资源桥接测试 fixture 到枚举
将图集切片、角色动画和规格资源 fixture 改用对应 AssetKind 变体。
2026-09-15 19:45:51 +08:00
k88936 30edfee00e 修复 Codex 资源引用测试 fixture
将 manifest 资源 kind 字符串 fixture 迁移为 Character 枚举。
2026-09-15 19:41:41 +08:00
k88936 c2da8e184e 修正 UI 工作流源资源枚举校验
工作流源图校验与 fixture 统一使用 UiDesign。

更新失败提示中的 manifest kind 名称。
2026-09-15 19:36:29 +08:00
k88936 a19319d815 修正 UI 原型资源桥接枚举使用
生产校验与测试 fixture 改用 GameCreationAppAssetKind::UiDesign。

保留 ui-prototype 作为本地文件路径标识。
2026-09-15 19:35:04 +08:00
k88936 d07ea186fa 收紧资源 kind 测试专用导入
仅在测试构建中导入 GameCreationAppAssetKind,避免正式构建产生未使用警告。
2026-09-15 19:33:13 +08:00
k88936 80caec373a 修正资源 kind 绑定测试断言
按 ts-rs 生成的单引号联合成员校验绑定内容。
2026-09-15 19:32:12 +08:00
k88936 3caefd6716 修复资源 kind 转义字符串反序列化
为 AssetKindVisitor 增加 visit_string 转发。

补充 JSON 转义 kind 的反序列化回归测试。
2026-09-15 19:31:21 +08:00
k88936 4d0dd2d721 收紧 UI 资源桥接的 manifest kind
UI 原型与文档链接改用生成枚举边界
删除 ui-prototype 的主动 alias 使用并更新测试清单
2026-09-15 18:06:27 +08:00
k88936 2befaaef9d 接入 AGC shell TS 资源 kind 边界
新增消费 Rust 生成绑定的未知值解析模块
迁移资源引用与画布引用入口并补充日志测试
更新逐项迁移清单与实施计划
2026-09-15 18:00:34 +08:00
k88936 20b0bf4dd7 补充 AGC manifest 未知 kind 验收
验证旧值读取为 Unknown 且序列化为 unknown
明确不执行 alias 迁移
2026-09-15 17:50:33 +08:00
k88936 b4fd8d9b8b 收紧 AGC manifest 资源 kind 字段
将 manifest 条目 kind 改为 Rust 枚举并把未知值收口为 Unknown
修正字体上传与图集切片的正式资源 kind
保持 source.kind 与 generationKind 等其他字符串字段不变
2026-09-15 17:37:28 +08:00
k88936 fe85fa2a62 建立 AGC 资源 kind Rust 枚举与 TypeScript 绑定
新增 GameCreationAppAssetKind 及 Unknown 解析边界
通过 ts-rs 生成 shell 专属 TypeScript 联合类型
补充未知值上下文日志与 Rust/绑定生成测试
2026-09-15 16:20:52 +08:00
k88936 b14a533b17 新增AGC资源kind绑定实施计划
限定首个代码切片为 Rust enum 与 ts-rs 生成边界。

明确不修改 manifest 字段和其他资源字符串领域。

列出绑定、serde round-trip 与定向验证门禁。
2026-09-15 15:29:37 +08:00
k88936 42969b2218 细化AGC资源kind构造器审计
补充 Rust manifest 构造器逐处迁移清单。

补充 shell TypeScript 裸 assetKind 消费方清单。

区分图集、工作流和 manifest kind 的处理边界。
2026-09-15 15:24:46 +08:00
k88936 c4ee75a1af 补充AGC资源kind词汇审计清单
记录 manifest kind 的 Rust 写入边界与 TypeScript 消费方。

区分资源 kind 与 source/generation/workflow 等字符串领域。

标注 font、audio、sound-effect、background-music 及旧值的生产状态。

通过文档索引、编码与 diff 检查。
2026-09-15 15:19:58 +08:00
k88936 6f2137cbb0 补充AGC资源kind枚举化规范
新增资源 kind 枚举化主规范与边界。

新增词汇审计、Rust 绑定、写入清理三个里程碑。

新增 shell-only 资源 kind 扫描 todo 清单。

通过文档索引、编码与 diff 检查。
2026-09-15 15:12:24 +08:00
k88936 70a2246e8b 移除 UI 文档引用新增测试
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m30s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m59s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m24s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 4m28s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m7s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m22s
Project CI / Frontend tests (pull_request) Failing after 3m51s
Project CI / Native shell tests (pull_request) Successful in 7m4s
删除 Direct Codex UI 文档 fixture 与专门测试用例

保留生产引用逻辑和现有业务测试
2026-09-14 21:41:53 +08:00
k88936 b7e3dac661 统一引用 UI 文档资产类型常量
前端入口、资源桥接与引用预览统一使用共享常量

Rust 持久化与测试复用共享契约常量

补齐 UI 编辑器入口 fixture 的新资产类型
2026-09-14 21:36:43 +08:00
k88936 2e4a1996c8 同步 UI 编辑器入口测试资产类型
将项目开发 UI 编辑器回程测试 fixture 更新为 ui-design-doc
2026-09-14 21:12:03 +08:00
k88936 fe4e952853 更新 UI 文档资源编辑器入口
将 ui-design-doc 资源纳入选中资源的 UI 编辑器按钮判定

保留 ui-prototype 图片资源的编辑器桥接入口
2026-09-14 21:04:08 +08:00
k88936 18654b6806 同步 UI 文档资产共享契约与前端识别
共享契约新增 ui-design-doc 分类及中英文映射

前端编辑器与资源桥接严格识别文档资产

更新技术方案、实施计划和长期决策记录
2026-09-14 20:45:04 +08:00
k88936 ee7d00c0b1 统一 UI 编辑器文档资产类型
新增 ui-design-doc 与 application/json 常量

创建、桥接、工作流和持久化统一使用严格资产类型
2026-09-14 20:44:49 +08:00
k88936 9cd1a94369 接入 UI 文档引用代码上下文生成
Direct Codex 引用 ui-design-doc 时生成带文档的 JS 文件

生成成功或失败信息追加到当前 prompt

补充成功、失败与 ui-prototype 隔离测试
2026-09-14 20:44:49 +08:00
k88936 470e85c0ff Add UI-specific prompt rendering for design assets in resource references 2026-09-14 20:44:49 +08:00
63 changed files with 2117 additions and 541 deletions
+1
View File
@@ -164,6 +164,7 @@ module.exports = {
'server-rs/target-*',
'apps/desktop-shell/src-tauri/target',
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
'apps/ai-game-creator-shell/src/contracts/generated/**',
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
'target',
'src/main.tsx',
+2
View File
@@ -4,6 +4,8 @@ node_modules
.codex-logs
public/Icons
apps/ai-game-creator-shell/src/features/ui-editor/types/
# ts-rs 直接落盘的生成产物:保持生成器原始格式,`cargo test export_bindings` 才可幂等复现。
apps/ai-game-creator-shell/src/contracts/generated/
apps/ai-game-creator-shell/src-tauri/resources/agc-skills/
media
*.log
+2
View File
@@ -4900,6 +4900,8 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"tracing",
"ts-rs",
]
[[package]]
@@ -14,7 +14,7 @@ cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-i
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
@@ -50,7 +50,7 @@ portable-pty = "0.9"
percent-encoding = "2"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
regex = "1"
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
@@ -1,6 +1,10 @@
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem};
use super::validation::validate_direct_codex_user_item;
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
use crate::ui_editor::persistence::{
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
};
use serde_json::Value;
use std::path::Path;
@@ -111,29 +115,165 @@ pub(crate) fn direct_codex_user_item_to_prompt(
item: &DirectCodexUserItem,
) -> Result<String, String> {
let wire = direct_codex_user_item_to_wire_input(root, item)?;
wire.as_array()
let DirectCodexUserItem::Message(message) = item;
let mut prompt = wire
.as_array()
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
.map(|parts| {
.and_then(|parts| {
parts
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<String>()
})
.and_then(|prompt| {
if prompt.trim().is_empty() {
Err("DirectProject user item 不能转换为空 prompt".to_string())
} else {
Ok(prompt)
.map(|part| {
part.get("text")
.and_then(Value::as_str)
.ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string())
})
.collect::<Result<String, String>>()
})?;
if let Some(code_context) = render_ui_design_code_context(root, message)? {
prompt.push('\n');
prompt.push_str(&code_context);
}
if prompt.trim().is_empty() {
return Err("DirectProject user item 不能转换为空 prompt".to_string());
}
Ok(prompt)
}
/// 本轮 prompt 的 UI 设计文档引用上下文:复用 UI Editor 代码导出,把带文档注释的
/// JS 片段路径交给模型;生成失败只追加原始错误,不阻断本轮引用,其它引用继续处理。
///
/// 只在生成本轮 prompt 时展开:历史 item 回读走 `direct_codex_user_item_to_response_item`
/// 的纯投影,不得在这里产生项目写副作用。
fn render_ui_design_code_context(
root: &Path,
message: &DirectCodexUserMessageItem,
) -> Result<Option<String>, String> {
let referenced_ids = message
.content
.iter()
.filter_map(|part| match part {
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
Some(resource_id.trim())
}
_ => None,
})
.collect::<Vec<_>>();
if referenced_ids.is_empty() {
return Ok(None);
}
let manifest = read_manifest_for_project(root)?;
let mut lines = Vec::new();
for resource_id in referenced_ids {
let is_ui_design_doc = manifest.assets.iter().any(|asset| {
asset.id == resource_id
&& asset.kind == UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
});
if !is_ui_design_doc {
continue;
}
lines.push(
match generate_ui_design_code_at(GenerateUiDesignCodeInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: manifest.project_id.clone(),
asset_id: resource_id.to_string(),
}) {
Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path),
Err(error) => format!("生成代码遇到错误{error}"),
},
);
}
if lines.is_empty() {
return Ok(None);
}
Ok(Some(lines.join("\n")))
}
#[cfg(test)]
mod tests {
use super::direct_codex_user_item_to_response_item;
use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item};
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
use serde_json::json;
use shared_contracts::game_creation_app::{
GameCreationAppAssetKind, GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
};
use std::path::Path;
const PROMPT_CONTEXT_PROJECT_ID: &str = "direct-prompt-context-project";
fn prompt_context_project() -> tempfile::TempDir {
let directory = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(
directory.path(),
PROMPT_CONTEXT_PROJECT_ID,
"Direct prompt 上下文测试",
)
.expect("init project");
directory
}
fn register_fixture_asset(
root: &Path,
relative_path: &str,
kind: GameCreationAppAssetKind,
media_type: &str,
) -> String {
let absolute_path = crate::resolve_local_project_path(root, relative_path)
.expect("resolve fixture asset path");
std::fs::create_dir_all(absolute_path.parent().expect("fixture asset parent"))
.expect("create fixture asset parent");
std::fs::write(&absolute_path, b"{}").expect("write fixture asset file");
crate::assets::register_local_asset_at(
root,
relative_path,
kind,
media_type,
"prompt-context-test",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: Some("prompt-context".to_string()),
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)
.expect("register fixture asset")
.id
}
fn ui_design_doc_fixture(initialize_state: bool) -> (tempfile::TempDir, String) {
let directory = prompt_context_project();
let asset_id = register_fixture_asset(
directory.path(),
"ui/design.json",
GameCreationAppAssetKind::UiDesignDoc,
UI_DESIGN_DOC_MEDIA_TYPE,
);
if initialize_state {
crate::ui_editor::persistence::initialize_ui_design_state_at(
directory.path(),
PROMPT_CONTEXT_PROJECT_ID,
&asset_id,
)
.expect("initialize UI design state");
}
(directory, asset_id)
}
fn user_item_with_resource_reference(asset_id: &str) -> serde_json::Value {
json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [{"type": "agc_resource_reference", "resourceId": asset_id}],
})
}
#[test]
fn standard_response_item_passes_through_without_agc_private_parts() {
let item = json!({
@@ -174,4 +314,81 @@ mod tests {
.expect_err("history item without type must fail");
assert!(error.contains("缺少 type"), "{error}");
}
#[test]
fn ui_design_doc_reference_appends_generated_code_context() {
let (project, asset_id) = ui_design_doc_fixture(true);
let item: super::DirectCodexUserItem =
serde_json::from_value(user_item_with_resource_reference(&asset_id))
.expect("canonical user item");
let prompt =
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
assert!(
prompt.contains(&format!(
"[素材引用 resourceId={asset_id};项目路径=ui/design.json]"
)),
"{prompt}"
);
assert!(
prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"),
"{prompt}"
);
}
#[test]
fn history_projection_never_writes_generated_ui_design_code() {
let (project, asset_id) = ui_design_doc_fixture(true);
let item = user_item_with_resource_reference(&asset_id);
direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection");
let generated_root =
crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory");
let generated_files = std::fs::read_dir(generated_root)
.expect("read ui directory")
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("generated-")
})
.count();
assert_eq!(
generated_files, 0,
"历史回读只做纯投影,不得生成 UI 设计代码"
);
}
#[test]
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
let (project, asset_id) = ui_design_doc_fixture(false);
let item: super::DirectCodexUserItem =
serde_json::from_value(user_item_with_resource_reference(&asset_id))
.expect("canonical user item");
let prompt =
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
}
#[test]
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
let project = prompt_context_project();
let asset_id = register_fixture_asset(
project.path(),
"assets/hero.png",
GameCreationAppAssetKind::Character,
"image/png",
);
let item: super::DirectCodexUserItem =
serde_json::from_value(user_item_with_resource_reference(&asset_id))
.expect("canonical user item");
let prompt =
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
assert!(
!prompt.contains("请先阅读生成的带有文档的代码片段"),
"{prompt}"
);
assert!(!prompt.contains("生成代码遇到错误"), "{prompt}");
}
}
@@ -69,32 +69,31 @@ const DIRECT_CODEX_ART_ASSET_PATHS: [&str; 3] = [
const DIRECT_CODEX_ART_AGENT_ID: &str = "direct-codex-art";
const DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER: &str = "[[AGC_CREATE_PROJECT]]";
fn direct_codex_game_outputs(root: &Path) -> Vec<(String, &'static str, &'static str)> {
/// 直连 Codex 生成的游戏工程文件 → manifest 登记项。
///
/// 这些不是用户素材,而是游戏工程自身的源码/配置(`index.html`、`style.css`、`game.js`、
/// `package.json`、`package-lock.json`、`vite.config.js`)。它们的 manifest kind 统一是正式成员
/// `code``code` 与旧的 `game-*` / `image` 兜底一样派生 `unclassified`(「待归类」),
/// 不会把工程文件塞进内容栏目,同时写入侧不再产出非 canonical 的 kind。
fn direct_codex_game_outputs(root: &Path) -> Vec<(String, GameCreationAppAssetKind, &'static str)> {
let entry = agent_runtime_game_entry_relative_path(root);
let prefix = if entry == AGENT_RUNTIME_GAME_ENTRY_ROOT_PATH {
""
} else {
"game/"
};
let code = GameCreationAppAssetKind::Code;
vec![
(entry.to_string(), "game-entry", "text/html"),
(format!("{prefix}style.css"), "game-style", "text/css"),
(format!("{prefix}game.js"), "game-script", "text/javascript"),
(
format!("{prefix}package.json"),
"game-package",
"application/json",
),
(entry.to_string(), code, "text/html"),
(format!("{prefix}style.css"), code, "text/css"),
(format!("{prefix}game.js"), code, "text/javascript"),
(format!("{prefix}package.json"), code, "application/json"),
(
format!("{prefix}package-lock.json"),
"game-lockfile",
code,
"application/json",
),
(
format!("{prefix}vite.config.js"),
"game-build-config",
"text/javascript",
),
(format!("{prefix}vite.config.js"), code, "text/javascript"),
]
}
@@ -938,7 +937,7 @@ fn direct_taonier_spritesheet_matches_retained_stage_result_at(
let Some(art_spec) = direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"/api/external/v1/editor/images/generations",
"spec",
None,
@@ -948,7 +947,7 @@ fn direct_taonier_spritesheet_matches_retained_stage_result_at(
let Some(spritesheet) = direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
Some(&art_spec),
@@ -2501,7 +2500,7 @@ fn direct_taonier_art_generation_runtime_context(
fn direct_taonier_art_asset_identity(
root: &Path,
expected_path: &str,
expected_kind: &str,
expected_kind: GameCreationAppAssetKind,
expected_generation_route: &str,
expected_generation_kind: &str,
expected_reference_source: Option<&DirectTaonierArtAssetIdentity>,
@@ -2535,7 +2534,7 @@ fn direct_taonier_art_asset_identity(
local_asset_id: asset.id.clone(),
source_sha256: validated.content_sha256,
media_type: asset.media_type.clone(),
canonical_asset_kind: asset.kind.clone(),
canonical_asset_kind: asset.kind.to_string(),
resource_id: asset
.source
.resource_id
@@ -2609,7 +2608,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
let Some(art_spec) = direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"/api/external/v1/editor/images/generations",
"spec",
None,
@@ -2619,7 +2618,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
let Some(background) = direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
"game-background",
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(&art_spec),
@@ -2634,7 +2633,7 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool {
let Some(art_spec) = direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"/api/external/v1/editor/images/generations",
"spec",
None,
@@ -2647,7 +2646,7 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool {
let Some(spritesheet) = direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
Some(&art_spec),
@@ -2698,7 +2697,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
let Some(art_spec) = direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"/api/external/v1/editor/images/generations",
"spec",
None,
@@ -2740,7 +2739,10 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
return Vec::new();
};
if assets.next().is_some()
|| asset.kind != "art-spritesheet-slice"
|| asset.kind != GameCreationAppAssetKind::Icon
|| !asset
.local_path
.starts_with("assets/art-spritesheet-slices/")
|| asset.media_type != "image/png"
|| asset.source.kind != GameCreationAppAssetSourceKind::Canvas
|| asset.source.generation_route.as_deref()
@@ -3050,7 +3052,7 @@ fn snapshot_direct_spritesheet_recovery_manifest_for_install(
let current_art_spec = direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"/api/external/v1/editor/images/generations",
"spec",
None,
@@ -3069,7 +3071,7 @@ fn register_direct_recovered_spritesheet_at(
register_local_asset_at(
root,
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
"image/png",
"platform-art-recovery",
source,
@@ -3106,7 +3108,7 @@ fn install_direct_recovered_spritesheet_binding_at(
&registered_asset.id,
content_sha256,
"image/png",
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet.as_str(),
)?;
let binding = new_external_editor_resource_binding(
project_id,
@@ -3153,7 +3155,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
&source_asset.id,
&format!("{:x}", Sha256::digest(&source_bytes)),
&source_asset.media_type,
&source_asset.kind,
source_asset.kind.as_str(),
)?;
let principal = external_editor_binding_principal(&access)?;
let Some(project_binding) =
@@ -3419,7 +3421,7 @@ async fn generate_direct_taonier_art_asset_at(
let art_spec = direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"/api/external/v1/editor/images/generations",
"spec",
None,
@@ -3579,7 +3581,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"/api/external/v1/editor/images/generations",
"spec",
None,
@@ -3590,7 +3592,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
"game-background",
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(art_spec),
@@ -3656,7 +3658,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"/api/external/v1/editor/images/generations",
"spec",
None,
@@ -3674,7 +3676,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
|| direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
"game-background",
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(&art_spec),
@@ -4414,7 +4416,7 @@ fn sync_direct_codex_project_file_projection_at(
register_local_asset_at(
root,
&local_path,
"game-source",
GameCreationAppAssetKind::Code,
media_type,
"direct-codex",
direct_codex_generated_source(),
@@ -7118,7 +7120,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"replacement-before-crash",
Vec::new(),
&replacement_spec,
@@ -7216,7 +7218,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"strict-crash-spec",
Vec::new(),
&new_spec,
@@ -7230,7 +7232,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
"game-background",
GameCreationAppAssetKind::Scene,
"strict-crash-background",
vec!["strict-crash-spec".to_string()],
&new_background,
@@ -7446,7 +7448,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"replacement-before-crash",
Vec::new(),
&tiny_replacement_png(64, 96, 224),
@@ -7529,7 +7531,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"unknown-stage-replacement",
Vec::new(),
&tiny_replacement_png(224, 64, 32),
@@ -8128,7 +8130,7 @@ mod tests {
let art_spec = direct_taonier_art_asset_identity(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"/api/external/v1/editor/images/generations",
"spec",
None,
@@ -8143,7 +8145,7 @@ mod tests {
let concurrent = register_local_asset_at(
root.path(),
concurrent_path,
"illustration",
GameCreationAppAssetKind::Image,
"image/png",
"uploaded",
GameCreationAppAssetSource {
@@ -8176,7 +8178,7 @@ mod tests {
let recovery = register_local_asset_at(
root.path(),
recovery_path,
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
"image/png",
"platform-art-recovery",
GameCreationAppAssetSource {
@@ -8224,7 +8226,7 @@ mod tests {
let registered = register_local_asset_at(
root.path(),
local_path,
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
"image/png",
"platform-art-recovery",
GameCreationAppAssetSource {
@@ -8341,14 +8343,18 @@ mod tests {
}));
}
fn register_direct_taonier_art_asset_fixture(root: &Path, local_path: &str, kind: &str) {
fn register_direct_taonier_art_asset_fixture(
root: &Path,
local_path: &str,
kind: GameCreationAppAssetKind,
) {
let (generation_route, generation_kind, reference_resource_ids) = match kind {
"art-spritesheet" => (
GameCreationAppAssetKind::IconSpritesheet => (
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
vec!["taonier-resource-icon-spec".to_string()],
),
"game-background" => (
GameCreationAppAssetKind::Scene => (
"/api/external/v1/editor/images/generations",
"spec",
vec!["taonier-resource-icon-spec".to_string()],
@@ -8359,6 +8365,14 @@ mod tests {
Vec::new(),
),
};
// 平台侧资源/对象/任务 id 按生成阶段命名(art-spec / game-background /
// art-spritesheet),与 manifest kind 是两套词汇;kind 收紧成枚举后不能让这些
// fixture 标识跟着改名。
let identity_slug = match kind {
GameCreationAppAssetKind::IconSpritesheet => "art-spritesheet",
GameCreationAppAssetKind::Scene => "game-background",
_ => kind.as_str(),
};
let mut png_bytes = Vec::new();
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
1,
@@ -8381,9 +8395,9 @@ mod tests {
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("taonier-project".to_string()),
resource_id: Some(format!("taonier-resource-{kind}")),
asset_object_id: Some(format!("taonier-object-{kind}")),
task_id: Some(format!("taonier-task-{kind}")),
resource_id: Some(format!("taonier-resource-{identity_slug}")),
asset_object_id: Some(format!("taonier-object-{identity_slug}")),
task_id: Some(format!("taonier-task-{identity_slug}")),
prompt: None,
model: Some("gpt-image-2".to_string()),
generation_route: Some(generation_route.to_string()),
@@ -8399,7 +8413,7 @@ mod tests {
register_direct_taonier_art_asset_fixture(
root,
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
);
let main_bytes = std::fs::read(root.join(DIRECT_CODEX_SPRITESHEET_ASSET_PATH))
.expect("spritesheet bytes");
@@ -8476,7 +8490,7 @@ mod tests {
register_local_asset_at(
root,
path,
"art-spritesheet-slice",
GameCreationAppAssetKind::Icon,
"image/png",
"platform-art",
GameCreationAppAssetSource {
@@ -8588,7 +8602,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root,
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
&spritesheet_resource_id,
vec![art_spec_resource_id.to_string()],
&spritesheet_bytes,
@@ -8675,19 +8689,20 @@ mod tests {
fn replace_direct_taonier_art_asset_fixture(
root: &Path,
local_path: &str,
kind: &str,
kind: GameCreationAppAssetKind,
resource_id: &str,
reference_resource_ids: Vec<String>,
bytes: &[u8],
) {
let (generation_route, generation_kind) = if kind == "art-spritesheet" {
(
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
)
} else {
("/api/external/v1/editor/images/generations", "spec")
};
let (generation_route, generation_kind) =
if kind == GameCreationAppAssetKind::IconSpritesheet {
(
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
)
} else {
("/api/external/v1/editor/images/generations", "spec")
};
std::fs::write(root.join(local_path), bytes).expect("replace fixture bytes");
register_local_asset_at(
root,
@@ -8716,12 +8731,12 @@ mod tests {
register_direct_taonier_art_asset_fixture(
root,
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
);
register_direct_taonier_art_asset_fixture(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
"game-background",
GameCreationAppAssetKind::Scene,
);
}
@@ -9016,7 +9031,11 @@ mod tests {
init_local_game_project_at(root.path(), "direct-independent-image", "独立平台图片")
.expect("init project");
std::fs::create_dir_all(root.path().join("assets")).expect("assets dir");
register_direct_taonier_art_asset_fixture(root.path(), "assets/neon-mine.png", "image");
register_direct_taonier_art_asset_fixture(
root.path(),
"assets/neon-mine.png",
GameCreationAppAssetKind::Image,
);
std::fs::write(root.path().join("game/index.html"), "<!doctype html>").expect("index");
std::fs::write(root.path().join("game/style.css"), "body {} ").expect("style");
std::fs::write(
@@ -9224,7 +9243,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"replacement-spec",
Vec::new(),
&tiny_replacement_png(32, 64, 192),
@@ -9235,7 +9254,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
"game-background",
GameCreationAppAssetKind::Scene,
"replacement-background",
vec!["replacement-spec".to_string()],
&tiny_replacement_png(16, 128, 224),
@@ -9323,7 +9342,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"durable-replacement-spec",
Vec::new(),
&tiny_replacement_png(32, 64, 192),
@@ -9336,7 +9355,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
"game-background",
GameCreationAppAssetKind::Scene,
"durable-replacement-background",
vec!["durable-replacement-spec".to_string()],
&tiny_replacement_png(16, 128, 224),
@@ -9421,7 +9440,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"uncertain-committed-spec",
Vec::new(),
&replacement,
@@ -9481,7 +9500,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
"uncertain-committed-spritesheet",
vec!["taonier-resource-icon-spec".to_string()],
&replacement,
@@ -9537,7 +9556,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"late-committed-spec",
Vec::new(),
&tiny_replacement_png(32, 64, 192),
@@ -9591,7 +9610,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"replacement-spec-before-background",
Vec::new(),
&tiny_replacement_png(32, 64, 192),
@@ -9610,7 +9629,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
"game-background",
GameCreationAppAssetKind::Scene,
"late-committed-background",
vec!["replacement-spec-before-background".to_string()],
&tiny_replacement_png(16, 128, 224),
@@ -9655,7 +9674,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"replacement-spec",
Vec::new(),
&replacement_spec,
@@ -9666,7 +9685,7 @@ mod tests {
replace_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
"game-background",
GameCreationAppAssetKind::Scene,
"replacement-background",
vec!["replacement-spec".to_string()],
&replacement_background,
@@ -1203,7 +1203,7 @@ fn bridge_registered_resource(
// (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。
// 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、
// 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。
"category": game_creation_app_asset_effective_category(&asset.kind, asset.category),
"category": game_creation_app_asset_effective_category(asset.kind.as_str(), asset.category),
"tags": asset.tags,
"canvasProjectId": asset.source.canvas_project_id,
"resourceId": asset.source.resource_id,
@@ -1244,7 +1244,8 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
arguments,
"kind",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS,
)?;
)?
.map(|kind| GameCreationAppAssetKind::parse_with_context(&kind, "asset.list.kind"));
let asset_id = bridge_optional_bounded_string(
arguments,
"assetId",
@@ -1264,7 +1265,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
let mut assets = manifest
.assets
.iter()
.filter(|asset| kind.as_ref().is_none_or(|kind| asset.kind == *kind))
.filter(|asset| kind.is_none_or(|kind| asset.kind == kind))
.filter(|asset| {
asset_id
.as_ref()
@@ -1862,7 +1863,7 @@ async fn bridge_create_or_derive_resource(
source_asset_id: source_asset.as_ref().map(|asset| asset.id.clone()),
source_path: source_asset.as_ref().map(|asset| asset.local_path.clone()),
source_media_type: source_asset.as_ref().map(|asset| asset.media_type.clone()),
source_subtype: source_asset.as_ref().map(|asset| asset.kind.clone()),
source_subtype: source_asset.as_ref().map(|asset| asset.kind.to_string()),
producer_task_id: source_asset
.as_ref()
.and_then(|asset| asset.source.task_id.clone()),
@@ -3323,7 +3324,7 @@ mod tests {
fn bridge_art_resource_exposes_only_the_safe_identity_projection() {
let asset = GameCreationAppAssetManifestEntry {
id: "local-art-1".to_string(),
kind: "art-spritesheet-slice".to_string(),
kind: GameCreationAppAssetKind::Icon,
media_type: "image/png".to_string(),
local_path: "assets/art-spritesheet-slices/player.png".to_string(),
image_sequence_frames: None,
@@ -3380,7 +3381,7 @@ mod tests {
fn bridge_registered_resource_exposes_formal_sequence_without_private_generation_fields() {
let asset = GameCreationAppAssetManifestEntry {
id: "animation-1".to_string(),
kind: "character-animation".to_string(),
kind: GameCreationAppAssetKind::CharacterAnimation,
media_type: "video/mp4".to_string(),
local_path: "assets/edits/animation.mp4".to_string(),
image_sequence_frames: Some(vec![
@@ -3431,7 +3432,7 @@ mod tests {
fn bridge_registered_resource_keeps_explicit_manifest_classification() {
let asset = GameCreationAppAssetManifestEntry {
id: "spec-1".to_string(),
kind: "spec".to_string(),
kind: GameCreationAppAssetKind::Spec,
media_type: "application/json".to_string(),
local_path: "assets/specs/hero.json".to_string(),
image_sequence_frames: None,
@@ -3467,13 +3468,13 @@ mod tests {
///
/// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈),
/// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘
/// `category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
/// `unclassified`——真机 122 条资产里有 55 条这样分叉
/// `category` 会让 `ui-design` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
/// `unclassified`,两侧分类口径必须一致
#[test]
fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() {
let asset = GameCreationAppAssetManifestEntry {
id: "ui-1".to_string(),
kind: "ui".to_string(),
id: "ui-design-1".to_string(),
kind: GameCreationAppAssetKind::UiDesign,
media_type: "application/json".to_string(),
local_path: "assets/UI 设计 1.json".to_string(),
image_sequence_frames: None,
@@ -3496,7 +3497,7 @@ mod tests {
};
let projection = bridge_registered_resource(&asset, false);
assert_eq!(projection["kind"], "ui");
assert_eq!(projection["kind"], "ui-design");
assert_eq!(projection["category"], "ui-interaction");
}
@@ -473,6 +473,27 @@ pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option
})
}
/// 平台美术生成请求的 `assetKind` → manifest 资源 kind。
///
/// `PlatformArtAssetGenerationOptions::asset_kind` 是**平台请求词汇**`ui-prototype` /
/// `art-spritesheet` / `game-background` / `game-art` …),跟 manifest 的正式
/// `GameCreationAppAssetKind` 不是同一套词表。生成结果登记进 manifest 之前必须在这里显式
/// 转换,否则 manifest 会落进非 canonical 值,读侧只能靠 shared-contracts 的别名表兜底。
///
/// 词表外的值不猜:返回 `Unknown`,由调用方按既有未知输入口径处理。
pub(crate) fn platform_art_asset_manifest_kind(asset_kind: &str) -> GameCreationAppAssetKind {
match asset_kind.trim() {
"image" | "game-art" => GameCreationAppAssetKind::Image,
"character" | "character-art" => GameCreationAppAssetKind::Character,
"spec" | "icon-spec" => GameCreationAppAssetKind::IconSpec,
"ui-prototype" | "ui-design" => GameCreationAppAssetKind::UiDesign,
"art-spritesheet" | "icon-spritesheet" => GameCreationAppAssetKind::IconSpritesheet,
"publication-material" => GameCreationAppAssetKind::PublicationMaterial,
"game-background" | "scene" => GameCreationAppAssetKind::Scene,
_ => GameCreationAppAssetKind::Unknown,
}
}
pub(in crate::agent) fn recover_persisted_visual_generation_options(
root: &Path,
pending: &AgentRuntimePendingToolAction,
@@ -1614,7 +1635,7 @@ async fn canonical_art_spec_reference_at(
.iter()
.find(|asset| {
asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH
&& asset.kind == "icon-spec"
&& asset.kind == GameCreationAppAssetKind::IconSpec
&& asset.media_type.starts_with("image/")
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
})
@@ -1638,7 +1659,7 @@ async fn canonical_art_spec_reference_at(
&source.id,
&format!("{:x}", Sha256::digest(&bytes)),
&source.media_type,
&source.kind,
source.kind.as_str(),
)?;
if let Some(binding) = read_external_editor_resource_binding_at(
root,
@@ -6847,7 +6868,7 @@ fn register_platform_art_slice_manifest_entries_at(
.iter_mut()
.find(|asset| asset.local_path == registration.local_path)
{
existing.kind = "art-spritesheet-slice".to_string();
existing.kind = GameCreationAppAssetKind::Icon;
existing.media_type = registration.media_type.clone();
existing.image_sequence_frames = None;
existing.image_sequence_duration_ms = None;
@@ -6861,12 +6882,14 @@ fn register_platform_art_slice_manifest_entries_at(
);
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: id.clone(),
kind: "art-spritesheet-slice".to_string(),
kind: GameCreationAppAssetKind::Icon,
media_type: registration.media_type.clone(),
local_path: registration.local_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
category: game_creation_app_asset_category_for_kind(
GameCreationAppAssetKind::Icon.as_str(),
),
tags: Vec::new(),
source,
});
@@ -6898,7 +6921,7 @@ fn existing_platform_art_slice_registrations_are_complete(
return Ok(false);
};
if candidates.next().is_some()
|| asset.kind != "art-spritesheet-slice"
|| asset.kind != GameCreationAppAssetKind::Icon
|| asset.media_type != "image/png"
|| asset.source.kind != GameCreationAppAssetSourceKind::Canvas
|| asset.source.generation_route.as_deref()
@@ -6987,10 +7010,12 @@ pub(in crate::agent) fn register_existing_platform_art_slices_at(
.clone()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "旧项目图集源资源缺少 sourceResourceId".to_string())?;
let has_partial_or_invalid_registration = manifest
.assets
.iter()
.any(|asset| asset.kind == "art-spritesheet-slice");
let has_partial_or_invalid_registration = manifest.assets.iter().any(|asset| {
asset.kind == GameCreationAppAssetKind::Icon
&& asset
.local_path
.starts_with("assets/art-spritesheet-slices/")
});
let receipt_path =
resolve_local_project_path(root, ".agent/runtime/art-spritesheet-contract.json")?;
@@ -7600,7 +7625,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
let registered = match register_local_asset_entry(
root,
&local_path,
&options.asset_kind,
platform_art_asset_manifest_kind(&options.asset_kind),
&download.media_type,
"platform-art",
GameCreationAppAssetSource {
@@ -11209,7 +11234,7 @@ mod canvas_generation_tests {
register_local_asset_at(
root,
"assets/art-spec.png",
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"image/png",
"changed-reference-test",
GameCreationAppAssetSource {
@@ -12779,7 +12804,7 @@ mod canvas_generation_tests {
let first_slice_ids = registered
.assets
.iter()
.filter(|asset| asset.kind == "art-spritesheet-slice")
.filter(|asset| asset.kind == GameCreationAppAssetKind::Icon)
.map(|asset| {
assert_eq!(asset.source.kind, GameCreationAppAssetSourceKind::Canvas);
assert_eq!(
@@ -12813,7 +12838,7 @@ mod canvas_generation_tests {
.clone();
mutate_manifest_at(root, |manifest| {
manifest.assets.retain(|asset| {
asset.kind != "art-spritesheet-slice" || asset.local_path == retained_path
asset.kind != GameCreationAppAssetKind::Icon || asset.local_path == retained_path
});
Ok(())
})
@@ -12827,7 +12852,7 @@ mod canvas_generation_tests {
let backfilled_slice_ids = backfilled
.assets
.iter()
.filter(|asset| asset.kind == "art-spritesheet-slice")
.filter(|asset| asset.kind == GameCreationAppAssetKind::Icon)
.map(|asset| (asset.local_path.clone(), asset.id.clone()))
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(backfilled_slice_ids.len(), 4);
@@ -12876,7 +12901,7 @@ mod canvas_generation_tests {
let replaced_slice_ids = replaced
.assets
.iter()
.filter(|asset| asset.kind == "art-spritesheet-slice")
.filter(|asset| asset.kind == GameCreationAppAssetKind::Icon)
.map(|asset| {
assert_eq!(
asset.source.reference_resource_ids,
@@ -12909,7 +12934,7 @@ mod canvas_generation_tests {
register_local_asset_entry(
root,
AGENT_RUNTIME_ART_SPRITESHEET_PATH,
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
"image/png",
"platform-art",
GameCreationAppAssetSource {
@@ -12970,12 +12995,12 @@ mod canvas_generation_tests {
assert!(after
.assets
.iter()
.all(|asset| asset.kind != "art-spritesheet-slice"));
.all(|asset| asset.kind != GameCreationAppAssetKind::Icon));
register_local_asset_entry(
root,
"assets/art-spritesheet-slices/player.png",
"art-spritesheet-slice",
GameCreationAppAssetKind::Icon,
"image/png",
"platform-art",
GameCreationAppAssetSource {
@@ -14531,7 +14556,10 @@ mod canvas_generation_tests {
let manifest = read_manifest_for_project(root).expect("read replacement manifest");
assert_eq!(manifest.assets.len(), 1);
assert_eq!(manifest.assets[0].local_path, "assets/art-spritesheet.png");
assert_eq!(manifest.assets[0].kind, "art-spritesheet");
assert_eq!(
manifest.assets[0].kind,
GameCreationAppAssetKind::IconSpritesheet
);
assert_eq!(
fs::read_dir(root.join("assets"))
.expect("read assets directory")
@@ -98,7 +98,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
output.push_str("- ");
output.push_str(&asset.id);
output.push_str(": ");
output.push_str(&asset.kind);
output.push_str(asset.kind.as_str());
output.push_str(" / ");
output.push_str(&asset.media_type);
output.push_str(" / ");
@@ -2061,17 +2061,23 @@ mod tests {
.expect("resume first manifest task fixture");
for (local_path, kind) in [
("assets/art-spec.png", "icon-spec"),
("assets/ui-prototype.png", "ui-prototype"),
("assets/art-spritesheet.png", "art-spritesheet"),
("assets/art-spec.png", GameCreationAppAssetKind::IconSpec),
(
"assets/ui-prototype.png",
GameCreationAppAssetKind::UiDesign,
),
(
"assets/art-spritesheet.png",
GameCreationAppAssetKind::IconSpritesheet,
),
] {
let (generation_route, generation_kind, reference_resource_ids) = match kind {
"icon-spec" => (
GameCreationAppAssetKind::IconSpec => (
"/api/external/v1/editor/images/generations",
"spec",
Vec::new(),
),
"ui-prototype" => (
GameCreationAppAssetKind::UiDesign => (
"/api/external/v1/editor/images/generations",
"ui-design",
vec!["resource-icon-spec".to_string()],
@@ -2082,7 +2088,7 @@ mod tests {
vec!["resource-icon-spec".to_string()],
),
};
let bytes = if kind == "art-spritesheet" {
let bytes = if kind == GameCreationAppAssetKind::IconSpritesheet {
use image::ImageEncoder;
let mut bytes = Vec::new();
image::codecs::png::PngEncoder::new(&mut bytes)
@@ -1216,11 +1216,19 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
return None;
}
let (expected_path, expected_kind, label) = match agent_id {
"art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"),
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"),
"art-director" => (
AGENT_RUNTIME_ART_SPEC_PATH,
GameCreationAppAssetKind::IconSpec,
"统一视觉规范图",
),
"design-foundation" => (
"assets/ui-prototype.png",
GameCreationAppAssetKind::UiDesign,
"策划界面原型图",
),
"art-asset-plan" => (
"assets/art-spritesheet.png",
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
"首版美术素材图",
),
_ => return None,
@@ -1561,10 +1561,14 @@ mod orphaned_external_generation_recovery_tests {
fs::create_dir_all(workflow_path.parent().expect("workflow parent"))
.expect("workflow directory");
let rollback_assets = [
("assets/art-spec.png", "icon-spec", b"old-spec".as_slice()),
(
"assets/art-spec.png",
GameCreationAppAssetKind::IconSpec,
b"old-spec".as_slice(),
),
(
"assets/direct-game-background.png",
"game-background",
GameCreationAppAssetKind::Scene,
b"old-background".as_slice(),
),
]
@@ -451,7 +451,7 @@ mod asset_generation_task_tests {
init_local_game_project_at, read_manifest_for_project, write_project_permission_policy_at,
};
use shared_contracts::game_creation_app::{
GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
GameCreationAppAssetKind, GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
};
fn temp_project_root(label: &str) -> PathBuf {
@@ -484,7 +484,7 @@ mod asset_generation_task_tests {
register_local_asset_at(
root,
local_path,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"image/png",
"generated",
GameCreationAppAssetSource {
@@ -478,7 +478,7 @@ pub(crate) fn external_editor_api_credentials_override_is_active() -> bool {
/// 分类时才生效,`kind` 本身错时自愈只会把错值放大。
///
/// 判不出内容类型时返回中性的 `asset`(派生 `unclassified` → 「待归类」),**不猜具体类型**。
fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAssetKind {
let media_type = media_type.trim().to_ascii_lowercase();
let extension = Path::new(file_name)
.extension()
@@ -492,20 +492,20 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
"mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus"
)
{
"audio"
GameCreationAppAssetKind::Audio
} else if media_type.starts_with("video/") || matches!(extension, "mp4" | "webm" | "mov") {
"video"
GameCreationAppAssetKind::Video
} else if media_type.starts_with("font/")
|| matches!(extension, "ttf" | "otf" | "woff" | "woff2")
{
"document"
GameCreationAppAssetKind::Font
} else if media_type.starts_with("image/")
|| matches!(
extension,
"png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp"
)
{
"image"
GameCreationAppAssetKind::Image
} else if matches!(media_type.as_str(), "text/html" | "text/css")
|| media_type.contains("javascript")
|| media_type.contains("typescript")
@@ -514,7 +514,7 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
"html" | "htm" | "css" | "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx"
)
{
"code"
GameCreationAppAssetKind::Code
} else if media_type.starts_with("text/")
|| matches!(media_type.as_str(), "application/json" | "application/xml")
|| matches!(
@@ -532,9 +532,9 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
| "xml"
)
{
"document"
GameCreationAppAssetKind::Document
} else {
"asset"
GameCreationAppAssetKind::Unknown
}
}
@@ -584,7 +584,7 @@ pub(crate) fn upload_local_asset_at(
pub(crate) fn register_local_asset_at(
root: &Path,
local_path: &str,
kind: &str,
kind: GameCreationAppAssetKind,
media_type: &str,
source_kind: &str,
source: GameCreationAppAssetSource,
@@ -647,7 +647,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
let (_, asset_changed) = register_local_asset_entry_with_change(
root,
&relative,
"document",
GameCreationAppAssetKind::Document,
media_type,
"document",
GameCreationAppAssetSource {
@@ -686,6 +686,7 @@ pub(crate) fn import_canvas_asset_at(
if resource_id.is_none() && asset_object_id.is_none() {
return Err("画板资源 ID 和资产对象 ID 至少需要一个".to_string());
}
let kind = GameCreationAppAssetKind::parse_with_context(kind.trim(), "canvas.asset_import");
register_local_asset_at(
root,
@@ -918,7 +919,12 @@ pub(crate) async fn sync_canvas_project_assets_at(
&local_path,
json_string_field(resource, "assetKind")
.as_deref()
.unwrap_or("canvas-resource"),
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
GameCreationAppAssetKind::parse_with_context(value, "canvas.asset_sync")
})
.unwrap_or(GameCreationAppAssetKind::Image),
&download.media_type,
"canvas",
GameCreationAppAssetSource {
@@ -1772,33 +1778,28 @@ pub(crate) fn normalized_zip_entry_name(name: &str) -> Result<String, String> {
/// 画板导出图层推断出的资源 kind。
///
/// 返回值必须是 **canonical kind**:这个值会被 `register_local_asset_entry` 原样写入
/// manifest并据以派生落盘 `category`。历史上这里写过非 canonical
/// `ui` / `animation` / `asset`,只能靠别名表兜底,等于同时维护两套词汇;别名表只用于
/// 兼容存量数据,不作为新写入值的来源。
/// 直接返回正式枚举成员:这个值会被 `register_local_asset_entry` 原样写入 manifest
/// 并据以派生落盘 `category`。分支字面量写成非 canonical 值在这里已经无法表达,
/// 不需要别名表兜底。
pub(crate) fn infer_canvas_export_asset_kind(
layer: &CanvasExportLayerMetadata,
file: &str,
) -> &'static str {
) -> GameCreationAppAssetKind {
let layer_type = layer.visible.layer_type.as_str();
let inferred = if file.starts_with("sequences/")
|| contains_any(layer_type, &["序列", "动画", "动作"])
{
"character-animation"
if file.starts_with("sequences/") || contains_any(layer_type, &["序列", "动画", "动作"]) {
GameCreationAppAssetKind::CharacterAnimation
} else if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"])
{
"audio"
GameCreationAppAssetKind::Audio
} else if contains_any(layer_type, &["角色"]) {
"character"
GameCreationAppAssetKind::Character
} else if contains_any(layer_type, &["场景", "背景"]) {
"scene"
GameCreationAppAssetKind::Scene
} else if contains_any(layer_type, &["UI", "界面", "图标"]) {
"ui-design"
GameCreationAppAssetKind::UiDesign
} else {
"image"
};
// 防御性归一:分支字面量写错时由覆盖测试暴露,这里再兜一层。
shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(inferred)
GameCreationAppAssetKind::Image
}
}
pub(crate) fn infer_canvas_export_media_type(file: &str) -> &'static str {
@@ -1871,7 +1872,7 @@ pub(crate) fn build_canvas_project_url(
pub(crate) fn register_local_asset_entry(
root: &Path,
local_path: &str,
kind: &str,
kind: GameCreationAppAssetKind,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
@@ -1883,7 +1884,7 @@ pub(crate) fn register_local_asset_entry(
fn register_local_asset_entry_with_change(
root: &Path,
local_path: &str,
kind: &str,
kind: GameCreationAppAssetKind,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
@@ -1891,7 +1892,6 @@ fn register_local_asset_entry_with_change(
let normalized_path = normalize_relative_path(local_path)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
let manifest_path = root.join(".agent/manifest.json");
let kind = if kind.is_empty() { "asset" } else { kind };
let media_type = if media_type.is_empty() {
"application/octet-stream"
} else {
@@ -1914,8 +1914,8 @@ fn register_local_asset_entry_with_change(
|| existing.media_type != media_type
|| existing.source != source;
if existing.kind != kind {
existing.kind = kind.to_string();
existing.category = game_creation_app_asset_category_for_kind(kind);
existing.kind = kind;
existing.category = game_creation_app_asset_category_for_kind(kind.as_str());
}
existing.media_type = media_type.to_string();
existing.source = source;
@@ -1928,12 +1928,12 @@ fn register_local_asset_entry_with_change(
);
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: id.clone(),
kind: kind.to_string(),
kind,
media_type: media_type.to_string(),
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(kind),
category: game_creation_app_asset_category_for_kind(kind.as_str()),
tags: Vec::new(),
source,
});
@@ -1946,7 +1946,7 @@ fn register_local_asset_entry_with_change(
"recordType": record_type,
"assetId": id.clone(),
"localPath": normalized_path.clone(),
"kind": kind,
"kind": kind.as_str(),
"mediaType": media_type,
"source": source_for_record,
}),
@@ -2199,13 +2199,21 @@ mod tests {
}
// 每个分支的代表输入 → 期望的 canonical kind。
let cases: [(&str, &str, &str); 6] = [
("序列", "sequences/01.png", "character-animation"),
("动画", "layer.png", "character-animation"),
("音频", "layer.png", "audio"),
("角色", "layer.png", "character"),
("场景", "layer.png", "scene"),
("UI", "layer.png", "ui-design"),
let cases: [(&str, &str, GameCreationAppAssetKind); 6] = [
(
"序列",
"sequences/01.png",
GameCreationAppAssetKind::CharacterAnimation,
),
(
"动画",
"layer.png",
GameCreationAppAssetKind::CharacterAnimation,
),
("音频", "layer.png", GameCreationAppAssetKind::Audio),
("角色", "layer.png", GameCreationAppAssetKind::Character),
("场景", "layer.png", GameCreationAppAssetKind::Scene),
("UI", "layer.png", GameCreationAppAssetKind::UiDesign),
// 无匹配时落到 image。
];
for (layer_type, file, expected) in cases {
@@ -2215,20 +2223,20 @@ mod tests {
// media/ 前缀同样走音频分支。
assert_eq!(
infer_canvas_export_asset_kind(&layer("图层"), "media/bgm.mp3"),
"audio"
GameCreationAppAssetKind::Audio
);
// 兜底分支是 image 而不是旧的 "asset"。
assert_eq!(
infer_canvas_export_asset_kind(&layer("图层"), "layer.png"),
"image"
GameCreationAppAssetKind::Image
);
// 所有分支的返回值都必须在 canonical 目录里,且不能是旧的非 canonical 写法。
// 所有分支的返回值都必须是正式枚举成员,且不能是旧的非 canonical 写法。
for layer_type in ["序列", "音频", "角色", "场景", "UI", "图层", "其他"] {
let kind = infer_canvas_export_asset_kind(&layer(layer_type), "layer.png");
assert!(
shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS
.contains(&kind),
.contains(&kind.as_str()),
"非 canonical kind: {kind}layer_type={layer_type}"
);
for legacy in ["ui", "animation", "asset"] {
@@ -2250,14 +2258,14 @@ mod tests {
assert_eq!(uploaded_asset_kind("theme.mp3", "audio/mpeg"), "audio");
assert_eq!(uploaded_asset_kind("intro.mp4", ""), "video");
assert_eq!(uploaded_asset_kind("rules.md", "text/markdown"), "document");
assert_eq!(uploaded_asset_kind("ui-font.ttf", "font/ttf"), "document");
assert_eq!(uploaded_asset_kind("ui-font.ttf", "font/ttf"), "font");
assert_eq!(uploaded_asset_kind("game.js", "text/javascript"), "code");
// 判不出内容类型 → 中性 `asset`(派生 `unclassified` → 「待归类」),不猜具体类型。
// 判不出内容类型 → `unknown`(派生 `unclassified` → 「待归类」),不猜具体类型。
assert_eq!(
uploaded_asset_kind("unknown.bin", "application/octet-stream"),
"asset"
"unknown"
);
assert_eq!(uploaded_asset_kind("no-extension", ""), "asset");
assert_eq!(uploaded_asset_kind("no-extension", ""), "unknown");
// 反查:正文里的"来源词"和"UI 默认值"都不得出现在返回值里。
for (file_name, media_type) in [
("hero.png", "image/png"),
@@ -2269,8 +2277,11 @@ mod tests {
assert_ne!(kind, "ui", "{file_name} 写回了非 canonical 的 `ui`");
assert!(
shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS
.contains(&kind)
|| kind == "asset",
.contains(&kind.as_str())
|| matches!(
kind,
GameCreationAppAssetKind::Font | GameCreationAppAssetKind::Unknown
),
"非 canonical kind: {kind}{file_name}"
);
}
@@ -2167,7 +2167,7 @@ pub(crate) fn register_local_asset(
register_local_asset_at(
root,
local_path.trim(),
kind.trim(),
GameCreationAppAssetKind::parse_with_context(kind.trim(), "asset.register"),
media_type.trim(),
source_kind.trim(),
GameCreationAppAssetSource {
@@ -2200,7 +2200,10 @@ pub(crate) fn create_ui_design_resource(
let next_index = manifest
.assets
.iter()
.filter(|asset| asset.kind == "UI")
.filter(|asset| {
asset.kind == crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE
})
.count()
+ 1;
let resource_name = format!("UI 设计 {next_index}");
@@ -2234,8 +2237,8 @@ pub(crate) fn create_ui_design_resource(
let asset = match register_local_asset_at(
root,
&relative_path,
"UI",
"application/json",
GameCreationAppAssetKind::UiDesignDoc,
crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE,
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -2725,7 +2728,7 @@ pub(crate) fn import_ui_editor_local_fonts(
let registered = register_local_asset_entry(
root,
&relative_path,
"font",
GameCreationAppAssetKind::Font,
validated.metadata.format.media_type(),
"font",
GameCreationAppAssetSource {
@@ -2837,7 +2840,7 @@ mod ui_editor_font_tests {
let registered = register_local_asset_entry(
root,
relative_path,
"font",
GameCreationAppAssetKind::Font,
"font/ttf",
"font",
GameCreationAppAssetSource {
@@ -2940,7 +2943,7 @@ mod ui_editor_font_tests {
symlink(fixture_font_path(), &target).expect("create font symlink");
let entry = GameCreationAppAssetManifestEntry {
id: "font-linked".to_string(),
kind: "font".to_string(),
kind: GameCreationAppAssetKind::Font,
media_type: "font/ttf".to_string(),
local_path: relative_path.to_string(),
image_sequence_frames: None,
@@ -3255,24 +3258,34 @@ mod agent_asset_import_tests {
/// 本用例必须变红(`character` / `scene` / 空值 / 未知值四组断言都会失败)。
#[test]
fn imported_platform_asset_kind_prefers_payload_type_over_ui_default() {
assert_eq!(imported_platform_asset_kind(Some("character")), "character");
assert_eq!(imported_platform_asset_kind(Some("scene")), "scene");
use GameCreationAppAssetKind as Kind;
assert_eq!(
imported_platform_asset_kind(Some("character")),
Kind::Character
);
assert_eq!(imported_platform_asset_kind(Some("scene")), Kind::Scene);
assert_eq!(
imported_platform_asset_kind(Some("character-animation")),
"character-animation"
Kind::CharacterAnimation
);
assert_eq!(
imported_platform_asset_kind(Some("icon-spec")),
Kind::IconSpec
);
assert_eq!(imported_platform_asset_kind(Some("icon-spec")), "icon-spec");
// 平台确实说它是 UI 设计稿时,才允许落 `ui-design`(→ 「UI 交互」);
// 大写 `UI` 与旧值 `ui` 都要归一到同一口径,但**不允许由我们替它默认**。
assert_eq!(imported_platform_asset_kind(Some("UI")), "ui-design");
assert_eq!(imported_platform_asset_kind(Some("ui")), "ui-design");
assert_eq!(imported_platform_asset_kind(Some("UI")), Kind::UiDesign);
assert_eq!(imported_platform_asset_kind(Some("ui")), Kind::UiDesign);
// 缺失 / 空白 / 未知值一律落 canonical `image` 兜底(→ 「待归类」)。
assert_eq!(imported_platform_asset_kind(None), "image");
assert_eq!(imported_platform_asset_kind(Some(" ")), "image");
assert_eq!(imported_platform_asset_kind(Some("mystery")), "image");
assert_eq!(imported_platform_asset_kind(None), Kind::Image);
assert_eq!(imported_platform_asset_kind(Some(" ")), Kind::Image);
assert_eq!(imported_platform_asset_kind(Some("mystery")), Kind::Image);
// `kind` 是外部输入,原型链键必须走 `image` 兜底,不能取到原型上的值。
assert_eq!(imported_platform_asset_kind(Some("__proto__")), "image");
assert_eq!(imported_platform_asset_kind(Some("constructor")), "image");
assert_eq!(imported_platform_asset_kind(Some("__proto__")), Kind::Image);
assert_eq!(
imported_platform_asset_kind(Some("constructor")),
Kind::Image
);
}
/// 反查门禁:两处平台素材导入必须继续用 `imported_platform_asset_kind` 解析 `kind`
@@ -3801,7 +3814,7 @@ pub(crate) async fn list_editor_assets_for_agent_at(
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct AgentLocalProjectFileType {
category: &'static str,
asset_kind: &'static str,
asset_kind: GameCreationAppAssetKind,
media_type: &'static str,
max_file_size: u64,
}
@@ -3856,14 +3869,14 @@ fn agent_local_project_file_type(
// 错值放大成显式的 `ui-interaction`。
media_type.map(|media_type| AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
asset_kind: GameCreationAppAssetKind::Image,
media_type,
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
})
}
"ttf" | "otf" | "woff" | "woff2" => Some(AgentLocalProjectFileType {
category: "font",
asset_kind: "font",
asset_kind: GameCreationAppAssetKind::Font,
media_type: match extension.as_str() {
"ttf" => "font/ttf",
"otf" => "font/otf",
@@ -3874,68 +3887,68 @@ fn agent_local_project_file_type(
}),
"mp3" => Some(AgentLocalProjectFileType {
category: "audio",
asset_kind: "audio",
asset_kind: GameCreationAppAssetKind::Audio,
media_type: "audio/mpeg",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"wav" => Some(AgentLocalProjectFileType {
category: "audio",
asset_kind: "audio",
asset_kind: GameCreationAppAssetKind::Audio,
media_type: "audio/wav",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"ogg" => Some(AgentLocalProjectFileType {
category: "audio",
asset_kind: "audio",
asset_kind: GameCreationAppAssetKind::Audio,
media_type: "audio/ogg",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"flac" => Some(AgentLocalProjectFileType {
category: "audio",
asset_kind: "audio",
asset_kind: GameCreationAppAssetKind::Audio,
media_type: "audio/flac",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"m4a" => Some(AgentLocalProjectFileType {
category: "audio",
asset_kind: "audio",
asset_kind: GameCreationAppAssetKind::Audio,
media_type: "audio/mp4",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"aac" => Some(AgentLocalProjectFileType {
category: "audio",
asset_kind: "audio",
asset_kind: GameCreationAppAssetKind::Audio,
media_type: "audio/aac",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"opus" => Some(AgentLocalProjectFileType {
category: "audio",
asset_kind: "audio",
asset_kind: GameCreationAppAssetKind::Audio,
media_type: "audio/opus",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"mp4" => Some(AgentLocalProjectFileType {
category: "video",
asset_kind: "video",
asset_kind: GameCreationAppAssetKind::Video,
media_type: "video/mp4",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"webm" => Some(AgentLocalProjectFileType {
category: "video",
asset_kind: "video",
asset_kind: GameCreationAppAssetKind::Video,
media_type: "video/webm",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"mov" => Some(AgentLocalProjectFileType {
category: "video",
asset_kind: "video",
asset_kind: GameCreationAppAssetKind::Video,
media_type: "video/quicktime",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml" | "csv" | "ini"
| "conf" | "xml" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "document",
asset_kind: GameCreationAppAssetKind::Document,
media_type: if extension == "json" {
"application/json"
} else if matches!(extension.as_str(), "yaml" | "yml") {
@@ -3952,7 +3965,7 @@ fn agent_local_project_file_type(
| "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql"
| "vue" | "svelte" => Some(AgentLocalProjectFileType {
category: "code",
asset_kind: "code",
asset_kind: GameCreationAppAssetKind::Code,
media_type: if matches!(extension.as_str(), "html" | "htm") {
"text/html"
} else if matches!(extension.as_str(), "css" | "scss" | "less") {
@@ -4070,7 +4083,7 @@ pub(crate) fn import_local_project_assets_for_agent(
}
inputs.push((
local_path,
file_type.asset_kind.to_string(),
file_type.asset_kind,
file_type.media_type.to_string(),
bytes,
));
@@ -4089,7 +4102,7 @@ pub(crate) fn import_local_project_assets_for_agent(
imported.push(ImportedAsset {
id: existing.id.clone(),
local_path: existing.local_path.clone(),
asset_kind: Some(existing.kind.clone()),
asset_kind: Some(existing.kind.to_string()),
});
continue;
}
@@ -4128,7 +4141,7 @@ pub(crate) fn import_local_project_assets_for_agent(
let registered = register_local_asset_entry(
root,
&local_path,
&asset_kind,
asset_kind,
&media_type,
"local",
GameCreationAppAssetSource {
@@ -4147,7 +4160,7 @@ pub(crate) fn import_local_project_assets_for_agent(
imported.push(ImportedAsset {
id: registered.id,
local_path: registered.local_path,
asset_kind: Some(asset_kind),
asset_kind: Some(asset_kind.to_string()),
});
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
format!("reconciliation-required: 本地资源已登记,但项目 revision 未能推进:{error}")
@@ -4164,15 +4177,14 @@ pub(crate) fn import_local_project_assets_for_agent(
/// 唯一一条"这东西是什么"的事实,并把角色立绘、怪物、道具、场景图一并钉进「UI 交互」栏
/// `ui → ui-design → ui-interaction`)。落盘 `category` 一旦是非 `unclassified` 值,
/// 读时自愈永远救不回来,所以宁可写 `image`(待归类)也不能写假 `ui`。
fn imported_platform_asset_kind(platform_kind: Option<&str>) -> String {
fn imported_platform_asset_kind(platform_kind: Option<&str>) -> GameCreationAppAssetKind {
match platform_kind.map(str::trim).filter(|kind| !kind.is_empty()) {
// 平台侧词汇可能与 canonical 目录不完全一致(例如大写 `UI`),统一过 canonical 归一,
// 保证写入侧只产出 canonical `kind`
Some(kind) => {
shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(kind)
.to_string()
}
None => "image".to_string(),
// 平台是外部系统,它的 `assetKind` 词汇(含大写 `UI` 这类变体)由共享契约统一收口到
// canonical 值;这里只把收口结果转成正式枚举,写入侧不再产出裸字符串
Some(kind) => GameCreationAppAssetKind::from_str_lossy(
shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(kind),
),
None => GameCreationAppAssetKind::Image,
}
}
@@ -4280,7 +4292,7 @@ pub(crate) async fn import_account_editor_assets_for_agent(
imported.push(ImportedAsset {
id: existing.id.clone(),
local_path: existing.local_path.clone(),
asset_kind: Some(existing.kind.clone()),
asset_kind: Some(existing.kind.to_string()),
});
continue;
}
@@ -4330,7 +4342,7 @@ pub(crate) async fn import_account_editor_assets_for_agent(
let registered = register_local_asset_entry(
root,
&local_path,
&asset_kind,
asset_kind,
&media_type,
"canvas",
GameCreationAppAssetSource {
@@ -4448,7 +4460,7 @@ pub(crate) async fn import_ui_editor_remote_assets(
let registered = register_local_asset_entry(
root,
&local_path,
&asset_kind,
asset_kind,
&media_type,
"canvas",
GameCreationAppAssetSource {
@@ -33,8 +33,8 @@ use shared_contracts::game_creation_app::{
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
GameCreationAppAgentGroup, GameCreationAppAssetKind, GameCreationAppAssetManifestEntry,
GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
@@ -1069,9 +1069,15 @@ pub(crate) fn validate_manifest_required_visual_asset(
task_id: &str,
) -> Result<(), String> {
let (expected_path, expected_kind) = match task_id {
"art-director" => ("assets/art-spec.png", "icon-spec"),
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype"),
"art-asset-plan" => ("assets/art-spritesheet.png", "art-spritesheet"),
"art-director" => ("assets/art-spec.png", GameCreationAppAssetKind::IconSpec),
"design-foundation" => (
"assets/ui-prototype.png",
GameCreationAppAssetKind::UiDesign,
),
"art-asset-plan" => (
"assets/art-spritesheet.png",
GameCreationAppAssetKind::IconSpritesheet,
),
_ => return Ok(()),
};
let asset = manifest
@@ -1141,7 +1147,10 @@ pub(crate) fn validate_manifest_required_visual_asset(
let art_spec = manifest
.assets
.iter()
.find(|asset| asset.local_path == "assets/art-spec.png" && asset.kind == "icon-spec")
.find(|asset| {
asset.local_path == "assets/art-spec.png"
&& asset.kind == GameCreationAppAssetKind::IconSpec
})
.ok_or_else(|| "缺少当前统一视觉规范图".to_string())?;
let art_spec_project_id = art_spec
.source
@@ -19,7 +19,7 @@ fn project_with_two_assets(test_name: &str) -> (PathBuf, String) {
let first = register_local_asset_entry(
&root,
"assets/hero.png",
"character",
GameCreationAppAssetKind::Character,
"image/png",
"asset",
GameCreationAppAssetSource {
@@ -39,7 +39,7 @@ fn project_with_two_assets(test_name: &str) -> (PathBuf, String) {
register_local_asset_entry(
&root,
"assets/theme.mp3",
"background-music",
GameCreationAppAssetKind::BackgroundMusic,
"audio/mpeg",
"asset",
GameCreationAppAssetSource {
@@ -96,7 +96,7 @@ fn manifest_task_and_asset_mutations_are_serialized_across_the_full_transaction(
crate::assets::register_local_asset_entry(
&second_root,
"assets/concurrent.png",
"ui-prototype",
GameCreationAppAssetKind::UiDesign,
"image/png",
"test",
GameCreationAppAssetSource {
@@ -180,7 +180,7 @@ fn successful_first_playable_registration_creates_one_initial_version_with_asset
let mut manifest = new_game_creation_app_manifest("project-first-playable", "首板项目");
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "asset-player".to_string(),
kind: "character".to_string(),
kind: GameCreationAppAssetKind::Character,
media_type: "image/png".to_string(),
local_path: "assets/player.png".to_string(),
image_sequence_frames: None,
@@ -652,7 +652,7 @@ mod tests {
) -> GameCreationAppAssetManifestEntry {
GameCreationAppAssetManifestEntry {
id: id.to_string(),
kind: "test".to_string(),
kind: GameCreationAppAssetKind::Unknown,
media_type: "image/png".to_string(),
local_path: format!("assets/{id}.png"),
image_sequence_frames: None,
@@ -180,6 +180,8 @@ pub(crate) struct DeriveLocalProjectResourceInput {
pub(crate) source_path: Option<String>,
#[serde(default)]
pub(crate) source_media_type: Option<String>,
/// 源资源 subtype:只接受 manifest 的正式 `GameCreationAppAssetKind` 成员。
/// 任务产物、附件、项目版本与 Agent 回执等非 manifest 来源不通过该字段传递。
#[serde(default)]
pub(crate) source_subtype: Option<String>,
#[serde(default)]
@@ -296,6 +298,7 @@ pub(crate) struct NormalizeLocalProjectRasterResourceInput {
pub(crate) source_resource_id: String,
pub(crate) source_path: String,
pub(crate) source_media_type: String,
/// 源资源 subtype:只接受 manifest 的正式 `GameCreationAppAssetKind` 成员。
#[serde(default)]
pub(crate) source_subtype: Option<String>,
pub(crate) producer_task_id: String,
@@ -309,12 +312,43 @@ pub(crate) struct NormalizeLocalProjectRasterResourceResult {
pub(crate) manifest: GameCreationAppManifest,
}
/// 资源编辑源身份的词汇表。
///
/// `Manifest` 只承载 manifest 的正式 `GameCreationAppAssetKind` 成员;项目版本快照与
/// Agent 回执文本没有 manifest kind,单独成变体,避免它们混进 kind 字段后被静默收口成
/// `unknown`。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ResourceEditSourceKind {
Manifest(GameCreationAppAssetKind),
ProjectVersion,
AgentResultText,
}
impl ResourceEditSourceKind {
/// 私有账本与外部接口上下文使用的稳定标签。
fn as_str(self) -> &'static str {
match self {
Self::Manifest(kind) => kind.as_str(),
Self::ProjectVersion => "project-version",
Self::AgentResultText => "agent-result-derivative",
}
}
/// 派生资源写回 manifest 时使用的正式 kind;非 manifest 谱系没有 kind。
fn manifest_kind(self) -> GameCreationAppAssetKind {
match self {
Self::Manifest(kind) => kind,
Self::ProjectVersion | Self::AgentResultText => GameCreationAppAssetKind::Unknown,
}
}
}
#[derive(Clone, Debug)]
struct ResourceEditSourceSnapshot {
canonical_resource_id: String,
source_path: Option<String>,
media_type: String,
asset_kind: String,
asset_kind: ResourceEditSourceKind,
source_sha256: String,
bytes: Option<Vec<u8>>,
generation_mode: LocalProjectResourceGenerationMode,
@@ -1125,19 +1159,28 @@ fn infer_resource_edit_source_media_type(
Some(media_type.to_string())
}
fn infer_resource_edit_source_asset_kind(edit_kind: &LocalProjectResourceEditKind) -> String {
match edit_kind {
LocalProjectResourceEditKind::ImageReference => "art-image",
LocalProjectResourceEditKind::Svg => "svg",
LocalProjectResourceEditKind::CharacterAnimation => "character-animation",
LocalProjectResourceEditKind::Video => "video",
LocalProjectResourceEditKind::SoundEffect => "sound-effect",
LocalProjectResourceEditKind::BackgroundMusic => "background-music",
LocalProjectResourceEditKind::Text => "text",
LocalProjectResourceEditKind::AgentResult => "agent-result-derivative",
LocalProjectResourceEditKind::Version => "project-version",
}
.to_string()
fn infer_resource_edit_source_asset_kind(
edit_kind: &LocalProjectResourceEditKind,
) -> ResourceEditSourceKind {
let kind = match edit_kind {
LocalProjectResourceEditKind::ImageReference | LocalProjectResourceEditKind::Svg => {
GameCreationAppAssetKind::Image
}
LocalProjectResourceEditKind::CharacterAnimation => {
GameCreationAppAssetKind::CharacterAnimation
}
LocalProjectResourceEditKind::Video => GameCreationAppAssetKind::Video,
LocalProjectResourceEditKind::SoundEffect => GameCreationAppAssetKind::SoundEffect,
LocalProjectResourceEditKind::BackgroundMusic => GameCreationAppAssetKind::BackgroundMusic,
LocalProjectResourceEditKind::Text => GameCreationAppAssetKind::Document,
LocalProjectResourceEditKind::AgentResult => {
return ResourceEditSourceKind::AgentResultText;
}
LocalProjectResourceEditKind::Version => {
return ResourceEditSourceKind::ProjectVersion;
}
};
ResourceEditSourceKind::Manifest(kind)
}
fn resolve_agent_result_source(
@@ -1183,7 +1226,7 @@ fn resolve_resource_edit_source(
canonical_resource_id: format!("version:{version_id}"),
source_path: None,
media_type: "application/vnd.genarrative.project-version+json".to_string(),
asset_kind: "project-version".to_string(),
asset_kind: ResourceEditSourceKind::ProjectVersion,
source_sha256: sha256_hex(
&serde_json::to_vec(&version)
.map_err(|error| format!("序列化源项目版本失败:{error}"))?,
@@ -1208,7 +1251,7 @@ fn resolve_resource_edit_source(
canonical_resource_id,
source_path: None,
media_type: "text/markdown".to_string(),
asset_kind: "agent-result-derivative".to_string(),
asset_kind: ResourceEditSourceKind::AgentResultText,
source_sha256: sha256_hex(content.as_bytes()),
bytes: None,
generation_mode: input.generation_mode,
@@ -1302,9 +1345,18 @@ fn resolve_resource_edit_source(
.unwrap_or_default();
let asset_kind = source_asset
.as_ref()
.map(|asset| asset.kind.clone())
.or_else(|| input.source_subtype.clone())
.unwrap_or_else(|| "asset".to_string());
.map(|asset| ResourceEditSourceKind::Manifest(asset.kind))
.or_else(|| {
input.source_subtype.as_deref().map(|value| {
ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::parse_with_context(
value,
"resource-edit.source-subtype",
))
})
})
.unwrap_or(ResourceEditSourceKind::Manifest(
GameCreationAppAssetKind::Unknown,
));
let canonical_resource_id = source_asset
.as_ref()
.map(source_asset_canonical_resource_id)
@@ -1363,7 +1415,7 @@ fn resolve_resource_edit_source(
if matches!(
input.edit_kind,
LocalProjectResourceEditKind::SoundEffect | LocalProjectResourceEditKind::BackgroundMusic
) && resource_edit_audio_kind(&asset_kind, &path) != input.edit_kind
) && resource_edit_audio_kind(asset_kind.as_str(), &path) != input.edit_kind
{
return Err("音频资源的音效/BGM 编辑类型与源资源用途不一致".to_string());
}
@@ -1818,7 +1870,7 @@ async fn confirm_resource_edit_source(
"contentType": source.media_type,
"contentLength": bytes.len(),
"contentHash": source.source_sha256,
"assetKind": source.asset_kind,
"assetKind": source.asset_kind.as_str(),
"accessPolicy": "private",
})),
)
@@ -1926,7 +1978,7 @@ async fn register_resource_edit_source_image(
"width": decoded.width(),
"height": decoded.height(),
"sourceType": "uploaded",
"assetKind": source.asset_kind,
"assetKind": source.asset_kind.as_str(),
"generationInputs": {
"source": RESOURCE_EDIT_QUEUE_SOURCE,
"localAssetId": local_asset_id,
@@ -2039,7 +2091,7 @@ async fn ensure_resource_edit_source_reference(
local_asset_id,
&source.source_sha256,
&source.media_type,
&source.asset_kind,
source.asset_kind.as_str(),
)?;
let principal_key =
external_editor_project_binding_key_sha256(&input.expected_project_id, principal)?;
@@ -2261,7 +2313,7 @@ fn build_resource_edit_audio_prompt(
.and_then(|asset| asset.source.prompt.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty());
let mut source_context = format!("名称 {source_name},用途 {}", source.asset_kind);
let mut source_context = format!("名称 {source_name},用途 {}", source.asset_kind.as_str());
if let Some(original_prompt) = original_prompt {
source_context.push_str(",原始描述 ");
source_context.push_str(original_prompt);
@@ -3797,20 +3849,24 @@ pub(crate) fn normalize_local_project_raster_resource_at(
.map_err(|error| format!("序列化源图片身份失败:{error}"))?;
let identity_hash = sha256_hex(&identity_material);
let asset_id = format!("normalized-{}", &identity_hash[..24]);
let source_subtype = input
// 该接口只把栅格源登记成 manifest 图片资产:源 subtype 只接受正式枚举成员,
// 未声明或非图片族成员时按图片收口。
let source_kind = input
.source_subtype
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "task-artifact")
.unwrap_or("art-image");
.filter(|value| !value.is_empty())
.map(|value| GameCreationAppAssetKind::parse_with_context(value, "resource-edit.normalize"))
.filter(|kind| *kind != GameCreationAppAssetKind::Unknown)
.unwrap_or(GameCreationAppAssetKind::Image);
let asset = GameCreationAppAssetManifestEntry {
id: asset_id.clone(),
kind: source_subtype.to_string(),
kind: source_kind,
media_type: verified_media_type,
local_path: source_path,
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(source_subtype),
category: game_creation_app_asset_category_for_kind(source_kind.as_str()),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -4051,11 +4107,11 @@ fn commit_resource_edit_asset_internal(
.transpose()?;
let image_sequence_duration_ms = ledger.remote_sequence_duration_ms;
let asset_kind = if input.edit_kind == LocalProjectResourceEditKind::CharacterAnimation {
"character-animation".to_string()
GameCreationAppAssetKind::CharacterAnimation
} else {
source.asset_kind.clone()
source.asset_kind.manifest_kind()
};
let category = game_creation_app_asset_category_for_kind(&asset_kind);
let category = game_creation_app_asset_category_for_kind(asset_kind.as_str());
let asset = GameCreationAppAssetManifestEntry {
id: asset_id.clone(),
kind: asset_kind,
@@ -5147,8 +5203,14 @@ pub(crate) async fn resume_local_project_resource_edit_at(
let source_subtype = ledger
.source_asset_kind
.clone()
.or_else(|| source_asset.as_ref().map(|asset| asset.kind.clone()))
.or_else(|| Some(infer_resource_edit_source_asset_kind(&ledger.edit_kind)));
.or_else(|| source_asset.as_ref().map(|asset| asset.kind.to_string()))
.or_else(|| {
Some(
infer_resource_edit_source_asset_kind(&ledger.edit_kind)
.as_str()
.to_string(),
)
});
derive_local_project_resource_at(DeriveLocalProjectResourceInput {
project_path: input.project_path,
expected_project_id: input.expected_project_id,
@@ -5247,7 +5309,7 @@ pub(crate) async fn derive_local_project_resource_at(
source_asset_id: source.source_asset.as_ref().map(|asset| asset.id.clone()),
source_path: source.source_path.clone(),
source_media_type: Some(source.media_type.clone()),
source_asset_kind: Some(source.asset_kind.clone()),
source_asset_kind: Some(source.asset_kind.as_str().to_string()),
producer_task_id: input.producer_task_id.clone().or_else(|| {
source
.source_asset
@@ -5966,7 +6028,7 @@ mod tests {
source_asset_id: source.source_asset.as_ref().map(|asset| asset.id.clone()),
source_path: source.source_path.clone(),
source_media_type: Some(source.media_type.clone()),
source_asset_kind: Some(source.asset_kind.clone()),
source_asset_kind: Some(source.asset_kind.as_str().to_string()),
producer_task_id: input.producer_task_id.clone().or_else(|| {
source
.source_asset
@@ -6231,7 +6293,7 @@ mod tests {
canonical_resource_id: "local-asset:video-1".to_string(),
source_path: Some("assets/source-video.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: Some(vec![1, 2, 3]),
generation_mode: LocalProjectResourceGenerationMode::Derive,
@@ -6452,7 +6514,7 @@ mod tests {
&uploaded.id,
&source.source_sha256,
&source.media_type,
&source.asset_kind,
source.asset_kind.as_str(),
)
.expect("owner A source identity");
let (a_resource_id, a_width, a_height) =
@@ -6599,7 +6661,7 @@ mod tests {
first_request.source_asset_id = Some(source_asset.id.clone());
first_request.source_path = Some(source_asset.local_path.clone());
first_request.source_media_type = Some(source_asset.media_type.clone());
first_request.source_subtype = Some(source_asset.kind.clone());
first_request.source_subtype = Some(source_asset.kind.to_string());
let mut second_request = first_request.clone();
second_request.operation_id = Uuid::new_v4().to_string();
second_request.idempotency_key = Uuid::new_v4().to_string();
@@ -6692,7 +6754,7 @@ mod tests {
&source_asset.id,
&source.source_sha256,
&source.media_type,
&source.asset_kind,
source.asset_kind.as_str(),
)
.expect("concurrent source identity");
let binding_key = external_editor_resource_binding_key_sha256(
@@ -6746,7 +6808,7 @@ mod tests {
request.source_asset_id = Some(source_asset.id.clone());
request.source_path = Some(source_asset.local_path.clone());
request.source_media_type = Some(source_asset.media_type.clone());
request.source_subtype = Some(source_asset.kind.clone());
request.source_subtype = Some(source_asset.kind.to_string());
let source = resolve_resource_edit_source(root, &manifest, &request)
.expect("resolve character source");
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared);
@@ -6780,7 +6842,7 @@ mod tests {
&source_asset.id,
&source.source_sha256,
&source.media_type,
&source.asset_kind,
source.asset_kind.as_str(),
)
.expect("character source identity");
write_external_editor_resource_binding_at(
@@ -6870,7 +6932,7 @@ mod tests {
request.source_asset_id = Some(source_asset.id.clone());
request.source_path = Some(source_asset.local_path.clone());
request.source_media_type = Some(source_asset.media_type.clone());
request.source_subtype = Some(source_asset.kind.clone());
request.source_subtype = Some(source_asset.kind.to_string());
let source =
resolve_resource_edit_source(root, &manifest, &request).expect("resolve switch source");
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared);
@@ -7041,7 +7103,7 @@ mod tests {
canonical_resource_id: request.source_resource_id.clone(),
source_path: Some("assets/accepted-switch-video.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: Some(b"\0\0\0\x18ftypisom".to_vec()),
generation_mode: request.generation_mode,
@@ -7099,7 +7161,7 @@ mod tests {
canonical_resource_id: request.source_resource_id.clone(),
source_path: Some("assets/video-one.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: Some(b"\0\0\0\x18ftypisom".to_vec()),
generation_mode: LocalProjectResourceGenerationMode::Derive,
@@ -7239,7 +7301,7 @@ mod tests {
canonical_resource_id: request.source_resource_id.clone(),
source_path: Some("assets/legacy-video.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: Some(b"\0\0\0\x18ftypisom".to_vec()),
generation_mode: LocalProjectResourceGenerationMode::Derive,
@@ -7412,7 +7474,7 @@ mod tests {
canonical_resource_id: request.source_resource_id.clone(),
source_path: Some("assets/identity-switch.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: None,
generation_mode: request.generation_mode,
@@ -7480,7 +7542,7 @@ mod tests {
canonical_resource_id: request.source_resource_id.clone(),
source_path: Some("assets/resource-identity-owner.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: None,
generation_mode: request.generation_mode,
@@ -7587,7 +7649,7 @@ mod tests {
canonical_resource_id: request.source_resource_id.clone(),
source_path: Some("assets/resource-owner-lease.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: None,
generation_mode: request.generation_mode,
@@ -7723,7 +7785,7 @@ mod tests {
request.source_asset_id = Some(source_asset.id.clone());
request.source_path = Some(source_asset.local_path.clone());
request.source_media_type = Some(source_asset.media_type.clone());
request.source_subtype = Some(source_asset.kind.clone());
request.source_subtype = Some(source_asset.kind.to_string());
let source = resolve_resource_edit_source(root, &manifest, &request)
.expect("resolve unowned source");
let prompt = normalize_resource_edit_prompt(&request.edit_kind, &request.prompt)
@@ -7785,7 +7847,7 @@ mod tests {
canonical_resource_id: "local-asset:video-submission-status".to_string(),
source_path: Some("assets/video-submission-status.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: Some(b"\0\0\0\x18ftypisom".to_vec()),
generation_mode: LocalProjectResourceGenerationMode::Derive,
@@ -7912,7 +7974,7 @@ mod tests {
canonical_resource_id: "local-asset:video-accepted-response".to_string(),
source_path: Some("assets/video-accepted-response.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: Some(b"\0\0\0\x18ftypisom".to_vec()),
generation_mode: LocalProjectResourceGenerationMode::Derive,
@@ -8078,7 +8140,7 @@ mod tests {
canonical_resource_id: request.source_resource_id.clone(),
source_path: Some("assets/video-terminal-failure.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: Some(b"\0\0\0\x18ftypisom".to_vec()),
generation_mode: LocalProjectResourceGenerationMode::Derive,
@@ -8230,7 +8292,7 @@ mod tests {
canonical_resource_id: request.source_resource_id.clone(),
source_path: Some("assets/archive-owner-video.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: Some(b"\0\0\0\x18ftypisom".to_vec()),
generation_mode: request.generation_mode,
@@ -8290,7 +8352,7 @@ mod tests {
canonical_resource_id,
source_path: Some("assets/pending-owner.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: None,
generation_mode: LocalProjectResourceGenerationMode::Derive,
@@ -8346,7 +8408,7 @@ mod tests {
canonical_resource_id: local_request.source_resource_id.clone(),
source_path: Some("assets/pending-local.md".to_string()),
media_type: "text/markdown".to_string(),
asset_kind: "text".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Document),
source_sha256: "b".repeat(64),
bytes: Some(b"local".to_vec()),
generation_mode: local_request.generation_mode,
@@ -8437,7 +8499,7 @@ mod tests {
canonical_resource_id: "local-asset:audio-1".to_string(),
source_path: Some("audio/theme.ogg".to_string()),
media_type: "audio/ogg".to_string(),
asset_kind: "background-music".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::BackgroundMusic),
source_sha256: "a".repeat(64),
bytes: Some(vec![1]),
generation_mode: LocalProjectResourceGenerationMode::Derive,
@@ -8535,7 +8597,7 @@ mod tests {
canonical_resource_id: "local-asset:media-1".to_string(),
source_path: Some("assets/source.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video),
source_sha256: "a".repeat(64),
bytes: Some(b"\0\0\0\x18ftypisom".to_vec()),
generation_mode: LocalProjectResourceGenerationMode::Derive,
@@ -9258,7 +9320,7 @@ mod tests {
request.source_asset_id = Some(source_asset.id.clone());
request.source_path = Some(source_asset.local_path.clone());
request.source_media_type = Some(source_asset.media_type.clone());
request.source_subtype = Some(source_asset.kind.clone());
request.source_subtype = Some(source_asset.kind.to_string());
let source = resolve_resource_edit_source(
root,
&read_existing_manifest_for_project(root).expect("reread manifest"),
@@ -9319,7 +9381,7 @@ mod tests {
request.source_asset_id = Some(source_asset.id.clone());
request.source_path = Some(source_asset.local_path.clone());
request.source_media_type = Some(source_asset.media_type.clone());
request.source_subtype = Some(source_asset.kind.clone());
request.source_subtype = Some(source_asset.kind.to_string());
let source =
resolve_resource_edit_source(root, &manifest, &request).expect("resolve commit source");
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded);
@@ -9432,7 +9494,7 @@ mod tests {
request.source_asset_id = Some(source_asset.id.clone());
request.source_path = Some(source_asset.local_path.clone());
request.source_media_type = Some(source_asset.media_type.clone());
request.source_subtype = Some(source_asset.kind.clone());
request.source_subtype = Some(source_asset.kind.to_string());
let current_source = resolve_resource_edit_source(root, &manifest, &request)
.expect("resolve current local source");
assert_eq!(
@@ -35,7 +35,7 @@ fn register_asset_delete_fixture_asset(root: &Path, relative_path: &str, task_id
register_local_asset_at(
root,
relative_path,
"character",
GameCreationAppAssetKind::Character,
"image/png",
"generated",
generated_asset_source(task_id),
@@ -9,7 +9,7 @@ fn asset_rename_project_fixture() -> (PathBuf, String) {
let asset = register_local_asset_at(
&root,
"assets/hero.png",
"character",
GameCreationAppAssetKind::Character,
"image/png",
"canvas",
asset_rename_source(),
@@ -192,7 +192,7 @@ fn rename_local_project_asset_rejects_extension_changes() {
let extensionless = register_local_asset_at(
&root,
"assets/notes",
"asset",
GameCreationAppAssetKind::Document,
"text/plain",
"canvas",
asset_rename_source(),
@@ -1401,7 +1401,11 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r
);
init_local_game_project_at(&root, "project-canvas-repair-atomic", "月光厨房")
.expect("project init");
register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec");
register_canvas_visual_asset_fixture(
&root,
"assets/art-spec.png",
GameCreationAppAssetKind::IconSpec,
);
bind_canvas_visual_asset_fixture_to_current_editor(
&root,
"assets/art-spec.png",
@@ -3602,28 +3602,32 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String {
base_url
}
fn register_canvas_visual_asset_fixture(root: &Path, local_path: &str, kind: &str) {
fn register_canvas_visual_asset_fixture(
root: &Path,
local_path: &str,
kind: GameCreationAppAssetKind,
) {
let absolute_path = root.join(local_path);
fs::create_dir_all(absolute_path.parent().expect("visual asset parent"))
.expect("create visual asset fixture directory");
let bytes = if kind == "art-spritesheet" {
let bytes = if kind == GameCreationAppAssetKind::IconSpritesheet {
transparent_test_png_bytes()
} else {
valid_test_png_bytes()
};
fs::write(&absolute_path, bytes).expect("write visual asset fixture");
let (generation_route, generation_kind, reference_resource_ids) = match kind {
"icon-spec" => (
GameCreationAppAssetKind::IconSpec => (
"/api/external/v1/editor/images/generations",
"spec",
Vec::new(),
),
"ui-prototype" => (
GameCreationAppAssetKind::UiDesign => (
"/api/external/v1/editor/images/generations",
"ui-design",
vec!["resource-icon-spec".to_string()],
),
"art-spritesheet" => (
GameCreationAppAssetKind::IconSpritesheet => (
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
vec!["resource-icon-spec".to_string()],

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