From e5fd454bb3ca9ff567183245cd91cdc97ef3854a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 15:07:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=20AGC=20=E8=B5=84=E6=BA=90?= =?UTF-8?q?=20kind=20=E7=B1=BB=E5=9E=8B=E8=BE=B9=E7=95=8C=E5=B9=B6?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=97=8F=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 manifest 与本地导入回执的资源 kind 改为 ts-rs 生成绑定 - 集中音频与视觉资源族判据并复用 Rust 枚举方法 - 放宽 Unknown 未解析语义并清理上传类型墓碑注释 - 标记共享生成绑定并同步项目决策与实施计划 --- .eslintrc.cjs | 1 - .gitattributes | 3 + .prettierignore | 2 - .prettierrc.json | 11 +++- .../src-tauri/src/assets.rs | 17 ++--- .../src-tauri/src/project/resource_editor.rs | 12 +--- .../src/components/AssetImporter/index.tsx | 3 +- .../src/components/AssetImporter/utils.ts | 6 +- .../project-summary/projectAssetSummaries.ts | 12 ++-- .../src/view/ui-editor/useUiEditorPage.ts | 13 +++- ...】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md | 3 +- .../shared-memory/decision-log.md | 2 +- .../src/contracts/gameCreationApp.test.ts | 20 ++++++ .../shared/src/contracts/gameCreationApp.ts | 50 ++++++++++++++- .../generated/GameCreationAppAssetKind.ts | 4 +- .../src/game_creation_app/asset_kind.rs | 63 ++++++++++++++++++- 16 files changed, 175 insertions(+), 47 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 9dabe1b8b..8a4b2d0c6 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -164,7 +164,6 @@ module.exports = { 'server-rs/target-*', 'apps/desktop-shell/src-tauri/target', 'apps/ai-game-creator-shell/src/features/ui-editor/types/**', - 'packages/shared/src/contracts/generated/**', 'apps/ai-game-creator-shell/src/features/project-workspace/generated/**', 'target', 'src/main.tsx', diff --git a/.gitattributes b/.gitattributes index 1cf14fb9f..572a7e25f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,3 +23,6 @@ *.meta text *.anim text *.controller text + +# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。 +packages/shared/src/contracts/generated/** linguist-generated=true diff --git a/.prettierignore b/.prettierignore index 4b92c3e59..9ec6037f3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,8 +4,6 @@ node_modules .codex-logs public/Icons apps/ai-game-creator-shell/src/features/ui-editor/types/ -# ts-rs 直接落盘的生成产物:保持生成器原始格式,`cargo test export_bindings` 才可幂等复现。 -packages/shared/src/contracts/generated/ apps/ai-game-creator-shell/src-tauri/resources/agc-skills/ media *.log diff --git a/.prettierrc.json b/.prettierrc.json index 7d2081a95..c4f9a8e5d 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,5 +1,14 @@ { "singleQuote": true, "semi": true, - "trailingComma": "all" + "trailingComma": "all", + "overrides": [ + { + "files": "packages/shared/src/contracts/generated/**/*.ts", + "options": { + "printWidth": 1000, + "singleQuote": false + } + } + ] } 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 781f6448f..a1d4b4675 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -468,16 +468,8 @@ pub(crate) fn external_editor_api_credentials_override_is_active() -> bool { } /// 上传素材落盘的 manifest `kind`:只由**内容证据**推导(`mediaType` 优先,扩展名兜底)。 -/// -/// 不用 `uploaded` 这类**来源词**当类型:它不在 canonical 目录里,会经别名表落到 -/// `image → unclassified`,把本该归「音频」「文档」的上传素材一起说成图片。 -/// -/// 更不能把 `ui` 当"图片的默认类型":`ui → ui-design → ui-interaction` 会把任意上传图片 -/// 钉死在「UI 交互」栏,而"是不是 UI 素材"跟"扩展名是不是 .png"毫无关系;这类错值还不可恢复 -/// ——读时自愈只在落盘 `category` 是 `unclassified` 且该 `kind` 能派生出非 `unclassified` -/// 分类时才生效,`kind` 本身错时自愈只会把错值放大。 -/// -/// 判不出内容类型时返回中性的 `asset`(派生 `unclassified` → 「待归类」),**不猜具体类型**。 +/// 判不出内容类型时返回 `Unknown`;调用者负责决定是否拒绝写入或保留待后续归类, +/// 这里不猜具体类型。 fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAssetKind { let media_type = media_type.trim().to_ascii_lowercase(); let extension = Path::new(file_name) @@ -538,8 +530,9 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAsse } } -/// 登记边界的 kind 解析:空白入参按「没有信息」处理,落中性 `image`(→「待归类」); -/// 非空但认不出的原值严格收口成 `Unknown`(同样「待归类」)并把原始串交给 reporter 留痕。 +/// 登记边界的 kind 解析:空白入参按「没有信息」处理,沿用登记入口的 `Image` 默认值; +/// 非空但认不出的原值严格收口成 `Unknown` 并把原始串交给 reporter 留痕, +/// 由调用者决定是否以错误结束或继续后续归类。 /// /// 三条外部登记边界必须同口径:平台导入(`commands::imported_platform_asset_kind`)、 /// 画板导入(`sync_canvas_project_assets_at`)、外部 `register_local_asset`。 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 5de25820f..d1a607495 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 @@ -3999,17 +3999,7 @@ pub(crate) fn normalize_local_project_raster_resource_at( "sourceSubtype 必须是图片族 kind,无法识别:{value}" )); } - if !matches!( - kind, - GameCreationAppAssetKind::Image - | GameCreationAppAssetKind::Scene - | GameCreationAppAssetKind::Character - | GameCreationAppAssetKind::CharacterAnimation - | GameCreationAppAssetKind::Icon - | GameCreationAppAssetKind::IconSpritesheet - | GameCreationAppAssetKind::IconSpec - | GameCreationAppAssetKind::UiDesign - ) { + if !kind.is_visual() { return Err(format!( "sourceSubtype 必须是图片族 kind,不能使用:{}", kind.as_str() diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx index 17bcfdb48..6dff48d17 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx @@ -6,6 +6,7 @@ import { open as openNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import type { GameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { loadEditorAssetLibrary, resolveClientAssetReadUrl, @@ -58,7 +59,7 @@ type LocalProjectFilesResponse = { files?: LocalProjectFile[] }; type ImportAssetResponse = { id: string; localPath: string; - assetKind?: string | null; + assetKind?: GameCreationAppAssetKind | null; }; type GenericImportResponse = { assets: ImportAssetResponse[] }; diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts index b8c80e344..7262c0b29 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts @@ -1,10 +1,12 @@ import type { FileManagerFile } from '@cubone/react-file-manager'; import type { ComponentType, ReactNode } from 'react'; +import type { GameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp'; + export type ImportedAsset = { id: string; localPath: string; - assetKind: string | null; + assetKind: GameCreationAppAssetKind | null; }; export type AssetSource = 'local' | 'remote'; @@ -104,7 +106,7 @@ export type ManifestAsset = { id: string; localPath: string; mediaType: string; - kind?: string; + kind?: GameCreationAppAssetKind; source?: { kind?: string }; }; diff --git a/apps/ai-game-creator-shell/src/features/project-summary/projectAssetSummaries.ts b/apps/ai-game-creator-shell/src/features/project-summary/projectAssetSummaries.ts index 518bc3a8c..3afb4fde7 100644 --- a/apps/ai-game-creator-shell/src/features/project-summary/projectAssetSummaries.ts +++ b/apps/ai-game-creator-shell/src/features/project-summary/projectAssetSummaries.ts @@ -1,6 +1,8 @@ import { type GameCreationAppAssetSourceKind, type GameCreationAppManifest, + isGameCreationAppAssetAudioKind, + isGameCreationAppAssetVisualKind, selectGameCreationAppReadyTasks, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { taskRowsFromManifest } from '../agent-runtime'; @@ -102,12 +104,9 @@ export function isProjectAudioAsset( asset: GameCreationAppManifest['assets'][number], ) { const mediaType = asset.mediaType.toLowerCase(); - const kind = asset.kind.toLowerCase(); return ( mediaType.startsWith('audio/') || - kind === 'audio' || - kind === 'sound-effect' || - kind === 'background-music' + isGameCreationAppAssetAudioKind(asset.kind) ); } @@ -115,14 +114,11 @@ export function isProjectVisualAsset( asset: GameCreationAppManifest['assets'][number], ) { const mediaType = asset.mediaType.toLowerCase(); - const kind = asset.kind.toLowerCase(); return ( mediaType.startsWith('image/') || mediaType.startsWith('video/') || mediaType === 'application/vnd.genarrative.image-sequence' || - kind === 'image' || - kind === 'icon' || - kind === 'character-animation' + isGameCreationAppAssetVisualKind(asset.kind) ); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index 6e7504963..2d2ccd165 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -1,6 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { GameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import type { ImportedAsset } from '../../components/AssetImporter'; import { prepareDesignImageBatch, @@ -78,7 +79,11 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus'; const SEPARATION_IMPORT_BATCH_SIZE = 100; type LocalImageImportResponse = { - assets: Array<{ id: string; localPath: string; assetKind?: string | null }>; + assets: Array<{ + id: string; + localPath: string; + assetKind?: GameCreationAppAssetKind | null; + }>; }; function normalizeProjectRelativePath(path: string): string { @@ -1074,7 +1079,11 @@ export function useUiEditorSession( ]; const importedByPath = new Map< string, - { id: string; localPath: string; assetKind: string | null } + { + id: string; + localPath: string; + assetKind: GameCreationAppAssetKind | null; + } >(); for ( let index = 0; diff --git a/docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md b/docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md index eeb6c5a53..f6e16cf41 100644 --- a/docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md +++ b/docs/project-memory/plans/【实施计划】AGC资源kindRust枚举与ts-rs绑定-2026-09-15.md @@ -23,7 +23,8 @@ - shell 内 manifest kind 的写入侧与读取侧统一为正式枚举成员:资源登记、字体导入、direct runtime 产物、平台美术登记与恢复、manifest 必填校验、资源编辑派生、UI 设计桥接与工作流。 - 资源编辑请求的 `sourceSubtype` 只传正式枚举成员;资源编辑内部源身份拆成「正式 kind / 项目版本 / Agent 回执」三类,项目版本与 Agent 回执不再冒充 manifest kind。 - ts-rs 绑定改由 `cargo test export_bindings` 生成:枚举自带 `#[ts(export, export_to = …/src/contracts/generated/)]`;退役的 `BindingDTO` / `BindingChange` 不再导出,手写的 `exports_ui_editor_types` 已删除。 -- `src/contracts/generated/` 加入 `.prettierignore` 与 eslint `ignorePatterns`,生成文件保持 ts-rs 原始格式,`cargo test export_bindings` 可幂等复现且不再污染工作树。 +- `packages/shared/src/contracts/generated/` 作为受控生成目录在 `.prettierrc.json` 中使用与 ts-rs 原始输出一致的格式选项,并在 `.gitattributes` 标记为 `linguist-generated`;生成文件仍参与 Prettier/ESLint 检查,`cargo test export_bindings` 可幂等复现且不再污染工作树。 +- `Unknown` 表示解析边界无法判定的输入;写入方可以保留该结果,但必须明确决定拒绝写入或交由后续归类,不得把它误写成某个具体 kind。 - shell TS 资源引用边界消费生成绑定,资源 kind 集合从生成的 union 穷举派生,并为未知值保留原始值与上下文日志。 明确不修改: diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c3d9a3f93..7af3dae57 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7,7 +7,7 @@ - 背景:kind 曾经有三份实现——Rust 手写 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` + `canonical_game_creation_app_asset_kind()`(带 legacy 别名表与 `font → document` 特例)、TS 手写 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` + `GAME_CREATION_APP_LEGACY_ASSET_KINDS` + `canonicalGameCreationAppAssetKind()`、以及 ts-rs 生成的 TS union。两份手写表互相引用又各自收口,判据直接分叉(同一个 `"UI"` 一边归一成 `ui-design`、一边收口成 `unknown`),跨语言一致性只能靠正则解析源码的测试来钉。 - 决策(唯一真源):kind 的变体、线上值、`as_str()`、`ALL`、严格解析与 ts-rs 绑定全部由 `server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs` 的声明表派生。两份手写 canonical 列表、legacy 别名表、`canonical_*()` 函数一律删除;生成的 TS union 落在 `packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts`,`packages/shared/src/contracts/gameCreationApp.ts` 只 re-export 它,运行期列表 `GAME_CREATION_APP_ASSET_KINDS` 用穷举 `Record` 守住。 - 决策(单一解析入口):`GameCreationAppAssetKind::parse_with_context()` 是唯一公开解析入口,严格匹配降为私有 `match_canonical()`;易混的 `from_str_lossy()` 与公开 `from_str_or_unknown()` 都已删除,认不出 canonical 值只有这一处收口并留痕(读侧回捞 legacy kind 只查 `ALL` 判真值,不另开解析函数)。口径是等值匹配——不 trim、不 lowercase、不查别名、不迁移;认不出的值收口成 `Unknown`(分类落 `unclassified`)。这是有意接受的行为(历史误写的 kind 不会被"救回"正确栏目),不再提供任何兼容入口;`font` 是正式成员,不再走别名。 -- 决策(登记边界的 kind 口径):外部登记统一走 `assets::registration_asset_kind()`——空白入参落中性 `image`,非空认不出的值严格收口成 `unknown` 并留痕;三条登记边界(Tauri `register_local_asset`、画板导入、平台导入)不再各写一份 trim/兜底。栅格归一化的 `source_subtype` 只接受图片族成员,文档/字体/音频等一律按 `image` 登记;Agent 回执派生物按 `Text => Document` 落 `document`,不主动写 `unknown`。 +- 决策(登记边界的 kind 口径):外部登记统一走 `assets::registration_asset_kind()`——空白入参落中性 `image`,非空认不出的值严格收口成 `unknown` 并留痕;三条登记边界(Tauri `register_local_asset`、画板导入、平台导入)不再各写一份 trim/兜底。`Unknown` 是无法解析的边界结果,具体写入方必须自行决定拒绝或保留待后续归类,不能静默猜成其他 kind。栅格归一化的 `source_subtype` 只接受图片族成员,文档/字体/音频等一律按 `image` 登记;Agent 回执派生物按 `Text => Document` 落 `document`。 - 决策(判据不许散落):切片残留登记只看 `assets/art-spritesheet-slices/` 路径(不再附带 `kind == Icon`);sprite 身份比较忽略随 kind 派生的 `metadata.asset_type`;TS 侧「UI 编辑器文档资产」判据只留 `isGameCreationAppUiDesignDocAsset()` 一份,资源画布入口 / UI 编辑器桥接 / 资源引用缩略图统一调用。 - 决策(已知代价,不补救):既有项目里 legacy kind 读入即 `unknown`,依赖 kind 等值比较的运行门禁会按"缺少该资源"处理。这是「不迁移、不 fallback」的必然结果,本次明确不为存量数据做迁移;将来若要迁就必须单独立项(一次性迁移或读侧白名单),不得把别名表加回解析路径。 - 决策(留痕必须真的落地):原实现用 `tracing::warn!`,而 AGC 壳没有 tracing subscriber,等于没有日志。现在 `shared-contracts` 只暴露可注册回调 `set_non_canonical_asset_kind_reporter()`,AGC 壳在 `main()` 里接到 `app_log!`,日志同时含原始输入串与调用上下文;`kind-observability` feature 与 `tracing` 依赖一并删除。TS 侧对应 `parseGameCreationAppAssetKind()` 的 `console.warn`。 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index d214cb871..98fdc7009 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -25,6 +25,8 @@ import { gameCreationAppAssetPersistedCategory, gameCreationAppAssetTags, type GameCreationAppManifest, + isGameCreationAppAssetAudioKind, + isGameCreationAppAssetVisualKind, isGameCreationAppUiDesignDocAsset, normalizeGameCreationAppAssetCategory, normalizeGameCreationAppAssetTags, @@ -798,6 +800,24 @@ describe('AI 游戏创作 App 共享契约', () => { } }); + it('shares exhaustive audio and visual kind families', () => { + expect( + GAME_CREATION_APP_ASSET_KINDS.filter(isGameCreationAppAssetAudioKind), + ).toEqual(['audio', 'sound-effect', 'background-music']); + expect( + GAME_CREATION_APP_ASSET_KINDS.filter(isGameCreationAppAssetVisualKind), + ).toEqual([ + 'image', + 'scene', + 'character', + 'character-animation', + 'icon', + 'icon-spritesheet', + 'icon-spec', + 'ui-design', + ]); + }); + it('识别 UI 编辑器文档资产只认 kind + mediaType 的唯一定义', () => { expect( isGameCreationAppUiDesignDocAsset({ diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 3981089de..50b0cb434 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -468,7 +468,7 @@ export interface GameCreationAppAssetSource { export interface GameCreationAppAssetManifestEntry { id: string; - kind: string; + kind: GameCreationAppAssetKind; mediaType: string; localPath: string; source: GameCreationAppAssetSource; @@ -556,6 +556,54 @@ export const GAME_CREATION_APP_ASSET_KINDS: readonly GameCreationAppAssetKind[] GAME_CREATION_APP_ASSET_KIND_MEMBERS, ) as GameCreationAppAssetKind[]; +const GAME_CREATION_APP_AUDIO_ASSET_KIND_MEMBERS: Record< + Extract< + GameCreationAppAssetKind, + 'audio' | 'sound-effect' | 'background-music' + >, + true +> = { + audio: true, + 'sound-effect': true, + 'background-music': true, +}; + +const GAME_CREATION_APP_VISUAL_ASSET_KIND_MEMBERS: Record< + Extract< + GameCreationAppAssetKind, + | 'image' + | 'scene' + | 'character' + | 'character-animation' + | 'icon' + | 'icon-spritesheet' + | 'icon-spec' + | 'ui-design' + >, + true +> = { + image: true, + scene: true, + character: true, + 'character-animation': true, + icon: true, + 'icon-spritesheet': true, + 'icon-spec': true, + 'ui-design': true, +}; + +export function isGameCreationAppAssetAudioKind( + kind: GameCreationAppAssetKind, +): boolean { + return kind in GAME_CREATION_APP_AUDIO_ASSET_KIND_MEMBERS; +} + +export function isGameCreationAppAssetVisualKind( + kind: GameCreationAppAssetKind, +): boolean { + return kind in GAME_CREATION_APP_VISUAL_ASSET_KIND_MEMBERS; +} + const GAME_CREATION_APP_ASSET_KIND_SET = new Set( GAME_CREATION_APP_ASSET_KINDS, ); diff --git a/packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts b/packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts index 4c2df2f9a..e2217b353 100644 --- a/packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts +++ b/packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts @@ -4,7 +4,7 @@ * GameCreationApp manifest 资源 kind 的**唯一**类型,也是 TS 侧 kind union 的唯一真源 * (经 ts-rs 生成,前端不再手写第二份列表)。 * - * JSON 使用 kebab-case;`Unknown` 只表示解析边界遇到尚未登记的输入, - * 正常资源写入路径不应主动选择它。 + * JSON 使用 kebab-case;`Unknown` 表示当前边界无法解析输入。 + * 写入方可以保留这个未解析结果,但必须由调用者决定是否拒绝写入或交给后续归类。 */ export type GameCreationAppAssetKind = "image" | "scene" | "character" | "character-animation" | "icon" | "icon-spritesheet" | "icon-spec" | "ui-design" | "ui-design-doc" | "publication-material" | "spec" | "video" | "audio" | "sound-effect" | "background-music" | "font" | "document" | "code" | "unknown"; diff --git a/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs b/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs index 91ca76794..790a1a9f5 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs @@ -45,8 +45,8 @@ macro_rules! game_creation_app_asset_kinds { /// GameCreationApp manifest 资源 kind 的**唯一**类型,也是 TS 侧 kind union 的唯一真源 /// (经 ts-rs 生成,前端不再手写第二份列表)。 /// - /// JSON 使用 kebab-case;`Unknown` 只表示解析边界遇到尚未登记的输入, - /// 正常资源写入路径不应主动选择它。 + /// JSON 使用 kebab-case;`Unknown` 表示当前边界无法解析输入。 + /// 写入方可以保留这个未解析结果,但必须由调用者决定是否拒绝写入或交给后续归类。 #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] #[cfg_attr( @@ -88,6 +88,29 @@ macro_rules! game_creation_app_asset_kinds { } } + /// 资源编辑器和摘要视图共用的音频资源族。 + pub const fn is_audio(self) -> bool { + matches!( + self, + Self::Audio | Self::SoundEffect | Self::BackgroundMusic + ) + } + + /// 资源编辑器和摘要视图共用的视觉资源族。 + pub const fn is_visual(self) -> bool { + matches!( + self, + Self::Image + | Self::Scene + | Self::Character + | Self::CharacterAnimation + | Self::Icon + | Self::IconSpritesheet + | Self::IconSpec + | Self::UiDesign + ) + } + /// 严格解析:只认 canonical 值,其余(含大小写、空白变体)一律 `Unknown`。 /// /// 私有实现细节,**不是**解析入口:外部一律走 [`Self::parse_with_context`], @@ -368,4 +391,40 @@ mod tests { // 每个非 canonical 变体都要留痕(5 条大小写/空白/legacy 变体都算异常)。 assert_eq!(recorded_reports().len(), 5); } + + #[test] + fn media_family_helpers_cover_the_shared_kind_sets() { + let audio = GameCreationAppAssetKind::ALL + .iter() + .copied() + .filter(|kind| kind.is_audio()) + .collect::>(); + assert_eq!( + audio, + vec![ + GameCreationAppAssetKind::Audio, + GameCreationAppAssetKind::SoundEffect, + GameCreationAppAssetKind::BackgroundMusic, + ] + ); + + let visual = GameCreationAppAssetKind::ALL + .iter() + .copied() + .filter(|kind| kind.is_visual()) + .collect::>(); + assert_eq!( + visual, + vec![ + GameCreationAppAssetKind::Image, + GameCreationAppAssetKind::Scene, + GameCreationAppAssetKind::Character, + GameCreationAppAssetKind::CharacterAnimation, + GameCreationAppAssetKind::Icon, + GameCreationAppAssetKind::IconSpritesheet, + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetKind::UiDesign, + ] + ); + } }