From b0ce384b8d2f2a7dd38c91ce9247d37f465e8c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 15:45:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E8=B5=84=E6=BA=90=20kind=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8F=AA=E6=B6=88=E8=B4=B9=20Rust=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=9A=84=20union=EF=BC=8C=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=A3=B3=E5=86=85=E7=AC=AC=E4=BA=8C=E4=BB=BD=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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。 --- .eslintrc.cjs | 2 +- .prettierignore | 2 +- .../src/contracts/assetKind.ts | 70 ---- .../project-workspace/resourceReferences.ts | 6 +- .../ui-editor/uiDesignResourceBridge.ts | 2 +- .../src/view/project-development/index.tsx | 8 +- .../tests/assetKind.test.ts | 2 +- .../tests/assetKindCanonicalMapping.test.ts | 344 ------------------ .../shared/src/contracts/gameCreationApp.ts | 187 +++++----- .../generated/GameCreationAppAssetKind.ts | 3 +- 10 files changed, 113 insertions(+), 513 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/contracts/assetKind.ts delete mode 100644 apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts rename {apps/ai-game-creator-shell => packages/shared}/src/contracts/generated/GameCreationAppAssetKind.ts (77%) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 54438e49e..9dabe1b8b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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', diff --git a/.prettierignore b/.prettierignore index d37969f8d..4b92c3e59 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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 diff --git a/apps/ai-game-creator-shell/src/contracts/assetKind.ts b/apps/ai-game-creator-shell/src/contracts/assetKind.ts deleted file mode 100644 index 439cc65df..000000000 --- a/apps/ai-game-creator-shell/src/contracts/assetKind.ts +++ /dev/null @@ -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(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'; -} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index f822d6407..938e65e3c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -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, diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignResourceBridge.ts b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignResourceBridge.ts index 2d4078959..9d341d98f 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignResourceBridge.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignResourceBridge.ts @@ -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; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 4b1476b40..f8e6a334a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -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, diff --git a/apps/ai-game-creator-shell/tests/assetKind.test.ts b/apps/ai-game-creator-shell/tests/assetKind.test.ts index 14251469d..c4949554b 100644 --- a/apps/ai-game-creator-shell/tests/assetKind.test.ts +++ b/apps/ai-game-creator-shell/tests/assetKind.test.ts @@ -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', () => { diff --git a/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts b/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts deleted file mode 100644 index 9bbedf2db..000000000 --- a/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts +++ /dev/null @@ -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 { - 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(); - 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(); - 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 { - 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(); - 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'); - }); -}); diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 8bf6bd92a..c6c8c21f6 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -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( + 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, @@ -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` 派生;未知分类值则在读侧失败关闭(不再退化成 diff --git a/apps/ai-game-creator-shell/src/contracts/generated/GameCreationAppAssetKind.ts b/packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts similarity index 77% rename from apps/ai-game-creator-shell/src/contracts/generated/GameCreationAppAssetKind.ts rename to packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts index 81c2ba217..4c2df2f9a 100644 --- a/apps/ai-game-creator-shell/src/contracts/generated/GameCreationAppAssetKind.ts +++ b/packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts @@ -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` 只表示解析边界遇到尚未登记的输入, * 正常资源写入路径不应主动选择它。