WIP: AGC 资源画布与替换改造 V3.0 #316

Draft
suzmii wants to merge 131 commits from feat/agc-canvas-resource-workbench-v3 into master
17 changed files with 536 additions and 5 deletions
Showing only changes of commit f84d0ea4d0 - Show all commits
@@ -264,6 +264,8 @@ mod tests {
},
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("character"),
tags: Vec::new(),
});
write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest");
directory
@@ -2781,6 +2781,8 @@ 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"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-1".to_string()),
@@ -2844,6 +2846,8 @@ mod tests {
},
]),
image_sequence_duration_ms: Some(4_000),
category: game_creation_app_asset_category_for_kind("character-animation"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-1".to_string()),
@@ -6786,6 +6786,8 @@ fn register_platform_art_slice_manifest_entries_at(
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"),
tags: Vec::new(),
source,
});
ids.push(id);
@@ -1768,6 +1768,8 @@ pub(crate) fn register_local_asset_entry(
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(kind),
tags: Vec::new(),
source,
});
Ok((id, "asset.register"))
@@ -2791,6 +2791,8 @@ 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"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -26,10 +26,10 @@ use serde::{Deserialize, Serialize};
use sha2::Digest;
use shared_contracts::error_reports::ErrorReportLogInput;
use shared_contracts::game_creation_app::{
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
game_creation_app_asset_category_for_kind, new_game_creation_app_manifest,
new_game_creation_app_seed_tasks, validate_game_iteration_versions,
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
@@ -3651,6 +3651,7 @@ fn commit_asset_canvas_at_internal(
.map(|value| value.generation_kind.clone()),
reference_resource_ids: references,
};
let category = game_creation_app_asset_category_for_kind(&asset_kind);
let asset = GameCreationAppAssetManifestEntry {
id: asset_id.clone(),
kind: asset_kind,
@@ -3658,6 +3659,8 @@ fn commit_asset_canvas_at_internal(
local_path: final_relative_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category,
tags: Vec::new(),
source,
};
manifest.assets.push(asset.clone());
@@ -4922,6 +4922,8 @@ mod tests {
local_path: "assets/source.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(source_asset_kind),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -131,6 +131,8 @@ fn initialize_refine_fixture_with_resource_id(resource_id: Option<&str>) -> Fixt
local_path: "assets/source.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("illustration"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -607,6 +609,8 @@ fn refine_preserves_source_and_records_non_destructive_lineage() {
local_path: "assets/source.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("illustration"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -752,6 +756,8 @@ fn legacy_refine_transaction_preserves_runtime_entry_for_next_commit() {
local_path: "assets/source.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("illustration"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -1498,6 +1504,8 @@ fn discovers_unique_active_refine_draft_and_rejects_ambiguous_candidates() {
local_path: "assets/source.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("illustration"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -1991,6 +1999,8 @@ fn rejects_symlinked_or_hardlinked_media_at_source_staging_and_final_paths() {
local_path: "assets/source.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("illustration"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -185,6 +185,8 @@ 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"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
@@ -657,6 +657,8 @@ mod tests {
local_path: format!("assets/{id}.png"),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("test"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: None,
@@ -3712,6 +3712,8 @@ pub(crate) fn normalize_local_project_raster_resource_at(
local_path: source_path,
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(source_subtype),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
@@ -3955,6 +3957,7 @@ fn commit_resource_edit_asset_internal(
} else {
source.asset_kind.clone()
};
let category = game_creation_app_asset_category_for_kind(&asset_kind);
let asset = GameCreationAppAssetManifestEntry {
id: asset_id.clone(),
kind: asset_kind,
@@ -3962,6 +3965,8 @@ fn commit_resource_edit_asset_internal(
local_path: relative_path.clone(),
image_sequence_frames,
image_sequence_duration_ms,
category,
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: if remote {
GameCreationAppAssetSourceKind::Canvas
@@ -8188,3 +8188,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 问题回答携带被回答卡片的 questionId,在已有回合锁内核对 Session 和当前问题;自由文本回答同样绑定问题,已完成回合保留幂等重放。此身份匹配服务于用户提交,不增加恢复门禁或模型输出要求。
- hydrate 结果(包括空结果)写入前端状态前同时核对请求序列和当前项目路径;过期结果直接丢弃,不重试、不阻塞正常 run。
## 2026-09-09 manifest 资源功能分类与自定义标签只做数据层
- 本地 manifest 资产条目末尾新增 `category`(单值,6 类,默认 `unclassified`)与 `tags`(字符串数组,默认空数组),字段始终序列化;不进 api-server、外部 OpenAPI 或 SpacetimeDB schema,也不新增 Tauri 写命令。
- 默认分类按 canonical kind 穷举映射(`GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND`Rust 与 TS 双契约同步):`icon / icon-spritesheet / icon-spec / ui-design → ui-interaction``character / character-animation → character``scene → scene``audio / sound-effect / background-music → audio``document / spec → document``image / video / code / publication-material → unclassified`;映射不到的原始 kind 经既有 canonical 化落 `image` 后同样归 `unclassified`
- 历史 manifest 缺少字段时读取按 `kind` 派生分类、`tags` 取空数组;显式合法分类原样保留,未知分类字符串按前向兼容退回 `kind` 派生。新建条目写派生默认值,更新既有条目保留已有 `category` / `tags`,不覆盖用户值。
- `kind` 的 canonical 契约本轮不动:`UI``font``test` 等非 canonical 原始 kind 仍归 `unclassified`,窄化别名留作后续项。
- 本轮不做 UI(画布功能画布与筛选、@ 面板标签筛选、标签管理面板)、数据迁移脚本、标签库聚合派生、Agent 检索工具与 `agc_list_registered_assets` 投影扩展。
@@ -14,6 +14,12 @@
保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,并保留当前模型 ID 和默认模型标记;模型选择仅同步 `selectedModelId``selectedModelIsDefault`;无冲突时不写覆盖文件。所有内容先完成序列化,多文件写入前保存原始内容,任一写入失败时逆序恢复已变更文件,回滚失败须明确报告。各文件沿用现有原子写入,不提供断电或进程崩溃下的多文件事务保证。全部成功后直接返回规范化配置,不执行保存后回读或外部诊断;单文件保存保持原路径。
## 2026-09-09 manifest 资源功能分类与自定义标签(数据层)
本地 manifest 资产条目末尾新增 `category`(单值,6 类 `ui-interaction / character / scene / audio / document / unclassified`,默认 `unclassified`)与 `tags`(字符串数组,默认空数组);字段始终序列化,不进 api-server、外部 OpenAPI 或 SpacetimeDB schema。默认分类由 `GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND` 按 canonical kind 穷举映射:`icon / icon-spritesheet / icon-spec / ui-design → ui-interaction``character / character-animation → character``scene → scene``audio / sound-effect / background-music → audio``document / spec → document``image / video / code / publication-material → unclassified`;映射不到 canonical kind 的原始 kind(例如 `font``UI``test`)经既有 `canonicalGameCreationAppAssetKind``image` 后同样归 `unclassified`
历史 manifest 缺少字段时读取按 `kind` 派生分类、`tags` 取空数组;显式写入的合法分类原样保留,未知分类字符串按前向兼容退回 `kind` 派生。新建资源条目写 `kind` 派生默认值,更新既有条目保留已有 `category` / `tags`,不覆盖用户值。`tags` 归一化(trim、去空、去重)只以纯函数交付,本轮不接入写入路径。本轮不做任何 UI(画布功能画布与筛选、@ 面板标签筛选、标签管理面板)、数据迁移脚本、标签库聚合派生与 Agent 检索工具。
## 2026-09-08 Web 游戏 npm 与 Phaser 4 产物合同
新建 Web 游戏使用 npm 工程:默认 `game/package.json` 声明 Phaser 4.2.1 和 Vite 构建工具,源码使用 `import Phaser from 'phaser'``package-lock.json` 由 npm 维护。默认脚手架文件位于 `game/`,包含 `index.html``game.js``style.css``vite.config.js` 和 npm 配置/锁文件;已有根目录 npm 工程沿用原根,可按需求拆分模块并添加任意其它 npm 依赖,不设置包名白名单。客户端不手工分发 Phaser bundle,不用 import map 模拟 package 导入。
@@ -8,12 +8,20 @@ import {
GAME_CREATION_AGENT_RUN_MAX_PASSES,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
GAME_CREATION_AGENT_TOOL_CALL_MAX,
GAME_CREATION_APP_ASSET_CATEGORIES,
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND,
GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS,
GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION,
type GameCreationAgentRunTrace,
gameCreationAppAssetCategory,
gameCreationAppAssetCategoryForKind,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetTags,
type GameCreationAppManifest,
normalizeGameCreationAppAssetCategory,
normalizeGameCreationAppAssetTags,
selectGameCreationAppReadyTasks,
} from './gameCreationApp';
@@ -740,4 +748,92 @@ describe('AI 游戏创作 App 共享契约', () => {
expect(canonicalGameCreationAppAssetKind('character')).toBe('character');
expect(canonicalGameCreationAppAssetKind('unknown-kind')).toBe('image');
});
it('maps every canonical asset kind to a functional category', () => {
expect(GAME_CREATION_APP_ASSET_CATEGORIES).toHaveLength(6);
expect(Object.keys(GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND)).toHaveLength(
GAME_CREATION_APP_CANONICAL_ASSET_KINDS.length,
);
for (const kind of GAME_CREATION_APP_CANONICAL_ASSET_KINDS) {
expect(GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[kind]).toBeDefined();
expect(gameCreationAppAssetCategoryForKind(kind)).toBe(
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[kind],
);
}
expect(gameCreationAppAssetCategoryForKind('icon')).toBe('ui-interaction');
expect(gameCreationAppAssetCategoryForKind('ui-design')).toBe(
'ui-interaction',
);
expect(gameCreationAppAssetCategoryForKind('character-animation')).toBe(
'character',
);
expect(gameCreationAppAssetCategoryForKind('scene')).toBe('scene');
expect(gameCreationAppAssetCategoryForKind('sound-effect')).toBe('audio');
expect(gameCreationAppAssetCategoryForKind('spec')).toBe('document');
for (const kind of ['image', 'video', 'code', 'publication-material']) {
expect(gameCreationAppAssetCategoryForKind(kind)).toBe('unclassified');
}
expect(gameCreationAppAssetCategoryForKind('game-background')).toBe(
'scene',
);
expect(gameCreationAppAssetCategoryForKind('UI')).toBe('unclassified');
expect(gameCreationAppAssetCategoryForKind('unknown-kind')).toBe(
'unclassified',
);
});
it('resolves asset category and tags with legacy and forward compatibility', () => {
const legacy: GameCreationAppAssetManifestEntry = {
id: 'asset-1',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'uploaded' },
};
expect(gameCreationAppAssetCategory(legacy)).toBe('character');
expect(gameCreationAppAssetTags(legacy)).toEqual([]);
expect(
gameCreationAppAssetCategory({ ...legacy, category: 'unclassified' }),
).toBe('unclassified');
expect(gameCreationAppAssetCategory({ ...legacy, category: 'audio' })).toBe(
'audio',
);
expect(
gameCreationAppAssetCategory({
...legacy,
category: 'future-category' as never,
}),
).toBe('character');
expect(
gameCreationAppAssetCategory({
kind: 'unknown-kind',
category: undefined,
}),
).toBe('unclassified');
expect(normalizeGameCreationAppAssetCategory(' audio ')).toBe('audio');
expect(normalizeGameCreationAppAssetCategory('future-category')).toBeNull();
expect(normalizeGameCreationAppAssetCategory(undefined)).toBeNull();
expect(
gameCreationAppAssetTags({
tags: ['主角', '战斗'],
}),
).toEqual(['主角', '战斗']);
expect(gameCreationAppAssetTags({ tags: undefined })).toEqual([]);
});
it('normalizes custom asset tags by trimming, dropping empty and deduping', () => {
expect(
normalizeGameCreationAppAssetTags([
' 主角 ',
'',
'主角',
' ',
'战斗',
'战斗',
]),
).toEqual(['主角', '战斗']);
expect(normalizeGameCreationAppAssetTags([])).toEqual([]);
});
});
@@ -474,6 +474,8 @@ export interface GameCreationAppAssetManifestEntry {
height: number;
}> | null;
imageSequenceDurationMs?: number | null;
category?: GameCreationAppAssetCategory;
tags?: string[];
}
export const GAME_CREATION_APP_CANONICAL_ASSET_KINDS = [
@@ -527,6 +529,91 @@ export function canonicalGameCreationAppAssetKind(
return 'image';
}
export const GAME_CREATION_APP_ASSET_CATEGORIES = [
'ui-interaction',
'character',
'scene',
'audio',
'document',
'unclassified',
] as const;
export type GameCreationAppAssetCategory =
(typeof GAME_CREATION_APP_ASSET_CATEGORIES)[number];
/** canonical kind 到功能分类的默认映射,必须穷举 GAME_CREATION_APP_CANONICAL_ASSET_KINDS。 */
export const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: Record<
GameCreationAppCanonicalAssetKind,
GameCreationAppAssetCategory
> = {
image: 'unclassified',
scene: 'scene',
character: 'character',
'character-animation': 'character',
icon: 'ui-interaction',
'icon-spritesheet': 'ui-interaction',
'icon-spec': 'ui-interaction',
'ui-design': 'ui-interaction',
'publication-material': 'unclassified',
spec: 'document',
video: 'unclassified',
'sound-effect': 'audio',
'background-music': 'audio',
audio: 'audio',
document: 'document',
code: 'unclassified',
};
export function normalizeGameCreationAppAssetCategory(
value: unknown,
): GameCreationAppAssetCategory | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
return (GAME_CREATION_APP_ASSET_CATEGORIES as readonly string[]).includes(
normalized,
)
? (normalized as GameCreationAppAssetCategory)
: null;
}
export function gameCreationAppAssetCategoryForKind(
kind: string,
): GameCreationAppAssetCategory {
return GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[
canonicalGameCreationAppAssetKind(kind)
];
}
/** 历史 manifest 缺少 category 时按 kind 派生;未知分类值同样退回 kind 派生。 */
export function gameCreationAppAssetCategory(
asset: Pick<GameCreationAppAssetManifestEntry, 'kind' | 'category'>,
): GameCreationAppAssetCategory {
return (
normalizeGameCreationAppAssetCategory(asset.category) ??
gameCreationAppAssetCategoryForKind(asset.kind)
);
}
export function gameCreationAppAssetTags(
asset: Pick<GameCreationAppAssetManifestEntry, 'tags'>,
): string[] {
return Array.isArray(asset.tags)
? asset.tags.filter((tag): tag is string => typeof tag === 'string')
: [];
}
export function normalizeGameCreationAppAssetTags(
tags: readonly string[],
): string[] {
const normalized: string[] = [];
for (const tag of tags) {
const trimmed = tag.trim();
if (!trimmed || normalized.includes(trimmed)) continue;
normalized.push(trimmed);
}
return normalized;
}
export const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION =
'game-creator-resource-layout.v1' as const;
export const GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION = 9_007_199_254_740_991;
@@ -485,7 +485,7 @@ pub struct GameCreationAppImageSequenceFrame {
pub height: u32,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppAssetManifestEntry {
pub id: String,
@@ -497,6 +497,53 @@ pub struct GameCreationAppAssetManifestEntry {
pub image_sequence_frames: Option<Vec<GameCreationAppImageSequenceFrame>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_sequence_duration_ms: Option<u64>,
pub category: GameCreationAppAssetCategory,
pub tags: Vec<String>,
}
/// 历史 manifest 缺少 `category` / `tags` 时按 `kind` 派生默认值;
/// 显式写入的合法分类必须原样保留,未知分类值按前向兼容退回 `kind` 派生。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GameCreationAppAssetManifestEntryWire {
id: String,
kind: String,
media_type: String,
local_path: String,
source: GameCreationAppAssetSource,
#[serde(default)]
image_sequence_frames: Option<Vec<GameCreationAppImageSequenceFrame>>,
#[serde(default)]
image_sequence_duration_ms: Option<u64>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
tags: Option<Vec<String>>,
}
impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = GameCreationAppAssetManifestEntryWire::deserialize(deserializer)?;
let category = wire
.category
.as_deref()
.and_then(game_creation_app_asset_category_from_str)
.unwrap_or_else(|| game_creation_app_asset_category_for_kind(&wire.kind));
Ok(Self {
id: wire.id,
kind: wire.kind,
media_type: wire.media_type,
local_path: wire.local_path,
source: wire.source,
image_sequence_frames: wire.image_sequence_frames,
image_sequence_duration_ms: wire.image_sequence_duration_ms,
category,
tags: wire.tags.unwrap_or_default(),
})
}
}
pub const GAME_CREATION_APP_CANONICAL_ASSET_KINDS: [&str; 16] = [
@@ -536,6 +583,90 @@ pub fn canonical_game_creation_app_asset_kind(value: &str) -> &'static str {
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GameCreationAppAssetCategory {
UiInteraction,
Character,
Scene,
Audio,
Document,
Unclassified,
}
pub const GAME_CREATION_APP_ASSET_CATEGORIES: [GameCreationAppAssetCategory; 6] = [
GameCreationAppAssetCategory::UiInteraction,
GameCreationAppAssetCategory::Character,
GameCreationAppAssetCategory::Scene,
GameCreationAppAssetCategory::Audio,
GameCreationAppAssetCategory::Document,
GameCreationAppAssetCategory::Unclassified,
];
/// canonical kind 到功能分类的默认映射,必须穷举 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS`。
pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: [(&str, GameCreationAppAssetCategory); 16] = [
("image", GameCreationAppAssetCategory::Unclassified),
("scene", GameCreationAppAssetCategory::Scene),
("character", GameCreationAppAssetCategory::Character),
(
"character-animation",
GameCreationAppAssetCategory::Character,
),
("icon", GameCreationAppAssetCategory::UiInteraction),
(
"icon-spritesheet",
GameCreationAppAssetCategory::UiInteraction,
),
("icon-spec", GameCreationAppAssetCategory::UiInteraction),
("ui-design", GameCreationAppAssetCategory::UiInteraction),
(
"publication-material",
GameCreationAppAssetCategory::Unclassified,
),
("spec", GameCreationAppAssetCategory::Document),
("video", GameCreationAppAssetCategory::Unclassified),
("sound-effect", GameCreationAppAssetCategory::Audio),
("background-music", GameCreationAppAssetCategory::Audio),
("audio", GameCreationAppAssetCategory::Audio),
("document", GameCreationAppAssetCategory::Document),
("code", GameCreationAppAssetCategory::Unclassified),
];
pub fn game_creation_app_asset_category_from_str(
value: &str,
) -> Option<GameCreationAppAssetCategory> {
match value.trim() {
"ui-interaction" => Some(GameCreationAppAssetCategory::UiInteraction),
"character" => Some(GameCreationAppAssetCategory::Character),
"scene" => Some(GameCreationAppAssetCategory::Scene),
"audio" => Some(GameCreationAppAssetCategory::Audio),
"document" => Some(GameCreationAppAssetCategory::Document),
"unclassified" => Some(GameCreationAppAssetCategory::Unclassified),
_ => None,
}
}
pub fn game_creation_app_asset_category_for_kind(kind: &str) -> GameCreationAppAssetCategory {
let canonical = canonical_game_creation_app_asset_kind(kind);
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND
.iter()
.find(|(candidate, _)| *candidate == canonical)
.map(|(_, category)| *category)
.unwrap_or(GameCreationAppAssetCategory::Unclassified)
}
pub fn normalize_game_creation_app_asset_tags(tags: &[String]) -> Vec<String> {
let mut normalized = Vec::new();
for tag in tags {
let tag = tag.trim();
if tag.is_empty() || normalized.iter().any(|existing| existing == tag) {
continue;
}
normalized.push(tag.to_string());
}
normalized
}
pub const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION: &str = "game-creator-resource-layout.v1";
pub const GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION: u64 = 9_007_199_254_740_991;
@@ -1849,6 +1980,8 @@ mod tests {
local_path: "assets/images/player.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: GameCreationAppAssetCategory::Character,
tags: vec!["主角".to_string()],
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-project-1".to_string()),
@@ -1870,6 +2003,8 @@ mod tests {
json!(GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION)
);
assert_eq!(payload["assets"][0]["mediaType"], json!("image"));
assert_eq!(payload["assets"][0]["category"], json!("character"));
assert_eq!(payload["assets"][0]["tags"], json!(["主角"]));
assert_eq!(payload["assets"][0]["source"]["kind"], json!("canvas"));
assert_eq!(payload["preview"]["status"], json!("running"));
assert_eq!(
@@ -1889,6 +2024,169 @@ mod tests {
assert_eq!(payload["godotProjectRoot"], json!("game-source"));
}
fn asset_entry_json(kind: &str) -> serde_json::Value {
json!({
"id": "asset-1",
"kind": kind,
"mediaType": "image/png",
"localPath": "assets/hero.png",
"source": { "kind": "uploaded" },
})
}
fn asset_entry_from_json(value: serde_json::Value) -> GameCreationAppAssetManifestEntry {
serde_json::from_value(value).expect("asset entry should deserialize")
}
#[test]
fn asset_category_mapping_covers_every_canonical_kind() {
assert_eq!(
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND.len(),
GAME_CREATION_APP_CANONICAL_ASSET_KINDS.len()
);
for kind in GAME_CREATION_APP_CANONICAL_ASSET_KINDS {
let mapped = GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND
.iter()
.find(|(candidate, _)| *candidate == kind)
.unwrap_or_else(|| panic!("canonical kind 缺少功能分类映射:{kind}"));
assert_eq!(
mapped.1,
game_creation_app_asset_category_for_kind(kind),
"canonical kind 映射不一致:{kind}"
);
assert!(GAME_CREATION_APP_ASSET_CATEGORIES.contains(&mapped.1));
}
assert_eq!(
game_creation_app_asset_category_for_kind("icon"),
GameCreationAppAssetCategory::UiInteraction
);
assert_eq!(
game_creation_app_asset_category_for_kind("ui-design"),
GameCreationAppAssetCategory::UiInteraction
);
assert_eq!(
game_creation_app_asset_category_for_kind("character-animation"),
GameCreationAppAssetCategory::Character
);
assert_eq!(
game_creation_app_asset_category_for_kind("scene"),
GameCreationAppAssetCategory::Scene
);
assert_eq!(
game_creation_app_asset_category_for_kind("sound-effect"),
GameCreationAppAssetCategory::Audio
);
assert_eq!(
game_creation_app_asset_category_for_kind("spec"),
GameCreationAppAssetCategory::Document
);
for kind in ["image", "video", "code", "publication-material"] {
assert_eq!(
game_creation_app_asset_category_for_kind(kind),
GameCreationAppAssetCategory::Unclassified,
"{kind} 必须落待归类"
);
}
assert_eq!(
game_creation_app_asset_category_for_kind("game-background"),
GameCreationAppAssetCategory::Scene
);
assert_eq!(
game_creation_app_asset_category_for_kind("UI"),
GameCreationAppAssetCategory::Unclassified
);
assert_eq!(
game_creation_app_asset_category_for_kind("unknown-kind"),
GameCreationAppAssetCategory::Unclassified
);
}
#[test]
fn asset_manifest_entry_defaults_category_and_tags_for_legacy_payloads() {
let character = asset_entry_from_json(asset_entry_json("character"));
assert_eq!(character.category, GameCreationAppAssetCategory::Character);
assert!(character.tags.is_empty());
let icon = asset_entry_from_json(asset_entry_json("icon-spritesheet"));
assert_eq!(icon.category, GameCreationAppAssetCategory::UiInteraction);
let legacy_alias = asset_entry_from_json(asset_entry_json("game-background"));
assert_eq!(legacy_alias.category, GameCreationAppAssetCategory::Scene);
let unknown = asset_entry_from_json(asset_entry_json("unknown-kind"));
assert_eq!(unknown.category, GameCreationAppAssetCategory::Unclassified);
}
#[test]
fn asset_manifest_entry_keeps_explicit_category_and_derives_unknown_category_from_kind() {
let mut explicit_unclassified = asset_entry_json("character");
explicit_unclassified["category"] = json!("unclassified");
assert_eq!(
asset_entry_from_json(explicit_unclassified).category,
GameCreationAppAssetCategory::Unclassified
);
let mut explicit_audio = asset_entry_json("character");
explicit_audio["category"] = json!("audio");
assert_eq!(
asset_entry_from_json(explicit_audio).category,
GameCreationAppAssetCategory::Audio
);
let mut unknown_category = asset_entry_json("character");
unknown_category["category"] = json!("future-category");
assert_eq!(
asset_entry_from_json(unknown_category).category,
GameCreationAppAssetCategory::Character
);
let mut unknown_category_without_kind_semantics = asset_entry_json("image");
unknown_category_without_kind_semantics["category"] = json!("future-category");
assert_eq!(
asset_entry_from_json(unknown_category_without_kind_semantics).category,
GameCreationAppAssetCategory::Unclassified
);
}
#[test]
fn asset_manifest_entry_round_trips_category_and_tags() {
let entry = asset_entry_from_json(asset_entry_json("character"));
let payload = serde_json::to_value(&entry).expect("asset entry should serialize");
assert_eq!(payload["category"], json!("character"));
assert_eq!(payload["tags"], json!([]));
let restored: GameCreationAppAssetManifestEntry =
serde_json::from_value(payload).expect("asset entry should round trip");
assert_eq!(restored, entry);
let mut tagged = asset_entry_json("sound-effect");
tagged["category"] = json!("audio");
tagged["tags"] = json!(["战斗", "音效"]);
let entry = asset_entry_from_json(tagged);
let payload = serde_json::to_value(&entry).expect("asset entry should serialize");
assert_eq!(payload["category"], json!("audio"));
assert_eq!(payload["tags"], json!(["战斗", "音效"]));
let restored: GameCreationAppAssetManifestEntry =
serde_json::from_value(payload).expect("asset entry should round trip");
assert_eq!(restored, entry);
}
#[test]
fn asset_tags_normalization_trims_drops_empty_and_dedupes() {
let tags = vec![
" 主角 ".to_string(),
String::new(),
"主角".to_string(),
" ".to_string(),
"战斗".to_string(),
"战斗".to_string(),
];
assert_eq!(
normalize_game_creation_app_asset_tags(&tags),
vec!["主角".to_string(), "战斗".to_string()]
);
assert!(normalize_game_creation_app_asset_tags(&[]).is_empty());
}
#[test]
fn iteration_versions_require_an_append_ordered_parent_graph() {
let root = GameIterationVersion {