合并 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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 +1,2 @@
|
||||
export { formatElapsedDuration } from './formatElapsedDuration';
|
||||
export { cn } from './utils';
|
||||
|
||||
Reference in New Issue
Block a user