前端资源 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:
+1
-1
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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` 派生;未知分类值则在读侧失败关闭(不再退化成
|
||||
|
||||
+2
-1
@@ -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` 只表示解析边界遇到尚未登记的输入,
|
||||
* 正常资源写入路径不应主动选择它。
|
||||
Reference in New Issue
Block a user