Files
Genarrative/packages/shared/src/contracts/gameCreationApp.ts
T
kdletters 54d0fb75ea 接入 Godot 编辑器插件与受控执行链路
新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存
接入 AGC 插件开关、Runner、Agent 工具与权限审计
完善执行回执确认、不确定状态阻断及卸载恢复
补齐 Windows 分发资源、定向测试与实机验收文档
2026-09-20 16:35:04 +08:00

1084 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 =
'game-creator-agent-run.v1';
export const GAME_CREATION_AGENT_RUN_MAX_PASSES = 3;
export const GAME_CREATION_AGENT_TOOL_CALL_MAX = 128;
export type GameCreationAppPermission = 'auto' | 'confirm' | 'deny';
export interface GameCreationAppCommandDescriptor {
id: string;
permission: GameCreationAppPermission;
}
export const GAME_CREATION_APP_COMMANDS = [
{ id: 'help.show', permission: 'auto' },
{ id: 'project.create', permission: 'confirm' },
{ id: 'project.rename', permission: 'confirm' },
{ id: 'project.status', permission: 'auto' },
{ id: 'project.index', permission: 'auto' },
{ id: 'project.checkpoint', permission: 'confirm' },
{ id: 'project.diff', permission: 'auto' },
{ id: 'project.git_inspect', permission: 'auto' },
{ id: 'project.git_commit', permission: 'confirm' },
{ id: 'project.patchset', permission: 'confirm' },
{ id: 'project.restore', permission: 'confirm' },
{ id: 'project.verify', permission: 'confirm' },
{ id: 'project.bootstrap', permission: 'confirm' },
{ id: 'project.export_package', permission: 'confirm' },
{ id: 'project.export_list', permission: 'auto' },
{ id: 'project.policy_read', permission: 'auto' },
{ id: 'project.policy_write', permission: 'confirm' },
{ id: 'task.list', permission: 'auto' },
{ id: 'task.create', permission: 'confirm' },
{ id: 'task.update', permission: 'confirm' },
{ id: 'agent.trace_read', permission: 'auto' },
{ id: 'agent.run_status', permission: 'auto' },
{ id: 'agent.compact', permission: 'auto' },
{ id: 'agent.kill', permission: 'confirm' },
{ id: 'agent.retry', permission: 'confirm' },
{ id: 'agent.resume', permission: 'confirm' },
{ id: 'agent.delegate', permission: 'confirm' },
{ id: 'agent.spawn_isolated', permission: 'confirm' },
{ id: 'agent.schedule_ready', permission: 'confirm' },
{ id: 'agent.capabilities', permission: 'auto' },
{ id: 'agent.audit', permission: 'auto' },
{ id: 'llm.config_check', permission: 'auto' },
{ id: 'mcp.call', permission: 'auto' },
{ id: 'game.generate_draft', permission: 'confirm' },
{ id: 'game.run_local', permission: 'confirm' },
{ id: 'file.list', permission: 'auto' },
{ id: 'file.read', permission: 'auto' },
{ id: 'file.write', permission: 'confirm' },
{ id: 'file.delete', permission: 'confirm' },
{ id: 'asset.list', permission: 'auto' },
{ id: 'asset.upload', permission: 'confirm' },
{ id: 'asset.register', permission: 'confirm' },
{ id: 'image.inspect', permission: 'auto' },
{ id: 'preview.start', permission: 'confirm' },
{ id: 'preview.validate', permission: 'auto' },
{ id: 'preview.open', permission: 'confirm' },
{ id: 'preview.stop', permission: 'auto' },
{ id: 'preview.status', permission: 'auto' },
{ id: 'command.run_limited', permission: 'confirm' },
{ id: 'command.exec', permission: 'confirm' },
{ id: 'command.output_read', permission: 'auto' },
{ id: 'command.start', permission: 'confirm' },
{ id: 'command.poll', permission: 'auto' },
{ 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' },
{ id: 'canvas.asset_generate', permission: 'confirm' },
{ id: 'canvas.export_import', permission: 'confirm' },
{ id: 'memory.read', permission: 'auto' },
{ id: 'memory.write', permission: 'confirm' },
{ id: 'memory.delete', permission: 'confirm' },
{ id: 'conversation.read', permission: 'auto' },
{ id: 'conversation.write', permission: 'auto' },
] as const satisfies readonly GameCreationAppCommandDescriptor[];
export interface GameCreationAgentCapabilityDescriptor {
id: string;
area: 'user' | 'agent-runtime' | 'local-runtime' | 'dev-runtime';
title: string;
platforms?: readonly ('linux' | 'macos' | 'windows')[];
}
export const GAME_CREATION_AGENT_CAPABILITIES = [
{ id: 'chat', area: 'user', title: '聊天入口' },
{ id: 'project-supervisor', area: 'agent-runtime', title: '项目总控 Agent' },
{
id: 'persistent-user-input',
area: 'agent-runtime',
title: '持久用户澄清请求',
},
{ id: 'file-upload', area: 'user', title: '上传文件' },
{ id: 'built-in-commands', area: 'agent-runtime', title: '内置命令调用' },
{ id: 'llm-draft-generation', area: 'agent-runtime', title: 'LLM 草案生成' },
{
id: 'provider-web-search',
area: 'agent-runtime',
title: 'Provider 原生联网检索',
},
{
id: 'context-compaction',
area: 'agent-runtime',
title: '持久上下文压缩',
},
{
id: 'mcp-tools',
area: 'agent-runtime',
title: 'Runner 托管的 MCP 动态工具',
},
{ id: 'task-decomposition', area: 'agent-runtime', title: '任务拆分' },
{ id: 'orchestration', area: 'agent-runtime', title: '任务编排' },
{
id: 'agent-loop',
area: 'agent-runtime',
title: 'Planner / Generator / Evaluator 循环',
},
{
id: 'task-graph-agenda',
area: 'agent-runtime',
title: '任务图议程',
},
{
id: 'evaluator-repair-routing',
area: 'agent-runtime',
title: 'Evaluator 返工路由',
},
{
id: 'tool-call-budget',
area: 'agent-runtime',
title: '工具调用预算',
},
{
id: 'multi-agent-collaboration',
area: 'agent-runtime',
title: '多智能体协作',
},
{
id: 'role-level-collaboration',
area: 'agent-runtime',
title: '组内角色协作',
},
{
id: 'isolated-subagents',
area: 'agent-runtime',
title: '动态隔离子 Agent',
},
{ id: 'quality-review', area: 'agent-runtime', title: '质量评审' },
{ id: 'run-lifecycle', area: 'agent-runtime', title: 'Run 生命周期控制' },
{
id: 'repair-loop-carryover',
area: 'agent-runtime',
title: '返工范围与 Carry-over',
},
{ id: 'short-term-memory', area: 'agent-runtime', title: '短期记忆' },
{ id: 'long-term-memory', area: 'agent-runtime', title: '长期记忆' },
{
id: 'conversation-history',
area: 'agent-runtime',
title: '对话记录上下文',
},
{ id: 'local-artifacts', area: 'local-runtime', title: '本地产物保存' },
{ id: 'project-checkpoints', area: 'local-runtime', title: '项目快照与恢复' },
{ id: 'project-index', area: 'local-runtime', title: '本地项目索引' },
{
id: 'repository-startup-context',
area: 'local-runtime',
title: '仓库启动上下文',
},
{
id: 'persistent-process-sessions',
area: 'local-runtime',
title: 'Runner 托管的持久进程会话',
},
{ id: 'local-preview', area: 'local-runtime', title: '本地 HTTP 预览' },
{
id: 'browser-validation',
area: 'local-runtime',
title: '浏览器试玩验证',
},
{
id: 'visual-inspection',
area: 'local-runtime',
title: '模型视觉检查',
},
{
id: 'canvas-project-sync',
area: 'local-runtime',
title: '画板项目资源同步',
},
{ id: 'developer-window', area: 'dev-runtime', title: '开发窗口' },
{ id: 'persistent-runner', area: 'dev-runtime', title: '独立持久 Runner' },
{
id: 'command-exec',
area: 'dev-runtime',
title: '结构化项目命令执行(Linux 工作区沙箱)',
},
{
id: 'os-workspace-sandbox',
area: 'dev-runtime',
title: 'Linux OS 强制工作区沙箱',
platforms: ['linux'],
},
{
id: 'command-output-read',
area: 'dev-runtime',
title: '命令输出分页回查',
},
{ id: 'guardrails', area: 'dev-runtime', title: '权限 Gate' },
{ id: 'project-policy', area: 'dev-runtime', title: '项目级权限策略' },
{ id: 'trace-log', area: 'dev-runtime', title: '执行日志' },
] as const satisfies readonly GameCreationAgentCapabilityDescriptor[];
export interface GameCreationAppLimitedRunCommandDescriptor {
id: string;
title: string;
}
export const GAME_CREATION_APP_LIMITED_RUN_COMMANDS = [
{ id: 'game.static_smoke', title: '静态入口自检' },
] as const satisfies readonly GameCreationAppLimitedRunCommandDescriptor[];
export type GameCreationAppAgentGroup =
| 'design'
| 'art'
| 'code'
| 'balance'
| 'audio'
| 'publishing';
export type GameCreationAppTaskStatus =
| 'pending'
| 'running'
| 'waiting-for-confirmation'
| 'completed'
| 'failed';
export interface GameCreationAppTaskState {
id: string;
title: string;
group: GameCreationAppAgentGroup;
role: string;
status: GameCreationAppTaskStatus;
dependencies: string[];
artifacts: string[];
acceptanceCriteria: string[];
}
export const GAME_CREATION_APP_SEED_TASKS = [
{
id: 'design-director',
title: '拆解创作方向',
group: 'design',
role: 'Director',
status: 'pending',
dependencies: [],
artifacts: ['.agent/spec.md'],
acceptanceCriteria: ['创作目标、范围和专业组分工明确'],
},
{
id: 'art-director',
title: '确定视觉方向与规范图',
group: 'art',
role: 'Director',
status: 'pending',
dependencies: [],
artifacts: [
'.agent/passes/pass-*/groups/art/director.md',
'assets/art-spec.png',
],
acceptanceCriteria: [
'角色、场景和 UI 的统一视觉方向明确,且已生成可复用的 1:1 图标规范图',
],
},
{
id: 'design-foundation',
title: '确定玩法规格与界面原型',
group: 'design',
role: 'Gameplay',
status: 'pending',
dependencies: ['design-director', 'art-director'],
artifacts: [
'memory/project.md',
'game/game_design.md',
'assets/ui-prototype.png',
],
acceptanceCriteria: [
'核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图',
],
},
{
id: 'balance-director',
title: '确定数值口径',
group: 'balance',
role: 'Director',
status: 'pending',
dependencies: ['design-foundation'],
artifacts: ['.agent/passes/pass-*/groups/balance/director.md'],
acceptanceCriteria: ['难度、节奏和得分口径可指导数值表'],
},
{
id: 'balance-seed',
title: '生成初版数值',
group: 'balance',
role: 'Difficulty',
status: 'pending',
dependencies: ['balance-director'],
artifacts: ['game/balance.json'],
acceptanceCriteria: ['速度、生命、得分和难度参数可被程序组读取'],
},
{
id: 'art-asset-plan',
title: '生成首版美术素材',
group: 'art',
role: 'Asset',
status: 'pending',
dependencies: ['art-director', 'design-foundation'],
artifacts: ['assets/manifest.art.json', 'assets/art-spritesheet.png'],
acceptanceCriteria: [
'角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记',
],
},
{
id: 'art-polish',
title: '检查美术可用性',
group: 'art',
role: 'Polish',
status: 'pending',
dependencies: ['art-asset-plan'],
artifacts: ['.agent/passes/pass-*/groups/art/polish.md'],
acceptanceCriteria: ['首版资产不阻塞可玩原型和后续画板精修'],
},
{
id: 'audio-director',
title: '确定声音方向',
group: 'audio',
role: 'Director',
status: 'pending',
dependencies: ['design-foundation'],
artifacts: ['.agent/passes/pass-*/groups/audio/director.md'],
acceptanceCriteria: ['BGM 氛围和交互音效边界明确'],
},
{
id: 'audio-asset-plan',
title: '规划音乐音效',
group: 'audio',
role: 'SFX',
status: 'pending',
dependencies: ['audio-director'],
artifacts: ['assets/manifest.audio.json'],
acceptanceCriteria: ['BGM 和核心交互音效需求已明确'],
},
{
id: 'code-director',
title: '拆解程序实现',
group: 'code',
role: 'Director',
status: 'pending',
dependencies: [],
artifacts: ['.agent/passes/pass-*/groups/code/director.md'],
acceptanceCriteria: ['渲染、输入、状态和数据读取边界明确'],
},
{
id: 'code-prototype',
title: '生成可运行原型',
group: 'code',
role: 'Code',
status: 'pending',
dependencies: [
'code-director',
'balance-seed',
'art-polish',
'audio-asset-plan',
],
artifacts: ['game/'],
acceptanceCriteria: ['本地 Web 游戏项目可以通过 HTTP server 打开'],
},
{
id: 'quality-review',
title: '执行质量评审',
group: 'code',
role: 'Review',
status: 'pending',
dependencies: ['code-prototype'],
artifacts: ['.agent/findings.md', '.agent/run.latest.json'],
acceptanceCriteria: [
'玩法、资产、数值、程序和发布包装通过跨专业组质量评审',
],
},
{
id: 'preview-readiness',
title: '执行静态自检',
group: 'code',
role: 'Preview',
status: 'pending',
dependencies: ['quality-review'],
artifacts: ['.agent/logs/command.log', '.agent/run.latest.json'],
acceptanceCriteria: ['HTML 自包含且通过本地静态 smoke'],
},
{
id: 'preview-playtest',
title: '预览并试玩验收',
group: 'code',
role: 'Playtest',
status: 'pending',
dependencies: ['preview-readiness'],
artifacts: ['.agent/logs/preview.log'],
acceptanceCriteria: ['预览不是空白页,主循环和基础输入可用'],
},
{
id: 'publish-strategy',
title: '整理运营定位',
group: 'publishing',
role: 'Director',
status: 'pending',
dependencies: ['preview-playtest'],
artifacts: ['.agent/passes/pass-*/groups/publishing/director.md'],
acceptanceCriteria: ['标题、卖点、标签和封面方向明确'],
},
{
id: 'publish-package',
title: '整理发布包装',
group: 'publishing',
role: 'Publish',
status: 'pending',
dependencies: ['publish-strategy'],
artifacts: ['exports/README.md'],
acceptanceCriteria: ['标题、简介、标签、封面需求和导出检查已完成'],
},
] satisfies readonly GameCreationAppTaskState[];
export function createGameCreationAppSeedTasks(): GameCreationAppTaskState[] {
return GAME_CREATION_APP_SEED_TASKS.map((task) => ({
...task,
dependencies: [...task.dependencies],
artifacts: [...task.artifacts],
acceptanceCriteria: [...task.acceptanceCriteria],
}));
}
export type GameCreationAppAssetSourceKind =
| 'uploaded'
| 'generated'
| 'canvas';
export interface GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind;
canvasProjectId?: string | null;
resourceId?: string | null;
assetObjectId?: string | null;
taskId?: string | null;
prompt?: string | null;
model?: string | null;
generationRoute?: string | null;
generationKind?: string | null;
referenceResourceIds?: string[];
}
export interface GameCreationAppAssetManifestEntry {
id: string;
kind: GameCreationAppAssetKind;
mediaType: string;
localPath: string;
source: GameCreationAppAssetSource;
imageSequenceFrames?: Array<{
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
}> | null;
imageSequenceDurationMs?: number | null;
category?: GameCreationAppAssetCategory;
tags?: string[];
}
/**
* 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: GameCreationAppAssetKind =
'ui-design-doc';
export const GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE =
'application/json' as const;
/**
* 该 manifest 资产是否是 UI 编辑器文档:`kind` + `mediaType` 双条件是唯一定义。
*
* 判据原本散在三处(资源画布入口、UI 编辑器桥接、资源引用入口),任一处改了 kind /
* mediaType 口径就会出现「能引用但打不开」这类分叉,所以只保留这一份实现。
* `kind` 走严格解析:认不出的值收口成 `unknown`,不等于文档 kind。
*/
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
> = {
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 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 (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';
}
export const GAME_CREATION_APP_ASSET_CATEGORIES = [
'ui-interaction',
'character',
'scene',
'audio',
'document',
'unclassified',
] as const;
export type GameCreationAppAssetCategory =
(typeof GAME_CREATION_APP_ASSET_CATEGORIES)[number];
/**
* kind 到功能分类的默认映射,按生成的 kind union **穷举**:少一个成员编译不过。
* 认不出的 kind 收口成 `unknown` 后同样落「待归类」,不做别名归一。
*/
export const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: Record<
GameCreationAppAssetKind,
GameCreationAppAssetCategory
> = {
image: 'unclassified',
scene: 'scene',
character: 'character',
'character-animation': 'character',
icon: 'ui-interaction',
'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(
value: unknown,
): GameCreationAppAssetCategory | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
return (GAME_CREATION_APP_ASSET_CATEGORIES as readonly string[]).includes(
normalized,
)
? (normalized as GameCreationAppAssetCategory)
: null;
}
export function gameCreationAppAssetCategoryForKind(
kind: GameCreationAppAssetKind,
): GameCreationAppAssetCategory {
return GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[kind];
}
/** 解析外部 kind 后再按共享枚举派生分类;内部业务逻辑应传入已解析的枚举。 */
export function gameCreationAppAssetCategoryForRawKind(
raw: unknown,
context: string,
): GameCreationAppAssetCategory {
return gameCreationAppAssetCategoryForKind(
parseGameCreationAppAssetKind(raw, context),
);
}
/**
* manifest 资产的最终功能分类。
*
* 优先级:落盘 `category`(用户可在「分类与标签」面板手动设置,是权威值)→ 按 `kind` 派生。
* 历史 manifest 缺少 category、或(内存对象上的)category 非法时都退回 kind 派生;
* 注意「落盘值非法」这一支已经不可达:Rust 读侧对未知 `category` 失败关闭(见
* `server-rs/crates/shared-contracts/src/game_creation_app.rs` 的
* `GameCreationAppAssetManifestEntryWire`),比本客户端新的值会让整份 manifest 直接读不出来。
*
* **例外(唯一的覆盖窗口)**:落盘值是 `unclassified`,而该资产 `kind` 能派生出明确的
* 非 `unclassified` 分类时,采用派生值。这条规则用于自愈「历史上被系统错误写成
* unclassified」的存量数据——典型例子是 UI 资产落盘 `unclassified` 而 kind 已是 `ui-design`。
* kind 认不出时只收口成 `unknown`(派生同样是 `unclassified`)并留痕原始串。
*
* 盲区:用户**手动**把一个 kind 已能明确分类的资产设成「待归类」时,该手动值会被这条
* 规则覆盖。这是有意接受的取舍——「手动设为待归类」本身意图边缘,且 kind 已经表达了分类;
* 而反过来漏掉这条规则,所有历史误判都无法自愈。
* kind 派生结果本身就是 `unclassified` 的(如 `image` / `video` / `code`)不受影响,
* 仍然信任落盘值。
*
* **这是「读显示」口径,不是「写回」口径。** 需要回写 manifest 的调用方必须用
* `gameCreationAppAssetPersistedCategory`,否则会把自愈值写回落盘、把「只改标签」
* 变成静默改分类。Rust 侧由 `game_creation_app_asset_effective_category` 实现同一口径
* (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(
parseGameCreationAppAssetKind(asset.kind, 'asset-category.display'),
);
if (persisted === null) {
return derivedFromKind;
}
if (persisted === 'unclassified' && derivedFromKind !== 'unclassified') {
return derivedFromKind;
}
return persisted;
}
/**
* 回写 manifest 用的落盘分类:**不套用读时自愈**,只做「缺失或非法 → 按 kind 派生」的兜底。
*
* 与 `gameCreationAppAssetCategory` 的分工是「读显示 / 写回」:显示层可以自愈历史上的误判值,
* 但写入层必须原样保留落盘值——「编辑标签」面板若回传自愈值,就把「只改标签」变成了
* 静默改分类(真机已发生:同一条资产出现 `unclassified` 与 `ui-interaction` 两种落盘值)。
*
* Rust 侧 manifest 反序列化(`game_creation_app.rs` 的 `GameCreationAppAssetManifestEntry`
* `Deserialize`)在字段缺失时用同一套 `kind` 派生;未知分类值则在读侧失败关闭(不再退化成
* 派生值),所以从 manifest 读进来的资产本函数结果必然等于它的落盘 `category`。
* 剩下这一支「非法值 → 派生」只服务内存对象(手工构造的资产),不是落盘口径的兜底。
*/
export function gameCreationAppAssetPersistedCategory(
asset: Pick<GameCreationAppAssetManifestEntry, 'kind' | 'category'>,
): GameCreationAppAssetCategory {
return (
normalizeGameCreationAppAssetCategory(asset.category) ??
gameCreationAppAssetCategoryForKind(
parseGameCreationAppAssetKind(asset.kind, 'asset-category.persisted'),
)
);
}
export function gameCreationAppAssetTags(
asset: Pick<GameCreationAppAssetManifestEntry, 'tags'>,
): string[] {
return Array.isArray(asset.tags)
? asset.tags.filter((tag): tag is string => typeof tag === 'string')
: [];
}
export function normalizeGameCreationAppAssetTags(
tags: readonly string[],
): string[] {
const normalized: string[] = [];
for (const tag of tags) {
const trimmed = tag.trim();
if (!trimmed || normalized.includes(trimmed)) continue;
normalized.push(trimmed);
}
return normalized;
}
export const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION =
'game-creator-resource-layout.v1' as const;
export const GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION = 9_007_199_254_740_991;
export const GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE = -1_000_000;
export const GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE = 1_000_000;
export function isSafeProjectResourceCanvasLayoutRevision(
value: unknown,
): value is number {
return (
typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= 0 &&
value <= GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
);
}
export function isSafeProjectResourceCanvasCoordinate(
value: unknown,
): value is number {
return (
typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE &&
value <= GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE
);
}
export type ProjectResourceCanvasLayoutMode = 'dependency' | 'type';
/**
* 资源画布分区轴:6 类资源资产功能分类 + 末尾独立的「项目版本」栏目。
*
* 项目版本不是资源资产,6 类资产分类轴对它不适用,因此固定在末尾单独成栏,
* 不与「待归类」混在一起。
*/
export const PROJECT_RESOURCE_CANVAS_SECTIONS = [
...GAME_CREATION_APP_ASSET_CATEGORIES,
'version',
] as const;
export type ProjectResourceCanvasCategory =
(typeof PROJECT_RESOURCE_CANVAS_SECTIONS)[number];
/**
* 旧四 / 五栏目分区值(扩展名 + mediaType 口径)的读兼容白名单。
*
* `document` / `audio` / `version` 在新旧口径下同名,只有 `art` / `code` 是旧口径独有值。
* 这五个值是布局 sidecar 的历史取值,只用于继续读取既有坐标,不作为写入值。
*/
export const LEGACY_PROJECT_RESOURCE_CANVAS_SECTIONS = [
'code',
'document',
'version',
'art',
'audio',
] as const;
export type LegacyProjectResourceCanvasSection =
(typeof LEGACY_PROJECT_RESOURCE_CANVAS_SECTIONS)[number];
/** 布局 sidecar 的 `section` 字段在磁盘上可能出现的全部取值:现行分区 + 旧栏目白名单。 */
export type PersistedProjectResourceCanvasSection =
| ProjectResourceCanvasCategory
| LegacyProjectResourceCanvasSection;
export function isProjectResourceCanvasCategory(
value: unknown,
): value is ProjectResourceCanvasCategory {
return (
typeof value === 'string' &&
(PROJECT_RESOURCE_CANVAS_SECTIONS as readonly string[]).includes(value)
);
}
export function isLegacyProjectResourceCanvasSection(
value: unknown,
): value is LegacyProjectResourceCanvasSection {
return (
typeof value === 'string' &&
(LEGACY_PROJECT_RESOURCE_CANVAS_SECTIONS as readonly string[]).includes(
value,
)
);
}
export interface ProjectResourceCanvasPosition {
resourceId: string;
/**
* sidecar 里可能仍是旧栏目取值,读取时必须经
* `normalizeResourceCanvasPosition` 归一到现行分区后再使用。
*/
section: PersistedProjectResourceCanvasSection;
x: number;
y: number;
manuallyPlaced: boolean;
}
export interface ProjectResourceCanvasLayout {
schemaVersion: typeof GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
revision: number;
positions: ProjectResourceCanvasPosition[];
updatedAt: number;
}
export type UpdateProjectResourceCanvasLayoutResult =
| {
status: 'updated';
layout: ProjectResourceCanvasLayout;
}
| {
status: 'conflict';
layout: ProjectResourceCanvasLayout;
};
export type GameCreationAppPreviewStatus =
| 'stopped'
| 'starting'
| 'running'
| 'failed';
export interface GameCreationAppPreviewState {
status: GameCreationAppPreviewStatus;
url?: string | null;
port?: number | null;
}
export interface GameCreationAppCommandRunState {
commandId: string;
status: 'completed' | 'failed';
output: string;
logPath: string;
updatedAt: number;
}
export type GameIterationVersionCreatedReason =
| 'initial'
| 'resource-replacement'
| 'agent-revision';
export interface GameIterationVersionResourceBinding {
slotId: string;
resourceId: string;
}
export interface GameIterationVersion {
versionId: string;
parentVersionId: string | null;
projectRevision: number;
resourceBindings: GameIterationVersionResourceBinding[];
createdReason: GameIterationVersionCreatedReason;
/**
* 创建时间,单位是 **Unix 秒**。
*
* 写入侧为 `project/manifest.rs` 的 `created_at: unix_timestamp()`,该函数返回
* `duration.as_secs()`,全仓版本条目统一按秒落盘。渲染时必须换算成毫秒
* (`new Date(createdAt * 1000)`),不要把秒值直接交给 `Date`。
*/
createdAt: number;
editPrompt?: string | null;
}
export interface GameCreationAppManifest {
schemaVersion: string;
projectId: string;
name: string;
goal?: string | null;
tasks: GameCreationAppTaskState[];
assets: GameCreationAppAssetManifestEntry[];
preview?: GameCreationAppPreviewState | null;
commandRuns?: GameCreationAppCommandRunState[];
versions?: GameIterationVersion[];
godotProjectRoot?: string | null;
cocosProjectRoot?: string | null;
}
export interface GameCreationAgentToolCallTrace {
toolId: string;
status: string;
inputPaths: string[];
outputPaths: string[];
summary: string;
}
export interface GameCreationAgentRunStep {
pass: number;
agent: string;
phase: string;
taskId: string | null;
group: GameCreationAppAgentGroup | null;
role: string | null;
status: string;
inputPaths: string[];
outputPaths: string[];
summary: string;
toolCalls: GameCreationAgentToolCallTrace[];
}
export interface GameCreationAgentArtifactTrace {
path: string;
sizeBytes: number;
checksum: string;
}
export interface GameCreationAgentRepairRouteTrace {
issue: string;
taskIds: string[];
reason: string;
}
export interface GameCreationAgentRunTaskGraphTrace {
goal: string;
readyTaskIds: string[];
activeTaskIds: string[];
carriedTaskIds: string[];
repairFocus: string[];
repairRoutes: GameCreationAgentRepairRouteTrace[];
tasks: GameCreationAppTaskState[];
}
export interface GameCreationAgentPassPlanTrace {
pass: number;
mode: 'initial' | 'repair' | string;
summary: string;
activeTaskIds: string[];
carriedTaskIds: string[];
dependencyWaves: string[][];
repairFocus: string[];
repairRoutes: GameCreationAgentRepairRouteTrace[];
}
export interface GameCreationAgentRunTrace {
schemaVersion: typeof GAME_CREATION_AGENT_RUN_SCHEMA_VERSION;
runId: string;
commandId: string;
status: string;
lifecycleStatus?: string | null;
passes: number;
maxPasses: number;
toolCallCount: number;
maxToolCalls: number;
stopReason: string;
goal: string;
coordination: string;
steps: GameCreationAgentRunStep[];
artifacts: GameCreationAgentArtifactTrace[];
taskGraph: GameCreationAgentRunTaskGraphTrace;
passPlans: GameCreationAgentPassPlanTrace[];
nextStep: string;
error: string | null;
updatedAt: number;
}
export function selectGameCreationAppReadyTasks(
manifest: Pick<GameCreationAppManifest, 'tasks'>,
): GameCreationAppTaskState[] {
const tasks =
manifest.tasks.length > 0
? manifest.tasks
: createGameCreationAppSeedTasks();
const completed = new Set(
tasks.filter((task) => task.status === 'completed').map((task) => task.id),
);
return tasks.filter(
(task) =>
task.status === 'pending' &&
task.dependencies.every((dependency) => completed.has(dependency)),
);
}
export function createGameCreationAppManifest(
projectId: string,
name: string,
): GameCreationAppManifest {
return {
schemaVersion: GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION,
projectId,
name,
goal: null,
tasks: createGameCreationAppSeedTasks(),
assets: [],
};
}