前端资源 kind 改为只消费 Rust 生成的 union,删除壳内第二份实现

- packages/shared/src/contracts/gameCreationApp.ts:删除手写 GAME_CREATION_APP_CANONICAL_ASSET_KINDS、GameCreationAppCanonicalAssetKind、GAME_CREATION_APP_LEGACY_ASSET_KINDS 与 canonicalGameCreationAppAssetKind(),改为 re-export ts-rs 生成的 union。
- packages/shared/src/contracts/gameCreationApp.ts:新增穷举 GAME_CREATION_APP_ASSET_KIND_MEMBERS / GAME_CREATION_APP_ASSET_KINDS、isGameCreationAppAssetKind 与严格解析 parseGameCreationAppAssetKind(认不出的原值 console.warn 留痕并返回 unknown)。
- packages/shared/src/contracts/gameCreationApp.ts:分类映射表与 gameCreationAppAssetCategoryForKind 改按生成 union 穷举,unknown 落 unclassified。
- packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts:ts-rs 生成物从壳内私有目录搬到 packages/shared(跨端契约目录)。
- apps/ai-game-creator-shell/src/contracts/assetKind.ts:删除这份壳内重复实现,消费方改从 packages/shared 导入。
- apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts / features/ui-editor/uiDesignResourceBridge.ts / view/project-development/index.tsx:导入点改到 packages/shared 的同一份 parser 与常量。
- apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts:删除「正则解析 Rust 源码比对两份手写表」的守卫用例,跨语言一致性改由生成 union 在编译期保证。
- .prettierignore / .eslintrc.cjs:忽略规则跟着生成目录一起搬到 packages/shared/src/contracts/generated。
This commit is contained in:
2026-09-17 15:45:24 +08:00
parent b56566f40e
commit b0ce384b8d
10 changed files with 113 additions and 513 deletions
+1 -1
View File
@@ -164,7 +164,7 @@ module.exports = {
'server-rs/target-*',
'apps/desktop-shell/src-tauri/target',
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
'apps/ai-game-creator-shell/src/contracts/generated/**',
'packages/shared/src/contracts/generated/**',
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
'target',
'src/main.tsx',
+1 -1
View File
@@ -5,7 +5,7 @@ node_modules
public/Icons
apps/ai-game-creator-shell/src/features/ui-editor/types/
# ts-rs 直接落盘的生成产物:保持生成器原始格式,`cargo test export_bindings` 才可幂等复现。
apps/ai-game-creator-shell/src/contracts/generated/
packages/shared/src/contracts/generated/
apps/ai-game-creator-shell/src-tauri/resources/agc-skills/
media
*.log
@@ -1,70 +0,0 @@
import type { GameCreationAppAssetKind } from './generated/GameCreationAppAssetKind';
export type { GameCreationAppAssetKind } from './generated/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 (isGameCreationAppAssetKind(raw)) {
return raw;
}
if (typeof raw !== 'string') {
console.warn('GameCreationApp 资源 kind 不是字符串,已收口为 unknown', {
rawType: typeof raw,
sourceContext: context,
});
return 'unknown';
}
if (raw !== 'unknown') {
console.warn('未知的 GameCreationApp 资源 kind,已收口为 unknown', {
rawKind: raw,
sourceContext: context,
});
}
return 'unknown';
}
@@ -1,19 +1,17 @@
import {
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
type GameCreationAppAssetKind,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetTags,
type GameIterationVersion,
parseGameCreationAppAssetKind,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
assetTagsMatchSelection,
buildGameCreationAppAssetTagLibrary,
type GameCreationAppAssetTagLibraryEntry,
} from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary';
import {
type GameCreationAppAssetKind,
parseGameCreationAppAssetKind,
} from '../../contracts/assetKind';
import type {
DirectCodexUserContentPart,
DirectCodexUserItem,
@@ -6,8 +6,8 @@ import {
GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
parseGameCreationAppAssetKind,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { parseGameCreationAppAssetKind } from '../../contracts/assetKind';
export type UiDesignResourceBridgeResult = {
asset: GameCreationAppAssetManifestEntry;
@@ -85,6 +85,10 @@ import {
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
isGameCreationAppAssetKind,
parseGameCreationAppAssetKind,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ImageCanvasCharacterAnimationPanelView } from '../../../../../src/components/image-editor/ImageCanvasCharacterAnimationPanelView';
import type {
CanvasLayer,
@@ -104,10 +108,6 @@ import {
isFloatingOverlayWheelEvent,
useImageCanvasFloatingOptionDismiss,
} from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import {
isGameCreationAppAssetKind,
parseGameCreationAppAssetKind,
} from '../../contracts/assetKind';
import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel';
import {
LocalGamePreviewFrame,
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
GAME_CREATION_APP_ASSET_KINDS,
parseGameCreationAppAssetKind,
} from '../src/contracts/assetKind';
} from '../../../packages/shared/src/contracts/gameCreationApp';
describe('shell generated asset kind boundary', () => {
it('keeps the generated kind values aligned with the manifest vocabulary', () => {
@@ -1,344 +0,0 @@
// kind 别名表必须跨语言一致:TS 侧 `GAME_CREATION_APP_LEGACY_ASSET_KINDS`(前端读投影用)
// 与 Rust 侧 `canonical_game_creation_app_asset_kind`(写入侧按 kind 派生 category 用)
// 是同一套口径的两份实现。任何一边漏加别名,就会让「落盘 category」与「读侧栏目」
// 互相矛盾——`ui` 漏加就正好是「57 条 UI 资产显示成待归类」的成因。
import { readFileSync } from 'node:fs';
import { describe, expect, test } from 'vitest';
import {
canonicalGameCreationAppAssetKind,
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND,
GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
gameCreationAppAssetCategoryForKind,
type GameCreationAppCanonicalAssetKind,
} from '../../../packages/shared/src/contracts/gameCreationApp';
const RUST_CONTRACT_PATH =
'server-rs/crates/shared-contracts/src/game_creation_app.rs';
/** 从 Rust 侧 `canonical_game_creation_app_asset_kind` 里解析出别名 → canonical 表。 */
function rustAssetKindAliases(): Map<string, string> {
const source = readFileSync(RUST_CONTRACT_PATH, 'utf8');
const start = source.indexOf('pub fn canonical_game_creation_app_asset_kind');
expect(start).toBeGreaterThan(-1);
// 取到函数体结束(下一个顶层 `pub fn` / 常量前的空行大括号收口)。
const body = source.slice(start, source.indexOf('\n}\n', start));
const aliases = new Map<string, string>();
for (const line of body.split('\n')) {
const arm = /^\s*((?:"[^"]+"\s*\|?\s*)+)=>\s*"([^"]+)",\s*$/.exec(line);
if (!arm) continue;
const sources = Array.from(arm[1]!.matchAll(/"([^"]+)"/g)).map(
(match) => match[1]!,
);
for (const one of sources) {
aliases.set(one, arm[2]!);
}
}
return aliases;
}
/** 从 Rust 侧解析 canonical kind 常量目录。 */
function rustCanonicalKinds(): string[] {
const source = expandRustAssetKindConsts(
readFileSync(RUST_CONTRACT_PATH, 'utf8'),
);
const start = source.indexOf(
'pub const GAME_CREATION_APP_CANONICAL_ASSET_KINDS',
);
expect(start).toBeGreaterThan(-1);
const body = source.slice(start, source.indexOf('];', start));
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();
}
/** 从 Rust 侧 `GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND` 里解析出 kind → 栏目表。 */
function rustAssetCategoryByKind(): Map<string, string> {
const source = expandRustAssetKindConsts(
readFileSync(RUST_CONTRACT_PATH, 'utf8'),
);
const start = source.indexOf(
'pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND',
);
expect(start).toBeGreaterThan(-1);
// 该表有多种排版(单行、以及 kind 与变体各占一行的多行条目),先去空白再逐条取。
const body = source
.slice(start, source.indexOf('];', start))
.replace(/\s+/g, '');
const table = new Map<string, string>();
for (const entry of body.matchAll(
/\("([^"]+)",GameCreationAppAssetCategory::(\w+),?\)/g,
)) {
table.set(entry[1]!, rustCategorySocketName(entry[2]!));
}
return table;
}
/**
* 从 Rust 侧解析「有效分类」的决策矩阵。
*
* 读时自愈(落盘 `unclassified` 而 kind 能派生出明确分类时采用派生值)在两侧必须只有
* 一个口径:TS 侧 `gameCreationAppAssetCategory`、Rust 侧
* `game_creation_app_asset_effective_category`Agent 投影走这条)。矩阵是唯一真源,
* 写在 Rust 源码里、由 Rust 单测逐条断言,本文件解析同一份矩阵再喂给 TS 实现对照,
* 两侧各写一份的话会重新分叉。
*/
function rustEffectiveCategoryContract(): Array<[string, string, string]> {
const source = readFileSync(RUST_CONTRACT_PATH, 'utf8');
const start = source.indexOf('const EFFECTIVE_CATEGORY_CONTRACT');
expect(start).toBeGreaterThan(-1);
const body = source.slice(start, source.indexOf('];', start));
const rows: Array<[string, string, string]> = [];
for (const row of body.matchAll(/\("([^"]*)",\s*"([^"]*)",\s*"([^"]*)"\)/g)) {
rows.push([row[1]!, row[2]!, row[3]!]);
}
return rows;
}
/** TS 侧的别名表(未导出,用 canonical 目录探测「非 canonical 输入被改写成什么」)。 */
const OBSERVED_ALIAS_INPUTS = [
'game-background',
'character-art',
'ui-prototype',
'ui',
'art-spritesheet',
'art-spritesheet-slice',
'illustration',
'game-art',
'game-entry',
'game-script',
'game-style',
'animation',
'asset',
] as const;
describe('资源 kind 别名表跨语言一致性', () => {
test('两侧 canonical kind 目录一致', () => {
expect(rustCanonicalKinds()).toEqual([
...GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
]);
});
test('两侧别名映射逐条一致', () => {
const rustAliases = rustAssetKindAliases();
const mismatches: Array<{ kind: string; ts: string; rust: string }> = [];
for (const [kind, rustCanonical] of rustAliases) {
const tsCanonical = canonicalGameCreationAppAssetKind(kind);
if (tsCanonical !== rustCanonical) {
mismatches.push({ kind, ts: tsCanonical, rust: rustCanonical });
}
}
expect(mismatches).toEqual([]);
// 反向:TS 侧认得的别名,Rust 侧也必须认得(否则写入侧会写出错 category)。
const rustOnly = new Set(rustAliases.keys());
const missingInRust = OBSERVED_ALIAS_INPUTS.filter(
(kind) =>
canonicalGameCreationAppAssetKind(kind) !== kind && !rustOnly.has(kind),
);
expect(missingInRust).toEqual([]);
});
test('每个别名都指向 canonical 目录内的值', () => {
const canonical =
GAME_CREATION_APP_CANONICAL_ASSET_KINDS as readonly string[];
for (const kind of OBSERVED_ALIAS_INPUTS) {
expect(canonical).toContain(canonicalGameCreationAppAssetKind(kind));
}
});
test('两侧 canonical kind → 栏目表逐条一致', () => {
const rustTable = rustAssetCategoryByKind();
expect([...rustTable.keys()].sort()).toEqual(
[...GAME_CREATION_APP_CANONICAL_ASSET_KINDS].sort(),
);
const mismatches: Array<{ kind: string; ts: string; rust: string }> = [];
for (const [kind, rustCategory] of rustTable) {
const tsCategory =
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[
kind as GameCreationAppCanonicalAssetKind
];
if (tsCategory !== rustCategory) {
mismatches.push({ kind, ts: tsCategory, rust: rustCategory });
}
}
expect(mismatches).toEqual([]);
});
test('读时自愈决策矩阵两侧一致(单一真源在 Rust 侧)', () => {
const rows = rustEffectiveCategoryContract();
// 矩阵必须覆盖:落盘明确值、落盘 unclassified + kind 可明确分类、
// 落盘 unclassified + 派生结果本来就是 unclassified、大写写侧字面量。
expect(rows.length).toBeGreaterThanOrEqual(8);
const mismatches: Array<{
kind: string;
persisted: string;
expected: string;
ts: string;
}> = [];
for (const [kind, persisted, expected] of rows) {
const ts = gameCreationAppAssetCategory({
kind,
category: persisted as GameCreationAppAssetCategory,
});
if (ts !== expected) {
mismatches.push({ kind, persisted, expected, ts });
}
}
expect(mismatches).toEqual([]);
});
});
describe('真机出现的 kind 归类口径', () => {
/** 已拍板的口径:现役 kind → canonical → 栏目。 */
const decided: Array<{
kind: string;
canonical: GameCreationAppCanonicalAssetKind;
category: string;
}> = [
{ kind: 'ui', canonical: 'ui-design', category: 'ui-interaction' },
{ kind: 'game-entry', canonical: 'code', category: 'unclassified' },
{ kind: 'game-script', canonical: 'code', category: 'unclassified' },
{ kind: 'game-style', canonical: 'code', category: 'unclassified' },
{
kind: 'animation',
canonical: 'character-animation',
category: 'character',
},
{ kind: 'asset', canonical: 'image', category: 'unclassified' },
// 明确保持不动:它落到 ui-interaction 是正确口径。
{
kind: 'art-spritesheet-slice',
canonical: 'icon',
category: 'ui-interaction',
},
// UI 文档与字体 kind,两者都必须落进明确栏目。
{ kind: 'UI', canonical: 'ui-design', category: 'ui-interaction' },
{
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
canonical: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
category: 'ui-interaction',
},
{ kind: 'font', canonical: 'font', category: 'document' },
// `Object.prototype` 上的键不是别名:TS 侧必须用 `Object.hasOwn` 挡住对象字面量的
// 原型命中,Rust 侧 match 字面量本来就落 `image`;这类极端输入两侧也必须一致。
{ kind: 'constructor', canonical: 'image', category: 'unclassified' },
{ kind: '__proto__', canonical: 'image', category: 'unclassified' },
];
test.each(decided)(
'$kind → $canonical → $category',
({ kind, canonical, category }) => {
expect(canonicalGameCreationAppAssetKind(kind)).toBe(canonical);
expect(gameCreationAppAssetCategoryForKind(kind)).toBe(category);
},
);
test('canonical 目录里没有 ui / animation / asset,避免出现同义 kind', () => {
const canonical =
GAME_CREATION_APP_CANONICAL_ASSET_KINDS as readonly string[];
for (const forbidden of ['ui', 'animation', 'asset', 'game-entry']) {
expect(canonical).not.toContain(forbidden);
}
// ui 与 ui-design 不能同时存在,否则就是两套词汇。
expect(canonical).toContain('ui-design');
});
test('分类表穷举 canonical 目录,别名不会落空', () => {
expect(
Object.keys(GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND).sort(),
).toEqual([...GAME_CREATION_APP_CANONICAL_ASSET_KINDS].sort());
});
});
describe('写侧 kind 字面量 → 分类的端到端口径', () => {
const UI_EDITOR_WRITE_SITES = [
'apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs',
'apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs',
'apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs',
];
/**
* 取每个 `register_local_asset_(at|entry)(` 调用的第 3 个实参——写侧固定把 kind 放在
* 第 3 位、单独占一行(`root` / 路径 / `"UI"`)。只认独占一行的字符串字面量,
* 传变量的写点直接跳过,避免把第 4 位的 mediaType 误当成 kind。
*/
function registeredKindLiterals(file: string): string[] {
const source = readFileSync(file, 'utf8');
const kinds: string[] = [];
for (const call of source.matchAll(
/register_local_asset_(?:at|entry)\(\r?\n(?:[^\n]*\r?\n){2}([^\n]*)/g,
)) {
const literal = /^\s*"([^"]*)",\s*$/.exec(call[1]!);
if (literal) kinds.push(literal[1]!);
}
return kinds;
}
test('UI 设计写点统一使用文档资产常量,且它落 UI 交互而不是待归类', () => {
for (const file of UI_EDITOR_WRITE_SITES) {
expect(readFileSync(file, 'utf8')).toContain('UI_DESIGN_DOC_ASSET_KIND');
}
expect(
gameCreationAppAssetCategoryForKind(
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
),
).toBe('ui-interaction');
});
test('UI 编辑器写点写出的每个 kind 都落明确栏目', () => {
// 画板导出这条链上的写点只写 UI 设计资产,不允许再写出落「待归类」的 kind。
for (const file of UI_EDITOR_WRITE_SITES) {
for (const kind of registeredKindLiterals(file)) {
expect(
gameCreationAppAssetCategoryForKind(kind),
`${file} 写出的 kind ${kind} 落到了待归类`,
).not.toBe('unclassified');
}
}
});
test('字体写点写 `"font"`,落文档栏目', () => {
const commandsSource = readFileSync(
'apps/ai-game-creator-shell/src-tauri/src/commands.rs',
'utf8',
);
// 字体上传的写入侧直接传正式枚举成员:`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*GameCreationAppAssetKind::Font,/,
);
expect(gameCreationAppAssetCategoryForKind('font')).toBe('document');
});
});
+101 -86
View File
@@ -1,3 +1,7 @@
import type { GameCreationAppAssetKind } from './generated/GameCreationAppAssetKind';
export type { GameCreationAppAssetKind } from './generated/GameCreationAppAssetKind';
export const GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION =
'game-creation-app.manifest.v1';
export const GAME_CREATION_AGENT_RUN_SCHEMA_VERSION =
@@ -480,95 +484,100 @@ export interface GameCreationAppAssetManifestEntry {
tags?: string[];
}
/** UI 设计图(原型图与页面设计图)资产的唯一 kind。 */
export const GAME_CREATION_APP_UI_DESIGN_ASSET_KIND = 'ui-design' as const;
/**
* UI 设计图(原型图与页面设计图)资产的唯一 kind。
*
* 常量类型钉在生成的 kind union 上:Rust 侧改名/改词表 → 生成 union 变化 → 这里直接编译报错,
* 不存在"手写常量与契约悄悄分叉"的可能。
*/
export const GAME_CREATION_APP_UI_DESIGN_ASSET_KIND: GameCreationAppAssetKind =
'ui-design';
/** UI Editor JSON 文档资产的唯一 kind 与媒体类型。 */
export const GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND =
'ui-design-doc' as const;
export const GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND: GameCreationAppAssetKind =
'ui-design-doc';
export const GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE =
'application/json' as const;
export const GAME_CREATION_APP_CANONICAL_ASSET_KINDS = [
'image',
'scene',
'character',
'character-animation',
'icon',
'icon-spritesheet',
'icon-spec',
GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
'publication-material',
'spec',
'video',
'sound-effect',
'background-music',
'audio',
'font',
'document',
'code',
] as const;
export type GameCreationAppCanonicalAssetKind =
(typeof GAME_CREATION_APP_CANONICAL_ASSET_KINDS)[number];
/**
* 线上出现的非 canonical kind → canonical kind 的别名表
* 运行期 kind 列表:穷举 Record 保证它不可能与生成 union 分叉(漏一个/多一个都编译报错)
* 键顺序即生成声明顺序。kind 字符串表**只有这一份运行期副本**,别名表已删除。
*
* 职责是「线上值 → canonical」,与该值是否属于历史遗留无关:画板导出等现役写入侧
* 仍会写出 `ui` / `animation` / `asset` 这类非 canonical 值,同样必须在这里收口,
* 否则会落到 `canonicalGameCreationAppAssetKind` 的 `image` 兜底,再经
* `image → unclassified` 被误分到「待归类」。
*
* 本表与 Rust 侧 `server-rs/crates/shared-contracts/src/game_creation_app.rs` 的
* `canonical_game_creation_app_asset_kind` 必须成对维护,由
* `tests/assetKindCanonicalMapping.test.ts` 交叉钉住。
*
* 注意不要动 `art-spritesheet-slice → icon`:它落到 ui-interaction 是正确口径。
* kind 的**唯一真源**是 Rust 侧 `GameCreationAppAssetKind`ts-rs 生成
* `./generated/GameCreationAppAssetKind`)。旧的手写 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS`、
* legacy 别名表与 `canonicalGameCreationAppAssetKind()` 已从本文件删除,Rust 侧同名的
* `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` / `canonical_game_creation_app_asset_kind()`
* 一并删除。口径是严格接受:不 trim、不大小写归一、不查别名、不做迁移。
*/
const GAME_CREATION_APP_LEGACY_ASSET_KINDS: Record<
string,
GameCreationAppCanonicalAssetKind
const GAME_CREATION_APP_ASSET_KIND_MEMBERS: Record<
GameCreationAppAssetKind,
true
> = {
'game-background': 'scene',
'character-art': 'character',
'ui-prototype': 'ui-design',
ui: 'ui-design',
'art-spritesheet': 'icon-spritesheet',
'art-spritesheet-slice': 'icon',
illustration: 'image',
'game-art': 'image',
'game-entry': 'code',
'game-script': 'code',
'game-style': 'code',
animation: 'character-animation',
asset: 'image',
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 function canonicalGameCreationAppAssetKind(
value: string,
): GameCreationAppCanonicalAssetKind {
/** kind 词汇大小写不敏感;UI Editor 文档写入侧使用 UI 文档常量。 */
const normalized = value.trim().toLowerCase();
/**
* `kind` 是外部输入,可能正好是 `Object.prototype` 上的键(`constructor` / `__proto__` /
* `toString` / `valueOf` …)。别名表是对象字面量,直接下标取值会拿到原型上的函数/对象
* truthy),被当成 canonical kind 返回;Rust 侧是 `match` 字面量,只会落 `image`。
* 必须先确认命中的是**表自己的键**,两侧对这类输入的归类才一致(都落 `image → unclassified`)。
*/
if (Object.hasOwn(GAME_CREATION_APP_LEGACY_ASSET_KINDS, normalized)) {
const legacyKind = GAME_CREATION_APP_LEGACY_ASSET_KINDS[normalized];
if (legacyKind) return legacyKind;
export const GAME_CREATION_APP_ASSET_KINDS: readonly GameCreationAppAssetKind[] =
Object.keys(
GAME_CREATION_APP_ASSET_KIND_MEMBERS,
) as GameCreationAppAssetKind[];
const GAME_CREATION_APP_ASSET_KIND_SET = new Set<string>(
GAME_CREATION_APP_ASSET_KINDS,
);
export function isGameCreationAppAssetKind(
raw: unknown,
): raw is GameCreationAppAssetKind {
return typeof raw === 'string' && GAME_CREATION_APP_ASSET_KIND_SET.has(raw);
}
/**
* 严格解析外部 kind 字符串(manifest / API):只认 canonical 值,认不出的收口成 `unknown`
* 并把**原始串**打到 `console.warn`Rust 侧同类留痕走 `app_log!`)。
*
* 不 trim、不做大小写归一、不查别名表、不做迁移:`"UI"` / `"ui"` / `" image "` 一律 `unknown`。
*/
export function parseGameCreationAppAssetKind(
raw: unknown,
context: string,
): GameCreationAppAssetKind {
if (isGameCreationAppAssetKind(raw)) {
return raw;
}
if (
(GAME_CREATION_APP_CANONICAL_ASSET_KINDS as readonly string[]).includes(
normalized,
)
) {
return normalized as GameCreationAppCanonicalAssetKind;
if (typeof raw !== 'string') {
console.warn('GameCreationApp 资源 kind 不是字符串,已收口为 unknown', {
rawType: typeof raw,
sourceContext: context,
});
return 'unknown';
}
return 'image';
if (raw !== 'unknown') {
console.warn('未知的 GameCreationApp 资源 kind,已收口为 unknown', {
rawKind: raw,
sourceContext: context,
});
}
return 'unknown';
}
export const GAME_CREATION_APP_ASSET_CATEGORIES = [
@@ -583,9 +592,12 @@ export const GAME_CREATION_APP_ASSET_CATEGORIES = [
export type GameCreationAppAssetCategory =
(typeof GAME_CREATION_APP_ASSET_CATEGORIES)[number];
/** canonical kind 到功能分类的默认映射,必须穷举 GAME_CREATION_APP_CANONICAL_ASSET_KINDS。 */
/**
* kind 到功能分类的默认映射,按生成的 kind union **穷举**:少一个成员编译不过。
* 认不出的 kind 收口成 `unknown` 后同样落「待归类」,不做别名归一。
*/
export const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: Record<
GameCreationAppCanonicalAssetKind,
GameCreationAppAssetKind,
GameCreationAppAssetCategory
> = {
image: 'unclassified',
@@ -595,8 +607,8 @@ export const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: Record<
icon: 'ui-interaction',
'icon-spritesheet': 'ui-interaction',
'icon-spec': 'ui-interaction',
[GAME_CREATION_APP_UI_DESIGN_ASSET_KIND]: 'ui-interaction',
[GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND]: 'ui-interaction',
'ui-design': 'ui-interaction',
'ui-design-doc': 'ui-interaction',
'publication-material': 'unclassified',
spec: 'document',
video: 'unclassified',
@@ -606,6 +618,7 @@ export const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: Record<
font: 'document',
document: 'document',
code: 'unclassified',
unknown: 'unclassified',
};
export function normalizeGameCreationAppAssetCategory(
@@ -624,7 +637,7 @@ export function gameCreationAppAssetCategoryForKind(
kind: string,
): GameCreationAppAssetCategory {
return GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[
canonicalGameCreationAppAssetKind(kind)
parseGameCreationAppAssetKind(kind, 'asset-category.by-kind')
];
}
@@ -639,8 +652,9 @@ export function gameCreationAppAssetCategoryForKind(
*
* **例外(唯一的覆盖窗口)**:落盘值是 `unclassified`,而该资产 `kind` 能派生出明确的
* 非 `unclassified` 分类时,采用派生值。这条规则用于自愈「历史上被系统错误写成
* unclassified」的存量数据——典型例子是 `kind:"ui"` 的 UI 资产曾因 kind 不在 canonical
* 目录而落到 `image → unclassified`,修复别名后并不会自动归位,因为落盘值已固化。
* unclassified」的存量数据——典型例子是 UI 资产落盘 `unclassified` 而 kind 已是 `ui-design`。
* kind 认不出时只收口成 `unknown`(派生同样是 `unclassified`)并留痕原始串,
* **不查别名表**:这条自愈规则不负责把 legacy kind 映射回 canonical。
*
* 盲区:用户**手动**把一个 kind 已能明确分类的资产设成「待归类」时,该手动值会被这条
* 规则覆盖。这是有意接受的取舍——「手动设为待归类」本身意图边缘,且 kind 已经表达了分类;
@@ -651,8 +665,9 @@ export function gameCreationAppAssetCategoryForKind(
* **这是「读显示」口径,不是「写回」口径。** 需要回写 manifest 的调用方必须用
* `gameCreationAppAssetPersistedCategory`,否则会把自愈值写回落盘、把「只改标签」
* 变成静默改分类。Rust 侧由 `game_creation_app_asset_effective_category` 实现同一口径
* Agent 投影走 Rust 那条)两侧不许各写一份,由
* `tests/assetKindCanonicalMapping.test.ts` 的决策矩阵用例交叉钉住。
* Agent 投影走 Rust 那条)两侧的决策矩阵分别在
* `packages/shared/src/contracts/gameCreationApp.test.ts`(本文件)与
* `shared-contracts` 的 `EFFECTIVE_CATEGORY_CONTRACT` 用例里逐条钉住。
*/
export function gameCreationAppAssetCategory(
asset: Pick<GameCreationAppAssetManifestEntry, 'kind' | 'category'>,
@@ -673,7 +688,7 @@ export function gameCreationAppAssetCategory(
*
* 与 `gameCreationAppAssetCategory` 的分工是「读显示 / 写回」:显示层可以自愈历史上的误判值,
* 但写入层必须原样保留落盘值——「编辑标签」面板若回传自愈值,就把「只改标签」变成了
* 静默改分类(真机已发生:同一条 `kind:"ui"` 资产出现 `unclassified` 与 `ui-interaction` 两种落盘值)。
* 静默改分类(真机已发生:同一条资产出现 `unclassified` 与 `ui-interaction` 两种落盘值)。
*
* Rust 侧 manifest 反序列化(`game_creation_app.rs` 的 `GameCreationAppAssetManifestEntry`
* `Deserialize`)在字段缺失时用同一套 `kind` 派生;未知分类值则在读侧失败关闭(不再退化成
@@ -1,7 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* GameCreationApp manifest kind Rust
* GameCreationApp manifest kind **** TS kind union
* ts-rs
*
* JSON 使 kebab-case`Unknown`
*