diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs index c8a3e07b2..f7d290391 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs @@ -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 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index bdbeb222c..6b148deed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -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()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 8d098b8e6..e242dbcd3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -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); diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index ad87b4f5e..9bc2923d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -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")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 351fcf45c..429c9709b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 317b82348..3498e32d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index 99c961d92..76f7ed0ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -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()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index 61f5f5306..8fe81da74 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs index d24f2c184..425f19ede 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index 65d8a1737..ec49b9c93 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs index 4fbfeecb0..8d32d431a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index ddc026873..4af6ed29c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -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 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d1c0b090c..6e5897319 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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` 投影扩展。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5cf960dcf..a9620c1b3 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -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 导入。 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 96be13fb6..799e18eab 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -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([]); + }); }); diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 3f3061f57..3f39ac09d 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -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, +): GameCreationAppAssetCategory { + return ( + normalizeGameCreationAppAssetCategory(asset.category) ?? + gameCreationAppAssetCategoryForKind(asset.kind) + ); +} + +export function gameCreationAppAssetTags( + asset: Pick, +): 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; diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 0cd799981..44f6a0d88 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -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>, #[serde(default, skip_serializing_if = "Option::is_none")] pub image_sequence_duration_ms: Option, + pub category: GameCreationAppAssetCategory, + pub tags: Vec, +} + +/// 历史 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>, + #[serde(default)] + image_sequence_duration_ms: Option, + #[serde(default)] + category: Option, + #[serde(default)] + tags: Option>, +} + +impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry { + fn deserialize(deserializer: D) -> Result + 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 { + 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 { + 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 {