收紧AGC资源kind枚举边界

canvas生成与运行时统一使用共享GameCreationAppAssetKind

删除平台kind兼容映射与退役kind拒绝测试

更新资源kind审计里程碑与绑定文档
This commit is contained in:
2026-09-19 01:28:27 +08:00
parent 0d5940433d
commit 48caa23997
68 changed files with 729 additions and 804 deletions
@@ -676,11 +676,11 @@ async function runSelfTest() {
designFoundationAssetCall?.arguments?.input?.outputPath ===
'assets/ui-prototype.png' &&
designFoundationAssetCall.arguments.input.aspectRatio === '16:9' &&
designFoundationAssetCall.arguments.input.assetKind === 'ui-prototype' &&
designFoundationAssetCall.arguments.input.assetKind === 'ui-design' &&
artAssetPlanAssetCall?.arguments?.input?.outputPath ===
'assets/art-spritesheet.png' &&
artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' &&
artAssetPlanAssetCall.arguments.input.assetKind === 'art-spritesheet',
artAssetPlanAssetCall.arguments.input.assetKind === 'icon-spritesheet',
'self-test-visual-assets-invalid',
);
@@ -70,7 +70,7 @@ const requiredFormalArtifactSpecs = [
{ path: 'game/balance.json', kind: 'json' },
{ path: 'assets/manifest.art.json', kind: 'json' },
{ path: 'assets/manifest.audio.json', kind: 'json' },
{ path: 'game/index.html', kind: 'game-entry' },
{ path: 'game/index.html', kind: 'file' },
{ path: 'exports/README.md', kind: 'file' },
];
const editorImageArtifactSpecs = [
@@ -290,7 +290,7 @@ function deterministicArtManifest(
{
id: 'garden-guardians-spritesheet',
path: 'assets/art-spritesheet.png',
kind: 'art-spritesheet',
kind: 'icon-spritesheet',
usage: ['defenders', 'enemies', 'battlefield-ui'],
source: 'canvas',
status: 'ready',
@@ -710,7 +710,7 @@ function canvasAssetCall(agentId) {
outputPath: 'assets/ui-prototype.png',
aspectRatio: '16:9',
imageSize: '2K',
assetKind: 'ui-prototype',
assetKind: 'ui-design',
assetLabel: '游戏横屏界面原型图',
replaceExisting: false,
});
@@ -721,7 +721,7 @@ function canvasAssetCall(agentId) {
outputPath: 'assets/art-spritesheet.png',
aspectRatio: '1:1',
imageSize: '1K',
assetKind: 'art-spritesheet',
assetKind: 'icon-spritesheet',
assetLabel: '游戏首版核心美术素材',
replaceExisting: false,
sliceMode: 'connected-components',
@@ -830,12 +830,12 @@ function missingGeneratedVisualAssetObservation(context, agentId) {
},
'design-foundation': {
path: 'assets/ui-prototype.png',
kind: 'ui-prototype',
kind: 'ui-design',
summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务',
},
'art-asset-plan': {
path: 'assets/art-spritesheet.png',
kind: 'art-spritesheet',
kind: 'icon-spritesheet',
summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务',
},
}[agentId];
@@ -2667,7 +2667,7 @@ function createDeterministicCanvasFixture(apiKey) {
asset: {
assetId: `asset-${sliceImageId}`,
assetObjectId: sliceAssetObjectId,
assetKind: 'art-spritesheet-slice',
assetKind: 'icon',
projectId,
taskId,
},
@@ -2707,7 +2707,7 @@ function createDeterministicCanvasFixture(apiKey) {
spritesheetAsset: {
assetId: `asset-${imageId}`,
assetObjectId,
assetKind: 'art-spritesheet',
assetKind: 'icon-spritesheet',
projectId,
taskId,
},
@@ -2749,7 +2749,7 @@ function createDeterministicCanvasFixture(apiKey) {
const assetObjectId = `asset-object-${imageId}`;
const resourceId = `resource-${imageId}`;
const assetKind =
typeof body?.assetKind === 'string' ? body.assetKind : 'game-art';
typeof body?.assetKind === 'string' ? body.assetKind : 'image';
images.set(imageId, {
...image,
objectKey,
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.24",
"version": "2026-08-26.25",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -63,7 +63,7 @@
"agents/openai.yaml",
"references/platform-art-contract.md"
],
"sha256": "47ac742d9b88e5d6cd9833484ab212152578e58ae27f7add312fd1d78183385c"
"sha256": "6668bf1aa69601bcc65c97fdcd879c699c3139befcd70805d95614997f6e44e9"
},
{
"name": "agc-web-game-development",
@@ -19,13 +19,13 @@ image, UI design image, or publication material; use `agc_edit_image` for an
edit of an existing registered image; use `taonier_prepare_game_art` only for
the complete game-art package and its canonical slices.
With `agc_generate_image`, `kind="character"` and `kind="art-spritesheet"`
With `agc_generate_image`, `kind="character"` and `kind="icon-spritesheet"`
generate the subject on a solid-colour background and automatically matte it
away afterwards, producing transparent-background results; write the prompt
for the subject only, never for a scene. `kind="image"` keeps the rendered
frame without extra processing.
When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is
When `agc_generate_image` is used with `kind="icon-spritesheet"`, `sliceMode` is
required and has no default, so decide it explicitly:
- Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user
@@ -1198,7 +1198,7 @@ mod tests {
"tool": "agc_generate_image",
"arguments": {
"prompt": prompt,
"kind": "art-spritesheet",
"kind": "icon-spritesheet",
"sliceMode": "connected-components",
"sliceCount": 8,
"screenColor": "#CFEFFF"
@@ -327,7 +327,7 @@ struct DirectTaonierArtAssetIdentity {
local_asset_id: String,
source_sha256: String,
media_type: String,
canonical_asset_kind: String,
canonical_asset_kind: GameCreationAppAssetKind,
resource_id: String,
asset_object_id: String,
canvas_project_id: String,
@@ -846,10 +846,20 @@ pub(in crate::agent) fn direct_taonier_regeneration_workflow_retains_stage_ledge
if agent_id != "direct-codex-art" {
return Ok(false);
}
// TODO: `run_id` 是 Direct 重生成阶段槽标识,不是 manifest `asset_kind`;两者必须保持独立。
let (output_path, asset_kind) = match run_id {
"art-spec" => (DIRECT_CODEX_ART_SPEC_ASSET_PATH, "icon-spec"),
"game-background" => (DIRECT_CODEX_BACKGROUND_ASSET_PATH, "game-background"),
"art-spritesheet" => (DIRECT_CODEX_SPRITESHEET_ASSET_PATH, "art-spritesheet"),
"art-spec" => (
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
GameCreationAppAssetKind::IconSpec,
),
"game-background" => (
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
),
"art-spritesheet" => (
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
GameCreationAppAssetKind::IconSpritesheet,
),
_ => return Ok(false),
};
if read_direct_taonier_regeneration_workflow_at(root)?.is_none() {
@@ -882,9 +892,18 @@ fn write_direct_taonier_regeneration_workflow_at(
fn clear_direct_taonier_regeneration_stage_ledgers_at(root: &Path) -> Result<(), String> {
for (output_path, asset_kind) in [
(DIRECT_CODEX_ART_SPEC_ASSET_PATH, "icon-spec"),
(DIRECT_CODEX_BACKGROUND_ASSET_PATH, "game-background"),
(DIRECT_CODEX_SPRITESHEET_ASSET_PATH, "art-spritesheet"),
(
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
GameCreationAppAssetKind::IconSpec,
),
(
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
),
(
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
GameCreationAppAssetKind::IconSpritesheet,
),
] {
let context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
remove_platform_art_generation_runtime_state_at(root, &context.agent_id, &context.run_id)?;
@@ -902,9 +921,9 @@ fn direct_taonier_regeneration_stage_ledgers_exist_at(root: &Path) -> bool {
fn validate_direct_taonier_retained_stage_ledgers_at(root: &Path) -> Result<(), String> {
for (run_id, asset_kind) in [
("art-spec", "icon-spec"),
("game-background", "game-background"),
("art-spritesheet", "art-spritesheet"),
("art-spec", GameCreationAppAssetKind::IconSpec),
("game-background", GameCreationAppAssetKind::Scene),
("art-spritesheet", GameCreationAppAssetKind::IconSpritesheet),
] {
let exists =
game_creator_agent_runtime_external_generation_exists(root, "direct-codex-art", run_id);
@@ -916,7 +935,8 @@ fn validate_direct_taonier_retained_stage_ledgers_at(root: &Path) -> Result<(),
)
.map_err(|error| {
format!(
"{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法校验陶泥儿 {asset_kind} 阶段账本,已保留现场:{error}"
"{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法校验陶泥儿 {} 阶段账本,已保留现场:{error}",
asset_kind.as_str()
)
})?
} else {
@@ -957,7 +977,7 @@ fn direct_taonier_spritesheet_matches_retained_stage_result_at(
let context = direct_taonier_art_generation_runtime_context(
root,
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
)?;
let retained = retained_platform_art_generation_runtime_spritesheet_identity_at(
root,
@@ -2471,7 +2491,7 @@ fn persist_direct_codex_failure_context(
fn direct_taonier_art_generation_runtime_context(
root: &Path,
output_path: &str,
asset_kind: &str,
asset_kind: GameCreationAppAssetKind,
) -> Result<PlatformArtGenerationRuntimeContext, String> {
let project_id = read_manifest(&root.join(".agent/manifest.json"))?
.project_id
@@ -2534,7 +2554,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.to_string(),
canonical_asset_kind: asset.kind,
resource_id: asset
.source
.resource_id
@@ -3185,7 +3205,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
local_asset_id: art_spec.local_asset_id.clone(),
source_sha256: art_spec.source_sha256.clone(),
media_type: art_spec.media_type.clone(),
canonical_asset_kind: art_spec.canonical_asset_kind.clone(),
canonical_asset_kind: art_spec.canonical_asset_kind,
resource_id: remote_spec_resource_id,
asset_object_id: spec_binding.asset_object_id,
canvas_project_id: project_binding.remote_project_id,
@@ -3374,24 +3394,29 @@ async fn generate_direct_taonier_art_asset_at(
prompt: &str,
output_path: &str,
aspect_ratio: &str,
asset_kind: &str,
asset_kind: GameCreationAppAssetKind,
asset_label: &str,
require_slices: bool,
retain_runtime_state: bool,
) -> Result<DirectTaonierArtGenerationOutcome, String> {
let parsed_asset_kind = asset_kind;
if parsed_asset_kind == GameCreationAppAssetKind::Unknown {
return Err("直连美术生成的资源 kind 无法解析".to_string());
}
let options = PlatformArtAssetGenerationOptions {
output_path: Some(output_path.to_string()),
aspect_ratio: aspect_ratio.to_string(),
image_size: "1K".to_string(),
asset_kind: asset_kind.to_string(),
asset_kind: parsed_asset_kind,
asset_label: asset_label.to_string(),
replace_existing: root.join(output_path).is_file(),
// 标准美术包必须产出四张 canonical 切片:连通域模式下显式声明目标数量,
// 让平台要么给出四张,要么以可执行的 422 说明实际识别数量。
slice_count: (asset_kind == "art-spritesheet").then_some(4),
slice_count: (parsed_asset_kind == GameCreationAppAssetKind::IconSpritesheet).then_some(4),
// 切分模式没有默认值:陶泥儿标准美术包按自由排布生成核心图集,因此只在
// art-spritesheet 阶段显式声明连通域切分。
slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()),
slice_mode: (parsed_asset_kind == GameCreationAppAssetKind::IconSpritesheet)
.then(|| "connected-components".to_string()),
grid_x: None,
grid_y: None,
screen_color: None,
@@ -3615,7 +3640,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
prompt,
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"1:1",
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"陶泥儿首版游戏视觉规范图",
false,
mode.regenerates_existing(),
@@ -3696,7 +3721,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
prompt,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
"16:9",
"game-background",
GameCreationAppAssetKind::Scene,
"陶泥儿首版游戏场景背景",
false,
mode.regenerates_existing(),
@@ -3813,7 +3838,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
prompt,
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
"1:1",
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
"陶泥儿首版核心游戏美术图集",
true,
mode.regenerates_existing(),
@@ -7375,7 +7400,7 @@ mod tests {
let context = direct_taonier_art_generation_runtime_context(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
)
.expect("direct stage context");
let request_body = serde_json::json!({
@@ -7996,7 +8021,7 @@ mod tests {
local_asset_id: "local-art-spec".to_string(),
source_sha256: "a".repeat(64),
media_type: "image/png".to_string(),
canonical_asset_kind: "icon-spec".to_string(),
canonical_asset_kind: GameCreationAppAssetKind::IconSpec,
resource_id: "taonier-resource-icon-spec".to_string(),
asset_object_id: "taonier-object-icon-spec".to_string(),
canvas_project_id: "taonier-project".to_string(),
@@ -8529,7 +8554,7 @@ mod tests {
let context = direct_taonier_art_generation_runtime_context(
root,
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
)
.expect("spritesheet runtime context");
let request_body = serde_json::json!({
@@ -1213,9 +1213,9 @@ fn bridge_registered_resource(
"mediaType": asset.media_type,
// Agent 与 UI 必须看到同一个口径:UI 栏目走 TS 的 `gameCreationAppAssetCategory`
// (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。
// 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、
// 直接透传落盘 `asset.category` 会让 UI 设计资产在 UI 显示「UI 交互」、
// 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。
"category": game_creation_app_asset_effective_category(asset.kind.as_str(), asset.category),
"category": game_creation_app_asset_effective_category(asset.kind, asset.category),
"tags": asset.tags,
"canvasProjectId": asset.source.canvas_project_id,
"resourceId": asset.source.resource_id,
@@ -2203,35 +2203,37 @@ async fn bridge_prepare_game_art(state: &DirectToolBridgeState, arguments: &Valu
result
}
fn bridge_image_generation_kind(arguments: &Value) -> Result<String, String> {
fn bridge_image_generation_kind(arguments: &Value) -> Result<GameCreationAppAssetKind, String> {
let kind = arguments
.get("kind")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("image");
normalize_platform_art_asset_generation_kind(kind)
.map(str::to_string)
.ok_or_else(|| {
format!(
"工具参数 kind 只允许 {}",
PLATFORM_ART_ASSET_GENERATION_KINDS.join("")
)
})
normalize_platform_art_asset_generation_kind(kind).ok_or_else(|| {
format!(
"工具参数 kind 只允许 {}",
PLATFORM_ART_ASSET_GENERATION_KINDS
.iter()
.map(|kind| kind.as_str())
.collect::<Vec<_>>()
.join("")
)
})
}
/// 切分模式没有默认值:图集必须显式声明,且声明必须与 kind 和网格参数自洽。
fn validate_generate_image_slice_declaration(
kind: &str,
kind: GameCreationAppAssetKind,
slice_mode: Option<&str>,
grid_x: Option<u32>,
grid_y: Option<u32>,
slice_count: Option<usize>,
) -> Result<(), String> {
if kind == "art-spritesheet" {
if kind == GameCreationAppAssetKind::IconSpritesheet {
if slice_mode.is_none() {
return Err(
"kind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components"
"kind=icon-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components"
.to_string(),
);
}
@@ -2244,19 +2246,19 @@ fn validate_generate_image_slice_declaration(
}
if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() {
return Err(format!(
"工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=art-spritesheet 生效,当前 kind={kind}"
"工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=icon-spritesheet 生效,当前 kind={kind}"
));
}
Ok(())
}
/// 抠图纯色背景只服务 character 与 art-spritesheet 链路;格式校验收口为
/// 抠图纯色背景只服务 character 与 icon-spritesheet 链路;格式校验收口为
/// `auto` 或 `#RRGGBB`(服务端另有支持色板,客户端不复制),`auto`/空串归一为
/// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝,
/// 避免服务端静默忽略造成“已生效”的误解。
fn normalize_generate_image_screen_color(
arguments: &Value,
kind: &str,
kind: GameCreationAppAssetKind,
) -> Result<Option<String>, String> {
let Some(value) = arguments.get("screenColor") else {
return Ok(None);
@@ -2264,9 +2266,12 @@ fn normalize_generate_image_screen_color(
if value.is_null() {
return Ok(None);
}
if !matches!(kind, "character" | "art-spritesheet") {
if !matches!(
kind,
GameCreationAppAssetKind::Character | GameCreationAppAssetKind::IconSpritesheet
) {
return Err(format!(
"工具参数 screenColor 仅对 kind=character 和 kind=art-spritesheet 生效,当前 kind={kind}"
"工具参数 screenColor 仅对 kind=character 和 kind=icon-spritesheet 生效,当前 kind={kind}"
));
}
let raw = value
@@ -2393,18 +2398,18 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
})
.transpose()?;
validate_generate_image_slice_declaration(
kind.as_str(),
kind,
slice_mode.as_deref(),
grid_x,
grid_y,
slice_count,
)?;
let screen_color = normalize_generate_image_screen_color(arguments, kind.as_str())?;
let screen_color = normalize_generate_image_screen_color(arguments, kind)?;
let options = PlatformArtAssetGenerationOptions {
output_path,
aspect_ratio,
image_size,
asset_kind: kind.clone(),
asset_kind: kind,
asset_label: asset_name.clone(),
replace_existing: false,
slice_count,
@@ -2891,14 +2896,19 @@ pub(crate) async fn start_direct_tool_bridge(
mod tests {
#[test]
fn generate_image_slice_declaration_is_explicit_and_self_consistent() {
let missing =
validate_generate_image_slice_declaration("art-spritesheet", None, None, None, None)
.expect_err("art-spritesheet without sliceMode must fail closed");
let missing = validate_generate_image_slice_declaration(
GameCreationAppAssetKind::IconSpritesheet,
None,
None,
None,
None,
)
.expect_err("icon-spritesheet without sliceMode must fail closed");
assert!(missing.contains("没有默认值"), "{missing}");
assert!(missing.contains("connected-components"), "{missing}");
assert!(validate_generate_image_slice_declaration(
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
Some("connected-components"),
None,
None,
@@ -2906,7 +2916,7 @@ mod tests {
)
.is_ok());
assert!(validate_generate_image_slice_declaration(
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
Some("grid"),
Some(3),
Some(2),
@@ -2914,7 +2924,7 @@ mod tests {
)
.is_ok());
let grid_with_count = validate_generate_image_slice_declaration(
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
Some("grid"),
Some(2),
Some(2),
@@ -2924,58 +2934,80 @@ mod tests {
assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}");
let wrong_kind = validate_generate_image_slice_declaration(
"image",
GameCreationAppAssetKind::Image,
Some("connected-components"),
None,
None,
None,
)
.expect_err("slice declaration must stay scoped to art-spritesheet");
.expect_err("slice declaration must stay scoped to icon-spritesheet");
assert!(
wrong_kind.contains("仅对 kind=art-spritesheet 生效"),
wrong_kind.contains("仅对 kind=icon-spritesheet 生效"),
"{wrong_kind}"
);
let wrong_kind_count =
validate_generate_image_slice_declaration("image", None, None, None, Some(8))
.expect_err("sliceCount-only violation must be rejected");
let wrong_kind_count = validate_generate_image_slice_declaration(
GameCreationAppAssetKind::Image,
None,
None,
None,
Some(8),
)
.expect_err("sliceCount-only violation must be rejected");
assert!(
wrong_kind_count.contains("sliceCount"),
"sliceCount-only violation must name sliceCount: {wrong_kind_count}"
);
assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok());
assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::Image,
None,
None,
None,
None
)
.is_ok());
}
#[test]
fn generate_image_screen_color_is_normalized_and_kind_gated() {
// 省略与显式 null 等价,且不触发 kind 门禁。
assert_eq!(
normalize_generate_image_screen_color(&json!({}), "image").expect("omitted"),
normalize_generate_image_screen_color(&json!({}), GameCreationAppAssetKind::Image)
.expect("omitted"),
None
);
assert_eq!(
normalize_generate_image_screen_color(&json!({"screenColor": null}), "image")
.expect("null"),
normalize_generate_image_screen_color(
&json!({"screenColor": null}),
GameCreationAppAssetKind::Image
)
.expect("null"),
None
);
// auto 家族归一为 None(服务端自动决策),大小写与空白不敏感。
for raw in ["auto", "AUTO", " auto ", ""] {
assert_eq!(
normalize_generate_image_screen_color(&json!({"screenColor": raw}), "character")
.expect("auto variants"),
normalize_generate_image_screen_color(
&json!({"screenColor": raw}),
GameCreationAppAssetKind::Character
)
.expect("auto variants"),
None,
"{raw}"
);
}
// hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。
assert_eq!(
normalize_generate_image_screen_color(&json!({"screenColor": "#cfefff"}), "character")
.expect("lowercase hex"),
normalize_generate_image_screen_color(
&json!({"screenColor": "#cfefff"}),
GameCreationAppAssetKind::Character
)
.expect("lowercase hex"),
Some("#CFEFFF".to_string())
);
assert_eq!(
normalize_generate_image_screen_color(
&json!({"screenColor": " #A0BBA0 "}),
"art-spritesheet"
GameCreationAppAssetKind::IconSpritesheet
)
.expect("padded hex"),
Some("#A0BBA0".to_string())
@@ -2983,19 +3015,24 @@ mod tests {
// 非 auto/非 hex、非字符串一律拒绝。
for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] {
assert!(
normalize_generate_image_screen_color(&json!({"screenColor": bad}), "character")
.is_err(),
normalize_generate_image_screen_color(
&json!({"screenColor": bad}),
GameCreationAppAssetKind::Character
)
.is_err(),
"{bad}"
);
}
// 其它 kind 携带该字段直接拒绝,即使取值合法。
let gated =
normalize_generate_image_screen_color(&json!({"screenColor": "#CFEFFF"}), "image")
.expect_err("screenColor must stay scoped to character/art-spritesheet");
let gated = normalize_generate_image_screen_color(
&json!({"screenColor": "#CFEFFF"}),
GameCreationAppAssetKind::Image,
)
.expect_err("screenColor must stay scoped to character/icon-spritesheet");
assert!(gated.contains("kind=character"), "{gated}");
assert!(normalize_generate_image_screen_color(
&json!({"screenColor": "auto"}),
"ui-prototype"
GameCreationAppAssetKind::UiDesign
)
.is_err());
}
@@ -3064,28 +3101,6 @@ mod tests {
.contains("x-genarrative-client:"));
}
/// `asset.list` 的 `kind` 过滤必须报错,而不是静默返回空列表:旧值会收口成 `unknown` 再参与
/// 等值过滤,匹配不到任何资产,让模型误以为项目里没有这类资产。校验必须发生在项目访问之前。
#[test]
fn bridge_asset_list_rejects_non_canonical_kind_filter() {
let root = tempfile::tempdir().expect("bridge root");
let rejected =
bridge_list_registered_assets(root.path(), &json!({ "kind": "art-spritesheet-slice" }));
assert_eq!(rejected["isError"], true);
assert!(
rejected.to_string().contains("art-spritesheet-slice"),
"错误里必须带上原值,调用方才能自纠:{rejected}"
);
// 字面 `unknown` 仍是合法过滤值:它应当越过 kind 校验,失败点换成项目侧(此目录未初始化)。
let allowed = bridge_list_registered_assets(root.path(), &json!({ "kind": "unknown" }));
assert!(
!allowed
.to_string()
.contains("不是已知的 manifest 资源 kind"),
"未知 kind 的字面量本身是合法输入:{allowed}"
);
}
/// 按 kind 的提示词上限只来自客户端权威口径;超限必须在构造工具输入时就被拒绝,
/// 不能再出现写死的数字(2026-09-17 的背景音乐 140 就是写死在桥这一层的)。
#[test]
@@ -3791,7 +3806,9 @@ mod tests {
local_path: "assets/art-spritesheet-slices/player.png".to_string(),
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::IconSpritesheet,
),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
@@ -3856,7 +3873,9 @@ mod tests {
},
]),
image_sequence_duration_ms: Some(4_000),
category: game_creation_app_asset_category_for_kind("character-animation"),
category: game_creation_app_asset_category_for_kind(
GameCreationAppAssetKind::CharacterAnimation,
),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
@@ -247,7 +247,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"type": "string",
"enum": PLATFORM_ART_ASSET_GENERATION_KINDS,
"default": "image",
"description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图"
"description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图"
},
"aspectRatio": {
"type": "string",
@@ -273,7 +273,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"sliceMode": {
"type": "string",
"enum": ["connected-components", "grid"],
"description": "仅 kind=art-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择"
"description": "仅 kind=icon-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择"
},
"gridX": {
"type": "integer",
@@ -291,11 +291,11 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"type": "integer",
"minimum": 1,
"maximum": 256,
"description": "只与 kind=art-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别"
"description": "只与 kind=icon-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别"
},
"screenColor": {
"type": "string",
"description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=art-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近"
"description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=icon-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近"
}
},
"required": ["prompt"],
@@ -2523,7 +2523,7 @@ mod tests {
// 目录外的 kind 必须在本地拒绝:既不下发 bridge,也不产生任何计费副作用。
let unsupported_kind = call_agc_generate_image(&json!({
"prompt": "像素月光主角",
"kind": "game-art"
"kind": "future-kind"
}))
.await;
assert_eq!(unsupported_kind["isError"], json!(true));
@@ -2533,7 +2533,7 @@ mod tests {
let oversized_prompt = call_agc_generate_image(&json!({
"prompt": "x".repeat(DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS + 1),
"kind": "art-spritesheet"
"kind": "icon-spritesheet"
}))
.await;
assert_eq!(oversized_prompt["isError"], json!(true));
@@ -2902,7 +2902,7 @@ mod tests {
local_path: local_path.to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("image"),
category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Image),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
File diff suppressed because it is too large Load Diff
@@ -31,9 +31,9 @@ fn is_direct_platform_art_generation_runtime_context(
context: &PlatformArtGenerationRuntimeContext,
) -> bool {
let asset_kind = match context.run_id.as_str() {
"art-spec" => "icon-spec",
"game-background" => "game-background",
"art-spritesheet" => "art-spritesheet",
"art-spec" => GameCreationAppAssetKind::IconSpec,
"game-background" => GameCreationAppAssetKind::Scene,
"art-spritesheet" => GameCreationAppAssetKind::IconSpritesheet,
_ => return false,
};
context.agent_id == "direct-codex-art"
@@ -42,7 +42,11 @@ fn is_direct_platform_art_generation_runtime_context(
&& context.source == "direct-codex"
&& context.action_id == format!("direct-taonier-{}", context.run_id)
&& context.action_fingerprint
== format!("direct-taonier-art-v1:{asset_kind}:{}", context.run_id)
== format!(
"direct-taonier-art-v1:{}:{}",
asset_kind.as_str(),
context.run_id
)
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -396,7 +396,7 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
let inspection_kind = detail
.get("inspectionKind")
.and_then(serde_json::Value::as_str)
.filter(|value| *value == AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND);
.filter(|value| *value == AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND);
let validation_profile = detail
.get("validationProfile")
.and_then(serde_json::Value::as_str)
@@ -408,7 +408,7 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
)
});
let (passed, checks, issues) = if validation_profile.is_some() {
if inspection_kind != Some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND) {
if inspection_kind != Some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND) {
return None;
}
let passed = detail.get("passed")?.as_bool()?;
@@ -1136,7 +1136,7 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked(
&& record
.get("inspectionKind")
.and_then(serde_json::Value::as_str)
== Some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND)
== Some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND)
&& record
.get("validationProfile")
.and_then(serde_json::Value::as_str)
@@ -16,7 +16,7 @@ pub(in crate::agent) fn art_manifest_content() -> String {
"status": "generated",
"assets": [{
"path": ART_SPRITESHEET_PATH,
"kind": "art-spritesheet",
"kind": GameCreationAppAssetKind::IconSpritesheet.as_str(),
"usage": REQUIRED_SLICE_USAGES,
}],
"sliceManifest": ART_SLICE_MANIFEST_PATH,
@@ -8,12 +8,12 @@ pub(in crate::agent) fn autonomous_registered_derived_visuals_need_repair_at(roo
(
"design-foundation",
"assets/ui-prototype.png",
"ui-prototype",
GameCreationAppAssetKind::UiDesign,
),
(
"art-asset-plan",
"assets/art-spritesheet.png",
"art-spritesheet",
GameCreationAppAssetKind::IconSpritesheet,
),
]
.into_iter()
@@ -644,9 +644,9 @@ fn standalone_platform_art_generation_ledger_identity_is_valid(
};
let direct_identity_is_valid = if agent_id == "direct-codex-art" {
let expected_asset_kind = match run_id {
"art-spec" => Some("icon-spec"),
"game-background" => Some("game-background"),
"art-spritesheet" => Some("art-spritesheet"),
"art-spec" => Some(GameCreationAppAssetKind::IconSpec),
"game-background" => Some(GameCreationAppAssetKind::Scene),
"art-spritesheet" => Some(GameCreationAppAssetKind::IconSpritesheet),
_ => None,
};
expected_asset_kind.is_some_and(|asset_kind| {
@@ -655,7 +655,9 @@ fn standalone_platform_art_generation_ledger_identity_is_valid(
&& string_field("source") == Some("direct-codex")
&& string_field("actionId") == Some(format!("direct-taonier-{run_id}").as_str())
&& string_field("actionFingerprint")
== Some(format!("direct-taonier-art-v1:{asset_kind}:{run_id}").as_str())
== Some(
format!("direct-taonier-art-v1:{}:{run_id}", asset_kind.as_str()).as_str(),
)
})
} else {
false
@@ -1706,7 +1708,7 @@ mod orphaned_external_generation_recovery_tests {
write_complete_stage(serde_json::json!({
"prompt": "Direct art spec",
"kind": "spec",
"assetKind": "game-background",
"assetKind": "scene",
"projectId": "canvas-project",
"assetFolderId": "canvas-assets",
"generationInputs": {
@@ -71,9 +71,8 @@ pub(crate) use isolated_joins::{
pub(crate) use media::observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test;
#[allow(unused_imports)]
pub(crate) use media::{
AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND,
AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH,
AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE,
AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND, AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE,
AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE,
};
pub(crate) use policy::{
fail_closed_agent_runtime_confirmation_for_run,
@@ -8,34 +8,15 @@ pub(in crate::agent) struct AgentRuntimeImageInspectInput {
pub(in crate::agent) question: Option<String>,
}
pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND: &str = "ui-prototype";
pub(crate) const AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND: &str = "ui-design";
pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_PATH: &str = "assets/ui-prototype.png";
pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE: &str = "ui-prototype.v1";
pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE: &str = "ui-prototype.v2";
pub(crate) const AGENT_RUNTIME_ART_SPEC_PATH: &str = "assets/art-spec.png";
pub(crate) const AGENT_RUNTIME_ART_SPRITESHEET_PATH: &str = "assets/art-spritesheet.png";
fn agent_runtime_canvas_asset_kind_is_supported(asset_kind: &str) -> bool {
let parsed = match asset_kind {
// 仅用于恢复旧的生成账本;新工具 schema 只暴露 canonical enum 值。
"game-art" => GameCreationAppAssetKind::Image,
"ui-prototype" => GameCreationAppAssetKind::UiDesign,
"art-spritesheet" => GameCreationAppAssetKind::IconSpritesheet,
_ => GameCreationAppAssetKind::parse_with_context(asset_kind, "canvas.asset_generate"),
};
AGENT_RUNTIME_CANVAS_ASSET_KINDS.contains(&parsed)
}
fn canvas_asset_kind_generation_value(asset_kind: &str) -> Option<&'static str> {
match asset_kind.trim() {
// 新工具输入使用 GameCreationAppAssetKind 的 canonical 值;生成适配器内部仍
// 使用平台请求所需的固定值,直到请求边界完成迁移。
"image" | "game-art" => Some("game-art"),
"icon-spec" => Some("icon-spec"),
"ui-design" | "ui-prototype" => Some("ui-prototype"),
"icon-spritesheet" | "art-spritesheet" => Some("art-spritesheet"),
_ => None,
}
fn agent_runtime_canvas_asset_kind_is_supported(asset_kind: GameCreationAppAssetKind) -> bool {
AGENT_RUNTIME_CANVAS_ASSET_KINDS.contains(&asset_kind)
}
pub(in crate::agent) fn design_foundation_ui_page_output_path_is_valid(path: &str) -> bool {
@@ -489,7 +470,7 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect(
"images": image_metadata,
"responseId": response_id,
"conclusionChars": conclusion_chars,
"inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND),
"inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND),
"validationProfile": validation_profile,
"passed": passed,
"checks": checks,
@@ -508,7 +489,7 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect(
"responseId": response_id,
"conclusionChars": conclusion_chars,
"conclusion": conclusion,
"inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND),
"inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND),
"validationProfile": validation_profile,
"passed": passed,
"checks": checks,
@@ -563,9 +544,10 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
let aspect_ratio = agent_runtime_tool_input_text(input, &["aspectRatio", "aspect_ratio"]);
let image_size = agent_runtime_tool_input_text(input, &["imageSize", "image_size"]);
let requested_asset_kind = agent_runtime_tool_input_text(input, &["assetKind", "asset_kind"]);
let asset_kind = canvas_asset_kind_generation_value(&requested_asset_kind)
.map(str::to_string)
.unwrap_or(requested_asset_kind);
let asset_kind = GameCreationAppAssetKind::parse_with_context(
&requested_asset_kind,
"canvas.asset_generate",
);
let asset_label = agent_runtime_tool_input_text(input, &["assetLabel", "asset_label"]);
let replace_existing = input
.get("replaceExisting")
@@ -720,12 +702,12 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
};
}
// 切分模式没有默认值:图集必须显式声明,且声明必须与 assetKind 和网格参数自洽。
if options.asset_kind == "art-spritesheet" {
if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet {
if options.slice_mode.is_none() {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
summary: "assetKind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供 gridX/gridY;自由排布时用 connected-components"
summary: "assetKind=icon-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供 gridX/gridY;自由排布时用 connected-components"
.to_string(),
detail: None,
};
@@ -760,13 +742,13 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
summary: format!(
"sliceMode/gridX/gridY/sliceCount 仅对 assetKind=art-spritesheet 生效,当前 assetKind={}",
"sliceMode/gridX/gridY/sliceCount 仅对 assetKind=icon-spritesheet 生效,当前 assetKind={}",
options.asset_kind
),
detail: None,
};
}
if !agent_runtime_canvas_asset_kind_is_supported(&options.asset_kind) {
if !agent_runtime_canvas_asset_kind_is_supported(options.asset_kind) {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
@@ -960,7 +942,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
);
}
};
let committed = if options.asset_kind == "art-spritesheet" {
let committed = if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet {
commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| {
let output_path = options
.output_path
@@ -1244,22 +1226,11 @@ mod platform_art_generation_observation_tests {
#[test]
fn canvas_asset_kind_validation_accepts_shared_catalog() {
for asset_kind in AGENT_RUNTIME_CANVAS_ASSET_KINDS {
assert!(agent_runtime_canvas_asset_kind_is_supported(
asset_kind.as_str()
));
assert!(agent_runtime_canvas_asset_kind_is_supported(*asset_kind));
}
for legacy_kind in ["game-art", "ui-prototype", "art-spritesheet"] {
assert!(agent_runtime_canvas_asset_kind_is_supported(legacy_kind));
}
assert_eq!(
canvas_asset_kind_generation_value("image"),
Some("game-art")
);
assert_eq!(
canvas_asset_kind_generation_value("ui-design"),
Some("ui-prototype")
);
assert!(!agent_runtime_canvas_asset_kind_is_supported("unsupported"));
assert!(!agent_runtime_canvas_asset_kind_is_supported(
GameCreationAppAssetKind::Unknown
));
}
#[test]
@@ -1036,10 +1036,10 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。",
"image.inspect" => "让视觉模型检查一至两张项目内图片。",
"canvas.asset_generate" => {
"通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=art-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。"
"通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。"
}
"ui.workflow.run" => {
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
}
"cocos.editor.execute" => {
"在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。"
@@ -1321,7 +1321,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
"assetKind": { "type": ["string", "null"], "enum": asset_kinds },
"assetLabel": { "type": ["string", "null"], "maxLength": 80 },
"replaceExisting": { "type": "boolean" },
"sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": "仅 assetKind=art-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components" },
"sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": "仅 assetKind=icon-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components" },
"gridX": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" },
"gridY": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" },
"sliceCount": { "type": ["integer", "null"], "minimum": 1, "maximum": 256, "description": "只与 sliceMode=connected-components 同时提供,用于约束目标素材张数" }
@@ -30,6 +30,7 @@ use crate::agent::{
};
use crate::commands::prepare_local_project_asset_generation;
use crate::project::{enforce_project_permission_policy, read_existing_manifest_for_project};
use shared_contracts::game_creation_app::GameCreationAppAssetKind;
pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1";
pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str =
@@ -69,7 +70,7 @@ const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str =
pub(crate) struct AssetGenerationTaskRecord {
pub(crate) task_id: String,
pub(crate) project_id: String,
pub(crate) kind: String,
pub(crate) kind: GameCreationAppAssetKind,
pub(crate) asset_name: String,
pub(crate) status: String,
/// 阶段文案:**由后端拥有**,前端只渲染。
@@ -297,14 +298,14 @@ pub(crate) fn begin_local_project_asset_generation_task(
root: &Path,
project_id: &str,
task_id: &str,
task_kind: &str,
task_kind: GameCreationAppAssetKind,
asset_name: &str,
output_path: Option<&str>,
) -> Result<AssetGenerationTaskRecord, String> {
let record = AssetGenerationTaskRecord {
task_id: task_id.to_string(),
project_id: project_id.trim().to_string(),
kind: task_kind.to_string(),
kind: task_kind,
asset_name: asset_name.to_string(),
status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(),
phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(),
@@ -409,7 +410,7 @@ pub(crate) async fn start_local_project_asset_generation(
&request.root,
&project_id,
&task_id,
&asset_kind,
asset_kind,
&asset_label,
request.options.output_path.as_deref(),
)?;
@@ -516,7 +517,7 @@ mod asset_generation_task_tests {
root,
"project-1",
task_id,
"image",
GameCreationAppAssetKind::Image,
"AI 图",
None,
)
@@ -532,7 +533,7 @@ mod asset_generation_task_tests {
root,
"project-1",
task_id,
"icon-spec",
GameCreationAppAssetKind::IconSpec,
"图标规范",
Some(output_path),
)
@@ -707,7 +708,7 @@ mod asset_generation_task_tests {
&root,
"project-1",
"task-dup",
"image",
GameCreationAppAssetKind::Image,
"AI 图",
None,
)
@@ -536,7 +536,7 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAsse
///
/// 三条外部登记边界必须同口径:平台导入(`commands::imported_platform_asset_kind`)、
/// 画板导入(`sync_canvas_project_assets_at`)、外部 `register_local_asset`。
/// 只做严格等值匹配——不 trim 归一、不查别名、不做迁移
/// 只做严格等值匹配,认不出时收口为 `Unknown`
pub(crate) fn registration_asset_kind(
value: &str,
context: &'static str,
@@ -1787,9 +1787,7 @@ pub(crate) fn normalized_zip_entry_name(name: &str) -> Result<String, String> {
/// 画板导出图层推断出的资源 kind。
///
/// 直接返回正式枚举成员:这个值会被 `register_local_asset_entry` 原样写入 manifest
/// 并据以派生落盘 `category`。分支字面量写成非 canonical 值在这里已经无法表达,
/// 不需要别名表兜底。
/// 直接返回正式枚举成员;该值会被登记边界原样写入 manifest
pub(crate) fn infer_canvas_export_asset_kind(
layer: &CanvasExportLayerMetadata,
file: &str,
@@ -1924,7 +1922,7 @@ fn register_local_asset_entry_with_change(
|| existing.source != source;
if existing.kind != kind {
existing.kind = kind;
existing.category = game_creation_app_asset_category_for_kind(kind.as_str());
existing.category = game_creation_app_asset_category_for_kind(kind);
}
existing.media_type = media_type.to_string();
existing.source = source;
@@ -1942,7 +1940,7 @@ fn register_local_asset_entry_with_change(
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(kind.as_str()),
category: game_creation_app_asset_category_for_kind(kind),
tags: Vec::new(),
source,
});
@@ -2186,10 +2184,6 @@ mod tests {
}
/// 画板导出推断出的 kind 必须已经是 canonical 值。
///
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
/// (历史上曾写 `ui` / `animation` / `asset`),就只能靠别名表兜底,等于同时维护
/// 两套词汇,且已落盘的 category 无法随别名修复自愈。这里逐分支钉死。
#[test]
fn canvas_export_asset_kind_is_always_canonical() {
fn layer(layer_type: &str) -> CanvasExportLayerMetadata {
@@ -2234,60 +2228,52 @@ mod tests {
infer_canvas_export_asset_kind(&layer("图层"), "media/bgm.mp3"),
GameCreationAppAssetKind::Audio
);
// 兜底分支是 image 而不是旧的 "asset"
// 无匹配时使用中性的 image
assert_eq!(
infer_canvas_export_asset_kind(&layer("图层"), "layer.png"),
GameCreationAppAssetKind::Image
);
// 所有分支的返回值都必须是正式枚举成员,且不能是旧的非 canonical 写法
// 所有分支的返回值都必须是正式枚举成员。
for layer_type in ["序列", "音频", "角色", "场景", "UI", "图层", "其他"] {
let kind = infer_canvas_export_asset_kind(&layer(layer_type), "layer.png");
assert!(
GameCreationAppAssetKind::ALL.contains(&kind),
"非 canonical kind: {kind}layer_type={layer_type}"
);
for legacy in ["ui", "animation", "asset"] {
assert_ne!(kind, legacy, "写回了非 canonical 的 {legacy}");
}
}
}
/// 上传素材的 `kind` 只由内容证据推导:既不能写 `uploaded`(来源词当类型),
/// 更不能把 `ui` 当图片默认值(`ui → ui-design → ui-interaction` 会把任意上传图片
/// 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来)。
///
/// 变异验证:把 `uploaded_asset_kind` 改回返回常量(`"uploaded"` 或 `"ui"`)必须让本用例变红。
#[test]
fn uploaded_asset_kind_uses_content_evidence_and_never_ui() {
assert_eq!(uploaded_asset_kind("hero.png", "image/png"), "image");
assert_eq!(uploaded_asset_kind("../角色.png", "image/png"), "image");
assert_eq!(uploaded_asset_kind("bg.webp", ""), "image");
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"), "font");
assert_eq!(uploaded_asset_kind("game.js", "text/javascript"), "code");
// 判不出内容类型 → `unknown`(派生 `unclassified` → 「待归类」),不猜具体类型。
fn uploaded_asset_kind_uses_content_evidence() {
assert_eq!(
uploaded_asset_kind("hero.png", "image/png"),
GameCreationAppAssetKind::Image
);
assert_eq!(
uploaded_asset_kind("theme.mp3", "audio/mpeg"),
GameCreationAppAssetKind::Audio
);
assert_eq!(
uploaded_asset_kind("intro.mp4", ""),
GameCreationAppAssetKind::Video
);
assert_eq!(
uploaded_asset_kind("rules.md", "text/markdown"),
GameCreationAppAssetKind::Document
);
assert_eq!(
uploaded_asset_kind("ui-font.ttf", "font/ttf"),
GameCreationAppAssetKind::Font
);
assert_eq!(
uploaded_asset_kind("game.js", "text/javascript"),
GameCreationAppAssetKind::Code
);
assert_eq!(
uploaded_asset_kind("unknown.bin", "application/octet-stream"),
"unknown"
GameCreationAppAssetKind::Unknown
);
assert_eq!(uploaded_asset_kind("no-extension", ""), "unknown");
// 反查:正文里的"来源词"和"UI 默认值"都不得出现在返回值里。
for (file_name, media_type) in [
("hero.png", "image/png"),
("theme.mp3", "audio/mpeg"),
("unknown.bin", ""),
] {
let kind = uploaded_asset_kind(file_name, media_type);
assert_ne!(kind, "uploaded", "{file_name} 写回了来源词");
assert_ne!(kind, "ui", "{file_name} 写回了非 canonical 的 `ui`");
assert!(
GameCreationAppAssetKind::ALL.contains(&kind),
"非 canonical kind: {kind}{file_name}"
);
}
}
#[test]
@@ -2942,7 +2942,7 @@ mod ui_editor_font_tests {
local_path: relative_path.to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("font"),
category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Font),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
@@ -3131,9 +3131,7 @@ mod agent_asset_import_tests {
assert!(first.assets[4].local_path.ends_with(".html"));
assert!(root.join(&first.assets[1].local_path).is_file());
// P0 回归:本地导入的图片只能落中性的 `image`(→ `unclassified` → 「待归类」)。
// 曾经写成 `ui`,经 `ui → ui-design → ui-interaction` 把任意图片钉死在「UI 交互」栏,
// 且落盘 `category` 成了非 `unclassified` 值后读时自愈永远救不回来。
// 把 `agent_local_project_file_type` 的图片分支改回 `"ui"` 必须让本用例变红。
// 不能把任意图片钉死在「UI 交互」栏,落盘 category 也必须保持中性。
let manifest: serde_json::Value = serde_json::from_str(
&fs::read_to_string(root.join(".agent/manifest.json")).expect("read imported manifest"),
)
@@ -3151,8 +3149,8 @@ mod agent_asset_import_tests {
assert!(
!imported_assets
.iter()
.any(|asset| asset["kind"] == "ui" || asset["category"] == "ui-interaction"),
"本地导入不得产出 `ui` / `ui-interaction`{manifest}"
.any(|asset| asset["category"] == "ui-interaction"),
"本地导入不得产出 `ui-interaction`{manifest}"
);
let revision_after_first = read_game_creator_agent_runtime_project_revision(root)
.expect("read imported revision")
@@ -3373,12 +3371,10 @@ mod agent_asset_import_tests {
}
/// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`**只接受 canonical 值**
/// 认不出的原值收口成 `unknown`(→ 「待归类」)并留痕原始串**永远不许回退成常量 `ui`**
/// 认不出的原值收口成 `unknown`(→ 「待归类」)并留痕原始串。
/// 缺失/空白退回中性的 `image`。
///
/// kind 没有别名表:平台侧写 `"UI"` / `"ui"` / `"mystery"` 时判据是"这不是合法 kind"
/// 而不是"帮它归一成 `ui-design`";缺失/空白才退回中性的 `image`。
///
/// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回 `"ui"` 的实现,
/// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回固定值的实现
/// 本用例必须变红(`character` / `scene` / 空值 / 未知值四组断言都会失败)。
#[test]
fn imported_platform_asset_kind_prefers_payload_type_over_ui_default() {
@@ -3404,17 +3400,7 @@ mod agent_asset_import_tests {
// 缺失 / 空白退回中性的 `image`(→ 「待归类」)。
assert_eq!(imported_platform_asset_kind(None), Kind::Image);
assert_eq!(imported_platform_asset_kind(Some(" ")), Kind::Image);
// 非 canonical 原值(含大小写变体、旧别名与原型链键)一律收口成 `unknown`:
// 判据落在「这不是合法 kind」,原值只出现在留痕日志里。
for raw in [
"UI",
"ui",
"mystery",
"game-art",
"ui-prototype",
"__proto__",
"constructor",
] {
for raw in ["UI", "mystery", "future-kind"] {
assert_eq!(
imported_platform_asset_kind(Some(raw)),
Kind::Unknown,
@@ -4438,21 +4424,12 @@ pub(crate) fn import_local_project_assets_for_agent(
Ok(RemoteImportResult { assets: imported })
}
/// 平台素材导入落盘的 manifest `kind`**有真实类型就用真实类型**,判不出才退回中性的
/// `image`(派生 `unclassified` → 「待归类」),认不出的原值落 `unknown`(同样「待归类」)
/// 并在日志里留下原始串。
///
/// 这里绝不允许回退成 `ui`:账户素材库记录与网页项目画布响应本来就带 `assetKind`
/// `AgentEditorAssetRecord::asset_kind` / 响应字段 `assetKind`),把它换成常量等于丢掉
/// 唯一一条"这东西是什么"的事实,并把角色立绘、怪物、道具、场景图一并钉进「UI 交互」栏
/// `ui → ui-design → ui-interaction`)。落盘 `category` 一旦是非 `unclassified` 值,
/// 读时自愈永远救不回来,所以宁可写 `image`(待归类)也不能写假 `ui`。
///
/// kind 只有一条口径:严格解析,不做别名归一、不做迁移(`Kind.ALL` 是唯一词汇表)。
/// 平台素材导入落盘的 manifest `kind`缺失时使用中性的 `image`,其它输入只走共享
/// `GameCreationAppAssetKind` 的严格解析,无法识别则得到 `Unknown` 并保留原始值留痕。
fn imported_platform_asset_kind(platform_kind: Option<&str>) -> GameCreationAppAssetKind {
// 平台是外部系统,但 kind 口径**只有一条**:严格解析 + 非 canonical 原值留痕
// (回调已由壳层注册成 `app_log!`,见 `main.rs`);缺失 / 空白按「没有信息」落
// 中性 `image`。这里不再做别名收口,认不出的值就是 `Unknown` → 「待归类」,
// 中性 `image`。认不出的值就是 `Unknown` → 「待归类」,
// 原值只出现在日志里。
platform_kind
.map(|kind| crate::assets::registration_asset_kind(kind, "platform.asset_kind"))
@@ -4921,7 +4898,7 @@ pub(crate) fn prepare_local_project_asset_generation(
"1K",
"图片尺寸",
)?,
asset_kind: asset_kind.to_string(),
asset_kind,
asset_label: local_project_asset_single_line(
asset_name,
LOCAL_PROJECT_ASSET_MAX_ASSET_NAME_CHARS,
@@ -4930,9 +4907,9 @@ pub(crate) fn prepare_local_project_asset_generation(
.unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()),
replace_existing: false,
slice_count: None,
// 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 art-spritesheet
// 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 icon-spritesheet
// 时显式声明连通域切分;等分网格或固定槽位需求由外部 API 显式传 grid + gridX/gridY。
slice_mode: (asset_kind == "art-spritesheet")
slice_mode: (asset_kind == GameCreationAppAssetKind::IconSpritesheet)
.then(|| "connected-components".to_string()),
grid_x: None,
grid_y: None,
@@ -4993,17 +4970,14 @@ mod local_project_asset_generation_tests {
for kind in [
"image",
"character",
"spec",
"icon-spec",
"ui-prototype",
"art-spritesheet",
"ui-design",
"icon-spritesheet",
] {
let request = prepare(kind, "像素月光厨房主角").expect("toolbar kind is supported");
assert_eq!(request.root, PathBuf::from("/tmp/project"));
assert_eq!(request.prompt, "像素月光厨房主角");
// `spec` 只放行到权威类型;其余 kind 原样进入参数化通道。
let expected = if kind == "spec" { "icon-spec" } else { kind };
assert_eq!(request.options.asset_kind, expected);
assert_eq!(request.options.asset_kind.as_str(), kind);
assert!(!request.options.replace_existing);
assert_eq!(request.options.slice_count, None);
}
@@ -5019,7 +4993,7 @@ mod local_project_asset_generation_tests {
let explicit = prepare_local_project_asset_generation(
" /tmp/project ",
" art-spritesheet ",
"icon-spritesheet",
" 像素图集 ",
Some("16:9"),
Some("2K"),
@@ -5029,7 +5003,10 @@ mod local_project_asset_generation_tests {
.expect("explicit options");
assert_eq!(explicit.root, PathBuf::from("/tmp/project"));
assert_eq!(explicit.prompt, "像素图集");
assert_eq!(explicit.options.asset_kind, "art-spritesheet");
assert_eq!(
explicit.options.asset_kind,
GameCreationAppAssetKind::IconSpritesheet
);
assert_eq!(explicit.options.aspect_ratio, "16:9");
assert_eq!(explicit.options.image_size, "2K");
assert_eq!(explicit.options.asset_label, "主角图集");
@@ -5061,12 +5038,12 @@ mod local_project_asset_generation_tests {
"生成要求不能为空"
);
assert_eq!(
prepare("game-art", "要求").expect_err("unverified kind"),
"素材类型不受支持:game-art"
prepare("future-kind", "要求").expect_err("unverified kind"),
"素材类型不受支持:future-kind"
);
assert_eq!(
prepare(
"spec",
"icon-spec",
&"x".repeat(LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS + 1)
)
.expect_err("oversized prompt"),
@@ -73,10 +73,10 @@ macro_rules! app_log {
///
/// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时,
/// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里,
/// 以便回查"还有谁在写 legacy kind"。kind 本身只接受严格值,不做别名、不做迁移
/// 以便回查非 canonical kind 的写入边界。kind 本身只接受严格值。
///
/// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉;
/// 去重后"有哪些 legacy 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看
/// 去重后"有哪些非 canonical 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看
/// `log_non_canonical_manifest_asset_kinds`(按资产去重,带 assetId / localPath)。
fn register_non_canonical_asset_kind_reporter() {
static REPORTED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
@@ -1184,7 +1184,7 @@ pub(crate) fn validate_manifest_required_visual_asset(
&art_spec.id,
&format!("{:x}", Sha256::digest(&art_spec_bytes)),
&art_spec.media_type,
&art_spec.kind,
art_spec.kind.as_str(),
)?;
external_editor_remote_reference_matches_local_source_at(
root,
@@ -1947,13 +1947,13 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
/// 读 manifest 时给非 canonical 的 `kind` 留痕。
///
/// `kind` 走严格解析:认不出的值收口成 `unknown`**不做 fallback、不做迁移**。收口之后原始串
/// `kind` 走严格解析:认不出的值收口成 `unknown`。收口之后原始串
/// 就丢了,所以这里在读到的原始 JSON 里回捞一遍,把原值连同资产 ID、路径写进应用日志
/// `diagnostics/application.log`),供后续定位"谁还在写 legacy kind"
/// `diagnostics/application.log`),供后续定位非 canonical kind 的写入边界
///
/// 同一份 manifest 里的同一个资产每次进程只记一次:manifest 读得很频繁,不去重会把其它诊断
/// 刷掉。去重键包含 manifest 来源与资产身份,而不是只有原值——只按原值去重会让"第二个还在
/// 写 legacy kind 的资产"静默消失;不带来源则会让不同项目恰好复用相同 ID/路径时互相抑制。
/// 刷掉。去重键包含 manifest 来源与资产身份,而不是只有原值;不带来源则会让不同项目恰好
/// 复用相同 ID/路径时互相抑制。
fn log_non_canonical_manifest_asset_kinds(payload: &str, source_path: &Path, label: &str) {
static LOGGED_OFFENDERS: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
for (raw_kind, asset_id, local_path) in non_canonical_manifest_asset_kinds(payload) {
@@ -185,7 +185,7 @@ fn successful_first_playable_registration_creates_one_initial_version_with_asset
local_path: "assets/player.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("character"),
category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Character),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -458,39 +458,3 @@ fn manifest_read_and_write_reject_symbolic_links() {
fs::remove_dir_all(root).ok();
}
/// 严格解析下旧 kind 会收口成 `unknown`,原始串只能从读到的 JSON 里回捞:
/// 这条用例钉住"回捞哪些条目、跳过哪些",也就是留痕日志的内容边界。
#[test]
fn non_canonical_manifest_asset_kinds_report_raw_values_only() {
let payload = r#"{
"assets": [
{ "id": "legacy-ui", "kind": "UI", "localPath": "ui/UI 设计 1.json" },
{ "id": "canonical-image", "kind": "image", "localPath": "assets/a.png" },
{ "id": "already-unknown", "kind": "unknown", "localPath": "assets/b.bin" },
{
"id": "legacy-slice",
"kind": "art-spritesheet-slice",
"localPath": "assets/art-spritesheet-slices/1.png"
}
]
}"#;
assert_eq!(
non_canonical_manifest_asset_kinds(payload),
vec![
(
"UI".to_string(),
"legacy-ui".to_string(),
"ui/UI 设计 1.json".to_string()
),
(
"art-spritesheet-slice".to_string(),
"legacy-slice".to_string(),
"assets/art-spritesheet-slices/1.png".to_string()
),
]
);
// 结构不是预期形状(或不是 JSON)时不报错、不留痕:读取路径自己会给出失败原因。
assert!(non_canonical_manifest_asset_kinds("not json").is_empty());
assert!(non_canonical_manifest_asset_kinds(r#"{"assets":{}}"#).is_empty());
}

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