Files
Genarrative/packages/shared/src/contracts/gameCreationApp.ts
T
kdletters 0f829cd252
Project CI / Repository checks (push) Failing after 2m44s
Project CI / Frontend tests (push) Failing after 2m53s
Project CI / Native shell tests (push) Failing after 6m39s
Project CI / Backend tests (push) Successful in 8m7s
完成 DirectProject Phaser 迁移闭环 (#353)
## 变更

单 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
2026-09-14 13:57:24 +08:00

994 lines
32 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.
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: [],
};
}