合并 origin/master 到 feat/tribo3d-integeration:ts-rs 一律常开、Cargo 清单去重

- server-rs/Cargo.toml:删除与 workspace git 固定重复的 registry `ts-rs = "12.0.1"`,保留固定 commit 并写明「ts-rs 一律常开、绑定命令固定为 cargo test -p shared-contracts export_bindings」
- server-rs/crates/shared-contracts/Cargo.toml:删除 master 侧新增的 `ts-bindings` feature,`ts-rs` 保持常开依赖
- server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs:去掉 `cfg_attr(feature = "ts-bindings", …)` 门控,derive 与字段级 `ts(...)` 无条件展开
- apps/ai-game-creator-shell/src-tauri/Cargo.toml:shared-contracts 依赖去掉 `features = ["ts-bindings"]`,继续保留本分支的 ts-rs git 固定
- apps/ai-game-creator-shell/src-tauri/Cargo.lock:按上述依赖变更同步(shared-contracts 指向 git 源 ts-rs)
- docs:同步 5 处绑定生成命令(去掉 `--features ts-bindings`),并在 decision-log 记本次合并的决策、代价与验证
- 其余暂存改动为 origin/master 带入的内容,冲突按两侧目的逐一合并

验证:cargo metadata --locked(server-rs 与 AGC 两个 workspace)、cargo test -p shared-contracts(119 + 5 + 5 + 2 + 2 全绿且无告警)、npm run contracts:model3d:generate 后 packages/shared 零 diff、cargo check -p spacetime-module --target wasm32-unknown-unknown、npm run check:encoding、npm run check:rustfmt、npm run check:spacetime-schema、git diff --check、暂存集 eslint / prettier 全过
This commit is contained in:
2026-09-22 13:12:28 +08:00
1079 changed files with 140332 additions and 77798 deletions
@@ -0,0 +1,65 @@
/**
* 过程区单行摘要(思考过程 / 外层执行过程 / 工具组共用)。
*
* 只有一行、没有大底色:预览过长省略,chevron 永远贴右。展开态由宿主的
* `[aria-expanded='true']` 或 `details[open]` 决定,这里只负责视觉。
*/
.agent-process-summary {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
min-height: 20px;
line-height: 1.4;
background: none;
}
.agent-process-summary-icon {
display: inline-flex;
flex: none;
align-items: center;
color: var(--platform-text-soft, #988476);
}
.agent-process-summary-preview {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.agent-process-summary-meta {
flex: none;
color: var(--platform-text-soft, #988476);
white-space: nowrap;
}
.agent-process-summary-chevron {
display: inline-flex;
flex: none;
align-items: center;
margin-left: auto;
color: var(--platform-text-soft, #988476);
transition: transform 120ms ease;
}
[aria-expanded='true'] > .agent-process-summary .agent-process-summary-chevron,
details[open] > summary .agent-process-summary .agent-process-summary-chevron {
transform: rotate(180deg);
}
/* 焦点画在共享摘要内,避免宿主按钮的 outline:none 抹掉键盘定位。 */
button:focus-visible > .agent-process-summary,
summary:focus-visible > .agent-process-summary {
outline: 2px solid var(--platform-accent, #c26a3e);
outline-offset: 2px;
border-radius: 3px;
}
@media (prefers-reduced-motion: reduce) {
.agent-process-summary-chevron {
transition: none;
}
}
@@ -0,0 +1,48 @@
import './AgentProcessSummary.css';
import type { ReactNode } from 'react';
/**
* 过程区单行摘要的**纯展示**骨架:`icon + 预览文字 + 右对齐 meta + chevron`。
*
* 它只负责视觉,不含状态、不含业务规则,也不渲染任何交互元素:宿主决定外层是可点的
* `button`(工具组)还是 `<details><summary>`(思考过程 / 外层执行过程),并把展开态交给宿主。
* 视觉上固定一行:预览过长省略,chevron 永远贴右,展开时由 `.agent-process-summary` 的
* `[aria-expanded='true']` / `details[open]` 规则旋转。
*/
export function AgentProcessSummary({
icon,
preview,
meta,
chevron,
}: {
/** 行首图标(通常由宿主传入 lucide 图标)。 */
icon?: ReactNode;
/** 单行预览文字;调用方负责先把它压成一行(Markdown 预览模型)。 */
preview: string;
/** 右侧补充信息(耗时 / 状态),可为空。 */
meta?: ReactNode;
/** 右端 chevron。 */
chevron?: ReactNode;
}) {
return (
<span className="agent-process-summary" data-agent-process-summary="">
{icon ? (
<span className="agent-process-summary-icon" aria-hidden="true">
{icon}
</span>
) : null}
<span className="agent-process-summary-preview">
{preview}
{meta ? (
<span className="agent-process-summary-meta">{meta}</span>
) : null}
</span>
{chevron ? (
<span className="agent-process-summary-chevron" aria-hidden="true">
{chevron}
</span>
) : null}
</span>
);
}
+1
View File
@@ -220,6 +220,7 @@ export { Textarea } from './ui/textarea';
// existing application adapters while keeping the public API product-neutral.
export type { AgentMessageTone } from './AgentMessageContent';
export { AgentMessageContent } from './AgentMessageContent';
export { AgentProcessSummary } from './AgentProcessSummary';
export { CanvasCardCornerActions } from './CanvasCardCornerActions';
export { OverflowActions } from './OverflowActions';
export type {
+56
View File
@@ -1006,6 +1006,62 @@ textarea.genarrative-ui-text-field__control {
background: var(--platform-overlay-fill);
}
/*
* 参考图 / 替换素材选择组件的**非模态浮层**形态(`nonModal`)。
*
* 与上面的 `platform-modal-shell` 同一处理由:一个组件两种外壳,壳样式放在这里,凡是引入本文件
* 的宿主一起命中;写进任一宿主的业务样式就会变成平行拷贝。落差只有外壳——底色 / 边框 / 阴影 /
* 头部 / 滚动区 + 一列动作条,**不铺遮罩、不抢焦点**,宿主画布保持可点(AGC 的「替换素材」用它
* 在画布上点选目标素材)。锚定位置由宿主给(AGC 是画布右上角),这里不写 `position`。
*
* 依赖 `theme.css` 的 token,由最近的主题祖先提供(AGC 的锚点容器自带
* `.platform-theme--light`)。
*/
.image-canvas-editor__project-asset-picker--floating {
display: flex;
width: min(22rem, 34vw);
max-height: min(30rem, 70vh);
flex-direction: column;
overflow: hidden;
border: 1px solid var(--platform-modal-border);
border-radius: 0.75rem;
background: var(--platform-modal-fill);
box-shadow: var(--platform-panel-shadow);
color: var(--platform-text-strong);
backdrop-filter: blur(10px);
}
.image-canvas-editor__project-asset-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 1px solid var(--platform-line-soft);
padding: 0.68rem 0.78rem;
}
.image-canvas-editor__project-asset-picker-header strong {
font-size: 0.82rem;
font-weight: 850;
}
.image-canvas-editor__project-asset-picker-scroll {
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.75rem 0.8rem;
scrollbar-width: thin;
}
.image-canvas-editor__project-asset-picker-actions {
display: flex;
align-items: center;
gap: 0.45rem;
border-top: 1px solid var(--platform-line-soft);
padding: 0.6rem 0.7rem;
}
.genarrative-ui-empty-state {
display: grid;
min-height: 10rem;
@@ -0,0 +1,14 @@
export interface ClientDownloadResponse {
downloads: ClientDownload[];
unavailablePlatforms: ClientDownloadPlatform[];
}
export type ClientDownloadPlatform = 'windows' | 'macos';
export type ClientDownloadArchitecture = 'x86_64' | 'aarch64';
export interface ClientDownload {
platform: ClientDownloadPlatform;
architecture: ClientDownloadArchitecture;
version: string;
downloadUrl: string;
}
@@ -1,10 +1,9 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
canonicalGameCreationAppAssetKind,
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
GAME_CREATION_AGENT_CAPABILITIES,
@@ -13,28 +12,46 @@ import {
GAME_CREATION_AGENT_TOOL_CALL_MAX,
GAME_CREATION_APP_ASSET_CATEGORIES,
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND,
GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
GAME_CREATION_APP_ASSET_KINDS,
GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS,
GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION,
GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
type GameCreationAgentRunTrace,
gameCreationAppAssetCategory,
gameCreationAppAssetCategoryForKind,
gameCreationAppAssetCategoryForRawKind,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetPersistedCategory,
gameCreationAppAssetTags,
type GameCreationAppManifest,
isGameCreationAppAssetAudioKind,
isGameCreationAppAssetVisualKind,
isGameCreationAppUiDesignDocAsset,
normalizeGameCreationAppAssetCategory,
normalizeGameCreationAppAssetTags,
parseGameCreationAppAssetKind,
PROJECT_RESOURCE_CANVAS_SECTIONS,
selectGameCreationAppReadyTasks,
} from './gameCreationApp';
describe('AI 游戏创作 App 共享契约', () => {
// 严格解析会给每个非 canonical 原值打一条留痕日志;契约用例大量喂 legacy 值,
// 这里只关心返回值,不把留痕刷进测试输出。
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('keeps command permissions explicit', () => {
const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id);
expect(GAME_CREATION_APP_COMMANDS).toHaveLength(66);
expect(GAME_CREATION_APP_COMMANDS).toHaveLength(67);
expect(commandIds).toContain('project.bootstrap');
expect(commandIds).toContain('project.git_inspect');
expect(commandIds).toContain('project.git_commit');
@@ -46,6 +63,11 @@ describe('AI 游戏创作 App 共享契约', () => {
expect(commandIds).toContain('command.stdin');
expect(commandIds).toContain('command.terminate');
expect(commandIds).toContain('cocos.editor.execute');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'godot.editor.execute',
)?.permission,
).toBe('confirm');
expect(commandIds).toContain('mcp.call');
expect(commandIds.indexOf('command.exec')).toBe(
commandIds.indexOf('command.run_limited') + 1,
@@ -736,35 +758,33 @@ describe('AI 游戏创作 App 共享契约', () => {
});
});
it('maps legacy canvas asset kinds to canonical kinds', () => {
expect(GAME_CREATION_APP_CANONICAL_ASSET_KINDS).toContain(
'character-animation',
it('parses kind strictly and closes unknown values over unknown', () => {
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('character-animation');
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('font');
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('unknown');
// 口径是严格接受:只认精确的 canonical 值。
for (const raw of ['future-kind', 'UI', 'FONT', ' image ']) {
expect(parseGameCreationAppAssetKind(raw, 'test.kind-boundary')).toBe(
'unknown',
);
expect(
gameCreationAppAssetCategoryForRawKind(raw, 'test.kind-category'),
).toBe('unclassified');
}
expect(parseGameCreationAppAssetKind('font', 'test.kind-boundary')).toBe(
'font',
);
expect(canonicalGameCreationAppAssetKind('game-background')).toBe('scene');
expect(canonicalGameCreationAppAssetKind('character-art')).toBe(
'character',
expect(
parseGameCreationAppAssetKind('character', 'test.kind-boundary'),
).toBe('character');
expect(parseGameCreationAppAssetKind('unknown', 'test.kind-boundary')).toBe(
'unknown',
);
expect(canonicalGameCreationAppAssetKind('ui-prototype')).toBe('ui-design');
expect(canonicalGameCreationAppAssetKind('art-spritesheet')).toBe(
'icon-spritesheet',
);
expect(canonicalGameCreationAppAssetKind('art-spritesheet-slice')).toBe(
'icon',
);
expect(canonicalGameCreationAppAssetKind('illustration')).toBe('image');
expect(canonicalGameCreationAppAssetKind('character')).toBe('character');
// 别名表大小写不敏感:UI 设计资产的现役写入侧写的是大写 `"UI"`。
expect(canonicalGameCreationAppAssetKind('UI')).toBe('ui-design');
expect(canonicalGameCreationAppAssetKind('UI-Prototype')).toBe('ui-design');
// 字体资产的现役写入侧写的是 `font`。
expect(canonicalGameCreationAppAssetKind('font')).toBe('document');
expect(canonicalGameCreationAppAssetKind('FONT')).toBe('document');
/**
* `Object.prototype` 上的键不是别名:别名表是对象字面量,直接下标取值会拿到原型上的
* 函数/对象(truthy)并被当成 canonical kind 返回;Rust 侧是 `match` 字面量,只会落
* `image`。必须用 `Object.hasOwn` 挡掉,两侧对这类输入的归类才一致
* (`image → unclassified`)。跨语言对照另见 `assetKindCanonicalMapping.test.ts` 的
* `decided` 表。
* `Object.prototype` 上的键不再是特例:kind 查表用 `Set`,不存在原型命中,
* 与普通未知串一样收口成 `unknown`(派生 `unclassified`)。
*/
for (const prototypeKey of [
'constructor',
@@ -773,20 +793,77 @@ describe('AI 游戏创作 App 共享契约', () => {
'valueOf',
'hasOwnProperty',
]) {
expect(canonicalGameCreationAppAssetKind(prototypeKey)).toBe('image');
expect(gameCreationAppAssetCategoryForKind(prototypeKey)).toBe(
'unclassified',
expect(parseGameCreationAppAssetKind(prototypeKey, 'test.legacy')).toBe(
'unknown',
);
expect(
gameCreationAppAssetCategoryForRawKind(prototypeKey, 'test.legacy'),
).toBe('unclassified');
}
expect(canonicalGameCreationAppAssetKind('unknown-kind')).toBe('image');
});
it('maps every canonical asset kind to a functional category', () => {
it('shares exhaustive audio and visual kind families', () => {
expect(
GAME_CREATION_APP_ASSET_KINDS.filter(isGameCreationAppAssetAudioKind),
).toEqual(['audio', 'sound-effect', 'background-music']);
expect(
GAME_CREATION_APP_ASSET_KINDS.filter(isGameCreationAppAssetVisualKind),
).toEqual([
'image',
'scene',
'character',
'character-animation',
'icon',
'icon-spritesheet',
'icon-spec',
'ui-design',
]);
});
it('识别 UI 编辑器文档资产只认 kind + mediaType 的唯一定义', () => {
expect(
isGameCreationAppUiDesignDocAsset({
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
}),
).toBe(true);
// 只满足一半不算文档:kind 对但 mediaType 不符、mediaType 对但 kind 不是文档。
expect(
isGameCreationAppUiDesignDocAsset({
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: 'text/plain',
}),
).toBe(false);
expect(
isGameCreationAppUiDesignDocAsset({
kind: 'ui-design',
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
}),
).toBe(false);
// 非 canonical kind 一律严格收口,不被当作文档(判据只做等值匹配)。
for (const raw of [
'UI',
'future-kind',
'ui-design-doc ',
'UI-DESIGN-DOC',
]) {
expect(
isGameCreationAppUiDesignDocAsset({
kind: raw,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
}),
).toBe(false);
}
});
it('maps every asset kind to a functional category', () => {
expect(GAME_CREATION_APP_ASSET_CATEGORIES).toHaveLength(6);
expect(Object.keys(GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND)).toHaveLength(
GAME_CREATION_APP_CANONICAL_ASSET_KINDS.length,
GAME_CREATION_APP_ASSET_KINDS.length,
);
for (const kind of GAME_CREATION_APP_CANONICAL_ASSET_KINDS) {
for (const kind of GAME_CREATION_APP_ASSET_KINDS) {
expect(GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[kind]).toBeDefined();
expect(gameCreationAppAssetCategoryForKind(kind)).toBe(
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[kind],
@@ -802,19 +879,20 @@ describe('AI 游戏创作 App 共享契约', () => {
expect(gameCreationAppAssetCategoryForKind('scene')).toBe('scene');
expect(gameCreationAppAssetCategoryForKind('sound-effect')).toBe('audio');
expect(gameCreationAppAssetCategoryForKind('spec')).toBe('document');
for (const kind of ['image', 'video', 'code', 'publication-material']) {
for (const kind of [
'image',
'video',
'code',
'publication-material',
] as const) {
expect(gameCreationAppAssetCategoryForKind(kind)).toBe('unclassified');
}
expect(gameCreationAppAssetCategoryForKind('game-background')).toBe(
'scene',
);
// 现役写入侧写的是大写 `"UI"`(画板导出的 UI 设计 JSON 资产)与 `font`(字体上传):
// 两者都必须落进明确栏目,否则 8 条真机 UI 资产会永远停在「待归类」。
expect(gameCreationAppAssetCategoryForKind('UI')).toBe('ui-interaction');
// `font` 是 canonical 成员(现役写入侧直接写 `font`),分类是 `document`。
expect(gameCreationAppAssetCategoryForKind('font')).toBe('document');
expect(gameCreationAppAssetCategoryForKind('unknown-kind')).toBe(
'unclassified',
);
expect(
gameCreationAppAssetCategoryForRawKind('unknown-kind', 'test.unknown'),
).toBe('unclassified');
expect(gameCreationAppAssetCategoryForKind('unknown')).toBe('unclassified');
});
it('resolves asset category and tags with legacy and forward compatibility', () => {
@@ -830,7 +908,7 @@ describe('AI 游戏创作 App 共享契约', () => {
// 有意行为(2026-09-10 分类自愈批次):落盘 `unclassified` 而 kind 能派生出明确的
// 非 `unclassified` 分类时,读取侧采用派生值。这条规则用于自愈历史上被系统误写成
// `unclassified` 的存量(真机 57 条 `kind:"ui"` 资产),无需迁移脚本且永久生效。
// `unclassified` 的存量由 canonical kind 的读显示口径决定,无需迁移脚本且永久生效。
// 已知盲区:用户手动把可明确分类的资产设为「待归类」时该手动值会被覆盖,属有意接受。
expect(
gameCreationAppAssetCategory({ ...legacy, category: 'unclassified' }),
@@ -881,8 +959,10 @@ describe('AI 游戏创作 App 共享契约', () => {
*
* 规则:落盘 `category === 'unclassified'` 且该资产 `kind` 能派生出明确的非
* `unclassified` 分类时采用派生值;其余情况信任落盘值。目的是自愈历史上被系统误写成
* `unclassified` 的存量(真机 57 条 `kind:"ui"` 资产因此从「待归类」归位「UI 交互」),
* 不写迁移脚本且永久生效。
* `unclassified` 的存量(真机出现过 UI 资产落盘 `unclassified` 而 kind 已是
* `ui-design` 的情况),不写迁移脚本且永久生效。
*
* 认不出的 kind 收口成 `unknown`,派生结果同样是 `unclassified`,规则不触发。
*/
it('自愈规则覆盖落盘 unclassified:这是显式接受的盲区', () => {
// 落盘 unclassified + kind 可明确分类 → 派生值覆盖落盘值。
@@ -895,15 +975,25 @@ describe('AI 游戏创作 App 共享契约', () => {
category: 'unclassified',
}),
).toBe('character');
expect(
gameCreationAppAssetCategory({ kind: 'ui', category: 'unclassified' }),
).toBe('ui-interaction');
expect(
gameCreationAppAssetCategory({
kind: 'art-spritesheet',
kind: 'ui-design',
category: 'unclassified',
}),
).toBe('ui-interaction');
expect(
gameCreationAppAssetCategory({
kind: 'icon',
category: 'unclassified',
}),
).toBe('ui-interaction');
// unknown kind 不在自愈窗口内:它没有可派生的明确分类。
expect(
gameCreationAppAssetCategory({
kind: 'future-kind',
category: 'unclassified',
}),
).toBe('unclassified');
});
it('派生结果本身就是 unclassified 的 kind 不受自愈规则影响,保持落盘值', () => {
@@ -931,7 +1021,10 @@ describe('AI 游戏创作 App 共享契约', () => {
gameCreationAppAssetCategory({ kind: 'character', category: 'audio' }),
).toBe('audio');
expect(
gameCreationAppAssetCategory({ kind: 'ui', category: 'document' }),
gameCreationAppAssetCategory({
kind: GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
category: 'document',
}),
).toBe('document');
});
@@ -943,7 +1036,7 @@ describe('AI 游戏创作 App 共享契约', () => {
* 「编辑标签」面板一旦用读显示口径回写,用户只改标签就会静默改分类。
*/
it('写回口径保留落盘 unclassified,读显示口径才自愈', () => {
const uiAsset = { kind: 'ui', category: 'unclassified' as const };
const uiAsset = { kind: 'ui-design', category: 'unclassified' as const };
expect(gameCreationAppAssetCategory(uiAsset)).toBe('ui-interaction');
expect(gameCreationAppAssetPersistedCategory(uiAsset)).toBe('unclassified');
@@ -965,16 +1058,18 @@ describe('AI 游戏创作 App 共享契约', () => {
// 落盘值合法且非 unclassified 时两个口径一致。
expect(
gameCreationAppAssetPersistedCategory({ kind: 'ui', category: 'audio' }),
gameCreationAppAssetPersistedCategory({
kind: GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
category: 'audio',
}),
).toBe('audio');
});
/**
* 分区顺序必须跨语言一致:Rust 侧 doc 明写 `PROJECT_RESOURCE_CANVAS_SECTIONS`
* 「必须与前端 `PROJECT_RESOURCE_CANVAS_SECTIONS` 保持一致」,但 Rust 单测只钉住自己那份的
* 序列化顺序,谁也发现不了另一侧改了顺序。这里解析 Rust 源码里的常量再与 TS 常量对齐,
* 由 `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析 Rust
* `EFFECTIVE_CATEGORY_CONTRACT` 的同一手法:两侧各写一份就一定会分叉。
* 序列化顺序,谁也发现不了另一侧改了顺序。这里解析 Rust 源码里的常量再与 TS 常量对齐——
* 分区顺序不是 ts-rs 生成物,只能靠这条用例跨语言钉住;两侧各写一份就一定会分叉。
*/
it('资源画布分区顺序与 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 对齐', () => {
const rustSource = readFileSync(
+181 -91
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 =
@@ -69,6 +73,7 @@ export const GAME_CREATION_APP_COMMANDS = [
{ id: 'command.stdin', permission: 'confirm' },
{ id: 'command.terminate', permission: 'confirm' },
{ id: 'cocos.editor.execute', permission: 'confirm' },
{ id: 'godot.editor.execute', permission: 'confirm' },
{ id: 'canvas.project_open', permission: 'confirm' },
{ id: 'canvas.project_sync', permission: 'confirm' },
{ id: 'canvas.asset_import', permission: 'confirm' },
@@ -464,7 +469,7 @@ export interface GameCreationAppAssetSource {
export interface GameCreationAppAssetManifestEntry {
id: string;
kind: string;
kind: GameCreationAppAssetKind;
mediaType: string;
localPath: string;
source: GameCreationAppAssetSource;
@@ -480,93 +485,159 @@ export interface GameCreationAppAssetManifestEntry {
tags?: string[];
}
export const GAME_CREATION_APP_CANONICAL_ASSET_KINDS = [
'image',
'scene',
'character',
'character-animation',
'icon',
'icon-spritesheet',
'icon-spec',
'ui-design',
'publication-material',
'spec',
'video',
'sound-effect',
'background-music',
'audio',
'document',
'code',
] as const;
/**
* UI 设计图(原型图与页面设计图)资产的唯一 kind。
*
* 常量类型钉在生成的 kind union 上:Rust 侧改名/改词表 → 生成 union 变化 → 这里直接编译报错,
* 不存在"手写常量与契约悄悄分叉"的可能。
*/
export const GAME_CREATION_APP_UI_DESIGN_ASSET_KIND: GameCreationAppAssetKind =
'ui-design';
export type GameCreationAppCanonicalAssetKind =
(typeof GAME_CREATION_APP_CANONICAL_ASSET_KINDS)[number];
/** UI Editor JSON 文档资产的唯一 kind 与媒体类型。 */
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;
/**
* 线上出现的非 canonical kind → canonical kind 的别名表。
* 该 manifest 资产是否是 UI 编辑器文档:`kind` + `mediaType` 双条件是唯一定义。
*
* 职责是「线上值 → canonical」,与该值是否属于历史遗留无关:画板导出等现役写入侧
* 仍会写出 `ui` / `animation` / `asset` 这类非 canonical 值,同样必须在这里收口,
* 否则会落到 `canonicalGameCreationAppAssetKind` 的 `image` 兜底,再经
* `image → unclassified` 被误分到「待归类」。
*
* `font` 同样属于现役写入侧:字体上传(`ttf / otf / woff / woff2`)登记的 manifest
* 资产 `kind` 就是 `font`,不在此收口就只能落「待归类」。
*
* 本表与 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 是正确口径。
* 判据原本散在三处(资源画布入口、UI 编辑器桥接、资源引用入口),任一处改了 kind /
* mediaType 口径就会出现「能引用但打不开」这类分叉,所以只保留这一份实现。
* `kind` 走严格解析:认不出的值收口成 `unknown`,不等于文档 kind。
*/
const GAME_CREATION_APP_LEGACY_ASSET_KINDS: Record<
string,
GameCreationAppCanonicalAssetKind
export function isGameCreationAppUiDesignDocAsset(
asset: Pick<GameCreationAppAssetManifestEntry, 'kind' | 'mediaType'>,
): boolean {
return (
parseGameCreationAppAssetKind(asset.kind, 'ui-design-doc.asset') ===
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND &&
asset.mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE
);
}
/**
* 运行期 kind 列表:穷举 Record 保证它不可能与生成 union 分叉(漏一个/多一个都编译报错)。
* 键顺序即生成声明顺序。kind 字符串表只有这一份运行期副本;输入解析只接受 canonical 值。
*/
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',
font: 'document',
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 设计资产的现役写入侧写的是**大写** `"UI"`
* (`src-tauri/src/ui_editor/resource_bridge.rs`、`workflow.rs`、`persistence.rs`)。
* 只按小写收口会让它落 `image` 兜底、再经 `image → unclassified` 永远停在「待归类」,
* 且派生值本身就是 `unclassified`,读时自愈也救不回来。
*/
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_AUDIO_ASSET_KIND_MEMBERS: Record<
Extract<
GameCreationAppAssetKind,
'audio' | 'sound-effect' | 'background-music'
>,
true
> = {
audio: true,
'sound-effect': true,
'background-music': true,
};
const GAME_CREATION_APP_VISUAL_ASSET_KIND_MEMBERS: Record<
Extract<
GameCreationAppAssetKind,
| 'image'
| 'scene'
| 'character'
| 'character-animation'
| 'icon'
| 'icon-spritesheet'
| 'icon-spec'
| 'ui-design'
>,
true
> = {
image: true,
scene: true,
character: true,
'character-animation': true,
icon: true,
'icon-spritesheet': true,
'icon-spec': true,
'ui-design': true,
};
export function isGameCreationAppAssetAudioKind(
kind: GameCreationAppAssetKind,
): boolean {
return kind in GAME_CREATION_APP_AUDIO_ASSET_KIND_MEMBERS;
}
export function isGameCreationAppAssetVisualKind(
kind: GameCreationAppAssetKind,
): boolean {
return kind in GAME_CREATION_APP_VISUAL_ASSET_KIND_MEMBERS;
}
const GAME_CREATION_APP_ASSET_KIND_SET = new Set<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!`)。
*
* 只接受精确的 canonical 值,其他输入一律收口为 `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 = [
@@ -581,9 +652,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',
@@ -594,14 +668,17 @@ export const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: Record<
'icon-spritesheet': 'ui-interaction',
'icon-spec': 'ui-interaction',
'ui-design': 'ui-interaction',
'ui-design-doc': 'ui-interaction',
'publication-material': 'unclassified',
spec: 'document',
video: 'unclassified',
'sound-effect': 'audio',
'background-music': 'audio',
audio: 'audio',
font: 'document',
document: 'document',
code: 'unclassified',
unknown: 'unclassified',
};
export function normalizeGameCreationAppAssetCategory(
@@ -617,11 +694,19 @@ export function normalizeGameCreationAppAssetCategory(
}
export function gameCreationAppAssetCategoryForKind(
kind: string,
kind: GameCreationAppAssetKind,
): GameCreationAppAssetCategory {
return GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[
canonicalGameCreationAppAssetKind(kind)
];
return GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[kind];
}
/** 解析外部 kind 后再按共享枚举派生分类;内部业务逻辑应传入已解析的枚举。 */
export function gameCreationAppAssetCategoryForRawKind(
raw: unknown,
context: string,
): GameCreationAppAssetCategory {
return gameCreationAppAssetCategoryForKind(
parseGameCreationAppAssetKind(raw, context),
);
}
/**
@@ -635,8 +720,8 @@ export function gameCreationAppAssetCategoryForKind(
*
* **例外(唯一的覆盖窗口)**:落盘值是 `unclassified`,而该资产 `kind` 能派生出明确的
* 非 `unclassified` 分类时,采用派生值。这条规则用于自愈「历史上被系统错误写成
* unclassified」的存量数据——典型例子是 `kind:"ui"` 的 UI 资产曾因 kind 不在 canonical
* 目录而落到 `image → unclassified`,修复别名后并不会自动归位,因为落盘值已固化。
* unclassified」的存量数据——典型例子是 UI 资产落盘 `unclassified` 而 kind 已是 `ui-design`。
* kind 认不出时只收口成 `unknown`(派生同样是 `unclassified`)并留痕原始串。
*
* 盲区:用户**手动**把一个 kind 已能明确分类的资产设成「待归类」时,该手动值会被这条
* 规则覆盖。这是有意接受的取舍——「手动设为待归类」本身意图边缘,且 kind 已经表达了分类;
@@ -647,14 +732,17 @@ 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'>,
): GameCreationAppAssetCategory {
const persisted = normalizeGameCreationAppAssetCategory(asset.category);
const derivedFromKind = gameCreationAppAssetCategoryForKind(asset.kind);
const derivedFromKind = gameCreationAppAssetCategoryForKind(
parseGameCreationAppAssetKind(asset.kind, 'asset-category.display'),
);
if (persisted === null) {
return derivedFromKind;
}
@@ -669,7 +757,7 @@ export function gameCreationAppAssetCategory(
*
* 与 `gameCreationAppAssetCategory` 的分工是「读显示 / 写回」:显示层可以自愈历史上的误判值,
* 但写入层必须原样保留落盘值——「编辑标签」面板若回传自愈值,就把「只改标签」变成了
* 静默改分类(真机已发生:同一条 `kind:"ui"` 资产出现 `unclassified` 与 `ui-interaction` 两种落盘值)。
* 静默改分类(真机已发生:同一条资产出现 `unclassified` 与 `ui-interaction` 两种落盘值)。
*
* Rust 侧 manifest 反序列化(`game_creation_app.rs` 的 `GameCreationAppAssetManifestEntry`
* `Deserialize`)在字段缺失时用同一套 `kind` 派生;未知分类值则在读侧失败关闭(不再退化成
@@ -681,7 +769,9 @@ export function gameCreationAppAssetPersistedCategory(
): GameCreationAppAssetCategory {
return (
normalizeGameCreationAppAssetCategory(asset.category) ??
gameCreationAppAssetCategoryForKind(asset.kind)
gameCreationAppAssetCategoryForKind(
parseGameCreationAppAssetKind(asset.kind, 'asset-category.persisted'),
)
);
}
@@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* GameCreationApp manifest 资源 kind 的**唯一**类型,也是 TS 侧 kind union 的唯一真源
* (经 ts-rs 生成,前端不再手写第二份列表)。
*
* JSON 使用 kebab-case;`Unknown` 表示当前边界无法解析输入。
* 写入方可以保留这个未解析结果,但必须由调用者决定是否拒绝写入或交给后续归类。
*/
export type GameCreationAppAssetKind = "image" | "scene" | "character" | "character-animation" | "icon" | "icon-spritesheet" | "icon-spec" | "ui-design" | "ui-design-doc" | "publication-material" | "spec" | "video" | "audio" | "sound-effect" | "background-music" | "font" | "document" | "code" | "unknown";
+1
View File
@@ -1,5 +1,6 @@
export * from './components';
export * from './contracts/auth';
export type * from './contracts/clientDownload';
export * from './contracts/common';
export type * from './contracts/editorAudio';
export * from './contracts/editorScene';
@@ -0,0 +1,26 @@
/**
* 毫秒耗时的统一显示:5.2秒 / 2分05秒 / 1时02分05秒。
* 不足一分钟保留一位小数,达到分钟后显示整数秒;先舍入再拆单位,避免进位溢出。
* 非法或未知耗时返回 null,不能以 0 冒充已测得结果。
*/
export function formatElapsedDuration(
milliseconds: number | null | undefined,
): string | null {
if (
milliseconds == null ||
!Number.isFinite(milliseconds) ||
milliseconds < 0
) {
return null;
}
const tenths = Math.round(milliseconds / 100);
if (tenths < 600) return `${(tenths / 10).toFixed(1)}秒`;
const totalSeconds = Math.round(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = String(totalSeconds % 60).padStart(2, '0');
if (hours > 0) {
return `${hours}时${String(minutes).padStart(2, '0')}分${seconds}秒`;
}
return `${minutes}分${seconds}秒`;
}
+1
View File
@@ -1 +1,2 @@
export { formatElapsedDuration } from './formatElapsedDuration';
export { cn } from './utils';