0f829cd252
## 变更 单 HTML → Phaser 4 + Vite 的迁移链路现在有明确且可执行的工程合同:Phaser/npm 目标固定走 DirectProject,旧 JSON Generator 继续只处理单文件 HTML。 - 新增受控 `project.bootstrap`,仅允许项目内 `game` 目录执行无参数 `npm install`,校验 package/lock、路径、npm 管理器并记录依赖指纹。 - `project.verify` 支持相对 `cwd`,构建前诊断缺失依赖,`cwd=game` 的 build 必须产出 `game/dist/index.html`。 - npm bootstrap 使用受控网络沙箱;通用 `command.exec npm install` 仍被拒绝。 - 同步 Runtime 工具广告、执行分发、审计、权限策略、DirectProject/Generator 提示、前端权限面板、TypeScript/Rust 契约和项目文档。 - Phaser 迁移提示覆盖 Scene、输入、敌人/守卫、波次、胜负、重开和桌面/移动双视口验收。 ## 验证 - `npm run ai-game-creator-shell:typecheck` - `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` - `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` - `cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app` - 定向 `project.verify` Rust 测试 - `npm run check:encoding` - `npm run check:doc-index` - `git diff --check` - 真实 DirectProject:`npm install`、`npm run build` 成功,`game/dist/index.html` 存在。 - 浏览器试玩:桌面视口画布非空;移动视口触摸开火、键盘换道和重开逻辑均已观察确认。 真实项目没有纳入仓库提交;PR 只包含客户端合同、Runtime 能力和验证门禁。 Reviewed-on: #353
994 lines
32 KiB
TypeScript
994 lines
32 KiB
TypeScript
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: '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: string;
|
||
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[];
|
||
}
|
||
|
||
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;
|
||
|
||
export type GameCreationAppCanonicalAssetKind =
|
||
(typeof GAME_CREATION_APP_CANONICAL_ASSET_KINDS)[number];
|
||
|
||
/**
|
||
* 线上出现的非 canonical kind → canonical kind 的别名表。
|
||
*
|
||
* 职责是「线上值 → 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 是正确口径。
|
||
*/
|
||
const GAME_CREATION_APP_LEGACY_ASSET_KINDS: Record<
|
||
string,
|
||
GameCreationAppCanonicalAssetKind
|
||
> = {
|
||
'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',
|
||
};
|
||
|
||
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;
|
||
}
|
||
if (
|
||
(GAME_CREATION_APP_CANONICAL_ASSET_KINDS as readonly string[]).includes(
|
||
normalized,
|
||
)
|
||
) {
|
||
return normalized as GameCreationAppCanonicalAssetKind;
|
||
}
|
||
return 'image';
|
||
}
|
||
|
||
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];
|
||
|
||
/** canonical kind 到功能分类的默认映射,必须穷举 GAME_CREATION_APP_CANONICAL_ASSET_KINDS。 */
|
||
export const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: Record<
|
||
GameCreationAppCanonicalAssetKind,
|
||
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',
|
||
'publication-material': 'unclassified',
|
||
spec: 'document',
|
||
video: 'unclassified',
|
||
'sound-effect': 'audio',
|
||
'background-music': 'audio',
|
||
audio: 'audio',
|
||
document: 'document',
|
||
code: '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: string,
|
||
): GameCreationAppAssetCategory {
|
||
return GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[
|
||
canonicalGameCreationAppAssetKind(kind)
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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」的存量数据——典型例子是 `kind:"ui"` 的 UI 资产曾因 kind 不在 canonical
|
||
* 目录而落到 `image → unclassified`,修复别名后并不会自动归位,因为落盘值已固化。
|
||
*
|
||
* 盲区:用户**手动**把一个 kind 已能明确分类的资产设成「待归类」时,该手动值会被这条
|
||
* 规则覆盖。这是有意接受的取舍——「手动设为待归类」本身意图边缘,且 kind 已经表达了分类;
|
||
* 而反过来漏掉这条规则,所有历史误判都无法自愈。
|
||
* kind 派生结果本身就是 `unclassified` 的(如 `image` / `video` / `code`)不受影响,
|
||
* 仍然信任落盘值。
|
||
*
|
||
* **这是「读显示」口径,不是「写回」口径。** 需要回写 manifest 的调用方必须用
|
||
* `gameCreationAppAssetPersistedCategory`,否则会把自愈值写回落盘、把「只改标签」
|
||
* 变成静默改分类。Rust 侧由 `game_creation_app_asset_effective_category` 实现同一口径
|
||
* (Agent 投影走 Rust 那条),两侧不许各写一份,由
|
||
* `tests/assetKindCanonicalMapping.test.ts` 的决策矩阵用例交叉钉住。
|
||
*/
|
||
export function gameCreationAppAssetCategory(
|
||
asset: Pick<GameCreationAppAssetManifestEntry, 'kind' | 'category'>,
|
||
): GameCreationAppAssetCategory {
|
||
const persisted = normalizeGameCreationAppAssetCategory(asset.category);
|
||
const derivedFromKind = gameCreationAppAssetCategoryForKind(asset.kind);
|
||
if (persisted === null) {
|
||
return derivedFromKind;
|
||
}
|
||
if (persisted === 'unclassified' && derivedFromKind !== 'unclassified') {
|
||
return derivedFromKind;
|
||
}
|
||
return persisted;
|
||
}
|
||
|
||
/**
|
||
* 回写 manifest 用的落盘分类:**不套用读时自愈**,只做「缺失或非法 → 按 kind 派生」的兜底。
|
||
*
|
||
* 与 `gameCreationAppAssetCategory` 的分工是「读显示 / 写回」:显示层可以自愈历史上的误判值,
|
||
* 但写入层必须原样保留落盘值——「编辑标签」面板若回传自愈值,就把「只改标签」变成了
|
||
* 静默改分类(真机已发生:同一条 `kind:"ui"` 资产出现 `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(asset.kind)
|
||
);
|
||
}
|
||
|
||
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: [],
|
||
};
|
||
}
|