AGC 资源功能分类与自定义标签数据层
- manifest 资源条目末尾新增 category(单值 6 类,默认 unclassified)与 tags(字符串数组,默认空数组),两字段始终序列化 - shared-contracts 新增 GameCreationAppAssetCategory 枚举、GAME_CREATION_APP_ASSET_CATEGORIES 与穷举 16 个 canonical kind 的默认映射表 GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND - 历史 manifest 缺少字段时按 kind 派生分类、tags 取空数组;显式合法分类原样保留,未知分类字符串按前向兼容退回 kind 派生 - packages/shared 同步 TS 契约:分类常量与类型、映射表、gameCreationAppAssetCategory / gameCreationAppAssetTags 取值 helper 与 tags 归一化 helper - 收编壳内 17 处 Rust 构造点:新建条目写 kind 派生默认值,更新既有条目保留已有 category / tags - 补契约测试:字段 round-trip、默认值、legacy 兼容、未知分类前向兼容、16 项映射穷举、tags 归一化 - 同步 docs/technical 技术方案与 docs/project-memory decision-log
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user