AGC 资源 kind 集合改为从生成 union 穷举派生

- assetKind.ts:新增 `GAME_CREATION_APP_ASSET_KIND_MEMBERS: Record<GameCreationAppAssetKind, true>`,生成 union 增删成员时会直接编译报错
- assetKind.ts:`GAME_CREATION_APP_ASSET_KINDS` 由该表派生,不再手写第二份字面量列表
- assetKind.ts:新增 `isGameCreationAppAssetKind` 类型守卫,`parseGameCreationAppAssetKind` 复用它
- view/project-development/index.tsx:资源引用的 `sourceSubtype` 只传正式 kind 成员,任务产物等非 manifest 来源传 `null`
- tests/assetKindCanonicalMapping.test.ts:两侧 kind 目录比对前先展开 Rust 常量引用(`ui-design-doc` 是常量),修正此前恒失败的两条;删除测试体里游离的 `'font',` 表达式
This commit is contained in:
2026-09-16 19:07:58 +08:00
parent b96cd4b7c2
commit f9bc2d917c
3 changed files with 82 additions and 33 deletions
@@ -2,36 +2,54 @@ import type { GameCreationAppAssetKind } from './generated/GameCreationAppAssetK
export type { GameCreationAppAssetKind } from './generated/GameCreationAppAssetKind';
export const GAME_CREATION_APP_ASSET_KINDS = [
'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',
] as const satisfies readonly GameCreationAppAssetKind[];
/**
* 穷举生成的 union:这里多写或少写一个成员都会直接报类型错误,
* 避免再维护一份与 `GameCreationAppAssetKind.ts` 分叉的手写列表。
*/
const GAME_CREATION_APP_ASSET_KIND_MEMBERS: Record<
GameCreationAppAssetKind,
true
> = {
image: true,
scene: true,
character: true,
'character-animation': true,
icon: true,
'icon-spritesheet': true,
'icon-spec': true,
'ui-design': true,
'ui-design-doc': true,
'publication-material': true,
spec: true,
video: true,
audio: true,
'sound-effect': true,
'background-music': true,
font: true,
document: true,
code: true,
unknown: true,
};
export const GAME_CREATION_APP_ASSET_KINDS: readonly GameCreationAppAssetKind[] =
Object.keys(
GAME_CREATION_APP_ASSET_KIND_MEMBERS,
) as GameCreationAppAssetKind[];
const KNOWN_ASSET_KINDS = new Set<string>(GAME_CREATION_APP_ASSET_KINDS);
export function isGameCreationAppAssetKind(
raw: unknown,
): raw is GameCreationAppAssetKind {
return typeof raw === 'string' && KNOWN_ASSET_KINDS.has(raw);
}
export function parseGameCreationAppAssetKind(
raw: unknown,
context: string,
): GameCreationAppAssetKind {
if (typeof raw === 'string' && KNOWN_ASSET_KINDS.has(raw)) {
return raw as GameCreationAppAssetKind;
if (isGameCreationAppAssetKind(raw)) {
return raw;
}
const rawValue = typeof raw === 'string' ? raw : String(raw);
@@ -96,7 +96,10 @@ import {
isFloatingOverlayWheelEvent,
useImageCanvasFloatingOptionDismiss,
} from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import { parseGameCreationAppAssetKind } from '../../contracts/assetKind';
import {
isGameCreationAppAssetKind,
parseGameCreationAppAssetKind,
} from '../../contracts/assetKind';
import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel';
import {
LocalGamePreviewFrame,
@@ -6096,7 +6099,11 @@ export default function ProjectDevelopmentView({
sourceResourceId: resource.id,
sourcePath: resource.path,
sourceMediaType: canonicalProjectedResourceMediaType(resource),
sourceSubtype: resource.subtype,
// sourceSubtype 只接受 manifest 的正式 kind;任务产物等非 manifest
// 来源不通过该字段传递。
sourceSubtype: isGameCreationAppAssetKind(resource.subtype)
? resource.subtype
: null,
producerTaskId: resource.producerTaskId ?? '',
},
}),
@@ -44,7 +44,9 @@ function rustAssetKindAliases(): Map<string, string> {
/** 从 Rust 侧解析 canonical kind 常量目录。 */
function rustCanonicalKinds(): string[] {
const source = readFileSync(RUST_CONTRACT_PATH, 'utf8');
const source = expandRustAssetKindConsts(
readFileSync(RUST_CONTRACT_PATH, 'utf8'),
);
const start = source.indexOf(
'pub const GAME_CREATION_APP_CANONICAL_ASSET_KINDS',
);
@@ -53,6 +55,27 @@ function rustCanonicalKinds(): string[] {
return Array.from(body.matchAll(/"([^"]+)"/g)).map((match) => match[1]!);
}
/**
* 把 Rust 侧 `pub const ..._ASSET_KIND: &str = "..."` 的常量引用展开成字符串字面量。
*
* 共享契约里 `ui-design-doc` 等 kind 是常量(如
* `GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND`)而不是裸字面量,不展开时下面的正则解析会
* 漏掉这些条目,两侧目录比对就会假红。
*/
function expandRustAssetKindConsts(source: string): string {
const consts = new Map<string, string>();
for (const decl of source.matchAll(
/pub const (\w*ASSET_KIND\w*)\s*:\s*&str\s*=\s*"([^"]+)";/g,
)) {
consts.set(decl[1]!, decl[2]!);
}
let expanded = source;
for (const [name, value] of consts) {
expanded = expanded.replaceAll(name, `"${value}"`);
}
return expanded;
}
/** Rust 的 `GameCreationAppAssetCategory` 变体名 → kebab-case 线上值。 */
function rustCategorySocketName(variant: string) {
return variant.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
@@ -60,7 +83,9 @@ function rustCategorySocketName(variant: string) {
/** 从 Rust 侧 `GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND` 里解析出 kind → 栏目表。 */
function rustAssetCategoryByKind(): Map<string, string> {
const source = readFileSync(RUST_CONTRACT_PATH, 'utf8');
const source = expandRustAssetKindConsts(
readFileSync(RUST_CONTRACT_PATH, 'utf8'),
);
const start = source.indexOf(
'pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND',
);
@@ -120,10 +145,9 @@ const OBSERVED_ALIAS_INPUTS = [
describe('资源 kind 别名表跨语言一致性', () => {
test('两侧 canonical kind 目录一致', () => {
'font',
expect(rustCanonicalKinds()).toEqual([
...GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
]);
expect(rustCanonicalKinds()).toEqual([
...GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
]);
});
test('两侧别名映射逐条一致', () => {
@@ -311,9 +335,9 @@ describe('写侧 kind 字面量 → 分类的端到端口径', () => {
'apps/ai-game-creator-shell/src-tauri/src/commands.rs',
'utf8',
);
// 字体上传的写入侧:`register_local_asset_entry(root, path, "font", …)`。
// 字体上传的写入侧直接传正式枚举成员`register_local_asset_entry(root, path, GameCreationAppAssetKind::Font, …)`。
expect(commandsSource).toMatch(
/register_local_asset_entry\(\s*\n\s*root,\s*\n\s*&relative_path,\s*\n\s*"font",/,
/register_local_asset_entry\(\s*\n\s*root,\s*\n\s*&relative_path,\s*\n\s*GameCreationAppAssetKind::Font,/,
);
expect(gameCreationAppAssetCategoryForKind('font')).toBe('document');
});